diff --git a/.changeset/worker-template-set.md b/.changeset/worker-template-set.md new file mode 100644 index 00000000..640050e5 --- /dev/null +++ b/.changeset/worker-template-set.md @@ -0,0 +1,8 @@ +--- +"@luzzle/web.worker": patch +--- + +Compile a named set of server templates into one standalone module sharing a +single Svelte runtime. The renderer selects a template by name, keeps output +isolated between calls, and retains source diagnostics. Configuration discovery, +revision publication and live web integration remain unchanged. diff --git a/packages/web/worker/README.md b/packages/web/worker/README.md index 439538cc..5be8b5cd 100644 --- a/packages/web/worker/README.md +++ b/packages/web/worker/README.md @@ -36,13 +36,24 @@ The worker registers and processes the following job types: ## Template compiler -`src/templates/compile.ts` contains a low-level `compileTemplate(source, filename)` -function. It uses the worker's Svelte compiler and esbuild to return a standalone -Node ESM module as `code`, alongside the Svelte source `warnings`. +`src/templates/compile.ts` contains a low-level `compileTemplates(templates)` +function. Each name maps to `{ source, filename }`. It compiles all sources with +Svelte and bundles them together with esbuild into one standalone Node ESM module, +returned as `code` alongside source `warnings`. -The generated module exports `render(props)`, which synchronously returns plain -`{ body, head }` strings. `head` includes injected component CSS. Svelte's server -runtime is bundled into that module; callers do not need to install Svelte. +```ts +const compiled = await compileTemplates({ + page: { source: pageSource, filename: 'page.svelte' }, + icon: { source: iconSource, filename: 'icon.svelte' }, +}) +``` + +The generated module exports `render(name, props)`, which synchronously returns +plain `{ body, head }` strings. `head` includes only the selected render's component +CSS. All templates share one Svelte server runtime; callers do not need to install +Svelte. Unknown names throw an error, not a fallback. Names are identifiers, not +filesystem paths or a piece-type allowlist. Diagnostics retain source filenames. +The names and sources are caller-supplied; this utility does not discover them. This utility does not read configuration, validate the supported authoring subset, cache/publish revisions, or register a background job. Open Graph and thumbnail diff --git a/packages/web/worker/src/templates/compile.test.ts b/packages/web/worker/src/templates/compile.test.ts index 35a3da92..7d12324e 100644 --- a/packages/web/worker/src/templates/compile.test.ts +++ b/packages/web/worker/src/templates/compile.test.ts @@ -4,13 +4,14 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { promisify } from 'node:util' -import { compileTemplate } from './compile.js' +import { compileTemplates } from './compile.js' const exec = promisify(execFile) -const fixture = new URL('../../test/fixtures/templates/page.svelte', import.meta.url) +const pageFixture = new URL('../../test/fixtures/templates/page.svelte', import.meta.url) +const contextFixture = new URL('../../test/fixtures/templates/context.svelte', import.meta.url) +const quotedName = 'quoted"\\\nname' -// A native Node process, not Vitest's module loader. Any dependency left outside -// the artifact must be a Node builtin, even if the workspace could resolve it. +// Native Node rejects dependencies the workspace might otherwise supply. const runArtifact = ` import { isBuiltin, registerHooks } from 'node:module'; const moduleUrl = process.argv[1]; @@ -23,15 +24,40 @@ registerHooks({ } }); const { render } = await import(moduleUrl); -const first = render({ data: { piece: { type: 'custom-one', title: '&' } } }); -const second = render({ data: { piece: { type: 'another-kind', title: 'Second' } } }); -console.log(JSON.stringify({ first, second })); +const first = render('page', { data: { piece: { type: 'custom-one', title: '&' } } }); +const contexts = []; +const capture = value => contexts.push(value); +const contextA = render('context-a', { data: { label: 'A', capture } }); +const contextB = render('context-b', { data: { label: 'B', capture } }); +if (contexts.length !== 2 || contexts[0] !== contexts[1]) { + throw new Error('Templates do not share one Svelte runtime'); +} +let contextCleared = false; +try { contexts[0]('fixture-label'); } catch { contextCleared = true; } +if (!contextCleared) throw new Error('Svelte context leaked outside rendering'); +const second = render('page', { data: { piece: { type: 'another-kind', title: 'Second' } } }); +const props = { data: { piece: { type: 'other', title: 'Named' } } }; +const reserved = render('__proto__', props); +const quoted = render(process.argv[2], props); +let unknown; +try { render('toString', props); } catch (error) { unknown = error.message; } +console.log(JSON.stringify({ first, contextA, contextB, second, reserved, quoted, unknown })); ` -describe('compileTemplate', () => { - test('produces a standalone renderer with escaped content and scoped CSS', async () => { - const source = await readFile(fixture, 'utf8') - const compiled = await compileTemplate(source, fileURLToPath(fixture)) +describe('compileTemplates', () => { + test('bundles named templates with one runtime and independent render output', async () => { + const page = { + source: await readFile(pageFixture, 'utf8'), + filename: fileURLToPath(pageFixture), + } + const contextSource = await readFile(contextFixture, 'utf8') + const compiled = await compileTemplates({ + page, + 'context-a': { source: contextSource, filename: 'context-a.svelte' }, + 'context-b': { source: contextSource, filename: 'context-b.svelte' }, + ['__proto__']: page, + [quotedName]: page, + }) expect(compiled.warnings).toEqual([]) const cache = fileURLToPath(new URL('../../node_modules/.cache/', import.meta.url)) @@ -45,32 +71,47 @@ describe('compileTemplate', () => { '-e', runArtifact, pathToFileURL(modulePath).href, + quotedName, ]) - const { first, second } = JSON.parse(stdout) + const { first, contextA, contextB, second, reserved, quoted, unknown } = JSON.parse(stdout) expect(Object.keys(first).sort()).toEqual(['body', 'head']) expect(first.body).toContain('custom-one: <First>&') expect(first.body).toMatch(/class="piece svelte-/) expect(first.head).toContain('--color-primary') - expect(first.head).toContain('A

') + expect(contextB.body).toContain('>B

') + expect(contextA.head).toContain('--color-secondary') + expect(contextA.head).not.toContain('--color-primary') expect(second.body).toContain('another-kind: Second') expect(second.body).not.toContain('First') + expect(second.head).toBe(first.head) + expect(reserved.body).toContain('other: Named') + expect(quoted.body).toBe(reserved.body) + expect(unknown).toBe('Unknown template: toString') } finally { await rm(directory, { recursive: true, force: true }) } }) - test('preserves Svelte source warnings for the caller', async () => { + test('collects warnings while preserving each source filename', async () => { const source = '

Hello

' - const compiled = await compileTemplate(source, 'unused.svelte') + const compiled = await compileTemplates({ + first: { source, filename: 'first.svelte' }, + second: { source, filename: 'second.svelte' }, + }) expect(compiled.warnings).toEqual([ - expect.objectContaining({ code: 'css_unused_selector', filename: 'unused.svelte' }), + expect.objectContaining({ code: 'css_unused_selector', filename: 'first.svelte' }), + expect.objectContaining({ code: 'css_unused_selector', filename: 'second.svelte' }), ]) }) - test('reports invalid source with its original filename', async () => { - await expect(compileTemplate('
', 'broken.svelte')).rejects.toMatchObject({ - code: 'element_unclosed', - filename: 'broken.svelte', - }) + test('rejects invalid source without losing the original filename', async () => { + await expect( + compileTemplates({ + valid: { source: '

Valid

', filename: 'valid.svelte' }, + broken: { source: '
', filename: 'broken.svelte' }, + }) + ).rejects.toMatchObject({ code: 'element_unclosed', filename: 'broken.svelte' }) }) }) diff --git a/packages/web/worker/src/templates/compile.ts b/packages/web/worker/src/templates/compile.ts index 30119ef4..903ad231 100644 --- a/packages/web/worker/src/templates/compile.ts +++ b/packages/web/worker/src/templates/compile.ts @@ -1,31 +1,55 @@ import { build } from 'esbuild' import { compile, type Warning } from 'svelte/compiler' -export interface CompiledTemplate { +export interface TemplateSource { + source: string + filename: string +} + +export interface CompiledTemplates { code: string warnings: Warning[] } -const templateModuleId = 'luzzle:template' -const renderEntry = ` -import Template from '${templateModuleId}'; -import { render as renderSvelte } from 'svelte/server'; +const templateModulePrefix = 'luzzle:template/' -export function render(props) { +export async function compileTemplates( + templates: Readonly> +): Promise { + const modules = new Map() + const imports: string[] = [] + const entries: string[] = [] + const warnings: Warning[] = [] + + for (const [name, template] of Object.entries(templates)) { + const component = `Template${modules.size}` + const moduleId = `${templateModulePrefix}${modules.size}` + const compiled = compile(template.source, { + filename: template.filename, + generate: 'server', + css: 'injected', + dev: false, + experimental: { async: false }, + }) + modules.set(moduleId, compiled.js.code) + warnings.push(...compiled.warnings) + imports.push(`import ${component} from ${JSON.stringify(moduleId)};`) + entries.push(`[${JSON.stringify(name)}, ${component}]`) + } + + const renderEntry = ` +${imports.join('\n')} +import { render as renderSvelte } from 'svelte/server'; +const templates = new Map([${entries.join(',\n')}]); + +export function render(name, props) { + const Template = templates.get(name); + if (!Template) throw new Error('Unknown template: ' + name); const { body, head } = renderSvelte(Template, { props }); return { body, head }; } ` -export async function compileTemplate(source: string, filename: string): Promise { - const compiled = compile(source, { - filename, - generate: 'server', - css: 'injected', - dev: false, - experimental: { async: false }, - }) - const bundled = await build({ stdin: { contents: renderEntry, resolveDir: import.meta.dirname }, bundle: true, @@ -37,14 +61,14 @@ export async function compileTemplate(source: string, filename: string): Promise define: { 'process.env.NODE_ENV': '"production"' }, plugins: [ { - name: 'compiled-template', + name: 'compiled-templates', setup(builder) { - builder.onResolve({ filter: new RegExp(`^${templateModuleId}$`) }, () => ({ - path: filename, - namespace: templateModuleId, + builder.onResolve({ filter: new RegExp(`^${templateModulePrefix}`) }, ({ path }) => ({ + path, + namespace: templateModulePrefix, })) - builder.onLoad({ filter: /.*/, namespace: templateModuleId }, () => ({ - contents: compiled.js.code, + builder.onLoad({ filter: /.*/, namespace: templateModulePrefix }, ({ path }) => ({ + contents: modules.get(path), loader: 'js', resolveDir: import.meta.dirname, })) @@ -53,5 +77,5 @@ export async function compileTemplate(source: string, filename: string): Promise ], }) - return { code: bundled.outputFiles[0].text, warnings: compiled.warnings } + return { code: bundled.outputFiles[0].text, warnings } } diff --git a/packages/web/worker/test/fixtures/templates/context.svelte b/packages/web/worker/test/fixtures/templates/context.svelte new file mode 100644 index 00000000..3cbe930d --- /dev/null +++ b/packages/web/worker/test/fixtures/templates/context.svelte @@ -0,0 +1,18 @@ + + +

{getContext('fixture-label')}

+ +