luzzle pieces
-export all the pieces
-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 @@
-
-
- export all the piecesluzzle pieces
- types
- latest
-
Browse pieces.
' + ) +}) + +test('raw HTML is text, not an active HTML override', () => { + const html = renderMarkdown('\n\n') + expect(html).not.toContain('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): Promiseexport all the pieces
+ {#if data.types.length} +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 @@ /> -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(/ -{data.feed.type}
-{data.feed.label}
-{data.pieces.length}
-{#each data.pieces as piece (piece.id)} -{piece.metadata.subtitle}
-