├── .gitignore ├── packages ├── core │ ├── slot.js │ ├── symbol.js │ ├── tsconfig.json │ ├── await.js │ ├── test │ │ ├── ssr.test.js │ │ ├── router.test.js │ │ ├── slots.test.js │ │ ├── render.test.js │ │ └── html.test.js │ ├── index.js │ ├── ssr │ │ └── default.js │ ├── demo │ │ ├── pages │ │ │ └── HtmlPage.js │ │ ├── index.html │ │ ├── sw.js │ │ └── bundled-sw.js │ ├── strategies.js │ ├── package.json │ ├── types.ts │ ├── dev.js │ ├── router.js │ ├── render.js │ ├── README.md │ └── html.js └── lit │ ├── tsconfig.json │ ├── package.json │ ├── dom-shim.js │ ├── index.js │ └── test │ └── index.test.js ├── package.json ├── tsconfig.json ├── README.md └── .github └── workflows ├── test.yml └── typecheck.yml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | packages/*/dist-types/ -------------------------------------------------------------------------------- /packages/core/slot.js: -------------------------------------------------------------------------------- 1 | import { SLOT_SYMBOL } from './symbol.js'; 2 | 3 | function Slot() {} 4 | 5 | Slot.kind = SLOT_SYMBOL; 6 | 7 | export { Slot }; -------------------------------------------------------------------------------- /packages/lit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["*.js"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "outDir": "./dist-types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/symbol.js: -------------------------------------------------------------------------------- 1 | export const COMPONENT_SYMBOL = Symbol('component'); 2 | export const CUSTOM_ELEMENT_SYMBOL = Symbol('customElement'); 3 | export const AWAIT_SYMBOL = Symbol('await'); 4 | export const SLOT_SYMBOL = Symbol('slot'); 5 | export const DEFAULT_RENDERER_SYMBOL = Symbol('defaultRenderer'); -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": [ 4 | "index.js", 5 | "await.js", 6 | "html.js", 7 | "router.js", 8 | "render.js", 9 | "symbol.js", 10 | "slot.js", 11 | "strategies.js", 12 | "ssr/*.js", 13 | "types.ts" 14 | ], 15 | "compilerOptions": { 16 | "composite": true, 17 | "outDir": "./dist-types" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swtl/root", 3 | "private": true, 4 | "license": "MIT", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/thepassle/swtl.git" 9 | }, 10 | "scripts": { 11 | "test": "npm run test --workspaces", 12 | "lint:types": "npm run lint:types --workspaces" 13 | }, 14 | "devDependencies": { 15 | "esbuild": "^0.19.0", 16 | "lit": "^3.1.2", 17 | "typescript": "^5.4.3" 18 | }, 19 | "workspaces": [ 20 | "packages/*" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/lit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@swtl/lit", 3 | "version": "0.1.5", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "node --test test/*.test.js", 9 | "test:watch": "node --watch --test test/*.test.js", 10 | "lint:types": "tsc", 11 | "lint:types:watch": "tsc --watch" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@lit-labs/ssr": "^3.2.2", 18 | "@lit-labs/ssr-dom-shim": "^1.2.0" 19 | }, 20 | "devDependencies": {} 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "NodeNext", 5 | "moduleResolution": "node16", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "declaration": true, 9 | "emitDeclarationOnly": true, 10 | "outDir": "./dist-types", 11 | "strict": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "skipLibCheck": true 15 | }, 16 | "files": [], 17 | "references": [ 18 | { 19 | "path": "./packages/core" 20 | }, 21 | { 22 | "path": "./packages/lit" 23 | }, 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SWTL 2 | 3 | A Service Worker Templating Language (`swtl`) for component-like templating in service workers. Streams templates to the browser as they're being parsed, and handles rendering iterables/Responses in templates by default. Also supports SSR/SWSRing custom elements, with a pluggable custom element renderer system. 4 | 5 | Runs in Service Workers, but can also be used in Node, or other server-side JS environments. 6 | 7 | 8 | ## Packages 9 | 10 | - [`swtl`](./packages/core) - Service Worker Templating Language 11 | - [`@swtl/lit`](./packages/lit) - Custom Element Renderer to SSR/SWSR LitElements -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Typecheck 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | typecheck: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run lint:types -------------------------------------------------------------------------------- /packages/core/await.js: -------------------------------------------------------------------------------- 1 | import { AWAIT_SYMBOL } from './symbol.js'; 2 | 3 | /** 4 | * @typedef {import('./types.js').Children} Children 5 | * @typedef {import('./html.js').html} html 6 | */ 7 | 8 | /** 9 | * @param {{ 10 | * promise: () => Promise, 11 | * children: Children 12 | * }} args 13 | * @returns {{ 14 | * promise: () => Promise, 15 | * template: () => ReturnType 16 | * }} 17 | */ 18 | function Await({promise, children}) { 19 | return { 20 | promise, 21 | template: /** @type {() => ReturnType} */ (children.find(c => typeof c === 'function')) 22 | }; 23 | } 24 | 25 | Await.kind = AWAIT_SYMBOL; 26 | 27 | /** 28 | * 29 | * @param {boolean} condition 30 | * @param {() => ReturnType} template 31 | * @returns 32 | */ 33 | const when = (condition, template) => condition ? template() : ''; 34 | 35 | export { Await, when }; -------------------------------------------------------------------------------- /packages/core/test/ssr.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { describe, it } from 'node:test'; 3 | import { renderToString } from '../render.js'; 4 | import { html } from '../html.js'; 5 | 6 | 7 | describe('ssr', () => { 8 | describe('default', () => { 9 | it('basic', async () => { 10 | const result = await renderToString(html`children`); 11 | assert.equal(result, 'children'); 12 | }); 13 | 14 | it('nested swtl component', async () => { 15 | function Foo() { 16 | return html`

foo

` 17 | } 18 | const result = await renderToString(html`<${Foo}/>`); 19 | assert.equal(result, '

foo

'); 20 | }); 21 | 22 | it('nested custom element', async () => { 23 | function Foo({children}) { 24 | return html`

${children}

` 25 | } 26 | const result = await renderToString(html`<${Foo}>`); 27 | assert.equal(result, '

'); 28 | }); 29 | }); 30 | }); -------------------------------------------------------------------------------- /packages/lit/dom-shim.js: -------------------------------------------------------------------------------- 1 | import { 2 | HTMLElement, 3 | Element, 4 | CustomElementRegistry, 5 | } from "@lit-labs/ssr-dom-shim"; 6 | 7 | export const getWindow = () => { 8 | class ShadowRoot {} 9 | class Document { 10 | get adoptedStyleSheets() { 11 | return []; 12 | } 13 | createTreeWalker() { 14 | return {}; 15 | } 16 | createTextNode() { 17 | return {}; 18 | } 19 | createElement() { 20 | return {}; 21 | } 22 | } 23 | class CSSStyleSheet { 24 | replace() {} 25 | } 26 | const window = { 27 | Element, 28 | HTMLElement, 29 | Document, 30 | document: new Document(), 31 | CSSStyleSheet, 32 | ShadowRoot, 33 | CustomElementRegistry, 34 | customElements: new CustomElementRegistry(), 35 | MutationObserver: class { 36 | observe() {} 37 | }, 38 | requestAnimationFrame() {}, 39 | window: undefined, 40 | }; 41 | return window; 42 | }; 43 | export const installWindowOnGlobal = () => { 44 | if (globalThis.window === undefined) { 45 | const window = getWindow(); 46 | Object.assign(globalThis, window); 47 | } 48 | }; 49 | 50 | installWindowOnGlobal(); 51 | -------------------------------------------------------------------------------- /packages/core/index.js: -------------------------------------------------------------------------------- 1 | export { html } from './html.js'; 2 | export { Await, when } from './await.js'; 3 | export { Router, HtmlResponse } from './router.js'; 4 | export { render, renderToString } from './render.js'; 5 | export { Slot } from './slot.js'; 6 | export { 7 | NetworkFirst, 8 | CacheFirst, 9 | CacheOnly, 10 | NetworkOnly, 11 | networkFirst, 12 | cacheFirst, 13 | cacheOnly, 14 | networkOnly 15 | } from './strategies.js'; 16 | 17 | /** 18 | * @typedef {import('./types.js').Attribute} Attribute 19 | * @typedef {import('./types.js').Property} Property 20 | * @typedef {import('./types.js').HtmlValue} HtmlValue 21 | * @typedef {import('./types.js').Children} Children 22 | * @typedef {import('./types.js').HtmlResult} HtmlResult 23 | * @typedef {import('./types.js').Component} Component 24 | * @typedef {import('./types.js').CustomElement} CustomElement 25 | * @typedef {import('./types.js').CustomElementRenderer} CustomElementRenderer 26 | * @typedef {import('./types.js').RouteResult} RouteResult 27 | * @typedef {import('./types.js').RouteArgs} RouteArgs 28 | * @typedef {import('./types.js').Plugin} Plugin 29 | * @typedef {import('./types.js').Route} Route 30 | * @typedef {import('./types.js').MatchedRoute} MatchedRoute 31 | */ -------------------------------------------------------------------------------- /packages/core/ssr/default.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RENDERER_SYMBOL } from '../symbol.js'; 2 | 3 | /** 4 | * @typedef {import('../types.js').CustomElementRenderer} CustomElementRenderer 5 | * @typedef {import('../types.js').HtmlResult} HtmlResult 6 | * @typedef {import('../types.js').HtmlValue} HtmlValue 7 | * @typedef {import('../types.js').Children} Children 8 | * @typedef {import('../types.js').Attribute} Attribute 9 | */ 10 | 11 | /** 12 | * @param {{ 13 | * tag: string, 14 | * children: Children, 15 | * attributes: Attribute[], 16 | * }} args 17 | * @param {(children: Children) => AsyncGenerator} renderChildren 18 | */ 19 | async function* render({ tag, children, attributes }, renderChildren) { 20 | const attrs = attributes.reduce((acc, { name, value }, index) => { 21 | const attribute = typeof value === 'boolean' && value ? name : `${name}="${value}"`; 22 | return index < attributes.length - 1 ? `${acc}${attribute} ` : `${acc}${attribute}`; 23 | }, ''); 24 | yield attrs.length ? `<${tag} ${attrs}>` : `<${tag}>`; 25 | yield* renderChildren(children); 26 | yield ``; 27 | } 28 | 29 | /** @type {CustomElementRenderer} */ 30 | export const defaultRenderer = { 31 | name: DEFAULT_RENDERER_SYMBOL, 32 | match() { 33 | return true; 34 | }, 35 | render 36 | } -------------------------------------------------------------------------------- /packages/core/test/router.test.js: -------------------------------------------------------------------------------- 1 | import { Router } from '../router.js'; 2 | import { html } from '../html.js'; 3 | import assert from 'node:assert'; 4 | import { describe, it } from 'node:test'; 5 | 6 | globalThis.URLPattern = class URLPattern { 7 | exec() { 8 | return true; 9 | } 10 | } 11 | 12 | describe('Router', () => { 13 | it('creates a default response', async () => { 14 | const router = new Router({ 15 | routes: [ 16 | { 17 | path: '/foo', 18 | response: () => html`foo` 19 | } 20 | ]}); 21 | 22 | const response = await router.handleRequest({url: 'https://example.com/foo'}); 23 | const result = await response.text(); 24 | assert.equal(result, 'foo'); 25 | 26 | assert.equal(response.headers.get('Content-Type'), 'text/html'); 27 | assert.equal(response.headers.get('Transfer-Encoding'), 'chunked'); 28 | }); 29 | 30 | it('can override defaults via `options`', async () => { 31 | const router = new Router({ 32 | routes: [ 33 | { 34 | path: '/foo', 35 | response: () => html`foo`, 36 | options: { 37 | status: 300, 38 | headers: { 39 | 'Content-Type': 'foo' 40 | } 41 | } 42 | } 43 | ]}); 44 | 45 | const response = await router.handleRequest({url: 'https://example.com/foo'}); 46 | 47 | assert.equal(response.status, 300); 48 | assert.equal(response.headers.get('Content-Type'), 'foo'); 49 | }); 50 | }); -------------------------------------------------------------------------------- /packages/core/test/slots.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { describe, it } from 'node:test'; 3 | import { Slot } from '../slot.js'; 4 | import { html } from '../html.js'; 5 | import { renderToString } from '../render.js'; 6 | 7 | describe('slots', () => { 8 | function Default({slots}) { 9 | return html`

${slots.default}

`; 10 | } 11 | 12 | function OnlyNamed({slots}) { 13 | return html`

${slots?.foo}

`; 14 | } 15 | 16 | function DefaultAndNamed({slots}) { 17 | return html`

${slots?.default}

${slots?.foo}

`; 18 | } 19 | 20 | function MultipleNamed({slots}) { 21 | return html`

${slots?.foo}

${slots?.bar}

`; 22 | } 23 | 24 | it('default', async () => { 25 | const result = await renderToString(html`<${Default}><${Slot}>hi`); 26 | assert.equal(result, '

hi

'); 27 | }); 28 | 29 | it('default and named', async () => { 30 | const result = await renderToString(html`<${DefaultAndNamed}><${Slot}>hi<${Slot} name="foo">foo`); 31 | assert.equal(result, '

hi

foo

'); 32 | }); 33 | 34 | it('multiple named', async () => { 35 | const result = await renderToString(html`<${MultipleNamed}><${Slot} name="foo">foo<${Slot} name="bar">bar`); 36 | assert.equal(result, '

foo

bar

'); 37 | }); 38 | 39 | it('only named', async () => { 40 | const result = await renderToString(html`<${OnlyNamed}><${Slot} name="foo">foo`); 41 | assert.equal(result, '

foo

'); 42 | }); 43 | }); -------------------------------------------------------------------------------- /packages/core/demo/pages/HtmlPage.js: -------------------------------------------------------------------------------- 1 | import { html } from '../../html.js'; 2 | 3 | export function HtmlPage({children, title}) { 4 | return html` 5 | 6 | 7 | 8 | 9 | 10 | ${title ?? ''} 11 | 12 | 13 |
    14 |
  • home
  • 15 |
  • a
  • 16 |
  • b
  • 17 |
18 | ${children} 19 | 41 | 42 | 43 | ` 44 | } 45 | -------------------------------------------------------------------------------- /packages/core/strategies.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./types.js').Children} Children 3 | * @typedef {{ 4 | * file: string, 5 | * children: Children 6 | * }} StrategyParams 7 | */ 8 | 9 | /** @param {StrategyParams} params */ 10 | export const NetworkFirst = ({file, children}) => fetch(file).catch(() => caches.match(file).then(r => r || children)); 11 | 12 | /** @param {StrategyParams} params */ 13 | export const CacheFirst = ({file, children}) => caches.match(file).then(r => r || fetch(file).catch(() => children)); 14 | 15 | /** @param {StrategyParams} params */ 16 | export const CacheOnly = ({file, children}) => caches.match(file).then(r => r || children); 17 | 18 | /** @param {StrategyParams} params */ 19 | export const NetworkOnly = ({file, children}) => fetch(file).catch(() => children); 20 | 21 | /** @param {Request} request */ 22 | export const networkFirst = (request) => fetch(request).catch(() => caches.match(request)); 23 | 24 | /** @param {Request} request */ 25 | export const cacheFirst = (request) => caches.match(request).then(r => r || fetch(request)); 26 | 27 | /** @param {Request} request */ 28 | export const cacheOnly = (request) => caches.match(request); 29 | 30 | /** @param {Request} request */ 31 | export const networkOnly = (request) => fetch(request); 32 | 33 | /** @param {Request} request */ 34 | export const staleWhileRevalidate = (request) => 35 | caches.open("swtl-cache").then((cache) => 36 | cache.match(request).then( 37 | (response) => 38 | response || 39 | fetch(request).then((networkResponse) => { 40 | cache.put(request, networkResponse.clone()); 41 | return networkResponse; 42 | }) 43 | ) 44 | ); 45 | -------------------------------------------------------------------------------- /packages/lit/index.js: -------------------------------------------------------------------------------- 1 | import './dom-shim.js'; 2 | import { LitElementRenderer } from "@lit-labs/ssr/lib/lit-element-renderer.js"; 3 | import { getElementRenderer } from "@lit-labs/ssr/lib/element-renderer.js"; 4 | 5 | /** 6 | * @typedef {import('swtl').CustomElementRenderer} CustomElementRenderer 7 | * @typedef {import('swtl').HtmlResult} HtmlResult 8 | * @typedef {import('swtl').HtmlValue} HtmlValue 9 | * @typedef {import('swtl').Children} Children 10 | * @typedef {import('swtl').Attribute} Attribute 11 | */ 12 | 13 | /** 14 | * @param {{ 15 | * tag: string, 16 | * children: Children, 17 | * attributes: Attribute[], 18 | * }} args 19 | * @param {(children: Children) => AsyncGenerator} renderChildren 20 | */ 21 | async function* render({ tag, children, attributes }, renderChildren) { 22 | const renderInfo = { 23 | elementRenderers: [LitElementRenderer], 24 | customElementInstanceStack: [], 25 | customElementHostStack: [], 26 | deferHydration: false, 27 | }; 28 | const renderer = getElementRenderer(renderInfo, tag); 29 | attributes.forEach(({ name, value }) => { 30 | if (name.startsWith('.')) { 31 | renderer.setProperty(name.slice(1), value); 32 | } else { 33 | renderer.attributeChangedCallback(name, null, /** @type {string} */ (value)); 34 | } 35 | }); 36 | renderer.connectedCallback(); 37 | 38 | yield `<${tag}>`; 39 | yield ``; 43 | yield* renderChildren(children); 44 | yield ``; 45 | } 46 | 47 | /** @type {CustomElementRenderer} */ 48 | export const litRenderer = { 49 | name: 'lit', 50 | match({tag}) { 51 | const ctor = customElements.get(tag); 52 | if (!ctor) return false; 53 | return LitElementRenderer.matchesClass(ctor); 54 | }, 55 | render 56 | } -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swtl", 3 | "version": "0.4.1", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "esbuild ./demo/sw.js --bundle --outfile=./demo/bundled-sw.js --watch --format=iife --servedir=demo", 9 | "start": "node --watch dev.js", 10 | "test": "node --test test/*.test.js", 11 | "test:watch": "node --watch --test test/*.test.js", 12 | "lint:types": "tsc", 13 | "lint:types:watch": "tsc --watch" 14 | }, 15 | "exports": { 16 | ".": { 17 | "types": "./dist-types/index.d.ts", 18 | "default": "./index.js" 19 | }, 20 | "./html.js": { 21 | "types": "./dist-types/html.d.ts", 22 | "default": "./html.js" 23 | }, 24 | "./await.js": { 25 | "types": "./dist-types/await.d.ts", 26 | "default": "./await.js" 27 | }, 28 | "./render.js": { 29 | "types": "./dist-types/render.d.ts", 30 | "default": "./render.js" 31 | }, 32 | "./router.js": { 33 | "types": "./dist-types/router.d.ts", 34 | "default": "./router.js" 35 | }, 36 | "./strategies.js": { 37 | "types": "./dist-types/strategies.d.ts", 38 | "default": "./strategies.js" 39 | }, 40 | "./slot.js": { 41 | "types": "./dist-types/slot.d.ts", 42 | "default": "./slot.js" 43 | }, 44 | "./ssr/*.js": { 45 | "types": "./dist-types/ssr/*.d.ts", 46 | "default": "./ssr/*.js" 47 | }, 48 | "./package.json": "./package.json" 49 | }, 50 | "files": [ 51 | "html.js", 52 | "render.js", 53 | "router.js", 54 | "symbol.js", 55 | "await.js", 56 | "strategies.js", 57 | "index.js", 58 | "slot.js", 59 | "ssr/*", 60 | "dist-types/*", 61 | "README.md" 62 | ], 63 | "keywords": [], 64 | "author": "", 65 | "license": "ISC", 66 | "devDependencies": { 67 | "@swtl/lit": "^0.1.5" 68 | }, 69 | "dependencies": {} 70 | } 71 | -------------------------------------------------------------------------------- /packages/core/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Installing service worker...

8 | 51 | 52 | -------------------------------------------------------------------------------- /packages/core/types.ts: -------------------------------------------------------------------------------- 1 | import { COMPONENT_SYMBOL, CUSTOM_ELEMENT_SYMBOL } from './symbol.js'; 2 | 3 | export interface Attribute { 4 | name: string; 5 | value: unknown; 6 | } 7 | export interface Property extends Attribute {} 8 | 9 | export type HtmlValue = unknown | string | Component | CustomElement | HtmlResult; 10 | export type Children = Array; 11 | export type HtmlResult = Generator; 12 | 13 | export interface Component { 14 | kind: typeof COMPONENT_SYMBOL; 15 | slots: Record; 16 | properties: Array; 17 | children: Children; 18 | fn?: (props: Record, children: Children) => Generator; 19 | } 20 | 21 | export interface CustomElement { 22 | tag: string; 23 | kind: typeof CUSTOM_ELEMENT_SYMBOL; 24 | attributes: Array; 25 | children: Children; 26 | } 27 | 28 | export interface CustomElementRenderer { 29 | name: string | symbol; 30 | match: (customElement: CustomElement) => boolean; 31 | render: (params: { 32 | tag: CustomElement["tag"], 33 | children: Children, 34 | attributes: Attribute[], 35 | renderers: CustomElementRenderer[] 36 | }, 37 | renderChildren: (children: Children) => AsyncGenerator 38 | ) => AsyncGenerator; 39 | } 40 | 41 | export type RouteResult = void | Promise | Response | Promise | HtmlResult | Promise; 42 | 43 | export interface RouteArgs { 44 | url: URL; 45 | params: Record; 46 | query: Record; 47 | request: Request; 48 | } 49 | 50 | export interface Plugin { 51 | name: string; 52 | beforeResponse?: (params: RouteArgs) => RouteResult; 53 | } 54 | 55 | export interface Route { 56 | path: string, 57 | response: (params: RouteArgs) => RouteResult, 58 | plugins?: Plugin[], 59 | options?: RequestInit 60 | } 61 | 62 | export interface MatchedRoute { 63 | params: Record; 64 | response: (params: RouteArgs) => RouteResult; 65 | plugins?: Plugin[]; 66 | options?: RequestInit; 67 | } -------------------------------------------------------------------------------- /packages/core/dev.js: -------------------------------------------------------------------------------- 1 | import { html } from './html.js'; 2 | import { render, renderToString } from './render.js'; 3 | import { Await, when } from './await.js'; 4 | import { Slot } from './slot.js'; 5 | import { LitElement, css, html as litHtml } from 'lit'; 6 | import { litRenderer } from '@swtl/lit'; 7 | import { defaultRenderer } from './ssr/default.js'; 8 | import { COMPONENT_SYMBOL } from './symbol.js'; 9 | import { HtmlPage } from './demo/pages/HtmlPage.js'; 10 | 11 | class MyEl extends LitElement { 12 | static styles = css`:host { color: red }` 13 | static properties = { disabled: { type: Boolean } }; 14 | render() { 15 | return litHtml`

hello ${this.disabled ? 'foo' : 'bar'}

`; 16 | } 17 | } 18 | customElements.define('my-el', MyEl); 19 | 20 | 21 | // console.log(await renderToString(renderLit({ 22 | // tag: 'my-el', 23 | // attributes: [{name: 'disabled', value: true}], 24 | // children: [`children`] 25 | // }))); 26 | 27 | function Bar() { 28 | return html`

bar

` 29 | } 30 | 31 | // console.log(await renderToString(defaultRenderer({ 32 | // tag: 'my-el', 33 | // attributes: [{name: 'disabled', value: true}], 34 | // children: [`children`, 35 | // { 36 | // fn: Bar, 37 | // properties: [], 38 | // children: [], 39 | // slots: {}, 40 | // kind: COMPONENT_SYMBOL 41 | // } 42 | // ] 43 | // }))); 44 | 45 | 46 | // function Html({title}) { 47 | // return html`${title}` 48 | // } 49 | 50 | // function Foo({data}) { 51 | // return html`

hi

` 52 | // } 53 | 54 | // @TODO BUG! 55 | // const bug2 = html` 56 | // <${Await} promise=${() => new Promise(r => setTimeout(() => r({foo:'foo'}), 500))}> 57 | // ${() => html` 58 | //

a

59 | // <${Foo}/> 60 | // `} 61 | // 62 | // ` 63 | 64 | // function Foo({a}) { 65 | // return html`${a}`; 66 | // } 67 | 68 | // function Parent({slots}) { 69 | // console.log(slots); 70 | // return html`

${slots?.default}

bar

` 71 | 72 | // } 73 | 74 | function Foo() { 75 | return html`1` 76 | } 77 | 78 | function unwrap(generator) { 79 | const result = []; 80 | 81 | let next = generator.next(); 82 | while(!next.done) { 83 | result.push(next.value); 84 | next = generator.next(); 85 | } 86 | 87 | return result; 88 | } 89 | 90 | function HtmlPage2({children}) { 91 | return children; 92 | } 93 | 94 | 95 | // class MyEl extends LitElement { 96 | // static styles = css`:host { color: red }` 97 | // static properties = { disabled: { type: Boolean } }; 98 | // render() { 99 | // return litHtml`

hello ${this.disabled ? 'foo' : 'bar'}

`; 100 | // } 101 | // } 102 | // customElements.define('my-el', MyEl); 103 | 104 | // html` 105 | // <${HtmlPage}> 106 | //

home

107 | // 108 | // 109 | // ` 110 | 111 | const r = unwrap(fixture); 112 | console.log(1, r); 113 | // console.log(JSON.stringify(r, null, 2)); 114 | 115 | // console.log(1, await renderToString(bug3)); 116 | // console.log(await renderToString(bug2)); -------------------------------------------------------------------------------- /packages/lit/test/index.test.js: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { describe, it } from "node:test"; 3 | import { LitElement, css, html as litHtml } from "lit"; 4 | import { html } from "swtl/html.js"; 5 | import { renderToString } from "swtl/render.js"; 6 | import { litRenderer } from "../index.js"; 7 | import { defaultRenderer } from 'swtl/ssr/default.js'; 8 | 9 | describe("ssr", () => { 10 | describe("lit", () => { 11 | class FooEl extends LitElement { 12 | static styles = css`h1 { color: red; }`; 13 | static properties = { 14 | disabled: { type: Boolean }, 15 | foo: { type: String }, 16 | }; 17 | render() { 18 | return litHtml`

foo: ${this.foo} ${this.disabled ? "yyy" : "zzz"}

`; 19 | } 20 | } 21 | 22 | customElements.define("foo-el", FooEl); 23 | 24 | it("basic", async () => { 25 | const result = await renderToString( 26 | html`children`, 27 | [litRenderer] 28 | ); 29 | 30 | assert(result.includes("")); 31 | assert(result.includes('")); 37 | assert(result.includes("")); 38 | }); 39 | 40 | it("nested swtl component", async () => { 41 | function Foo() { 42 | return html`

Foo

`; 43 | } 44 | const result = await renderToString( 45 | html`<${Foo} />`, 46 | [litRenderer] 47 | ); 48 | assert(result.includes("")); 49 | assert(result.includes('")); 56 | assert(result.includes("")); 57 | }); 58 | 59 | it("property", async () => { 60 | class PropertyEl extends LitElement { 61 | static properties = { foo: { type: Object } }; 62 | render() { 63 | return litHtml`

${this.foo.a}

`; 64 | } 65 | } 66 | customElements.define("property-el", PropertyEl); 67 | const result = await renderToString( 68 | html``, 69 | [litRenderer] 70 | ); 71 | assert(result.includes("zzz")); 72 | }); 73 | }); 74 | 75 | it("supports multiple renderers", async () => { 76 | class A extends LitElement { 77 | render() { 78 | return litHtml`

lit

`; 79 | } 80 | } 81 | customElements.define("lit-el", A); 82 | 83 | const result = await renderToString( 84 | html``, 85 | [litRenderer, defaultRenderer] 86 | ); 87 | assert(result.includes("