[web] configure home and feed Markdown sections #152

Merged
eleith merged 1 commits from feat/site-sections into main 2026-10-25 00:09:17 +00:00
36 changed files with 984 additions and 1063 deletions

No files matched your search

+7
View File
@@ -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.
+1
View File
@@ -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
+9 -2
View File
@@ -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:
+4
View File
@@ -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.
+5
View File
@@ -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).
+1
View File
@@ -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"
}
}
+15
View File
@@ -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')
@@ -1,76 +0,0 @@
<script lang="ts">
import type { RootPageProps } from '$lib/content/types.js'
let { types, pieces, components }: RootPageProps = $props()
const { ContentBanner, PieceIcon } = components
</script>
<ContentBanner showRandom={true} showHome={false} />
<main id="main-content">
<section class="section">
<div>
<h1>luzzle pieces</h1>
<p>export all the <a href="/pieces">pieces</a></p>
</div>
</section>
{#if types.length}
<section class="section">
<h2>types</h2>
<div class="types-list">
{#each types as type, index (index)}
<a href="/pieces/{type}" class="type-link">{type}</a>
{/each}
</div>
</section>
{/if}
{#if pieces.length}
<section class="section">
<h2>latest</h2>
<div class="pieces-grid">
{#each pieces as piece (piece.id)}
<a href="/pieces/{piece.type}/{piece.slug}" class="piece">
<div class="piece-card">
<PieceIcon {piece} size={{ width: 80 }} />
<span>{piece.title}</span>
</div>
</a>
{/each}
</div>
</section>
{/if}
</main>
<style>
.section {
max-width: 800px;
margin: auto;
padding: var(--space-5);
}
h2 {
font-size: var(--font-size-md);
margin-bottom: var(--space-3);
}
.types-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pieces-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-4);
}
.piece-card {
display: flex;
align-items: top;
gap: var(--space-3);
height: 100%;
}
</style>
@@ -0,0 +1,8 @@
<script lang="ts">
let { html }: { html: string } = $props()
</script>
<div class="markdown">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html html}
</div>
@@ -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('<h1>First</h1>')
await writeFile(file, '# Changed')
expect(loadMarkdownFile(file)).toBe(first)
expect(await loadMarkdownFile(file)).toBe('<h1>First</h1>')
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')
})
@@ -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<string, Promise<string>>()
export function loadMarkdownFile(filename: string): Promise<string> {
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
}
@@ -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(
'<h1>Welcome</h1>\n<p>Browse <a href="/pieces">pieces</a>.</p>'
)
})
test('raw HTML is text, not an active HTML override', () => {
const html = renderMarkdown('<script>alert(1)</script>\n\n<style>body{display:none}</style>')
expect(html).not.toContain('<script>')
expect(html).not.toContain('<style>')
expect(html).toContain('&lt;script&gt;')
})
test('rejects dangerous link protocols and preserves ordinary public asset URLs', () => {
expect(renderMarkdown('[bad](javascript:alert(1))')).toContain('href=""')
expect(renderMarkdown('![Cover](/pieces/assets/cover.png)')).toContain(
'src="/pieces/assets/cover.png"'
)
expect(renderMarkdown('[relative](other.html)')).toContain('href="other.html"')
})
test('has no variables or author extensions and permits empty content', () => {
expect(renderMarkdown('{{ latestPieces() }}')).toContain('{{ latestPieces() }}')
expect(renderMarkdown('')).toBe('')
})
@@ -0,0 +1,8 @@
import { micromark } from 'micromark'
export function renderMarkdown(markdown: string): string {
return micromark(markdown, {
allowDangerousHtml: false,
allowDangerousProtocol: false
})
}
-218
View File
@@ -1,218 +0,0 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import type { PublicWebPiece } from '@luzzle/web.pieces'
import type { FeedPageProps } from '@luzzle/web.templates/site/props'
import { config } from '$lib/server/config'
import type { LoadedTemplateBundle } from '$lib/server/templates.js'
import { renderPreparedFeedPage } from './page.server.js'
const { loadTemplateBundle } = vi.hoisted(() => ({ loadTemplateBundle: vi.fn() }))
vi.mock('$lib/server/templates.js', () => ({ loadTemplateBundle }))
vi.mock('$lib/server/config', async () => {
const { makeConfig } = await import('$lib/server/config.fixture.js')
const config = makeConfig()
config.paths.config = '/deployment/garden/config.yaml'
config.paths.prepared = '../prepared'
config.url.app = 'https://public.example/garden/?discard=1#fragment'
config.url.luzzle_assets = 'https://cdn.example/media/'
config.url.app_assets = 'https://static.example/site/'
return { config }
})
const defaults = structuredClone(config)
type RenderProps = Pick<FeedPageProps, 'data' | 'helpers'>
let captured: RenderProps[]
let bundle: LoadedTemplateBundle
let logged: ReturnType<typeof vi.spyOn>
function piece(): PublicWebPiece {
return {
id: 'note-id',
key: 'note-key',
slug: 'a note #?%',
type: 'field-notes',
title: 'A note',
date_added: 1,
metadata: { cover: 'cover-key' },
assets: [
{
asset_key: 'cover-key',
transformation: 'image.s.png',
asset_path: 'notes/cover small.png',
mime_type: 'image/png'
}
]
}
}
function request() {
return {
feed: { type: 'tag' as const, label: 'field notes', url: '/rss/tags/field%20notes/feed.xml' },
pieces: [piece()],
url: new URL('https://request.example/rss/tags/field%20notes/feed.html')
}
}
function makeBundle(revision: string): LoadedTemplateBundle {
return {
revision,
themeCss: `/* ${revision} theme */`,
templateLookup: { site: { feed: 'owner-feed' } },
render: vi.fn((_name: string, props: object) => {
const input = props as RenderProps
captured.push(input)
input.helpers.showNavBanner({ background: '#123456' })
return { body: `<main>${revision}</main>`, head: `<style>.feed-${revision}{}</style>` }
})
}
}
beforeEach(() => {
vi.resetAllMocks()
Object.assign(config, structuredClone(defaults))
captured = []
bundle = makeBundle('first')
loadTemplateBundle.mockResolvedValue(bundle)
logged = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => vi.restoreAllMocks())
test('keeps the built-in available without deployment metadata or a selected bundle', async () => {
config.paths.config = ''
expect(await renderPreparedFeedPage(request())).toBeNull()
expect(loadTemplateBundle).not.toHaveBeenCalled()
Object.assign(config, structuredClone(defaults))
loadTemplateBundle.mockResolvedValue(null)
expect(await renderPreparedFeedPage(request())).toBeNull()
expect(bundle.render).not.toHaveBeenCalled()
expect(logged).not.toHaveBeenCalled()
})
test.each([{}, { root: 'root' }, { feed: '' }, Object.create({ feed: 'inherited' })])(
'requires an own nonempty site.feed selector: %j',
async (site) => {
bundle.templateLookup.site = site
expect(await renderPreparedFeedPage(request())).toBeNull()
expect(bundle.render).not.toHaveBeenCalled()
expect(logged).not.toHaveBeenCalled()
}
)
test('pins the selected module/theme and returns only serializable presentation', async () => {
const input = request()
const result = await renderPreparedFeedPage(input)
expect(loadTemplateBundle).toHaveBeenCalledExactlyOnceWith('/deployment/prepared')
expect(bundle.render).toHaveBeenCalledExactlyOnceWith('owner-feed', {
data: { feed: input.feed, pieces: input.pieces },
helpers: captured[0].helpers
})
expect(captured[0].data.feed).not.toBe(input.feed)
expect(captured[0].data.pieces).not.toBe(input.pieces)
expect(result).toEqual({
revision: 'first',
body: '<main>first</main>',
head: '<style>.feed-first{}</style><style data-prepared-theme>/* first theme */</style>',
navBanner: { background: '#123456' }
})
expect(JSON.parse(JSON.stringify(result))).toEqual(result)
expect(logged).not.toHaveBeenCalled()
})
test('binds real public piece/image/static helpers, never preview URL context', async () => {
await renderPreparedFeedPage(request())
const { helpers, data } = captured[0]
const bound = helpers.forPiece(data.pieces[0])
expect(bound.getUrl()).toBe(
'https://public.example/garden/pieces/field-notes/a%20note%20%23%3F%25'
)
expect(bound.getImageUrl('cover-key', 125, 'png')).toBe(
'https://cdn.example/media/pieces/assets/notes/cover%20small.png'
)
expect(helpers.getStaticAssetUrl('images/mark.svg')).toBe(
'https://static.example/site/images/mark.svg'
)
expect(helpers.getStaticAssetUrl('../private')).toBeUndefined()
})
test('each request keeps its own origin and navigation, while later requests select newer output', async () => {
config.url.app = '/garden/'
config.url.luzzle_assets = ''
const second = makeBundle('second')
second.render = vi.fn((_name: string, props: object) => {
captured.push(props as RenderProps)
return { body: '<main>second</main>', head: '' }
})
loadTemplateBundle.mockResolvedValueOnce(bundle).mockResolvedValueOnce(second)
const firstInput = request()
const secondInput = request()
secondInput.url = new URL('https://second.example/rss/pieces/feed.html')
const [first, next] = await Promise.all([
renderPreparedFeedPage(firstInput),
renderPreparedFeedPage(secondInput)
])
expect(first?.revision).toBe('first')
expect(first?.navBanner).toEqual({ background: '#123456' })
expect(next).toEqual({
revision: 'second',
body: '<main>second</main>',
head: '<style data-prepared-theme>/* second theme */</style>',
navBanner: null
})
config.url.app = 'https://changed.example'
firstInput.url.hostname = 'changed.example'
expect(captured[0].helpers.forPiece(piece()).getUrl()).toMatch(
/^https:\/\/request\.example\/garden\//
)
expect(captured[1].helpers.forPiece(piece()).getUrl()).toMatch(
/^https:\/\/second\.example\/garden\//
)
})
test('navigation defaults and the last call retain the existing page-helper semantics', async () => {
bundle.render = vi.fn((_name: string, props: object) => {
const { helpers } = props as RenderProps
helpers.showNavBanner({ background: '#123456' })
helpers.showNavBanner()
return { body: 'default navigation', head: '' }
})
expect((await renderPreparedFeedPage(request()))?.navBanner).toEqual({})
})
test('a throwing template cannot mutate fallback data or return partial navigation/head', async () => {
const input = request()
const before = structuredClone({ feed: input.feed, pieces: input.pieces })
bundle.render = vi.fn((_name: string, props: object) => {
const { data, helpers } = props as RenderProps
helpers.showNavBanner({ background: '#abcdef' })
Object.assign(data.feed, { label: 'Corrupted label' })
data.pieces[0].metadata.cover = 'Corrupted metadata'
throw new Error('feed owner failed')
})
expect(await renderPreparedFeedPage(input)).toBeNull()
expect({ feed: input.feed, pieces: input.pieces }).toEqual(before)
expect(logged).toHaveBeenCalledExactlyOnceWith(
'Prepared feed rendering failed for tag: field notes',
expect.any(Error)
)
})
test('successful templates also receive isolated serving data', async () => {
const input = request()
const before = structuredClone(input.pieces)
bundle.render = vi.fn((_name: string, props: object) => {
const { data } = props as RenderProps
data.pieces[0].metadata.cover = 'Owner mutation'
return { body: 'Rendered', head: '' }
})
expect((await renderPreparedFeedPage(input))?.body).toBe('Rendered')
expect(input.pieces).toEqual(before)
})
test.each(['Missing module', 'Malformed selected output', 'Filesystem failure'])(
'load failure falls back without swallowing configuration or query work: %s',
async (message) => {
loadTemplateBundle.mockRejectedValue(new Error(message))
expect(await renderPreparedFeedPage(request())).toBeNull()
expect(bundle.render).not.toHaveBeenCalled()
expect(logged).toHaveBeenCalledOnce()
}
)
-27
View File
@@ -1,27 +0,0 @@
import { resolvePreparedDirectory } from '@luzzle/web.config'
import type { FeedPageProps } from '@luzzle/web.templates/site/props'
import type { PreparedPage } from '$lib/content/types.js'
import { createTemplateUrlBuilders } from '$lib/pieces/urls.server.js'
import { config } from '$lib/server/config'
import { loadTemplateBundle } from '$lib/server/templates.js'
import { renderTemplatePage } from '$lib/server/template-page.js'
type FeedPageRequest = FeedPageProps['data'] & { url: URL }
export async function renderPreparedFeedPage({
feed,
pieces,
url
}: FeedPageRequest): Promise<PreparedPage | null> {
if (!config.paths.config) return null
try {
const bundle = await loadTemplateBundle(resolvePreparedDirectory(config))
if (!bundle || !Object.hasOwn(bundle.templateLookup.site, 'feed')) return null
const name = bundle.templateLookup.site.feed
if (!name) return null
return renderTemplatePage(bundle, name, { feed, pieces }, createTemplateUrlBuilders(url))
} catch (error) {
console.error(`Prepared feed rendering failed for ${feed.type}: ${feed.label}`, error)
return null
}
}
+33 -18
View File
@@ -1,31 +1,46 @@
<script lang="ts">
import type { FeedPageProps } from '@luzzle/web.templates/site/props'
import FeedDefault from '@luzzle/web.templates/components/feed.default.svelte'
import type { PreparedPage } from '$lib/content/types.js'
import NavBanner from '$lib/components/layout/simple/NavBanner.svelte'
import SeoHead from '$lib/components/layout/simple/SeoHead.svelte'
import Markdown from '$lib/content/markdown/Markdown.svelte'
import Subscription from './subscription.svelte'
type Props = FeedPageProps['data'] & { presentation: PreparedPage | null }
let { feed, pieces, presentation }: Props = $props()
type Props = FeedPageProps['data'] & { about?: string; links: boolean }
let { feed, pieces, about, links }: Props = $props()
</script>
<SeoHead />
<svelte:head>
<!-- The app owns feed metadata even when no navigation is requested. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html presentation?.head ?? ''}
</svelte:head>
<NavBanner showRandom />
{#key feed.url}
{#if presentation}
{#if presentation.navBanner !== null}
<NavBanner showRandom {...presentation.navBanner} />
{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html presentation.body}
{:else}
<NavBanner showRandom />
<FeedDefault data={{ feed, pieces }} />
{#if about || links}
<div class="feed-cards">
{#if about}
<aside class="about" aria-label="About this feed"><Markdown html={about} /></aside>
{/if}
{#if links}
<Subscription url={feed.url} />
{/if}
</div>
{/if}
<FeedDefault data={{ feed, pieces }} />
{/key}
<style>
.feed-cards {
margin: var(--space-4) auto;
padding: 0 var(--space-4);
width: 85%;
max-width: clamp(500px, 66.6666%, 1000px);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.about {
background: var(--color-surface);
border: 1px solid var(--color-surface-container-high);
padding: var(--space-5);
border-radius: var(--radius-medium);
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
let { url }: { url: string } = $props()
let input: HTMLInputElement | undefined = $state()
let copyStatus = $state<'idle' | 'copied' | 'manual'>('idle')
async function copySubscription() {
input?.focus()
input?.select()
try {
await navigator.clipboard.writeText(url)
copyStatus = 'copied'
} catch {
copyStatus = 'manual'
}
}
</script>
<aside class="subscription" aria-label="RSS subscription">
<p>
View the raw data files: <a href="feed.xml">XML</a> | <a href="feed.json">JSON</a> |
<a href="feed.md">Markdown</a>
</p>
<p id="rss-instructions">Copy the link and paste it into an RSS reader.</p>
<div class="copy-controls">
<input
bind:this={input}
type="text"
readonly
value={url}
aria-label="RSS feed URL"
aria-describedby="rss-instructions"
onclick={() => input?.select()}
class="input"
/>
<button type="button" onclick={copySubscription} class="button"
>{copyStatus === 'copied' ? 'copied' : 'copy'}</button
>
</div>
<p class="status" role="status" aria-live="polite">
{#if copyStatus === 'copied'}
Link copied.
{:else if copyStatus === 'manual'}
Select the link and copy it into an RSS reader.
{/if}
</p>
</aside>
<style>
.subscription {
background: var(--color-surface);
border: 1px solid var(--color-surface-container-high);
padding: var(--space-5);
border-radius: var(--radius-medium);
}
.copy-controls {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
input {
flex: 1;
min-width: 0;
}
.status {
margin-bottom: 0;
}
.status:empty {
display: none;
}
</style>
+42
View File
@@ -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('<p>Section</p>')
})
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('<p>Section</p>')
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)
})
+10
View File
@@ -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<string | undefined> {
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)
}
+52 -1
View File
@@ -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()
+19 -1
View File
@@ -18,16 +18,34 @@ export function getPieces(): Pieces {
export async function getRecentPieces(
db: Kysely<AppDatabase>,
limit: number
limit: number,
types: readonly string[]
): Promise<WebPieces[]> {
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<AppDatabase>,
limit: number,
types: readonly string[],
excludedIds: readonly string[] = []
): Promise<WebPieces[]> {
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<AppDatabase>,
limit: number
+25 -16
View File
@@ -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 }
}
+73 -9
View File
@@ -1,16 +1,15 @@
<script lang="ts">
import { page } from '$app/state'
import RootPageDefault from '$lib/content/components/root.default.svelte'
import loadContent from '$lib/content/load'
import type { PageProps } from './$types'
import ContentBanner from '$lib/content/components/ContentBanner.svelte'
import PieceIcon from '$lib/pieces/components/icon.svelte'
import Markdown from '$lib/content/markdown/Markdown.svelte'
let { data } = $props()
const glob = import.meta.glob('$lib/content/components/custom/root.svelte', { eager: true })
const RootPage = $derived(loadContent('root', RootPageDefault, glob))
const components = { ContentBanner, PieceIcon }
let { data }: PageProps = $props()
const collections = $derived([
{ title: 'latest', pieces: data.latest },
{ title: 'random', pieces: data.random }
])
</script>
<svelte:head>
@@ -22,4 +21,69 @@
/>
</svelte:head>
<RootPage types={data.types} pieces={data.pieces} {components} />
<ContentBanner showRandom showHome={false} />
<main id="main-content">
{#if data.body === undefined}
<section class="section">
<h1>luzzle pieces</h1>
<p>export all the <a href="/pieces">pieces</a></p>
{#if data.types.length}
<h2>types</h2>
<div class="types-list">
{#each data.types as type (type)}
<a href="/pieces/{type}" class="type-link">{type}</a>
{/each}
</div>
{/if}
</section>
{:else if data.body}
<section class="section"><Markdown html={data.body} /></section>
{/if}
{#each collections as collection (collection.title)}
{#if collection.pieces.length}
<section class="section">
<h2>{collection.title}</h2>
<div class="pieces-grid">
{#each collection.pieces as piece (piece.id)}
<a href="/pieces/{piece.type}/{piece.slug}" class="piece">
<div class="piece-card">
<PieceIcon {piece} size={{ width: 80 }} />
<span>{piece.title}</span>
</div>
</a>
{/each}
</div>
</section>
{/if}
{/each}
</main>
<style>
.section {
max-width: 800px;
margin: auto;
padding: var(--space-5);
}
h2 {
font-size: var(--font-size-md);
margin-bottom: var(--space-3);
}
.types-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.pieces-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: var(--space-4);
}
.piece-card {
display: flex;
align-items: flex-start;
gap: var(--space-3);
height: 100%;
}
</style>
@@ -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<typeof load>[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('<h1>Welcome</h1>')
expect(await load(event)).toMatchObject({ body: '<h1>Welcome</h1>', 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([[[]], [[]]])
})
@@ -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
}
}
@@ -13,4 +13,4 @@
/>
</svelte:head>
<FeedPage feed={data.feed} pieces={data.pieces} presentation={data.presentation} />
<FeedPage feed={data.feed} pieces={data.pieces} about={data.about} links={data.links} />
@@ -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: '<article>Prepared feed</article>',
head: '<title>Prepared feed</title><style data-prepared-theme>/* feed */</style>',
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<typeof load>[0]
return { params: { piece: type } } as Parameters<typeof load>[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('<p>About this feed</p>')
expect(await load(event())).toMatchObject({
pieces,
about: '<p>About this feed</p>',
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)
})
@@ -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
}
}
@@ -13,4 +13,4 @@
/>
</svelte:head>
<FeedPage feed={data.feed} pieces={data.pieces} presentation={data.presentation} />
<FeedPage feed={data.feed} pieces={data.pieces} about={data.about} links={data.links} />
@@ -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: '<article>Prepared tag feed</article>',
head: '<title>Prepared tag feed</title><style data-prepared-theme>/* feed */</style>',
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<typeof load>[0]
return { params: { tag } } as Parameters<typeof load>[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('<h2>About</h2>')
expect(await load(event())).toMatchObject({ about: '<h2>About</h2>', 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')
})
+183 -500
View File
@@ -50,10 +50,9 @@ const feedCases = [
count: 0
}
] as const
type FeedCase = (typeof feedCases)[number]
type MachineFeeds = Record<string, { contentType: string | null; body: string }>
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<string, string>
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: '<p>Published rendered note</p>' }
{ ...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: '<p>Published rendered note</p>' }
]).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(/<head>([\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(/<script\b[^>]*>[\s\S]*?<\/script>/g) ?? []).filter((script) =>
script.includes('window.localStorage.getItem')
const preferenceScripts = (head.match(/<script\b[^>]*>[\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(/<link\b[^>]*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(/<nav[^>]*class="banner svelte-[a-z0-9]+"/)
expect(html.indexOf('aria-label="Global"')).toBeLessThan(html.indexOf(before))
return html.match(/<nav\b[^>]*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(`<h1[^>]*>RSS feed for ${title}</h1>`))
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}</a>`)
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</button>')
} 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(`<h1>${label}</h1>`)
expect(html).toContain(`id="feed-reference" href="${feed.folder}/feed.xml"`)
expect(html).toContain(`<p id="feed-kind">${feed.type}</p>`)
expect(html).toContain(`<p id="feed-label">${feed.label}</p>`)
expect(html).toContain(`<p id="feed-count">${feed.count}</p>`)
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('<p>Published rendered note</p>')
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<MachineFeeds> {
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<MachineFeeds> {
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<MachineFeeds> {
return snapshot
}
function expectAppSeo(html: string) {
const head = htmlHead(html)
expect(head.match(/<title>/g)).toHaveLength(1)
expect(head).toContain('<title>Real Piece | Cutover Garden</title>')
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(/<h1(?:\s[^>]*)?>Real Piece<\/h1>/)
expect(html).toContain('<p>Published rendered note</p>')
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(/<title>/g)).toHaveLength(1)
expect(head).toContain('<title>Real Piece | Cutover Garden</title>')
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 `<script lang="ts">
import type { FeedPageProps } from '@luzzle/web.templates/site/props';
let { data, helpers, components }: FeedPageProps = $props();
${navigation}
</script>
<svelte:head><meta name="cutover-feed-revision" content="${label}" /></svelte:head>
<main id="cutover-feed" class="cutover-feed">
<h1>${label}</h1>
<a id="feed-reference" href={data.feed.url}>{data.feed.url}</a>
<p id="feed-kind">{data.feed.type}</p>
<p id="feed-label">{data.feed.label}</p>
<p id="feed-count">{data.pieces.length}</p>
{#each data.pieces as piece (piece.id)}
<section data-feed-piece={piece.id}>
<h2><a href={helpers.forPiece(piece).getUrl()}>{piece.title}</a></h2>
<components.PieceIcon {piece} size={{ width: 125 }} />
{#if piece.id === 'cutover-id'}
<p>{piece.metadata.subtitle}</p>
<img id="feed-cover" src={helpers.forPiece(piece).getImageUrl(piece.metadata.cover, 125, 'png')} alt="Cover" />
<img id="feed-mark" src={helpers.getStaticAssetUrl('images/favicon.png')} alt="Site mark" />
{@html helpers.forPiece(piece).getAssetContent(piece.key, 'markdown') ?? ''}
{/if}
</section>
{/each}
</main>
<style>.cutover-feed { color: var(--color-primary); border-color: ${cssColor}; }</style>`
}
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<string> {
@@ -473,11 +350,18 @@ async function startBuiltApp(): Promise<string> {
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'),
'<svg xmlns="http://www.w3.org/2000/svg"></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</')
expect(html.indexOf('aria-label="Global"')).toBeLessThan(html.indexOf('<h1'))
const response = await request('/pieces/assets/cutover/cover%20small.png')
expect(response.status).toBe(200)
expect(Buffer.from(await response.arrayBuffer())).toEqual(
const image = await request('/pieces/assets/cutover/cover%20small.png')
expect(image.status).toBe(200)
expect(Buffer.from(await image.arrayBuffer())).toEqual(
await readFile(path.join(webRoot, 'static/images/favicon.png'))
)
})
test('rejects retired piece source keys rather than reading or publishing arbitrary Svelte', async () => {
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(`<h1[^>]*>RSS feed for ${title}</h1>`))
for (const [format, label] of [
['xml', 'XML'],
['json', 'JSON'],
['md', 'Markdown']
]) {
expect(html).toContain(`href="feed.${format}">${label}</a>`)
}
const subscription = html.match(/<div class="subscription-url[^"]*">([\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('<button')
const scopedClass = html.match(/class="subscription-url (svelte-[a-z0-9]+)"/)?.[1]
expect(scopedClass).toBeDefined()
const head = htmlHead(html)
expect(head).toContain(`.subscription-url.${scopedClass}`)
expect(head).toContain(`.content-wrapper.${scopedClass}`)
expect(head).toContain('var(--color-surface)')
expect(html).toContain(`href="${piecePath}"`)
expect(html).toContain('Real Piece')
expect(html).toContain('Raw indexed note')
expect(html).not.toContain('Orphan must not be visible')
}
test('stock home keeps type links in its introduction and only queries configured published types', async () => {
const html = await pageHtml('/')
expect(html).toMatch(/<h1[^>]*>luzzle pieces<\/h1>/)
expect(html).toContain('class="type-link"')
expect(html).toMatch(/<h2[^>]*>latest<\/h2>/)
expect(html).not.toMatch(/<h2[^>]*>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<script>window.siteInjected=1</script>'
)
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('<h1>Native home</h1>')
expect(html).toContain('href="/pieces"')
expect(html).not.toContain('class="type-link"')
expect(html).not.toContain('<script>window.siteInjected=1</script>')
expect(html).toContain('&lt;script&gt;')
const visibleCollections = [...html.matchAll(/<h2[^>]*>(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: '<div>' })).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('<h2>What is this?</h2>')
}
}
})
)
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('<h1>Written after server startup</h1>')
await writeFile(file, '# Later file edit')
expect(await pageHtml('/rss/pieces/feed.html')).toContain(
'<h1>Written after server startup</h1>'
)
expect(await pageHtml('/')).not.toContain('<h1>Later file edit</h1>')
await restartWithPages(settings)
expect(await pageHtml('/')).toContain('<h1>Later file edit</h1>')
})
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)
})
@@ -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();
},
);
@@ -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,
},
@@ -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.",
+46
View File
@@ -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.",
+3
View File
@@ -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