[web] connect preview and published overviews #203

Merged
eleith merged 1 commits from web/overview-connections into main 2026-11-10 17:11:22 +00:00
17 changed files with 236 additions and 283 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Connect Published and Preview overviews and offer fresh saved-source previews when a preview fails or expires.
@@ -0,0 +1,76 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms'
import { resolve } from '$app/paths'
import { encodeArchivePath } from '#lib/pieces/navigation.js'
type Props = {
file: string
preview?: 'saved' | 'retry'
hasPublishedVersion?: boolean
}
let { file, preview, hasPublishedVersion = false }: Props = $props()
const path = $derived(encodeArchivePath(file))
let starting = $state(false)
const startPreview: SubmitFunction = ({ cancel }) => {
if (starting) {
cancel()
return
}
starting = true
return async ({ update }) => {
try {
await update()
} finally {
starting = false
}
}
}
</script>
{#if preview}
<form
method="post"
action={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
use:enhance={startPreview}
aria-busy={starting}
>
<button type="submit" disabled={starting}>
{#if starting}Starting preview…
{:else if preview === 'retry'}Generate fresh preview
{:else}Generate{/if}
</button>
</form>
{/if}
{#if hasPublishedVersion}
<a href={resolve('/(auth)/admin/piece/[...path]/published', { path })}>View published</a>
{/if}
<style>
form {
margin: 0;
}
a {
color: var(--color-primary);
}
button {
font: inherit;
font-weight: var(--font-weight-normal);
color: var(--color-primary);
background: var(--color-surface-container);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-small);
padding: var(--space-1) var(--space-2);
max-width: 100%;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>
@@ -67,7 +67,7 @@
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-outline-variant);
text-align: left;
vertical-align: top;
vertical-align: middle;
overflow-wrap: anywhere;
}
.overview :global(.overview-facts th) {
@@ -112,7 +112,7 @@
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
border-top: 1px solid var(--color-outline-variant);
padding: 0;
align-items: start;
align-items: center;
}
.overview :global(.overview-facts th),
.overview :global(.overview-facts td) {
@@ -1,6 +1,9 @@
import type { LayoutServerLoad } from './$types'
import { db } from '#lib/server/database/index.js'
import { getWebPiece } from '#lib/server/pieces.js'
export const load: LayoutServerLoad = ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, params }) => {
locals.inlineConfiguredStyles()
return {}
const published = await getWebPiece(db, params.path)
return { hasPublishedVersion: !!published }
}
@@ -146,6 +146,7 @@
<PieceOverview title="Preview" fileUrl={() => undefined}>
<PreviewFacts
file={data.file}
hasPublishedVersion={data.hasPublishedVersion}
{phases}
{status}
{streamDisconnected}
@@ -24,6 +24,7 @@
>
<PreviewFacts
file={data.file}
hasPublishedVersion={data.hasPublishedVersion}
phases={data.phases ?? []}
type={data.piece?.type}
status="completed"
@@ -1,11 +1,13 @@
<script lang="ts">
import type { ProgressPhase } from '#lib/components/progress.js'
import { formatDuration, previewDuration } from './duration.js'
import OverviewActions from '#lib/pieces/viewer/OverviewActions.svelte'
interface Props {
file: string
type?: string
status: string
hasPublishedVersion?: boolean
streamDisconnected?: boolean
createdAt: string | null
finishedAt: string | null
@@ -16,6 +18,7 @@
file,
type,
status,
hasPublishedVersion = false,
streamDisconnected = false,
createdAt,
finishedAt,
@@ -84,9 +87,7 @@
{#if !streamDisconnected && (status === 'enqueued' || status === 'waiting')}
<div class="status-sub">Waiting for worker…</div>
{:else if !streamDisconnected && status === 'expired'}
<div class="status-sub">
Assets are purged after 2 days. Re-run the preview from the editor.
</div>
<div class="status-sub">Assets are purged after 2 days.</div>
{/if}
</td>
</tr>
@@ -131,6 +132,18 @@
{/if}
</td>
</tr>
{#if hasPublishedVersion}
<tr role="row">
<th scope="row" role="rowheader">Publication</th>
<td role="cell"><OverviewActions {file} {hasPublishedVersion} /></td>
</tr>
{/if}
{#if status === 'failed' || status === 'expired'}
<tr role="row">
<th scope="row" role="rowheader">Preview</th>
<td role="cell"><OverviewActions {file} preview="retry" /></td>
</tr>
{/if}
</tbody>
</table>
</div>
@@ -1,10 +1,15 @@
import { readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { ProgressPhase } from '#lib/components/progress.js'
import PreviewFacts from './PreviewFacts.svelte'
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
vi.mock('$app/paths', () => ({
resolve: (route: string, params: { path: string }) =>
route.replace('/(auth)', '').replace('[...path]', () => params.path)
}))
function facts(overrides: Partial<ComponentProps<typeof PreviewFacts>> = {}) {
return render(PreviewFacts, {
props: {
@@ -45,52 +50,6 @@ function field(html: string, name: string) {
return rows(html).find(([label]) => label === name)?.[1]
}
test('fixed UI copy is literal markup rather than JavaScript label maps', () => {
const source = readFileSync(new URL('./PreviewFacts.svelte', import.meta.url), 'utf8')
const markup = source
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/g, '')
.replace(/\{[^}]*\}/g, '')
.replace(/\s+/g, ' ')
expect(source).not.toMatch(/\b(?:statusLabels|durationLabels)\b|\bnew\s+Map\s*\(/)
for (const label of [
'Preview',
'File',
'Type',
'State',
'Duration',
'Created',
'Finished',
'Unknown',
'Queued',
'Building',
'Completed',
'Failed',
'Expired',
'Refresh to reconnect',
'Waiting for worker…',
'Assets are purged after 2 days. Re-run the preview from the editor.',
'elapsed',
'total time',
'stopped at',
'UTC'
])
expect(markup).toContain(label)
})
test('facts have no state icon imports, markup, classification, motion, or local badge styles', () => {
const source = readFileSync(new URL('./PreviewFacts.svelte', import.meta.url), 'utf8')
const style = source.match(/<style>([\s\S]*?)<\/style>/)?.[1]
expect(style).toBeDefined()
expect(source).not.toMatch(
/virtual:icons|<svg\b|status-icon|status-\{|spin|@keyframes|prefers-reduced-motion/
)
expect(style).not.toMatch(/\.status\s*\{|data-status|background|animation|transform/)
expect(source).toMatch(
/<span\s+class="status"\s+data-status=\{!streamDisconnected && knownStatus \? status : 'unknown'\}\s*>/
)
})
test.each(['enqueued', 'running'])(
'disconnection replaces the %s state label without inventing a job failure',
(status) => {
@@ -104,88 +63,19 @@ test.each(['enqueued', 'running'])(
}
)
test('shared overview styles give Published and Preview identical badge colors and neutral pending states', () => {
const source = readFileSync(
new URL('../../../../../../../lib/pieces/viewer/PieceOverview.svelte', import.meta.url),
'utf8'
)
const base = source.match(/\.overview\s+:global\(\.overview-facts \.status\)\s*\{([^}]+)\}/)?.[1]
expect(base?.replace(/\s+/g, ' ').trim()).toBe(
'display: inline-block; padding: var(--space-1) var(--space-2); border-radius: var(--radius-small); font-size: inherit; font-weight: var(--font-weight-medium); background: var(--color-surface-container-highest); color: var(--color-on-surface-variant);'
)
const blocks = [...source.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
for (const [status, tone] of [
['matching', 'primary'],
['completed', 'primary'],
['changed', 'secondary'],
['running', 'secondary'],
['failed', 'error']
]) {
const block = blocks.find(([, selector]) =>
selector.includes(`.overview :global(.overview-facts .status[data-status='${status}'])`)
)
expect(block, status).toBeDefined()
expect(block?.[2]).toContain(`background: var(--color-${tone}-container);`)
expect(block?.[2]).toContain(`color: var(--color-on-${tone}-container);`)
}
for (const status of ['waiting', 'enqueued', 'expired', 'unknown'])
expect(source).not.toMatch(new RegExp(`data-status=['"]${status}['"]`))
})
test('Preview facts have the six actual fields and explicit mobile table semantics', () => {
const html = facts()
expect(html).toContain('class="overview-facts"')
expect(html).toMatch(/<table[^>]*role="table"/)
expect(html.match(/<table\b/g)).toHaveLength(1)
expect(html).toContain('<caption>Preview</caption>')
expect(html).toMatch(/<tbody[^>]*role="rowgroup"/)
expect(html.match(/<tr[^>]*role="row"/g)).toHaveLength(6)
expect(html.match(/<th[^>]*scope="row"[^>]*role="rowheader"/g)).toHaveLength(6)
expect(html.match(/<td[^>]*role="cell"/g)).toHaveLength(6)
expect(rows(html)).toEqual([
['File', 'books/a-book.books.md'],
['Type', 'books'],
['State', 'Completed'],
['Duration', 'Unknown'],
['Created', '1/2/2026, 3:04:05 AM UTC'],
['Finished', '1/2/2026, 3:05:06 AM UTC']
])
for (const excluded of [
'<h1',
'<main',
'<a ',
'status-card',
'Title',
'Source',
'Publication',
'Last publish',
'Matches publish',
'Unpublished changes',
'URL',
'Started'
])
expect(html).not.toContain(excluded)
})
test.each([
['waiting', 'Queued Waiting for worker…'],
['enqueued', 'Queued Waiting for worker…'],
['running', 'Building'],
['completed', 'Completed'],
['failed', 'Failed'],
['expired', 'Expired Assets are purged after 2 days. Re-run the preview from the editor.']
['expired', 'Expired Assets are purged after 2 days.']
])('state %s retains facts with the agreed label %s', (status, label) => {
const html = facts({ status, finishedAt: null })
expect(html).toMatch(new RegExp(`<span class="status" data-status="${status}">`))
const fields = rows(html)
expect(fields).toEqual([
['File', 'books/a-book.books.md'],
['Type', 'books'],
['State', label],
['Duration', 'Unknown'],
['Created', '1/2/2026, 3:04:05 AM UTC'],
['Finished', 'Unknown']
])
expect(field(html, 'State')).toBe(label)
expect(field(html, 'File')).toBe('books/a-book.books.md')
expect(field(html, 'Finished')).toBe('Unknown')
})
test('missing type is Unknown without inferring any identity', () => {
@@ -272,21 +162,6 @@ test('the entire raw filename is escaped, direction-isolated, and available as i
expect(html).not.toContain('<a ')
})
test.each(['completed', 'running', 'enqueued', 'waiting', 'failed', 'expired'])(
'%s uses the same icon-free State badge element and closed status attribute',
(status) => {
const html = facts({ status })
const cell = html.match(/>State<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/)?.[1]
expect(cell).toBeDefined()
expect(cell!.match(/<span\b/g)).toHaveLength(1)
expect(cell).toContain(`<span class="status" data-status="${status}">`)
expect(cell).not.toContain('status-icon')
expect(html).not.toContain('<svg')
expect(html).not.toContain('spin')
expect(field(html, 'State')).toBeTruthy()
}
)
test.each([
['completed', '2s total time'],
['running', '2s elapsed'],
@@ -3,7 +3,6 @@ import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
import PreviewLogs from './PreviewLogs.svelte'
import PreviewFacts from './PreviewFacts.svelte'
const started = Date.parse('2026-01-02T03:04:00Z')
const phases: ProgressPhase[] = [
@@ -72,46 +71,6 @@ test('logs are a labelled section with an instance-scoped heading, not a main or
expect(headingId(first)).not.toBe(headingId(second))
})
test('file metadata is preserved by facts rather than duplicated in the logs section', () => {
const file = `${'nested folder/'.repeat(24)}雪 & <script>draft</script> #?%.books.md`
const html = render(PreviewLogs, { props: props() }).body
const facts = render(PreviewFacts, {
props: { file, status: 'completed', phases, createdAt: null, finishedAt: null }
}).body
expect(facts).toContain(file.replace(/&/g, '&amp;').replace(/</g, '&lt;'))
expect(html).not.toContain('nested folder/')
expect(facts).not.toContain('<script>')
expect(facts).not.toContain('<a ')
expect(html).not.toContain('class="meta')
expect(html).not.toContain('Publication')
})
test.each([
['expired', 'Expired', 'Assets are purged after 2 days. Re-run the preview from the editor.'],
['enqueued', 'Queued', 'Waiting for worker…'],
['running', 'Building', '1m 30s elapsed'],
['failed', 'Failed', '1m 30s stopped at'],
['completed', 'Completed', '1m 30s total time']
])(
'keeps %s summary in the facts table while preserving phase/log delegation',
(status, title, detail) => {
const html = render(PreviewLogs, { props: props({ status }) }).body
const facts = render(PreviewFacts, {
props: { file: 'book.md', status, phases, createdAt: null, finishedAt: null }
}).body
expect(text(facts)).toContain(title)
expect(text(facts)).toContain(detail)
expect(facts).not.toContain('<svg')
expect(facts).not.toContain('spin')
expect(html).not.toContain(title)
expect(html).not.toContain('status-card')
expect(html).not.toContain('status-title')
expect(html).toContain('timeline')
expect(text(html)).toContain('Output ready')
expect(text(html)).toContain('Recorded asset error')
}
)
test.each(['waiting', 'future-state'])(
'passes %s through without inventing console status copy',
(status) => {
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { describe, expect, test } from 'vitest'
import { render } from 'svelte/server'
import WorkflowConsole from './WorkflowConsole.svelte'
import PreviewFacts from './PreviewFacts.svelte'
import type { ProgressPhase, ProgressLog } from '#lib/components/progress.js'
const started = Date.parse('2026-01-02T03:04:00Z')
@@ -20,19 +19,6 @@ const logs: Record<string, ProgressLog[]> = {
]
}
function summary(status: string, items: ProgressPhase[] = phases) {
return render(PreviewFacts, {
props: {
file: 'book.md',
type: 'book',
status,
phases: items,
createdAt: '2026-01-02T03:04:00Z',
finishedAt: null
}
}).body.replace(/<!--[^]*?-->/g, '')
}
function text(html: string) {
return html
.replace(/<[^>]+>/g, '')
@@ -40,34 +26,7 @@ function text(html: string) {
.trim()
}
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(started + 75_000)
})
afterEach(() => vi.restoreAllMocks())
describe('preview status summary', () => {
test.each([
['expired', 'Expired', 'Assets are purged after 2 days. Re-run the preview from the editor.'],
['enqueued', 'Queued', 'Waiting for worker…'],
['running', 'Building', '1m 5s elapsed'],
['failed', 'Failed', '1m 5s stopped at'],
['completed', 'Completed', '1m 5s total time']
])('retains wording and duration for %s', (status, title, subtitle) => {
const result = render(WorkflowConsole, { props: { status, phases, logs } })
const facts = summary(status)
expect(text(facts)).toContain(title)
expect(text(facts)).toContain(subtitle)
expect(facts).not.toContain('<svg')
expect(facts).not.toContain('spin')
expect(result.body).not.toContain('status-card')
expect(result.body).not.toContain(title)
expect(result.body).toContain('phase-icon')
expect(result.body).toContain('log-viewport')
expect(text(result.body)).toContain('Rendered successfully')
expect(text(result.body)).toContain('03:04:00')
expect(text(result.body)).toContain('Output ready')
})
describe('preview error reporting', () => {
test('preserves an empty summary for an unrecognized status', () => {
const result = render(WorkflowConsole, { props: { status: 'unknown', phases: [], logs: {} } })
expect(result.body).not.toContain('status-title')
@@ -95,7 +54,6 @@ describe('preview phases and logs', () => {
logs
}
})
expect(text(summary('completed'))).toContain('1m 5s total time')
expect(result.body).not.toContain('Completed')
expect(text(result.body)).toContain('1 lines')
expect(result.body).toContain('Auto-scroll locked')
@@ -139,7 +97,7 @@ describe('preview phases and logs', () => {
expect(viewportId(first.body)).not.toBe(viewportId(second.body))
})
test('measures total preview duration from the first phase through the last', () => {
test('shows each phase duration rather than a total preview time', () => {
const result = render(WorkflowConsole, {
props: {
status: 'completed',
@@ -155,44 +113,10 @@ describe('preview phases and logs', () => {
logs: {}
}
})
expect(
text(
summary('completed', [
phases[0],
{
...phases[0],
phase: 'publish',
started_at: started + 65_000,
finished_at: started + 90_000
}
])
)
).toContain('1m 30s total time')
expect(result.body).not.toContain('total time')
expect(text(result.body)).toContain('25s')
})
test.each([
[[], 'Unknown'],
[[{ ...phases[0], started_at: 0, finished_at: 2000 }], '2s'],
[[{ ...phases[0], finished_at: null }], '1m 15s'],
[[{ ...phases[0], finished_at: started + 5000 }], '5s'],
[[{ ...phases[0], finished_at: started - 1000 }], '0s']
])('retains elapsed-time formatting for %j', (items, duration) => {
const result = render(WorkflowConsole, {
props: {
status: 'running',
phases: items,
logs: {}
}
})
const facts = summary('running', items)
const durationCell = facts.match(/>Duration<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/)?.[1]
expect(durationCell).toBeDefined()
expect(text(durationCell!)).toContain(duration === 'Unknown' ? duration : `${duration} elapsed`)
expect(result.body).not.toContain('Building')
})
test('retains phase statuses and stderr/error highlighting', () => {
const items = ['running', 'failed', 'skipped', 'waiting'].map((status) => ({
...phases[0],
@@ -37,6 +37,7 @@ const { page, EventSource, resolver, resolve } = vi.hoisted(() => {
}
})
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
vi.mock('$app/paths', () => ({ resolve }))
const file = 'books/book.books.md'
@@ -51,6 +52,7 @@ function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
config: makePublicConfig(),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
hasPublishedVersion: false,
file,
job: 'preview-id',
createdAt: '2026-06-20T00:00:00Z',
@@ -67,9 +69,13 @@ function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
return { ...common, status }
}
function body(status: Status = 'completed') {
function body(status: Status = 'completed', hasPublishedVersion = false) {
return render(PreviewLayout, {
props: { data: data(status), params: { path: file, jobId: 'preview-id' }, children }
props: {
data: { ...data(status), hasPublishedVersion },
params: { path: file, jobId: 'preview-id' },
children
}
}).body.replace(/<!--[^]*?-->/g, '')
}
@@ -80,6 +86,8 @@ function anchors(html: string) {
function expectAppNavigation(path = file, jobId = 'preview-id') {
const calls = [
['/(auth)/admin/piece/[...path]/source', { path }],
['/(auth)/admin/piece/[...path]/preview', { path }],
['/(auth)/admin/piece/[...path]/published', { path }],
[previewRoute, { path, jobId }],
[`${previewRoute}/page`, { path, jobId }],
[`${previewRoute}/icon`, { path, jobId }],
@@ -196,7 +204,17 @@ test('Preview registers Share without retaining an OG route or redirect alias',
test.each(['waiting', 'running', 'completed', 'failed', 'expired'] as const)(
'Preview %s retains destinations and availability inside the shared responsive header',
(status) => {
const html = body(status)
const html = body(status, status === 'failed')
const forms = html.match(/<form\b[^>]*>[\s\S]*?<\/form>/g) ?? []
if (status === 'failed' || status === 'expired') {
expect(forms).toHaveLength(1)
expect(forms[0]).toContain('method="post"')
expect(forms[0]).toContain(`action="/admin/piece/${file}/preview"`)
expect(forms[0]).toContain('Generate fresh preview')
} else {
expect(forms).toHaveLength(0)
}
expect(html.includes(`href="/admin/piece/${file}/published"`)).toBe(status === 'failed')
const header = html.slice(0, html.indexOf('</header>') + '</header>'.length)
const links = anchors(header)
expect(links).toHaveLength(5)
@@ -289,7 +307,7 @@ test.each(['', '/page', '/icon', '/share'])(
}
)
test('Preview header retains encoded file and job identity reached through View', () => {
test('Preview retains encoded file and job navigation and publication identity', () => {
const props = data('completed')
props.file = 'books/literal%2F/源 #?%.books.md'
props.job = 'job /#?%2F'
@@ -310,6 +328,17 @@ test('Preview header retains encoded file and job identity reached through View'
expect(links.filter((link) => link.includes('aria-current="page"'))).toHaveLength(1)
expect(links.at(-1)).toContain('aria-current="page"')
expectAppNavigation(path, jobId)
const running = data('running')
running.file = props.file
running.job = props.job
running.hasPublishedVersion = true
const pendingHtml = render(PreviewLayout, {
props: { data: running, params: { path: running.file, jobId: running.job }, children }
}).body
expect(pendingHtml.split(`href="${encodedRoot}/published"`)).toHaveLength(2)
expect(pendingHtml).not.toMatch(/<form\b/)
expectAppNavigation(path, jobId)
expect(EventSource).not.toHaveBeenCalled()
})
@@ -13,6 +13,7 @@ vi.mock('$app/state', () => ({
route: { id: '/(auth)/admin/piece/[...path]/preview/[jobId]' }
}
}))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
vi.mock('$app/paths', () => ({
resolve: (route: string, params: { path: string; jobId?: string }) =>
route
@@ -34,6 +35,7 @@ const job = {
}),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
hasPublishedVersion: false,
file: 'books/book.md',
job: 'current-job',
createdAt: '2026-11-01T12:00:00.000Z',
@@ -112,8 +114,16 @@ test.each(['running', 'completed'] as const)(
}
)
test('completed Preview inspects only generated outputs in the current job namespace', () => {
const html = renderPreview(completedPreview)
test('completed Preview links its publication and inspects only current-job generated outputs', () => {
const html = renderPreview({
...completedPreview,
file: 'books/源 #?%.books.md',
hasPublishedVersion: true
})
expect(
html.split('href="/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published"')
).toHaveLength(2)
expect(html).toContain('Preview &lt;Draft>')
expect(html).toContain('Preview logs')
@@ -130,6 +140,7 @@ test('empty generated outputs do not fall back to SDK passthrough assets', () =>
expect(html).toContain('Preview assets')
expect(html).toContain('None recorded')
expect(html).not.toContain('/published"')
expect(html).not.toContain('generated/cover.jpg')
expect(html).not.toContain('archive/manual.pdf')
})
@@ -40,9 +40,10 @@ const { page, resolve } = vi.hoisted(() => ({
})
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
vi.mock('$app/paths', () => ({ resolve }))
// Only the layout's five destinations and explicit page tag links may use this mock.
// Only layout destinations, the retry action and explicit page tag links may use this mock.
// Resource/helper URLs must remain independent; this is not native Kit resolution evidence.
function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
const calls = [
@@ -52,6 +53,7 @@ function expectAppNavigation(jobs = ['job'], tags: string[] = []) {
const jobId = encodeURIComponent(job)
return [
['/(auth)/admin/piece/[...path]/source', { path }],
['/(auth)/admin/piece/[...path]/preview', { path }],
[route, { path, jobId }],
[`${route}/page`, { path, jobId }],
[`${route}/icon`, { path, jobId }],
@@ -89,6 +91,7 @@ function data(status: Status = 'completed', png = true): Data {
}),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
hasPublishedVersion: false,
file: 'books/book.md',
job: 'job',
createdAt: '1970-01-01T00:00:00.000Z',
@@ -1,10 +1,40 @@
import { expect, test, vi } from 'vitest'
import { load } from './+layout.server'
import { db } from '#lib/server/database/index.js'
import { getWebPiece } from '#lib/server/pieces.js'
test('the preview layout requests configured CSS without returning it as page data', async () => {
vi.mock('#lib/server/database/index.js', () => ({ db: {} }))
vi.mock('#lib/server/pieces.js', () => ({ getWebPiece: vi.fn() }))
test('the preview layout supplies publication availability and inlines configured CSS', async () => {
const inlineConfiguredStyles = vi.fn()
expect(
await load({ locals: { inlineConfiguredStyles } } as unknown as Parameters<typeof load>[0])
).toEqual({})
vi.mocked(getWebPiece).mockResolvedValue(undefined)
const file = 'books/example.books.md'
const event = {
params: { path: file },
locals: { inlineConfiguredStyles }
} as unknown as Parameters<typeof load>[0]
expect(await load(event)).toEqual({ hasPublishedVersion: false })
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
expect(inlineConfiguredStyles).toHaveBeenCalledExactlyOnceWith()
const foundRow: NonNullable<Awaited<ReturnType<typeof getWebPiece>>> = {
id: 'published-id',
key: 'published-key',
file_path: file,
title: 'Published title',
type: 'books',
slug: 'example',
date_added: 10,
date_updated: 20,
json_metadata: '{}',
content_hash: 'published-hash',
last_published_at: 1234
}
vi.mocked(getWebPiece).mockResolvedValue(foundRow)
expect(await load(event)).toEqual({ hasPublishedVersion: true })
expect(getWebPiece).toHaveBeenLastCalledWith(db, file)
expect(inlineConfiguredStyles).toHaveBeenCalledTimes(2)
})
@@ -1,17 +1,13 @@
<script lang="ts">
import type { PageProps } from './$types'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
import OverviewActions from '#lib/pieces/viewer/OverviewActions.svelte'
import { assetPathToUrl } from '@luzzle/web.pieces'
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
let { data }: PageProps = $props()
function fileUrl(path: string) {
return assetPathToUrl(path, data.config.url.luzzle_assets || data.config.url.app)
}
const statusLabels: Record<MarkdownPublicationStatus, string> = {
[MarkdownPublicationStatus.MATCHING]: 'Matches publish',
[MarkdownPublicationStatus.CHANGED]: 'Unpublished changes',
[MarkdownPublicationStatus.UNKNOWN]: 'Unknown'
}
const publishedAt = $derived(
data.publication.lastPublishedAt === null ? null : new Date(data.publication.lastPublishedAt)
)
@@ -49,9 +45,13 @@
<tr role="row">
<th scope="row" role="rowheader">State</th>
<td role="cell">
<span class="status" data-status={data.publication.markdownStatus}
>{statusLabels[data.publication.markdownStatus]}</span
>
<span class="status" data-status={data.publication.markdownStatus}>
{#if data.publication.markdownStatus === MarkdownPublicationStatus.MATCHING}Matches
publish
{:else if data.publication.markdownStatus === MarkdownPublicationStatus.CHANGED}Unpublished
changes
{:else}Unknown{/if}
</span>
</td>
</tr>
<tr role="row">
@@ -68,6 +68,12 @@
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</td>
</tr>
{#if data.publication.markdownStatus === MarkdownPublicationStatus.CHANGED}
<tr role="row">
<th scope="row" role="rowheader">Preview</th>
<td role="cell"><OverviewActions file={data.file} preview="saved" /></td>
</tr>
{/if}
</tbody>
</table>
</div>
@@ -14,6 +14,7 @@ const { page } = vi.hoisted(() => ({
}
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
vi.mock('$app/paths', () => ({
resolve: (route: string, params?: { path?: string; tag?: string }) => {
if (route === '/tags/[tag]') return `/tags/${params?.tag}`
@@ -71,6 +72,7 @@ test('overview shows the published snapshot, recorded completion time and public
expect(html).toContain('Published &lt;Book>')
expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"')
expect(html).toContain('data-status="matching"')
expect(html).not.toMatch(/<form\b/)
expect(html).toContain(`href="${data.publishedUrl}"`)
expect(html).toContain('href="/cdn/pieces/assets/generated/share.png"')
expect(html).not.toContain('/admin/preview/')
@@ -95,7 +97,14 @@ test('unpublished changes do not hide the previous publication or claim it match
const html = render(Overview, { props: { data, params: { path: data.file } } }).body
expect(html).toContain('data-status="changed"')
expect(html).toContain('Unpublished changes')
expect(html).toMatch(/Unpublished\s+changes/)
const forms = html.match(/<form\b[^>]*>[\s\S]*?<\/form>/g) ?? []
expect(forms).toHaveLength(1)
expect(forms[0]).toContain('method="post"')
expect(forms[0]).toContain('action="/admin/piece/books/draft%20%23%3F.books.md/preview"')
expect(forms[0]?.replace(/<!--[\s\S]*?-->/g, '')).toMatch(
/<button\b[^>]*type="submit"[^>]*>\s*Generate\s*<\/button>/
)
expect(html).not.toContain('Matches publish')
expect(html).toContain('datetime="2026-11-01T12:00:00.000Z"')
expect(html).toContain(`href="${data.publishedUrl}"`)
@@ -108,6 +117,7 @@ test('missing publication evidence remains unknown rather than using source date
const html = render(Overview, { props: { data, params: { path: data.file } } }).body
expect(html).toContain('data-status="unknown"')
expect(html).not.toMatch(/<form\b/)
expect(html).toContain('Unknown')
expect(html).not.toMatch(/<time\b/)
})
+7
View File
@@ -31,6 +31,13 @@ not save; use **Save** when ready. Use **Attach** to download or upload assets e
**Preview** always generates a fresh preview of the saved file; save your edits first.
**View published** opens the existing public version's Overview.
When saved Markdown has unpublished changes, Published Overview adds a **Preview**
row to the Publication table. Its **Generate** action creates a fresh preview
of the saved file without changing the publication. The Preview table adds a
**Publication → View published** row when a public version exists. Failed or expired
previews add a **Preview → Generate fresh preview** row;
this starts a new job from saved content rather than reusing the old preview.
When saved Markdown matches the publication, the toolbar is quieter. **Publish**
remains available in **More** to regenerate unchanged Markdown after attachment,
template, or renderer changes.