From 7af443066cb8fb0c87048b956f3a4735cf2f94b1 Mon Sep 17 00:00:00 2001 From: eleith Date: Sat, 24 Oct 2026 16:59:36 -0700 Subject: [PATCH] [web] configure home and feed Markdown sections --- .changeset/site-markdown-sections.md | 7 + apps/web/demo/Dockerfile | 1 + apps/web/demo/config/config.yaml | 11 +- apps/web/demo/content/feed-about.md | 4 + apps/web/demo/content/home.md | 5 + apps/web/package.json | 1 + apps/web/scripts/config-mounts.test.ts | 15 + .../content/components/root.default.svelte | 76 -- .../src/lib/content/markdown/Markdown.svelte | 8 + .../lib/content/markdown/files.server.test.ts | 41 ++ .../src/lib/content/markdown/files.server.ts | 20 + .../content/markdown/render.server.test.ts | 28 + .../src/lib/content/markdown/render.server.ts | 8 + apps/web/src/lib/feeds/page.server.test.ts | 218 ------ apps/web/src/lib/feeds/page.server.ts | 27 - apps/web/src/lib/feeds/page.svelte | 51 +- apps/web/src/lib/feeds/subscription.svelte | 70 ++ apps/web/src/lib/server/pages.test.ts | 42 ++ apps/web/src/lib/server/pages.ts | 10 + apps/web/src/lib/server/pieces.test.ts | 53 +- apps/web/src/lib/server/pieces.ts | 20 +- apps/web/src/routes/(root)/+page.server.ts | 41 +- apps/web/src/routes/(root)/+page.svelte | 82 ++- .../web/src/routes/(root)/page.server.test.ts | 69 ++ .../[[piece]]/feed.html/+page.server.ts | 9 +- .../pieces/[[piece]]/feed.html/+page.svelte | 2 +- .../[[piece]]/feed.html/page.server.test.ts | 122 +--- .../rss/tags/[tag]/feed.html/+page.server.ts | 9 +- .../rss/tags/[tag]/feed.html/+page.svelte | 2 +- .../tags/[tag]/feed.html/page.server.test.ts | 143 +--- apps/web/test/templates/routes.test.ts | 683 +++++------------- .../web/config/src/lib/config/pages.test.ts | 31 + packages/web/config/src/lib/config/schema.ts | 43 ++ .../src/lib/config/web.config.schema.json | 46 ++ packages/web/templates/config.schema.json | 46 ++ pnpm-lock.yaml | 3 + 36 files changed, 984 insertions(+), 1063 deletions(-) create mode 100644 .changeset/site-markdown-sections.md create mode 100644 apps/web/demo/content/feed-about.md create mode 100644 apps/web/demo/content/home.md delete mode 100644 apps/web/src/lib/content/components/root.default.svelte create mode 100644 apps/web/src/lib/content/markdown/Markdown.svelte create mode 100644 apps/web/src/lib/content/markdown/files.server.test.ts create mode 100644 apps/web/src/lib/content/markdown/files.server.ts create mode 100644 apps/web/src/lib/content/markdown/render.server.test.ts create mode 100644 apps/web/src/lib/content/markdown/render.server.ts delete mode 100644 apps/web/src/lib/feeds/page.server.test.ts delete mode 100644 apps/web/src/lib/feeds/page.server.ts create mode 100644 apps/web/src/lib/feeds/subscription.svelte create mode 100644 apps/web/src/lib/server/pages.test.ts create mode 100644 apps/web/src/lib/server/pages.ts create mode 100644 apps/web/src/routes/(root)/page.server.test.ts create mode 100644 packages/web/config/src/lib/config/pages.test.ts diff --git a/.changeset/site-markdown-sections.md b/.changeset/site-markdown-sections.md new file mode 100644 index 00000000..b431c08e --- /dev/null +++ b/.changeset/site-markdown-sections.md @@ -0,0 +1,7 @@ +--- +"@luzzle/web": patch +--- + +Add configuration-selected Markdown sections for home and feed pages, with +independent latest/random and subscription controls. Render ordinary Markdown +server-side without raw HTML, authored extensions or asset processing. diff --git a/apps/web/demo/Dockerfile b/apps/web/demo/Dockerfile index a6aa511f..720a9134 100644 --- a/apps/web/demo/Dockerfile +++ b/apps/web/demo/Dockerfile @@ -15,6 +15,7 @@ FROM node:24-alpine WORKDIR /app COPY --from=builder /deploy ./ +COPY content/home.md content/feed-about.md ./content/ COPY config/config.yaml ./config/config.yaml RUN ln -sfn config/config.yaml config.yaml diff --git a/apps/web/demo/config/config.yaml b/apps/web/demo/config/config.yaml index 1a5ba686..5924b734 100644 --- a/apps/web/demo/config/config.yaml +++ b/apps/web/demo/config/config.yaml @@ -39,11 +39,18 @@ content: description: an example luzzle site description component: - root: ./content/components/pages/root.svelte - feed: ./content/components/pages/feed.svelte '404': ./content/components/pages/404.svelte error: ./content/components/pages/error.svelte +pages: + home: + body: ./content/home.md + latest: true + random: true + feed: + about: ./content/feed-about.md + links: true + pieces: - type: books templates: diff --git a/apps/web/demo/content/feed-about.md b/apps/web/demo/content/feed-about.md new file mode 100644 index 00000000..40b04b32 --- /dev/null +++ b/apps/web/demo/content/feed-about.md @@ -0,0 +1,4 @@ +## What is this? + +RSS (Really Simple Syndication) lets you subscribe to updates without providing +your email address. Choose a subscription link below and add it to your RSS reader. diff --git a/apps/web/demo/content/home.md b/apps/web/demo/content/home.md new file mode 100644 index 00000000..32d50cc1 --- /dev/null +++ b/apps/web/demo/content/home.md @@ -0,0 +1,5 @@ +# hello + +This site lets me recall, search and share [books](/pieces/books), +[films](/pieces/films), [words](/pieces/words), [snippets](/pieces/snippets) +and other [luzzle pieces](/pieces). diff --git a/apps/web/package.json b/apps/web/package.json index d1928403..c243b50a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -92,6 +92,7 @@ "@replit/codemirror-vim": "6.4.0", "eventsource-parser": "4.1.1", "kysely": "0.28.17", + "micromark": "4.0.2", "openworkflow": "0.9.2" } } diff --git a/apps/web/scripts/config-mounts.test.ts b/apps/web/scripts/config-mounts.test.ts index e742e20c..cf5d27a2 100644 --- a/apps/web/scripts/config-mounts.test.ts +++ b/apps/web/scripts/config-mounts.test.ts @@ -23,6 +23,21 @@ test('the demo configuration is a regular file in its mounted directory', async expect((await lstat(canonical)).isFile()).toBe(true) }) +test('the standalone demo runtime copies the Markdown files referenced by its config', async () => { + const config = parse(await readFile(path.join(web, 'demo/config/config.yaml'), 'utf8')) + const dockerfile = await readFile(path.join(web, 'demo/Dockerfile'), 'utf8') + const runtime = dockerfile.slice(dockerfile.lastIndexOf('\nFROM ')) + const copy = runtime + .split('\n') + .find((line) => line.startsWith('COPY ') && line.endsWith(' ./content/')) + expect(copy).toBeDefined() + for (const filename of [config.pages.home.body, config.pages.feed.about]) { + const source = filename.replace(/^\.\//, '') + expect(copy!.split(/\s+/)).toContain(source) + expect((await stat(path.join(web, 'demo', source))).isFile()).toBe(true) + } +}) + test('the schema header resolves from the canonical editable config location', async () => { const canonical = path.join(web, 'demo/config/config.yaml') const source = await readFile(canonical, 'utf8') diff --git a/apps/web/src/lib/content/components/root.default.svelte b/apps/web/src/lib/content/components/root.default.svelte deleted file mode 100644 index 809e4200..00000000 --- a/apps/web/src/lib/content/components/root.default.svelte +++ /dev/null @@ -1,76 +0,0 @@ - - - - -
-
-
-

luzzle pieces

-

export all the pieces

-
-
- - {#if types.length} -
-

types

-
- {#each types as type, index (index)} - {type} - {/each} -
-
- {/if} - - {#if pieces.length} -
-

latest

-
- {#each pieces as piece (piece.id)} - -
- - {piece.title} -
-
- {/each} -
-
- {/if} -
- - diff --git a/apps/web/src/lib/content/markdown/Markdown.svelte b/apps/web/src/lib/content/markdown/Markdown.svelte new file mode 100644 index 00000000..72f52154 --- /dev/null +++ b/apps/web/src/lib/content/markdown/Markdown.svelte @@ -0,0 +1,8 @@ + + +
+ + {@html html} +
diff --git a/apps/web/src/lib/content/markdown/files.server.test.ts b/apps/web/src/lib/content/markdown/files.server.test.ts new file mode 100644 index 00000000..ecaeb0c2 --- /dev/null +++ b/apps/web/src/lib/content/markdown/files.server.test.ts @@ -0,0 +1,41 @@ +import { afterAll, beforeAll, expect, test } from 'vitest' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { loadMarkdownFile } from './files.server.js' + +let directory: string +beforeAll(async () => { + const cache = path.resolve('node_modules/.cache') + await mkdir(cache, { recursive: true }) + directory = await mkdtemp(path.join(cache, 'markdown-files-')) +}) +afterAll(async () => { + if (directory) await rm(directory, { recursive: true, force: true }) +}) + +test('loads rendered HTML once without writing into application source', async () => { + const file = path.join(directory, 'home.md') + await writeFile(file, '# First') + const first = loadMarkdownFile(file) + expect(loadMarkdownFile(file)).toBe(first) + expect(await first).toBe('

First

') + await writeFile(file, '# Changed') + expect(loadMarkdownFile(file)).toBe(first) + expect(await loadMarkdownFile(file)).toBe('

First

') + expect(await readFile(file, 'utf8')).toBe('# Changed') +}) + +test('an empty configured file is an explicit empty result', async () => { + const file = path.join(directory, 'empty.md') + await writeFile(file, '') + expect(await loadMarkdownFile(file)).toBe('') +}) + +test('missing explicit files fail with an actionable server-side path', async () => { + const file = path.join(directory, 'missing.md') + await expect(loadMarkdownFile(file)).rejects.toThrow(`Unable to load Markdown file "${file}"`) +}) + +test('the general loader never guesses a working-directory base', () => { + expect(() => loadMarkdownFile('relative.md')).toThrow('must be absolute') +}) diff --git a/apps/web/src/lib/content/markdown/files.server.ts b/apps/web/src/lib/content/markdown/files.server.ts new file mode 100644 index 00000000..36df6db2 --- /dev/null +++ b/apps/web/src/lib/content/markdown/files.server.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { renderMarkdown } from './render.server.js' + +const files = new Map>() + +export function loadMarkdownFile(filename: string): Promise { + if (!path.isAbsolute(filename)) throw new Error('Markdown file paths must be absolute') + const key = path.normalize(filename) + let html = files.get(key) + if (!html) { + html = readFile(key, 'utf8') + .then(renderMarkdown) + .catch((cause: unknown) => { + throw new Error(`Unable to load Markdown file "${key}"`, { cause }) + }) + files.set(key, html) + } + return html +} diff --git a/apps/web/src/lib/content/markdown/render.server.test.ts b/apps/web/src/lib/content/markdown/render.server.test.ts new file mode 100644 index 00000000..3770ece0 --- /dev/null +++ b/apps/web/src/lib/content/markdown/render.server.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest' +import { renderMarkdown } from './render.server.js' + +test('renders ordinary Markdown without page or filesystem context', () => { + expect(renderMarkdown('# Welcome\n\nBrowse [pieces](/pieces).')).toBe( + '

Welcome

\n

Browse pieces.

' + ) +}) + +test('raw HTML is text, not an active HTML override', () => { + const html = renderMarkdown('\n\n') + expect(html).not.toContain(' - - - - - {@html presentation?.head ?? ''} - + {#key feed.url} - {#if presentation} - {#if presentation.navBanner !== null} - - {/if} - - {@html presentation.body} - {:else} - - + {#if about || links} +
+ {#if about} + + {/if} + {#if links} + + {/if} +
{/if} + {/key} + + diff --git a/apps/web/src/lib/feeds/subscription.svelte b/apps/web/src/lib/feeds/subscription.svelte new file mode 100644 index 00000000..12bdc370 --- /dev/null +++ b/apps/web/src/lib/feeds/subscription.svelte @@ -0,0 +1,70 @@ + + + + + diff --git a/apps/web/src/lib/server/pages.test.ts b/apps/web/src/lib/server/pages.test.ts new file mode 100644 index 00000000..bdb70f89 --- /dev/null +++ b/apps/web/src/lib/server/pages.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { loadPageMarkdown } from './pages.js' + +const { config, loadMarkdownFile } = vi.hoisted(() => ({ + config: { paths: { config: '/deployment/config/config.yaml' } }, + loadMarkdownFile: vi.fn() +})) +vi.mock('$lib/server/config', () => ({ config })) +vi.mock('$lib/content/markdown/files.server.js', () => ({ loadMarkdownFile })) + +beforeEach(() => { + vi.clearAllMocks() + config.paths.config = '/deployment/config/config.yaml' + loadMarkdownFile.mockResolvedValue('

Section

') +}) + +test('omission does not read a file or select feed fallback copy', async () => { + expect(await loadPageMarkdown()).toBeUndefined() + expect(loadMarkdownFile).not.toHaveBeenCalled() +}) + +test('relative references follow the logical supplied config path', async () => { + expect(await loadPageMarkdown('site/home.md')).toBe('

Section

') + expect(loadMarkdownFile).toHaveBeenCalledExactlyOnceWith('/deployment/config/site/home.md') + config.paths.config = '/deployment/config.yaml' + await loadPageMarkdown('site/home.md') + expect(loadMarkdownFile).toHaveBeenLastCalledWith('/deployment/site/home.md') +}) + +test('loads only the requested section rather than discovering or preloading other pages', async () => { + await loadPageMarkdown('home.md') + expect(loadMarkdownFile.mock.calls).toEqual([['/deployment/config/home.md']]) +}) + +test('an explicit file needs a recorded config path and loading errors are not masked', async () => { + config.paths.config = '' + expect(() => loadPageMarkdown('home.md')).toThrow('supplied config path') + config.paths.config = '/deployment/config.yaml' + const failure = new Error('Unreadable Markdown') + loadMarkdownFile.mockRejectedValueOnce(failure) + await expect(loadPageMarkdown('home.md')).rejects.toBe(failure) +}) diff --git a/apps/web/src/lib/server/pages.ts b/apps/web/src/lib/server/pages.ts new file mode 100644 index 00000000..288c091f --- /dev/null +++ b/apps/web/src/lib/server/pages.ts @@ -0,0 +1,10 @@ +import path from 'node:path' +import { config } from '$lib/server/config' +import { loadMarkdownFile } from '$lib/content/markdown/files.server.js' + +export function loadPageMarkdown(file?: string): Promise { + if (file === undefined) return Promise.resolve(undefined) + if (!config.paths.config) throw new Error('A supplied config path is required for page Markdown') + const filename = path.resolve(path.dirname(config.paths.config), file) + return loadMarkdownFile(filename) +} diff --git a/apps/web/src/lib/server/pieces.test.ts b/apps/web/src/lib/server/pieces.test.ts index 7324b943..b31bf36c 100644 --- a/apps/web/src/lib/server/pieces.test.ts +++ b/apps/web/src/lib/server/pieces.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from 'vitest' import { createAppDb, runWebMigrations } from '@luzzle/web.db' -import { getWebPiece, getRecentlyEditedPieces } from './pieces.js' +import { getWebPiece, getRecentlyEditedPieces, getRecentPieces, getRandomPieces } from './pieces.js' // pieces.js pulls in getPieces()'s module-level dependencies on import vi.mock('$lib/server/config', () => ({ config: {} })) @@ -94,6 +94,57 @@ describe('getRecentlyEditedPieces', () => { }) }) +describe('public home collections', () => { + test('latest and random stay inside configured types and random excludes latest IDs', async () => { + const db = await createTestDb() + try { + await db + .insertInto('web_pieces') + .values([ + { + id: 'a', + key: 'ka', + title: 'A', + slug: 'a', + type: 'inventory', + file_path: 'a.md', + json_metadata: '{}', + date_added: 3 + }, + { + id: 'b', + key: 'kb', + title: 'B', + slug: 'b', + type: 'inventory', + file_path: 'b.md', + json_metadata: '{}', + date_added: 2 + }, + { + id: 'orphan', + key: 'ko', + title: 'Hidden', + slug: 'orphan', + type: 'unconfigured', + file_path: 'orphan.md', + json_metadata: '{}', + date_added: 4 + } + ]) + .execute() + expect((await getRecentPieces(db, 1, ['inventory'])).map((piece) => piece.id)).toEqual(['a']) + expect((await getRandomPieces(db, 5, ['inventory'], ['a'])).map((piece) => piece.id)).toEqual( + ['b'] + ) + expect(await getRecentPieces(db, 5, [])).toEqual([]) + expect(await getRandomPieces(db, 5, [])).toEqual([]) + } finally { + await db.destroy() + } + }) +}) + describe('getWebPiece', () => { test('is undefined when there is no web_pieces row', async () => { const db = await createTestDb() diff --git a/apps/web/src/lib/server/pieces.ts b/apps/web/src/lib/server/pieces.ts index 59dfb709..4f80901f 100644 --- a/apps/web/src/lib/server/pieces.ts +++ b/apps/web/src/lib/server/pieces.ts @@ -18,16 +18,34 @@ export function getPieces(): Pieces { export async function getRecentPieces( db: Kysely, - limit: number + limit: number, + types: readonly string[] ): Promise { + if (!types.length) return [] return db .selectFrom('web_pieces') .selectAll() + .where('type', 'in', types) .orderBy('date_added', 'desc') .limit(limit) .execute() } +export async function getRandomPieces( + db: Kysely, + limit: number, + types: readonly string[], + excludedIds: readonly string[] = [] +): Promise { + if (!types.length) return [] + let query = db.selectFrom('web_pieces').selectAll().where('type', 'in', types) + if (excludedIds.length) query = query.where('id', 'not in', excludedIds) + return query + .orderBy(sql`RANDOM()`) + .limit(limit) + .execute() +} + export async function getRecentlyEditedPieces( db: Kysely, limit: number diff --git a/apps/web/src/routes/(root)/+page.server.ts b/apps/web/src/routes/(root)/+page.server.ts index f9add1d2..ca451c00 100644 --- a/apps/web/src/routes/(root)/+page.server.ts +++ b/apps/web/src/routes/(root)/+page.server.ts @@ -1,22 +1,31 @@ import { db } from '$lib/server/database' import type { PageServerLoad } from './$types' import { hydrateWithAssets } from '$lib/pieces/assets.server' -import { getRecentPieces } from '$lib/server/pieces' +import { getRecentPieces, getRandomPieces } from '$lib/server/pieces' +import { getConfiguredPieceTypes } from '$lib/pieces/types.server.js' +import { config } from '$lib/server/config' +import { loadPageMarkdown } from '$lib/server/pages.js' + +const HOME_PIECE_COUNT = 5 export const load: PageServerLoad = async () => { - const typesData = await db - .selectFrom('web_pieces') - .select('type') - .distinct() - .orderBy('type', 'asc') - .execute() - - const types = typesData.map((t) => t.type) - - const pieces = await hydrateWithAssets(await getRecentPieces(db, 5)) - - return { - types, - pieces - } + const types = getConfiguredPieceTypes() + const settings = config.pages.home + const [body, latestRows] = await Promise.all([ + loadPageMarkdown(settings.body), + settings.latest ? getRecentPieces(db, HOME_PIECE_COUNT, types) : [] + ]) + const randomRows = settings.random + ? await getRandomPieces( + db, + HOME_PIECE_COUNT, + types, + latestRows.map((piece) => piece.id) + ) + : [] + const [latest, random] = await Promise.all([ + hydrateWithAssets(latestRows), + hydrateWithAssets(randomRows) + ]) + return { types, body, latest, random } } diff --git a/apps/web/src/routes/(root)/+page.svelte b/apps/web/src/routes/(root)/+page.svelte index 6865f05d..a3f97508 100644 --- a/apps/web/src/routes/(root)/+page.svelte +++ b/apps/web/src/routes/(root)/+page.svelte @@ -1,16 +1,15 @@ @@ -22,4 +21,69 @@ /> - + + +
+ {#if data.body === undefined} +
+

luzzle pieces

+

export all the pieces

+ {#if data.types.length} +

types

+
+ {#each data.types as type (type)} + {type} + {/each} +
+ {/if} +
+ {:else if data.body} +
+ {/if} + + {#each collections as collection (collection.title)} + {#if collection.pieces.length} +
+

{collection.title}

+
+ {#each collection.pieces as piece (piece.id)} + +
+ + {piece.title} +
+
+ {/each} +
+
+ {/if} + {/each} +
+ + diff --git a/apps/web/src/routes/(root)/page.server.test.ts b/apps/web/src/routes/(root)/page.server.test.ts new file mode 100644 index 00000000..b096fcfb --- /dev/null +++ b/apps/web/src/routes/(root)/page.server.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { load } from './+page.server.js' + +const { config, db, getRecentPieces, getRandomPieces, hydrateWithAssets, loadPageMarkdown } = + vi.hoisted(() => ({ + config: { + pieces: [{ type: 'inventory' }], + pages: { home: { body: undefined as string | undefined, latest: true, random: false } } + }, + db: {}, + getRecentPieces: vi.fn(), + getRandomPieces: vi.fn(), + hydrateWithAssets: vi.fn(), + loadPageMarkdown: vi.fn() + })) +vi.mock('$lib/server/config', () => ({ config })) +vi.mock('$lib/server/database', () => ({ db })) +vi.mock('$lib/server/pieces', () => ({ getRecentPieces, getRandomPieces })) +vi.mock('$lib/pieces/assets.server', () => ({ hydrateWithAssets })) +vi.mock('$lib/server/pages.js', () => ({ loadPageMarkdown })) +const recent = [{ id: 'one', title: 'Latest' }] +const random = [{ id: 'two', title: 'Random' }] + +beforeEach(() => { + vi.clearAllMocks() + config.pages.home = { body: undefined, latest: true, random: false } + getRecentPieces.mockResolvedValue(recent) + getRandomPieces.mockResolvedValue(random) + hydrateWithAssets.mockImplementation(async (pieces) => pieces) + loadPageMarkdown.mockResolvedValue(undefined) +}) +const event = {} as Parameters[0] + +test('stock home uses published latest pieces and does not request random data', async () => { + expect(await load(event)).toEqual({ + types: ['inventory'], + body: undefined, + latest: recent, + random: [] + }) + expect(getRecentPieces).toHaveBeenCalledExactlyOnceWith(db, 5, ['inventory']) + expect(getRandomPieces).not.toHaveBeenCalled() +}) + +test('Markdown body does not replace dynamic lists and random excludes latest IDs', async () => { + config.pages.home.body = 'home.md' + config.pages.home.random = true + loadPageMarkdown.mockResolvedValue('

Welcome

') + expect(await load(event)).toMatchObject({ body: '

Welcome

', latest: recent, random }) + expect(loadPageMarkdown).toHaveBeenCalledExactlyOnceWith('home.md') + expect(getRandomPieces).toHaveBeenCalledExactlyOnceWith(db, 5, ['inventory'], ['one']) +}) + +test('disabled lists skip piece queries and delegate empty collections to hydration', async () => { + config.pages.home.latest = false + expect(await load(event)).toMatchObject({ latest: [], random: [] }) + expect(getRecentPieces).not.toHaveBeenCalled() + expect(getRandomPieces).not.toHaveBeenCalled() + expect(hydrateWithAssets.mock.calls).toEqual([[[]], [[]]]) +}) + +test('random works independently of latest and delegates empty collections to hydration', async () => { + config.pages.home.latest = false + config.pages.home.random = true + getRandomPieces.mockResolvedValue([]) + expect(await load(event)).toMatchObject({ latest: [], random: [] }) + expect(getRandomPieces).toHaveBeenCalledExactlyOnceWith(db, 5, ['inventory'], []) + expect(hydrateWithAssets.mock.calls).toEqual([[[]], [[]]]) +}) diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts index b5e2cdd3..2bfdcb28 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.server.ts @@ -1,11 +1,11 @@ import { getPiecesForFeed } from '$lib/feeds/utils' -import { renderPreparedFeedPage } from '$lib/feeds/page.server.js' +import { loadPageMarkdown } from '$lib/server/pages.js' import { config } from '$lib/server/config' import { error } from '@sveltejs/kit' import { isConfiguredPieceType } from '$lib/pieces/types.server.js' import type { PageServerLoad } from './$types' -export const load: PageServerLoad = async ({ params, url }) => { +export const load: PageServerLoad = async ({ params }) => { const { piece: type } = params if (type && !isConfiguredPieceType(type)) { error(404, 'piece type not found') @@ -17,7 +17,7 @@ export const load: PageServerLoad = async ({ params, url }) => { type: 'piece' as const, label: type || 'all' } - const presentation = await renderPreparedFeedPage({ feed, pieces, url }) + const about = await loadPageMarkdown(config.pages.feed.about) return { pieces, @@ -27,6 +27,7 @@ export const load: PageServerLoad = async ({ params, url }) => { description: type ? `an RSS feed for ${type}` : 'RSS feed for all pieces' }, feed, - presentation + about, + links: config.pages.feed.links } } diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte index ce9ab1c7..199c62fd 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/+page.svelte @@ -13,4 +13,4 @@ /> - + diff --git a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts index d553ac95..42b2d3ab 100644 --- a/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts +++ b/apps/web/src/routes/rss/pieces/[[piece]]/feed.html/page.server.test.ts @@ -1,100 +1,76 @@ import { beforeEach, expect, test, vi } from 'vitest' import { load } from './+page.server' -const { config, getPiecesForFeed, renderPreparedFeedPage } = vi.hoisted(() => ({ +const { config, getPiecesForFeed, loadPageMarkdown } = vi.hoisted(() => ({ config: { pieces: [{ type: 'field-notes' }, { type: 'specimens' }, { type: 'field / notes & café' }], - url: { app: 'https://garden.example/base' } + url: { app: 'https://garden.example/base' }, + pages: { feed: { about: undefined as string | undefined, links: true } } }, getPiecesForFeed: vi.fn(), - renderPreparedFeedPage: vi.fn() + loadPageMarkdown: vi.fn() })) vi.mock('$lib/server/config', () => ({ config })) vi.mock('$lib/feeds/utils', () => ({ getPiecesForFeed })) -vi.mock('$lib/feeds/page.server.js', () => ({ renderPreparedFeedPage })) - +vi.mock('$lib/server/pages.js', () => ({ loadPageMarkdown })) const pieces = [{ id: 'note', type: 'field-notes', title: 'A note', assets: [] }] -const presentation = { - revision: 'feed-revision', - body: '
Prepared feed
', - head: 'Prepared feed', - navBanner: { background: 'var(--color-primary)' } -} function event(type?: string) { - return { - params: { piece: type }, - url: new URL('https://request.example/rss/pieces/feed.html?view=recent'), - parent: vi.fn(() => { - throw new Error('feed config must not depend on parent data') - }) - } as unknown as Parameters[0] + return { params: { piece: type } } as Parameters[0] } - beforeEach(() => { vi.clearAllMocks() config.url.app = 'https://garden.example/base' + config.pages.feed = { about: undefined, links: true } getPiecesForFeed.mockResolvedValue(pieces) - renderPreparedFeedPage.mockResolvedValue(presentation) + loadPageMarkdown.mockResolvedValue(undefined) }) test.each(config.pieces.map(({ type }) => type))( - 'HTML feeds accept runtime-configured %s and retain metadata', + 'HTML feed %s retains metadata without a prepared bundle', async (type) => { - const request = event(type) - const feed = { - url: `https://garden.example/base/rss/pieces/${encodeURIComponent(type)}/feed.xml`, - type: 'piece', - label: type - } - const result = await load(request) + const result = await load(event(type)) expect(result).toEqual({ pieces, type, meta: { title: `RSS feed | ${type}`, description: `an RSS feed for ${type}` }, - feed, - presentation + feed: { + url: `https://garden.example/base/rss/pieces/${encodeURIComponent(type)}/feed.xml`, + type: 'piece', + label: type + }, + about: undefined, + links: true }) expect(getPiecesForFeed).toHaveBeenCalledExactlyOnceWith(type) - expect(renderPreparedFeedPage).toHaveBeenCalledExactlyOnceWith({ - feed, - pieces, - url: request.url - }) - expect(renderPreparedFeedPage.mock.calls[0][0].url).toBe(request.url) - expect(result && result.presentation).toBe(presentation) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) - expect(request.parent).not.toHaveBeenCalled() + expect(loadPageMarkdown).toHaveBeenCalledExactlyOnceWith(undefined) } ) test.each([undefined, ''])('optional type %s retains the all-pieces feed', async (type) => { - const request = event(type) - const feed = { - url: 'https://garden.example/base/rss/pieces/feed.xml', - type: 'piece', - label: 'all' - } - expect(await load(request)).toEqual({ + expect(await load(event(type))).toMatchObject({ pieces, type, meta: { title: 'RSS feed | all', description: 'RSS feed for all pieces' }, - feed, - presentation + feed: { url: 'https://garden.example/base/rss/pieces/feed.xml', type: 'piece', label: 'all' } }) - expect(getPiecesForFeed).toHaveBeenCalledExactlyOnceWith(type) - expect(renderPreparedFeedPage).toHaveBeenCalledExactlyOnceWith({ - feed, - pieces, - url: request.url - }) - expect(request.parent).not.toHaveBeenCalled() }) -test('unknown types fail before collecting pieces or preparing presentation', async () => { +test('about Markdown and subscription controls are independent', async () => { + config.pages.feed = { about: 'feed.md', links: false } + loadPageMarkdown.mockResolvedValue('

About this feed

') + expect(await load(event())).toMatchObject({ + pieces, + about: '

About this feed

', + links: false + }) + expect(loadPageMarkdown).toHaveBeenCalledExactlyOnceWith('feed.md') +}) + +test('unknown types fail before collecting pieces or loading Markdown', async () => { await expect(load(event('unconfigured'))).rejects.toMatchObject({ status: 404 }) expect(getPiecesForFeed).not.toHaveBeenCalled() - expect(renderPreparedFeedPage).not.toHaveBeenCalled() + expect(loadPageMarkdown).not.toHaveBeenCalled() }) test.each(['', '/garden'])( @@ -102,35 +78,17 @@ test.each(['', '/garden'])( async (app) => { config.url.app = app expect(await load(event('field / notes & café'))).toMatchObject({ - feed: { - url: `${app}/rss/pieces/field%20%2F%20notes%20%26%20caf%C3%A9/feed.xml`, - type: 'piece', - label: 'field / notes & café' - } + feed: { url: `${app}/rss/pieces/field%20%2F%20notes%20%26%20caf%C3%A9/feed.xml` } }) } ) -test('returns the helper-owned null presentation without changing feed data', async () => { - renderPreparedFeedPage.mockResolvedValue(null) - expect(await load(event('field-notes'))).toMatchObject({ - pieces, - type: 'field-notes', - presentation: null - }) - expect(renderPreparedFeedPage).toHaveBeenCalledOnce() -}) - -test('query failures propagate before presentation instead of becoming fallback', async () => { +test('query and Markdown errors propagate instead of selecting unrelated fallback', async () => { const failure = new Error('feed query failed') getPiecesForFeed.mockRejectedValueOnce(failure) - await expect(load(event('field-notes'))).rejects.toBe(failure) - expect(renderPreparedFeedPage).not.toHaveBeenCalled() -}) - -test('leaves presentation fallback policy to the helper', async () => { - const failure = new Error('unexpected preparation failure') - renderPreparedFeedPage.mockRejectedValueOnce(failure) - await expect(load(event('field-notes'))).rejects.toBe(failure) - expect(getPiecesForFeed).toHaveBeenCalledExactlyOnceWith('field-notes') + await expect(load(event())).rejects.toBe(failure) + expect(loadPageMarkdown).not.toHaveBeenCalled() + getPiecesForFeed.mockResolvedValue(pieces) + loadPageMarkdown.mockRejectedValueOnce(failure) + await expect(load(event())).rejects.toBe(failure) }) diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts index 0d0c54e0..091e541e 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.server.ts @@ -1,9 +1,9 @@ import { getPiecesForTagFeed } from '$lib/feeds/utils' -import { renderPreparedFeedPage } from '$lib/feeds/page.server.js' +import { loadPageMarkdown } from '$lib/server/pages.js' import { config } from '$lib/server/config' import type { PageServerLoad } from './$types' -export const load: PageServerLoad = async ({ params, url }) => { +export const load: PageServerLoad = async ({ params }) => { const { tag } = params const pieces = await getPiecesForTagFeed(tag) const feed = { @@ -11,7 +11,7 @@ export const load: PageServerLoad = async ({ params, url }) => { type: 'tag' as const, label: tag } - const presentation = await renderPreparedFeedPage({ feed, pieces, url }) + const about = await loadPageMarkdown(config.pages.feed.about) return { pieces, @@ -21,6 +21,7 @@ export const load: PageServerLoad = async ({ params, url }) => { description: `an RSS feed for pieces tagged with ${tag}` }, feed, - presentation + about, + links: config.pages.feed.links } } diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte index 4df0c188..7319aa3f 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/+page.svelte @@ -13,4 +13,4 @@ /> - + diff --git a/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts b/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts index 8df45916..9ff49592 100644 --- a/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts +++ b/apps/web/src/routes/rss/tags/[tag]/feed.html/page.server.test.ts @@ -1,143 +1,80 @@ import { beforeEach, expect, test, vi } from 'vitest' import { load } from './+page.server' -const { config, getPiecesForTagFeed, renderPreparedFeedPage } = vi.hoisted(() => ({ - config: { url: { app: 'https://garden.example/base' } }, +const { config, getPiecesForTagFeed, loadPageMarkdown } = vi.hoisted(() => ({ + config: { + url: { app: 'https://garden.example/base' }, + pages: { feed: { about: undefined as string | undefined, links: true } } + }, getPiecesForTagFeed: vi.fn(), - renderPreparedFeedPage: vi.fn() + loadPageMarkdown: vi.fn() })) vi.mock('$lib/server/config', () => ({ config })) vi.mock('$lib/feeds/utils', () => ({ getPiecesForTagFeed })) -vi.mock('$lib/feeds/page.server.js', () => ({ renderPreparedFeedPage })) - +vi.mock('$lib/server/pages.js', () => ({ loadPageMarkdown })) const pieces = [{ id: 'note', type: 'field-notes', title: 'A note', assets: [] }] -const presentation = { - revision: 'tag-feed-revision', - body: '
Prepared tag feed
', - head: 'Prepared tag feed', - navBanner: { background: 'var(--color-primary)' } -} - function event(tag = 'field-notes') { - return { - params: { tag }, - url: new URL('https://request.example/rss/tags/field-notes/feed.html?view=recent'), - parent: vi.fn(() => { - throw new Error('feed config must not depend on parent data') - }) - } as unknown as Parameters[0] + return { params: { tag } } as Parameters[0] } - beforeEach(() => { vi.clearAllMocks() config.url.app = 'https://garden.example/base' + config.pages.feed = { about: undefined, links: true } getPiecesForTagFeed.mockResolvedValue(pieces) - renderPreparedFeedPage.mockResolvedValue(presentation) + loadPageMarkdown.mockResolvedValue(undefined) }) -test('retains tag data and metadata and forwards the actual request context', async () => { - const request = event() - const feed = { - url: 'https://garden.example/base/rss/tags/field-notes/feed.xml', - type: 'tag', - label: 'field-notes' - } - const result = await load(request) - expect(result).toEqual({ +test('retains tag data and metadata without selecting a prepared bundle', async () => { + expect(await load(event())).toEqual({ pieces, tag: 'field-notes', meta: { title: 'RSS feed | tag: field-notes', description: 'an RSS feed for pieces tagged with field-notes' }, - feed, - presentation + feed: { + url: 'https://garden.example/base/rss/tags/field-notes/feed.xml', + type: 'tag', + label: 'field-notes' + }, + about: undefined, + links: true }) expect(getPiecesForTagFeed).toHaveBeenCalledExactlyOnceWith('field-notes') - expect(renderPreparedFeedPage).toHaveBeenCalledExactlyOnceWith({ - feed, - pieces, - url: request.url - }) - expect(renderPreparedFeedPage.mock.calls[0][0].url).toBe(request.url) - expect(result && result.presentation).toBe(presentation) - expect(JSON.parse(JSON.stringify(result))).toEqual(result) - expect(request.parent).not.toHaveBeenCalled() + expect(loadPageMarkdown).toHaveBeenCalledExactlyOnceWith(undefined) }) -test.each(['', 'unknown-tag'])( - 'tag %j with no matching pieces still returns a prepared empty feed', - async (tag) => { - getPiecesForTagFeed.mockResolvedValue([]) - const request = event(tag) - const feed = { - url: `https://garden.example/base/rss/tags/${tag}/feed.xml`, - type: 'tag', - label: tag - } - expect(await load(request)).toEqual({ - pieces: [], - tag, - meta: { - title: `RSS feed | tag: ${tag}`, - description: `an RSS feed for pieces tagged with ${tag}` - }, - feed, - presentation - }) - expect(getPiecesForTagFeed).toHaveBeenCalledExactlyOnceWith(tag) - expect(renderPreparedFeedPage).toHaveBeenCalledExactlyOnceWith({ - feed, - pieces: [], - url: request.url - }) - } -) +test.each(['', 'unknown-tag'])('tag %j with no records keeps a useful empty feed', async (tag) => { + getPiecesForTagFeed.mockResolvedValue([]) + expect(await load(event(tag))).toMatchObject({ + pieces: [], + tag, + feed: { url: `https://garden.example/base/rss/tags/${tag}/feed.xml`, type: 'tag', label: tag } + }) +}) test.each(['https://garden.example/base', '', '/garden'])( - 'preserves configured app base %j and encodes only the tag URL segment', + 'preserves app base %j and encodes tag segments', async (app) => { config.url.app = app - const tag = 'field / notes & café?#%' - const request = event(tag) - const feed = { - url: `${app}/rss/tags/field%20%2F%20notes%20%26%20caf%C3%A9%3F%23%25/feed.xml`, - type: 'tag', - label: tag - } - expect(await load(request)).toMatchObject({ - tag, - feed, - meta: { - title: `RSS feed | tag: ${tag}`, - description: `an RSS feed for pieces tagged with ${tag}` - } - }) - expect(getPiecesForTagFeed).toHaveBeenCalledExactlyOnceWith(tag) - expect(renderPreparedFeedPage).toHaveBeenCalledExactlyOnceWith({ - feed, - pieces, - url: request.url + expect(await load(event('field / notes & café?#%'))).toMatchObject({ + feed: { url: `${app}/rss/tags/field%20%2F%20notes%20%26%20caf%C3%A9%3F%23%25/feed.xml` } }) } ) -test('returns the helper-owned null presentation without changing feed data', async () => { - renderPreparedFeedPage.mockResolvedValue(null) - expect(await load(event())).toMatchObject({ pieces, tag: 'field-notes', presentation: null }) - expect(renderPreparedFeedPage).toHaveBeenCalledOnce() +test('the about card can be enabled while subscription controls are hidden', async () => { + config.pages.feed = { about: 'feed.md', links: false } + loadPageMarkdown.mockResolvedValue('

About

') + expect(await load(event())).toMatchObject({ about: '

About

', links: false, pieces }) }) -test('query failures propagate before presentation instead of becoming fallback', async () => { - const failure = new Error('tag feed query failed') +test('query and Markdown failures propagate rather than displaying fallback output', async () => { + const failure = new Error('tag query failed') getPiecesForTagFeed.mockRejectedValueOnce(failure) await expect(load(event())).rejects.toBe(failure) - expect(renderPreparedFeedPage).not.toHaveBeenCalled() -}) - -test('leaves presentation fallback policy to the helper', async () => { - const failure = new Error('unexpected preparation failure') - renderPreparedFeedPage.mockRejectedValueOnce(failure) + expect(loadPageMarkdown).not.toHaveBeenCalled() + getPiecesForTagFeed.mockResolvedValue(pieces) + loadPageMarkdown.mockRejectedValueOnce(failure) await expect(load(event())).rejects.toBe(failure) - expect(getPiecesForTagFeed).toHaveBeenCalledExactlyOnceWith('field-notes') }) diff --git a/apps/web/test/templates/routes.test.ts b/apps/web/test/templates/routes.test.ts index 071a727d..e715e762 100644 --- a/apps/web/test/templates/routes.test.ts +++ b/apps/web/test/templates/routes.test.ts @@ -50,10 +50,9 @@ const feedCases = [ count: 0 } ] as const + type FeedCase = (typeof feedCases)[number] type MachineFeeds = Record -let originalMachineFeeds: MachineFeeds -let originalPublicPiece: unknown let directory: string let configFilename: string let preparedDirectory: string @@ -61,6 +60,8 @@ let origin: string let server: ChildProcess | undefined let serverErrors = '' let originalBuild: Record +let originalMachineFeeds: MachineFeeds +let originalPublicPiece: unknown let childEnv: NodeJS.ProcessEnv const startServer = ` @@ -71,7 +72,6 @@ if (!server.server.listening) await once(server.server, 'listening'); process.send({ port: server.server.address().port }); ` -// Seed through the real package in native Node, not Vitest's module/native SQLite environment. const seedDatabase = ` import { pathToFileURL } from 'node:url'; const { createAppDb, runWebMigrations } = await import(pathToFileURL(process.argv[1]).href); @@ -92,29 +92,16 @@ try { { ...row, id: 'orphan-id', key: 'orphan-key', type: 'unconfigured-kind', file_path: 'orphan.unconfigured-kind.md', title: 'Orphan must not be visible' } ]).execute(); - await db.insertInto('web_pieces_tags').values({ - piece_type: row.type, piece_slug: row.slug, piece_id: row.id, - tag: 'Field Notes', slug: 'field-notes' - }).execute(); - const asset = { - piece_file_path: row.file_path, piece_key: row.key, asset_key: 'cover-key', - piece_asset_path: 'cover.png', is_embedded: 1 - }; + await db.insertInto('web_pieces_tags').values({ piece_type: row.type, piece_slug: row.slug, piece_id: row.id, tag: 'Field Notes', slug: 'field-notes' }).execute(); + const asset = { piece_file_path: row.file_path, piece_key: row.key, asset_key: 'cover-key', piece_asset_path: 'cover.png', is_embedded: 1 }; await db.insertInto('web_pieces_assets').values([ - { ...asset, transformation: 'image.s.jpg', asset_path: 'cutover/cover small.png', - mime_type: 'image/png', is_embedded: 0 }, - { ...asset, transformation: 'image.s.png', asset_path: 'cutover/cover small.png', - mime_type: 'image/png', is_embedded: 0 }, - { ...asset, asset_key: row.key, transformation: 'opengraph', asset_path: 'cutover/share.png', - mime_type: 'image/png', is_embedded: 0 }, - { ...asset, transformation: 'palette', mime_type: 'application/json', - content: JSON.stringify({ accent: '#123456', background: '#234567' }) }, - { ...asset, asset_key: row.key, piece_asset_path: row.file_path, - transformation: 'markdown', mime_type: 'text/html', content: '

Published rendered note

' } + { ...asset, transformation: 'image.s.jpg', asset_path: 'cutover/cover small.png', mime_type: 'image/png', is_embedded: 0 }, + { ...asset, transformation: 'image.s.png', asset_path: 'cutover/cover small.png', mime_type: 'image/png', is_embedded: 0 }, + { ...asset, asset_key: row.key, transformation: 'opengraph', asset_path: 'cutover/share.png', mime_type: 'image/png', is_embedded: 0 }, + { ...asset, transformation: 'palette', mime_type: 'application/json', content: JSON.stringify({ accent: '#123456', background: '#234567' }) }, + { ...asset, asset_key: row.key, piece_asset_path: row.file_path, transformation: 'markdown', mime_type: 'text/html', content: '

Published rendered note

' } ]).execute(); -} finally { - await db.destroy(); -} +} finally { await db.destroy(); } ` async function readOptional(filename: string) { @@ -129,16 +116,13 @@ async function readOptional(filename: string) { async function buildBlankApp() { const filenames = ['config.yaml', '.env'].map((name) => path.join(webRoot, name)) const originals = await Promise.all(filenames.map(readOptional)) - // Never temporarily replace an owner's configuration, even if we could restore it later. for (const [index, bytes] of originals.entries()) { - if (bytes?.toString('utf8').trim()) { + if (bytes?.toString('utf8').trim()) throw new Error( - `Real-app route tests require absent or blank ${filenames[index]}; refusing to overwrite owner configuration. Provide a blank CI checkout.` + `Real-app route tests require absent or blank ${filenames[index]}; refusing to overwrite owner configuration.` ) - } } try { - // No owner source or configured type exists at build time. await writeFile(filenames[0], JSON.stringify({ pieces: [] })) await writeFile(filenames[1], '') const { stdout, stderr } = await exec('pnpm', ['run', 'build'], { @@ -178,13 +162,11 @@ async function request(pathname = piecePath, accept = 'text/html') { signal: AbortSignal.timeout(10000) }) } - async function pageHtml(pathname = piecePath) { const response = await request(pathname) expect(response.status).toBe(200) return response.text() } - function htmlHead(html: string) { const head = html.match(/([\s\S]*?)<\/head>/)?.[1] if (head === undefined) throw new Error('Expected the real app HTML head') @@ -199,89 +181,49 @@ function expectFeedSeo(html: string, feed: FeedCase) { expect(head).toContain(`name="description" content="${feed.description}"`) expect(head.match(/property="og:title"/g)).toHaveLength(1) expect(head).toContain(`property="og:title" content="${feed.title}"`) - const themeScripts = (head.match(/]*>[\s\S]*?<\/script>/g) ?? []).filter((script) => - script.includes('window.localStorage.getItem') + const preferenceScripts = (head.match(/]*>[\s\S]*?<\/script>/g) ?? []).filter( + (script) => script.includes('window.localStorage.getItem') ) - expect(themeScripts).toHaveLength(1) - expect(themeScripts[0]).toContain('data-theme-preference') - expect(themeScripts[0]).toContain('prefers-color-scheme: dark') + expect(preferenceScripts).toHaveLength(1) + expect(preferenceScripts[0]).toContain('data-theme-preference') + expect(preferenceScripts[0]).toContain('prefers-color-scheme: dark') const discovery = head.match(/]*type="application\/rss\+xml"[^>]*>/g) ?? [] expect(discovery).toHaveLength(1) - expect(discovery[0]).toContain('rel="alternate"') expect(discovery[0]).toContain(`title="${feed.discoveryTitle}"`) expect(discovery[0]).toContain(`href="${feed.folder}/feed.xml"`) } -function expectFeedBanner(html: string, before: string) { - expect(html.match(/aria-label="Global"/g)).toHaveLength(1) - for (const label of ['main page', 'search', 'change theme', 'random']) { - expect(html.match(new RegExp(`aria-label="${label}"`, 'g')), label).toHaveLength(1) - } - expect(html).toContain('href="/random"') - expect(html).toMatch(/]*class="banner svelte-[a-z0-9]+"/) - expect(html.indexOf('aria-label="Global"')).toBeLessThan(html.indexOf(before)) - return html.match(/]*aria-label="Global"[^>]*>/)![0] -} - -function expectDefaultFeed(html: string, feed: FeedCase) { +function expectNativeFeed(html: string, feed: FeedCase, about: boolean, links: boolean) { expectFeedSeo(html, feed) - expectFeedBanner(html, 'id="default-feed"') + expect(html.match(/aria-label="Global"/g)).toHaveLength(1) + for (const label of ['main page', 'search', 'change theme', 'random']) + expect(html).toContain(`aria-label="${label}"`) expect(html).toContain('id="default-feed"') - const scopedClass = html.match(/class="content-wrapper (svelte-[a-z0-9]+)"/)?.[1] - expect(scopedClass).toBeDefined() - for (const selector of ['content-wrapper', 'item-card', 'item-date', 'item-note']) { - expect(htmlHead(html)).toContain(`.${selector}.${scopedClass}`) - } - expect(html).toMatch(/class="pieces-list svelte-[a-z0-9]+"/) expect(html.match(/class="item-card\b/g) ?? []).toHaveLength(feed.count) const title = feed.type === 'tag' ? `tag: ${feed.label}` : feed.label expect(html).toMatch(new RegExp(`]*>RSS feed for ${title}`)) + expect(html.includes('aria-label="About this feed"')).toBe(about) + expect(html.includes('aria-label="RSS subscription"')).toBe(links) + if (links) { + for (const [format, label] of [ + ['xml', 'XML'], + ['json', 'JSON'], + ['md', 'Markdown'] + ]) + expect(html).toContain(`href="feed.${format}">${label}`) + expect(html).toContain('aria-label="RSS feed URL"') + expect(html).toContain('aria-describedby="rss-instructions"') + expect(html).toMatch(/\breadonly(?:\s|=|>)/) + expect(html).toContain(`value="${feed.folder}/feed.xml"`) + expect(html).toContain('copy') + } else expect(html).not.toContain('aria-label="RSS feed URL"') if (feed.count) { expect(html).toContain(`href="${piecePath}" class="item-card`) - expect(html).toMatch(/class="item-title [^"]*">Real Piece<\/span>/) + expect(html).toContain('Raw indexed note') expect(html).toContain('Nov 14, 2023') - expect(html).toMatch(/class="item-note [^"]*">Raw indexed note<\/div>/) - } else { - expect(html).not.toContain('Real Piece') } - // Unconfigured rows intentionally remain in the all-pieces feed, unlike /pieces listings. expect(html.includes('Orphan must not be visible')).toBe(feed.label === 'all') - for (const marker of [ - 'aria-label="RSS subscription info"', - 'id="rss-instructions"', - 'copy-container', - 'rss-url-input', - 'data-demo-feed', - 'id="cutover-feed"', - 'cutover-feed-revision', - '.cutover-feed.', - '.cutover-icon.', - 'data-prepared-theme', - '--banner-background-color: #123456', - 'feed-clone-corrupted', - directory - ]) { - expect(html).not.toContain(marker) - } -} - -function expectOwnerFeed(html: string, feed: FeedCase, label: string) { - expectFeedSeo(html, feed) - expect(html).toContain(`

${label}

`) - expect(html).toContain(`id="feed-reference" href="${feed.folder}/feed.xml"`) - expect(html).toContain(`

${feed.type}

`) - expect(html).toContain(`

${feed.label}

`) - expect(html).toContain(`

${feed.count}

`) - expect(html.match(/data-feed-piece="/g) ?? []).toHaveLength(feed.count) - expect(html.includes('Orphan must not be visible')).toBe(feed.label === 'all') - if (feed.count) { - expect(html).toContain('class="default-icon') - expect(html).toContain('Published metadata') - expect(html).toContain('

Published rendered note

') - expect(html).toContain(`href="${origin}${piecePath}"`) - expect(htmlHead(html)).toContain('.default-icon.') - } - expect(html).not.toContain('aria-label="RSS subscription info"') + expect(html).not.toContain('data-prepared-theme') expect(html).not.toContain('/admin/preview/') expect(html).not.toContain(directory) } @@ -300,7 +242,6 @@ async function machineFeedSnapshot(): Promise { expect(response.headers.get('content-type')).toContain(contentType) const body = await response.text() expect(body).toContain('Cutover Garden') - expect(body).not.toContain('cutover-feed') expect(body.includes('Real Piece')).toBe(feed.count > 0) expect(body.includes('Orphan must not be visible')).toBe(feed.label === 'all') if (feed.count) { @@ -314,15 +255,8 @@ async function machineFeedSnapshot(): Promise { feed.type === 'tag' ? `Cutover Garden | tag: ${feed.label}` : 'Cutover Garden' ) expect(json.language).toBe('en') - // feedsmith omits the items field for an empty feed; preserve that behavior too. expect(json.items ?? []).toHaveLength(feed.count) - if (feed.count) { - expect(json.items).toContainEqual( - expect.objectContaining({ id: 'cutover-id', title: 'Real Piece', url: piecePath }) - ) - } } - // XML/Markdown build timestamps are request-time metadata, not a byte-stability claim. snapshot[pathname] = { contentType: response.headers.get('content-type'), body: body @@ -337,89 +271,32 @@ async function machineFeedSnapshot(): Promise { return snapshot } -function expectAppSeo(html: string) { - const head = htmlHead(html) - expect(head.match(//g)).toHaveLength(1) - expect(head).toContain('<title>Real Piece | Cutover Garden') - expect(head.match(/name="description"/g)).toHaveLength(1) - expect(head).toContain('name="description" content="Published route description"') - expect(head.match(/property="og:title"/g)).toHaveLength(1) - expect(head).toContain('property="og:image" content="/pieces/assets/cutover/share.png"') -} - function expectBuiltIn(html: string) { expect(html).toMatch(/class="hero svelte-[a-z0-9]+"/) expect(html).toContain('class="poster') expect(html).toMatch(/]*)?>Real Piece<\/h1>/) expect(html).toContain('

Published rendered note

') expect(html.match(/aria-label="Global"/g)).toHaveLength(1) - expect(html).toContain('href="/random"') - expect(html).not.toContain('id="cutover-owner"') - expect(html).not.toContain('data-prepared-theme') expect(html).toContain('--banner-background-color: #234567') + expect(html).not.toContain('data-prepared-theme') expect(html).not.toContain(directory) - expectAppSeo(html) + const head = htmlHead(html) + expect(head.match(//g)).toHaveLength(1) + expect(head).toContain('<title>Real Piece | Cutover Garden') + expect(head).toContain('name="description" content="Published route description"') + expect(head).toContain('property="og:image" content="/pieces/assets/cutover/share.png"') } -function ownerFeedSource( - label: string, - navigation = "helpers.showNavBanner({ background: '#123456', color: '#456789', hoverColor: '#56789a' });", - cssColor = '#abcdef' -) { - return ` - -
-

${label}

-{data.feed.url} -

{data.feed.type}

-

{data.feed.label}

-

{data.pieces.length}

-{#each data.pieces as piece (piece.id)} -
-

{piece.title}

- -{#if piece.id === 'cutover-id'} -

{piece.metadata.subtitle}

-Cover -Site mark -{@html helpers.forPiece(piece).getAssetContent(piece.key, 'markdown') ?? ''} -{/if} -
-{/each} -
-` -} - -type PublishOptions = { - feed?: string | null - themePrimary?: string -} - -async function publishSource(options: PublishOptions = {}) { - await writeFile( - path.join(directory, 'owner-feed.svelte'), - options.feed ?? ownerFeedSource('Companion feed') - ) - // The server started without a feed override and has cached its config. - // Only the standard worker publisher rereads this; lookup selection needs no restart. - const config = JSON.parse(await readFile(configFilename, 'utf8')) - config.content.component = options.feed === null ? {} : { feed: './owner-feed.svelte' } - config.theme.light['color-primary'] = options.themePrimary ?? '#135790' - await writeFile(configFilename, JSON.stringify(config)) - const { stdout } = await exec( - process.execPath, - [ - path.join(repository, 'packages/web/worker/dist/src/commands/publish-templates.js'), - '--config', - configFilename - ], - { cwd: directory, env: childEnv, timeout: 30000 } - ) - return JSON.parse(stdout) as { revision: string } +async function stopBuiltApp() { + if (!server || server.exitCode !== null || server.signalCode !== null) return + const exited = once(server, 'exit') + server.kill('SIGTERM') + const timeout = setTimeout(() => server?.kill('SIGKILL'), 5000) + try { + await exited + } finally { + clearTimeout(timeout) + } } async function startBuiltApp(): Promise { @@ -473,11 +350,18 @@ async function startBuiltApp(): Promise { return `http://127.0.0.1:${port}` } +async function restartWithPages(pages: object) { + await stopBuiltApp() + const config = JSON.parse(await readFile(configFilename, 'utf8')) + config.pages = pages + await writeFile(configFilename, JSON.stringify(config)) + origin = await startBuiltApp() +} + beforeAll(async () => { const cache = path.join(webRoot, 'node_modules/.cache') await mkdir(cache, { recursive: true }) - directory = await mkdtemp(path.join(cache, 'template-routes-')) - // Keep package-manager reads/writes inside the ignored test directory, not ~/.npmrc. + directory = await mkdtemp(path.join(cache, 'site-routes-')) const npmUserConfig = path.join(directory, 'user.npmrc') const npmGlobalConfig = path.join(directory, 'global.npmrc') await Promise.all([writeFile(npmUserConfig, ''), writeFile(npmGlobalConfig, '')]) @@ -493,9 +377,6 @@ beforeAll(async () => { } await buildBlankApp() originalBuild = await buildHashes() - expect(Object.keys(originalBuild).length).toBeGreaterThan(0) - - // The stock catalog handles an arbitrary type configured only after build. configFilename = path.join(directory, 'config.yaml') preparedDirectory = path.join(directory, 'prepared') await writeFile( @@ -507,10 +388,7 @@ beforeAll(async () => { prepared: './prepared', static: path.join(directory, 'owner-static') }, - content: { - text: { title: 'Cutover Garden' }, - component: {} - }, + content: { text: { title: 'Cutover Garden' }, component: {} }, pieces: [ { type: 'cutover-kind', @@ -544,11 +422,7 @@ beforeAll(async () => { theme: { light: { 'color-primary': '#135790' } } }) ) - await mkdir(path.join(directory, 'owner-static/images'), { recursive: true }) - await writeFile( - path.join(directory, 'owner-static/images/owner-only.svg'), - '' - ) + await mkdir(path.join(directory, 'sections'), { recursive: true }) await mkdir(path.join(directory, 'assets/pieces/cutover'), { recursive: true }) await writeFile( path.join(directory, 'assets/pieces/cutover/cover small.png'), @@ -570,60 +444,43 @@ beforeAll(async () => { afterAll(async () => { try { - if (server && server.exitCode === null && server.signalCode === null) { - const exited = once(server, 'exit') - server.kill('SIGTERM') - const timeout = setTimeout(() => server?.kill('SIGKILL'), 5000) - try { - await exited - } finally { - clearTimeout(timeout) - } - } + await stopBuiltApp() } finally { if (directory) await rm(directory, { recursive: true, force: true }) } }) -describe('stock pieces and published site feeds through production HTTP routes', () => { - test('uses the real default page and public catalog for types configured only after build', async () => { +describe('stock pieces and configured site sections through production HTTP', () => { + test('uses runtime-configured piece types and the native stock page after a blank build', async () => { const html = await pageHtml() expectBuiltIn(html) expect(html).toContain('pieceTypes:["cutover-kind"]') }) - test('keeps built-in piece/tag/empty feed subscriptions and machine formats before publication', async () => { - for (const feed of feedCases) { - expectDefaultFeed(await pageHtml(`${feed.folder}/feed.html`), feed) - } + test('omitted feed.about hides the card while subscription and machine feeds remain available', async () => { + for (const feed of feedCases) + expectNativeFeed(await pageHtml(`${feed.folder}/feed.html`), feed, false, true) originalMachineFeeds = await machineFeedSnapshot() }) - test('serves runtime-filtered listings and typed/untyped feeds without a compiled type matcher', async () => { + test('serves typed/untyped listings and rejects unknown types and feed formats', async () => { for (const pathname of [ '/pieces/cutover-kind', '/rss/pieces/cutover-kind/feed.html', '/rss/pieces/cutover-kind/feed.md' ]) { - const response = await request(pathname) - expect(response.status, pathname).toBe(200) - const html = await response.text() + const html = await pageHtml(pathname) expect(html).toContain('Real Piece') expect(html).not.toContain('Orphan must not be visible') } - for (const pathname of ['/pieces', '/rss/pieces/feed.html', '/rss/pieces/feed.md']) { - const response = await request(pathname) - expect(response.status, pathname).toBe(200) - expect(await response.text()).toContain('Real Piece') - } - }) - - test('keeps unknown type filters and rejected feed formats out of listing/feed handlers', async () => { + for (const pathname of ['/pieces', '/rss/pieces/feed.html', '/rss/pieces/feed.md']) + expect(await pageHtml(pathname)).toContain('Real Piece') for (const pathname of [ '/pieces/unconfigured-kind', '/rss/pieces/unconfigured-kind/feed.html', '/rss/pieces/unconfigured-kind/feed.md', - '/rss/pieces/cutover-kind/feed.invalid' + '/rss/pieces/cutover-kind/feed.invalid', + '/pieces/cutover-kind/missing' ]) { const response = await request(pathname) expect(response.status, pathname).toBe(404) @@ -631,7 +488,7 @@ describe('stock pieces and published site feeds through production HTTP routes', } }) - test('uses the runtime type authority for API filters while preserving the invalid-type status', async () => { + test('keeps API template data public and independent of site section settings', async () => { const response = await request('/api/pieces?type=cutover-kind', 'application/json') expect(response.status).toBe(200) const result = await response.json() @@ -640,75 +497,39 @@ describe('stock pieces and published site feeds through production HTTP routes', expect(result.pieces[0]).toMatchObject({ title: 'Real Piece', type: 'cutover-kind', - note: 'Raw indexed note', - metadata: { artwork: 'cover-key', subtitle: 'Published metadata' }, + metadata: { artwork: 'cover-key' }, templates: { icon: { name: 'default', fields: { image: 'cover-key' } }, - page: { - name: 'default', - fields: { - image: 'cover-key', - dateReleased: '2001-09-09', - runtime: 123, - category: 'Native catalog', - url: 'https://example.com/real-piece', - isbn: '9780000000001', - people: [{ name: 'Ada Example' }] - } - }, - opengraph: { - name: 'default', - fields: { - image: 'cover-key', - director: 'Ada Example', - dateReleased: '2001-09-09', - runtime: 123 - } - } + page: { name: 'default', fields: { runtime: 123, people: [{ name: 'Ada Example' }] } }, + opengraph: { name: 'default', fields: { director: 'Ada Example', runtime: 123 } } } }) - for (const template of Object.values(result.pieces[0].templates)) { + for (const template of Object.values(result.pieces[0].templates)) expect(Object.keys(template as object).sort()).toEqual(['fields', 'name']) - } - for (const key of ['components', 'file_path', 'json_metadata']) { + for (const key of ['components', 'file_path', 'json_metadata']) expect(result.pieces[0]).not.toHaveProperty(key) - } const invalid = await request('/api/pieces?type=unconfigured-kind', 'application/json') expect(invalid.status).toBe(400) expect(await invalid.text()).toBe('invalid type') }) - test('404s an unknown type even with a matching orphan row, and a missing slug', async () => { - for (const pathname of [ - '/pieces/unconfigured-kind/real-piece', - '/pieces/cutover-kind/missing' - ]) { - const response = await request(pathname) - expect(response.status).toBe(404) - expect(await response.text()).not.toContain('Orphan must not be visible') - } - }) - - test('renders mapped stock page fields, poster assets, tags and palette navigation as useful SSR', async () => { + test('keeps mapped page data, assets, tags, navigation and SEO useful in native SSR', async () => { const html = await pageHtml() expectBuiltIn(html) - for (const text of ['Native catalog', 'Ada Example', '2001', '123 min']) { + for (const text of ['Native catalog', 'Ada Example', '2001', '123 min']) expect(html).toContain(text) - } expect(html).toContain('href="https://example.com/real-piece"') expect(html).toContain('href="/tags/field-notes"') expect(html).toContain('src="/pieces/assets/cutover/cover%20small.png"') - expect(html).not.toContain('/admin/preview/') - expect(html).not.toContain('Raw indexed note { + test('rejects retired piece source configuration rather than reading arbitrary Svelte', async () => { const original = await readFile(configFilename, 'utf8') for (const role of ['icon', 'page', 'opengraph']) { try { @@ -731,13 +552,9 @@ describe('stock pieces and published site feeds through production HTTP routes', await writeFile(configFilename, original) } } - expectBuiltIn(await pageHtml()) }) - // Anonymous hook-boundary checks, not authenticated preview rendering coverage. - // Existing thrown redirects omit event.setHeaders; JSON denials retain no-store. - // Redirect header hardening is separate from this rendering cutover. - test('preserves anonymous redirects and private JSON denials for admin page routes', async () => { + test('preserves anonymous admin redirects and private JSON denials', async () => { for (const pathname of [ '/admin/piece/real-piece.cutover-kind.md/published/page', '/admin/piece/real-piece.cutover-kind.md/preview/selected-job/page', @@ -747,254 +564,120 @@ describe('stock pieces and published site feeds through production HTTP routes', const browser = await request(pathname) expect(browser.status).toBe(302) expect(browser.headers.get('location')).toBe('/') - expect(await browser.text()).not.toContain('cutover-owner') const api = await request(pathname, 'application/json') expect(api.status).toBe(403) expect(api.headers.get('cache-control')).toBe('private, no-store') - expect(await api.text()).not.toContain('cutover-owner') } }) - test('serves the actual demo feed through native HTTP as rich SSR only for typed and tag feeds', async () => { - await publishSource({ - feed: await readFile(path.join(webRoot, 'demo/content/components/pages/feed.svelte'), 'utf8') - }) - // Subscription is ordinary HTML: retain the URL field without an inactive copy button. - for (const feed of [feedCases[0], feedCases[2]]) { - const html = await pageHtml(`${feed.folder}/feed.html`) - expectFeedSeo(html, feed) - expectFeedBanner(html, 'data-demo-feed') - expect(html).toContain('data-demo-feed') - expect(html).not.toContain('id="default-feed"') - expect(html).toContain('aria-label="RSS subscription info"') - expect(html).toContain('Select the link and copy it into an RSS reader') - const title = feed.type === 'tag' ? `tag: ${feed.label}` : feed.label - expect(html).toMatch(new RegExp(`]*>RSS feed for ${title}`)) - for (const [format, label] of [ - ['xml', 'XML'], - ['json', 'JSON'], - ['md', 'Markdown'] - ]) { - expect(html).toContain(`href="feed.${format}">${label}`) - } - const subscription = html.match(/
([\s\S]*?)<\/div>/)?.[1] - expect(subscription).toBeDefined() - expect(subscription).toContain('id="rss-url-input"') - expect(subscription).toContain('aria-label="RSS feed URL"') - expect(subscription).toContain('aria-describedby="rss-instructions"') - expect(subscription).toMatch(/\breadonly(?:\s|=|>)/) - expect(subscription).toContain(`value="${feed.folder}/feed.xml"`) - expect(subscription).not.toContain(' { + const html = await pageHtml('/') + expect(html).toMatch(/]*>luzzle pieces<\/h1>/) + expect(html).toContain('class="type-link"') + expect(html).toMatch(/]*>latest<\/h2>/) + expect(html).not.toMatch(/]*>random<\/h2>/) + expect(html).toContain('Real Piece') + expect(html).not.toContain('Orphan must not be visible') }) - test('publishes the canonical owner feed late and refreshes references across piece/tag requests', async () => { - const published = await publishSource({ - feed: ownerFeedSource('First owner feed') - }) - // Separate HTTP navigations exercise new server props, not browser hydration behavior. - for (const feed of [feedCases[0], feedCases[2], feedCases[1], feedCases[3], feedCases[0]]) { - const html = await pageHtml(`${feed.folder}/feed.html`) - expectOwnerFeed(html, feed, 'First owner feed') - const banner = expectFeedBanner(html, 'id="cutover-feed"') - expect(banner).toContain('--banner-background-color: #123456') - expect(banner).toContain('--banner-text-color: #456789') - expect(banner).toContain('--banner-hover-color: #56789a') - expect(html).toContain(published.revision) - const head = htmlHead(html) - expect(head).toContain('name="cutover-feed-revision" content="First owner feed"') - const scopedClass = html.match(/class="cutover-feed (svelte-[a-z0-9]+)"/)?.[1] - expect(scopedClass).toBeDefined() - expect(head).toContain(`.cutover-feed.${scopedClass}`) - expect(head).toContain('#abcdef') - expect(head).toContain( - await readFile( - path.join(preparedDirectory, 'revisions', published.revision, 'theme.css'), - 'utf8' - ) - ) - if (feed.count) { - expect(html).toContain('src="/pieces/assets/cutover/cover%20small.png"') - expect(html).toContain('src="/images/favicon.png"') - } + test.each([ + { label: 'latest only', latest: true, random: false, headings: ['latest'] }, + { label: 'random only', latest: false, random: true, headings: ['random'] }, + { label: 'both lists disabled', latest: false, random: false, headings: [] }, + { + label: 'latest consumes the only eligible piece, leaving random empty', + latest: true, + random: true, + headings: ['latest'] } - for (const pathname of ['/images/favicon.png', '/pieces/assets/cutover/cover%20small.png']) { - const response = await request(pathname) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toContain('image/png') - expect(Buffer.from(await response.arrayBuffer())).toEqual( - await readFile(path.join(webRoot, 'static/images/favicon.png')) - ) - } - }) - - test('edits owner feed CSS and theme as one new selected revision without rebuilding', async () => { - const previous = JSON.parse( - await readFile(path.join(preparedDirectory, 'current.json'), 'utf8') - ) - const published = await publishSource({ - feed: ownerFeedSource('Edited owner feed', 'helpers.showNavBanner();', '#fedcba'), - themePrimary: '#246801' - }) - expect(published.revision).not.toBe(previous.revision) - for (const feed of [feedCases[0], feedCases[2]]) { - const html = await pageHtml(`${feed.folder}/feed.html`) - expectOwnerFeed(html, feed, 'Edited owner feed') - expectFeedBanner(html, 'id="cutover-feed"') - expect(html).toContain(published.revision) - const head = htmlHead(html) - expect(head).toContain('content="Edited owner feed"') - expect(head).toContain('#fedcba') - expect(head).toContain('--color-primary: #246801;') - expect(head).toContain( - await readFile( - path.join(preparedDirectory, 'revisions', published.revision, 'theme.css'), - 'utf8' - ) - ) - expect(head).not.toContain('First owner feed') - expect(head).not.toContain('#abcdef') - expect(head).not.toContain('--color-primary: #135790;') - } - }) - - test('falls back as a whole feed for a missing role or selected bundle', async () => { - await publishSource({ feed: null }) - for (const feed of [feedCases[0], feedCases[2]]) { - expectDefaultFeed(await pageHtml(`${feed.folder}/feed.html`), feed) - } - const published = await publishSource({ - feed: ownerFeedSource('Missing bundle feed') - }) - await rm(path.join(preparedDirectory, 'revisions', published.revision, 'templates.mjs')) - for (const feed of [feedCases[0], feedCases[2]]) { - expectDefaultFeed(await pageHtml(`${feed.folder}/feed.html`), feed) - } - }) - - test('isolates the entire feed data clone before a throwing owner render falls back', async () => { - await publishSource({ - feed: ownerFeedSource( - 'Never visible feed', - ` -helpers.showNavBanner({ background: '#123456' }); -data.feed.url = '/feed-clone-corrupted.xml'; -data.feed.label = 'feed-clone-corrupted'; -data.feed.type = 'feed-clone-corrupted'; -data.pieces[0].title = 'feed-clone-corrupted'; -data.pieces[0].note = 'feed-clone-corrupted'; -data.pieces[0].metadata.subtitle = 'feed-clone-corrupted'; -data.pieces[0].assets[0].content = 'feed-clone-corrupted'; -data.pieces.splice(0); -throw new Error('cutover feed render failure');` - ) - }) - for (const feed of [feedCases[0], feedCases[2]]) { - const html = await pageHtml(`${feed.folder}/feed.html`) - expectDefaultFeed(html, feed) - expect(html).toContain('Published metadata') - expect(html).not.toContain('Never visible feed') - expect(html).not.toContain('cutover feed render failure') - } - expect(serverErrors).toContain('cutover feed render failure') - }) - - test('discards owner output when a selected site bundle has incompatible exports', async () => { - const published = await publishSource({ feed: ownerFeedSource('Invalid module feed') }) + ])('home: $label', async ({ latest, random, headings }) => { await writeFile( - path.join(preparedDirectory, 'revisions', published.revision, 'templates.mjs'), - 'export const wrong = true;' + path.join(directory, 'sections/home.md'), + '# Native home\n\nBrowse [pieces](/pieces).\n\n' ) - for (const feed of [feedCases[0], feedCases[2]]) { - expectDefaultFeed(await pageHtml(`${feed.folder}/feed.html`), feed) - } - expect(serverErrors).toContain('Invalid prepared template exports') - expectBuiltIn(await pageHtml()) + await restartWithPages({ home: { body: 'sections/home.md', latest, random } }) + const html = await pageHtml('/') + expect(html).toContain('

Native home

') + expect(html).toContain('href="/pieces"') + expect(html).not.toContain('class="type-link"') + expect(html).not.toContain('') + expect(html).toContain('<script>') + const visibleCollections = [...html.matchAll(/]*>(latest|random)<\/h2>/g)].map( + (match) => match[1] + ) + expect(visibleCollections).toEqual(headings) + expect(html.includes('Orphan must not be visible')).toBe(false) + expect(html).not.toContain(directory) }) - test('repairs on the next request and keeps the last selection when feed compilation fails', async () => { - await publishSource({ - feed: ownerFeedSource('Repaired owner feed') - }) - expectOwnerFeed( - await pageHtml(`${feedCases[2].folder}/feed.html`), - feedCases[2], - 'Repaired owner feed' - ) - const pointer = await readFile(path.join(preparedDirectory, 'current.json'), 'utf8') - await expect(publishSource({ feed: '
' })).rejects.toMatchObject({ code: 1 }) - expect(await readFile(path.join(preparedDirectory, 'current.json'), 'utf8')).toBe(pointer) - expectOwnerFeed( - await pageHtml(`${feedCases[0].folder}/feed.html`), - feedCases[0], - 'Repaired owner feed' - ) - }) - - test('keeps SEO/discovery outside optional feed navigation and replaces, rather than merges, colors', async () => { - for (const [label, navigation, background] of [ - ['No feed banner', '', null], - ['Default feed banner', 'helpers.showNavBanner();', 'transparent'], - ['Empty feed banner', 'helpers.showNavBanner({});', 'transparent'], - [ - 'Replaced feed banner', - "helpers.showNavBanner({ background: '#123456', hoverColor: '#56789a' }); helpers.showNavBanner({ color: '#456789' });", - 'transparent' - ] - ] as const) { - await publishSource({ - feed: ownerFeedSource(label, navigation) - }) - for (const feed of [feedCases[0], feedCases[2]]) { + test.each([ + [false, false], + [true, false], + [false, true], + [true, true] + ])( + 'native feed about=%s/links=%s preserve all feed kinds and discovery', + async (about, links) => { + await writeFile( + path.join(directory, 'sections/about.md'), + '## What is this?\n\nA [normal link](/pieces).' + ) + await restartWithPages({ feed: { ...(about ? { about: 'sections/about.md' } : {}), links } }) + for (const feed of feedCases) { const html = await pageHtml(`${feed.folder}/feed.html`) - expectOwnerFeed(html, feed, label) - if (background === null) { - expect(html).not.toContain('aria-label="Global"') - expect(html).not.toContain('href="/random"') - } else { - const banner = expectFeedBanner(html, 'id="cutover-feed"') - expect(banner).toContain(`--banner-background-color: ${background}`) - expect(banner).toContain('--banner-hover-color: var(--color-primary)') - expect(banner).toContain( - label === 'Replaced feed banner' - ? '--banner-text-color: #456789' - : '--banner-text-color: var(--color-on-surface)' - ) - } + expectNativeFeed(html, feed, about, links) + if (about) expect(html).toContain('

What is this?

') } } - }) + ) - test('rejects unknown types before prepared rendering and preserves machine feed metadata/items', async () => { - const sentinel = 'machine feeds must never render owner feed' - await publishSource({ - feed: ownerFeedSource('Forbidden machine feed', `throw new Error('${sentinel}');`) - }) - for (const format of ['html', 'xml', 'json', 'md']) { - const response = await request(`/rss/pieces/unconfigured-kind/feed.${format}`) - expect(response.status).toBe(404) - const html = await response.text() - expect(html).not.toContain('Orphan must not be visible') - expect(html).not.toContain('Forbidden machine feed') + test('loads lazily and caches shared Markdown across home/feed until restart', async () => { + const file = path.join(directory, 'sections/shared.md') + await writeFile(file, '# First shared copy') + const settings = { + home: { body: 'sections/shared.md', latest: false }, + feed: { about: 'sections/shared.md', links: false } } - expect(await machineFeedSnapshot()).toEqual(originalMachineFeeds) - expect(serverErrors).not.toContain(sentinel) + await restartWithPages(settings) + await writeFile(file, '# Written after server startup') + expect(await pageHtml('/')).toContain('

Written after server startup

') + await writeFile(file, '# Later file edit') + expect(await pageHtml('/rss/pieces/feed.html')).toContain( + '

Written after server startup

' + ) + expect(await pageHtml('/')).not.toContain('

Later file edit

') + await restartWithPages(settings) + expect(await pageHtml('/')).toContain('

Later file edit

') }) - test('leaves indexed piece data and every adapter build file unchanged', async () => { + test('a missing home override fails on first use without blocking unrelated feeds at boot', async () => { + await restartWithPages({ home: { body: 'sections/missing.md' }, feed: { links: true } }) + expectNativeFeed(await pageHtml('/rss/pieces/feed.html'), feedCases[1], false, true) + const failedHome = await request('/') + expect(failedHome.status).toBe(500) + expect(await failedHome.text()).not.toContain(directory) + }) + + test('Markdown image URLs do not publish neighboring filesystem assets', async () => { + await writeFile(path.join(directory, 'sections/image.md'), '![Private neighbor](neighbor.png)') + await writeFile(path.join(directory, 'sections/neighbor.png'), 'not a public asset') + await restartWithPages({ feed: { about: 'sections/image.md' } }) + expect(await pageHtml('/rss/pieces/feed.html')).toContain('src="neighbor.png"') + expect((await request('/rss/pieces/neighbor.png')).status).toBe(404) + }) + + test('ignores stale prepared bundles and preserves machine-feed metadata and item data', async () => { + await mkdir(preparedDirectory, { recursive: true }) + await writeFile(path.join(preparedDirectory, 'current.json'), '{broken old bundle selector') + await restartWithPages({ feed: { links: false } }) + for (const feed of feedCases) + expectNativeFeed(await pageHtml(`${feed.folder}/feed.html`), feed, false, false) + expect(await machineFeedSnapshot()).toEqual(originalMachineFeeds) + }) + + test('leaves piece data and every built adapter file unchanged after runtime content/config changes', async () => { expectBuiltIn(await pageHtml()) const response = await request('/api/pieces?type=cutover-kind', 'application/json') - expect(response.status).toBe(200) expect((await response.json()).pieces).toEqual([originalPublicPiece]) expect(await buildHashes()).toEqual(originalBuild) }) diff --git a/packages/web/config/src/lib/config/pages.test.ts b/packages/web/config/src/lib/config/pages.test.ts new file mode 100644 index 00000000..c9db7cbd --- /dev/null +++ b/packages/web/config/src/lib/config/pages.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { validateConfig } from "./config.js"; + +test("site pages have flat default settings and no default feed explanation", () => { + expect(validateConfig({}, {}).pages).toEqual({ + home: { latest: true, random: false }, + feed: { links: true }, + }); +}); + +test("site sections accept Markdown file references and independent booleans", () => { + const pages = { + home: { body: "./home.md", latest: false, random: true }, + feed: { about: "./feed.md", links: false }, + }; + expect(validateConfig({ pages }, {}).pages).toEqual(pages); +}); + +test.each([ + { home: { latest: { enabled: true } } }, + { home: { body: { markdownFile: "./home.md" } } }, + { home: { body: "" } }, + { feed: { about: true } }, + { feed: { about: false } }, + { feed: { links: "./feed.md" } }, +])( + "rejects nested section options and boolean feed explanations: %j", + (pages) => { + expect(() => validateConfig({ pages }, {})).toThrow(); + }, +); diff --git a/packages/web/config/src/lib/config/schema.ts b/packages/web/config/src/lib/config/schema.ts index 790cef52..740340f3 100644 --- a/packages/web/config/src/lib/config/schema.ts +++ b/packages/web/config/src/lib/config/schema.ts @@ -297,6 +297,48 @@ const AssetsSchema = Type.Object( { default: {}, additionalProperties: false }, ); +const PagesSchema = Type.Object( + { + home: Type.Object( + { + body: Type.Optional( + Type.String({ + minLength: 1, + description: + "Markdown file replacing the home introduction, relative to the supplied config file.", + }), + ), + latest: Type.Boolean({ + default: true, + description: "Show the latest published pieces.", + }), + random: Type.Boolean({ + default: false, + description: "Show a random selection of published pieces.", + }), + }, + { default: {}, additionalProperties: false }, + ), + feed: Type.Object( + { + about: Type.Optional( + Type.String({ + minLength: 1, + description: + "Markdown file for the feed explanation card. Omit to hide the card.", + }), + ), + links: Type.Boolean({ + default: true, + description: "Show feed format links and subscription copy controls.", + }), + }, + { default: {}, additionalProperties: false }, + ), + }, + { default: {}, additionalProperties: false }, +); + const ContentSchema = Type.Object( { component: Type.Optional( @@ -697,6 +739,7 @@ export const ConfigSchema = Type.Object( paths: PathsSchema, assets: AssetsSchema, content: ContentSchema, + pages: PagesSchema, pieces: PiecesSchema, theme: ThemeSchema, }, diff --git a/packages/web/config/src/lib/config/web.config.schema.json b/packages/web/config/src/lib/config/web.config.schema.json index b1466335..729cd9db 100644 --- a/packages/web/config/src/lib/config/web.config.schema.json +++ b/packages/web/config/src/lib/config/web.config.schema.json @@ -409,6 +409,52 @@ } } }, + "pages": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "home": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the home introduction, relative to the supplied config file.", + "type": "string" + }, + "latest": { + "default": true, + "description": "Show the latest published pieces.", + "type": "boolean" + }, + "random": { + "default": false, + "description": "Show a random selection of published pieces.", + "type": "boolean" + } + } + }, + "feed": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "about": { + "minLength": 1, + "description": "Markdown file for the feed explanation card. Omit to hide the card.", + "type": "string" + }, + "links": { + "default": true, + "description": "Show feed format links and subscription copy controls.", + "type": "boolean" + } + } + } + } + }, "pieces": { "default": [], "description": "Ordered presentation and frontmatter mappings for archive piece types.", diff --git a/packages/web/templates/config.schema.json b/packages/web/templates/config.schema.json index 148911c6..39870b48 100644 --- a/packages/web/templates/config.schema.json +++ b/packages/web/templates/config.schema.json @@ -409,6 +409,52 @@ } } }, + "pages": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "home": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "body": { + "minLength": 1, + "description": "Markdown file replacing the home introduction, relative to the supplied config file.", + "type": "string" + }, + "latest": { + "default": true, + "description": "Show the latest published pieces.", + "type": "boolean" + }, + "random": { + "default": false, + "description": "Show a random selection of published pieces.", + "type": "boolean" + } + } + }, + "feed": { + "default": {}, + "additionalProperties": false, + "type": "object", + "properties": { + "about": { + "minLength": 1, + "description": "Markdown file for the feed explanation card. Omit to hide the card.", + "type": "string" + }, + "links": { + "default": true, + "description": "Show feed format links and subscription copy controls.", + "type": "boolean" + } + } + } + } + }, "pieces": { "default": [], "description": "Ordered presentation and frontmatter mappings for archive piece types.", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a57dc08..f83def4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: kysely: specifier: 0.28.17 version: 0.28.17 + micromark: + specifier: 4.0.2 + version: 4.0.2 openworkflow: specifier: 0.9.2 version: 0.9.2 -- 2.54.0