[web] configure home and feed Markdown sections #152
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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('<script>')
|
||||
})
|
||||
|
||||
test('rejects dangerous link protocols and preserves ordinary public asset URLs', () => {
|
||||
expect(renderMarkdown('[bad](javascript:alert(1))')).toContain('href=""')
|
||||
expect(renderMarkdown('')).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
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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('<script>')
|
||||
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'), '')
|
||||
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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user