Piece viewer — incremental review #115

Merged
eleith merged 17 commits from feat/piece-viewer into main 2026-10-16 22:59:28 +00:00
119 changed files with 9799 additions and 696 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
"@luzzle/web": patch
"@luzzle/web.worker": patch
---
Add a Published piece viewer and align Preview with shared Overview and composition-only Share views, including inline output inspection. Replace legacy Live/OG navigation with View → Published / Preview and Share.
Record successful per-piece publication times, sync saved Markdown to its index, and compare indexed and published hashes.
Restrict public and Preview asset file access to validated directories, including resolved symlink targets.
@@ -0,0 +1,40 @@
import { expect, expectTypeOf, test } from 'vitest'
import { asCoreDatabase, getDatabaseClient } from './client.js'
import migrate from './migrations.js'
import type { LuzzleDatabase } from './tables/index.js'
test('asCoreDatabase accepts an existing core client without replacing it', async () => {
const db = getDatabaseClient(':memory:')
try {
const core = asCoreDatabase(db)
expect(core).toBe(db)
expectTypeOf(core).toEqualTypeOf<LuzzleDatabase>()
// @ts-expect-error The input schema must contain all core tables.
expectTypeOf(asCoreDatabase<{ application_items: { id: string } }>).toBeFunction()
} finally {
await db.destroy()
}
})
test('asCoreDatabase shares the expanded client and its data, retaining app table access', async () => {
const db = getDatabaseClient(':memory:').withTables<{ application_items: { id: string } }>()
try {
const core = asCoreDatabase(db)
expect(core).toBe(db)
expectTypeOf(core).toEqualTypeOf<LuzzleDatabase>()
expect((await migrate(core)).error).toBeUndefined()
await core
.insertInto('pieces_cache')
.values({ id: 'cache-id', file_path: 'books/test.books.md', content_hash: 'saved-hash' })
.execute()
expect(
(await db.selectFrom('pieces_cache').selectAll().executeTakeFirstOrThrow()).content_hash
).toBe('saved-hash')
await db.schema.createTable('application_items').addColumn('id', 'text').execute()
await db.insertInto('application_items').values({ id: 'app-id' }).execute()
expect(await db.selectFrom('application_items').selectAll().execute()).toEqual([{ id: 'app-id' }])
} finally {
await db.destroy()
}
})
+6 -2
View File
@@ -1,7 +1,7 @@
import { Kysely } from 'kysely'
import { NodeSqliteDialect } from './NodeSqliteDialect.js'
import { DatabaseSync } from 'node:sqlite'
import type { LuzzleTables } from './tables/index.js'
import type { LuzzleDatabase, LuzzleTables } from './tables/index.js'
function getDatabaseClient(pathToDb: string, debug = false) {
const db = new DatabaseSync(pathToDb)
@@ -17,4 +17,8 @@ function getDatabaseClient(pathToDb: string, debug = false) {
})
}
export { getDatabaseClient }
function asCoreDatabase<DB extends LuzzleTables>(db: Kysely<DB>): LuzzleDatabase {
return db as unknown as LuzzleDatabase
}
export { getDatabaseClient, asCoreDatabase }
+1
View File
@@ -6,6 +6,7 @@ import * as index from './index.js'
describe('index.ts', () => {
test('schema', () => {
expect(index).toBeDefined()
expect(index.asCoreDatabase).toBeTypeOf('function')
})
test('preserves the public generation exports without exposing provider internals', () => {
+2 -1
View File
@@ -3,7 +3,7 @@ import compile from './lib/ajv.js'
import { sql } from 'kysely'
import { extractFullMarkdown } from './lib/markdown.js'
import { addFrontMatter } from './lib/frontmatter.js'
import { getDatabaseClient } from './database/client.js'
import { getDatabaseClient, asCoreDatabase } from './database/client.js'
import {
filterFrontmatterFields,
findFrontmatterField,
@@ -23,6 +23,7 @@ export { mergeGeneratedFields, appendGeneratedBody } from './pieces/utils/genera
export {
getDatabaseClient,
asCoreDatabase,
migrate,
sql,
compile,
+14
View File
@@ -156,9 +156,23 @@ describe('src/pieces/item.ts', () => {
file_path: path,
note_markdown: note,
frontmatter_json: JSON.stringify(frontmatter),
assets_json_array: '[]',
})
})
test('makePieceItemUpdatable clears the last asset reference', () => {
const data = makePieceItemSelectable({
frontmatter_json: JSON.stringify({ title: 'title', cover: 'cover.jpg' }),
assets_json_array: JSON.stringify(['cover.jpg']),
})
const markdown = makeMarkdownSample(data.file_path, 'books', 'note', { title: 'title' })
const schema = makeSchema({ cover: { type: 'string', format: 'asset' } })
const update = database.makePieceItemUpdatable(markdown, schema, data)
expect(update.assets_json_array).toBe('[]')
})
test('makePieceItemUpdatable with recursive assets', () => {
const data = makePieceItemSelectable()
const note = 'note'
+1 -1
View File
@@ -116,7 +116,7 @@ function makePieceItemUpdatable<F extends PieceFrontmatter>(
}
if (force || data.assets_json_array !== assetString) {
update.assets_json_array = assetString !== '[]' ? assetString : undefined
update.assets_json_array = assetString
}
return update
+2
View File
@@ -14,6 +14,8 @@ declare global {
interface PageData {
session: Session | null
config: AppConfigPublic
preview?: boolean
job?: string
meta: {
title?: string
description?: string
+96 -3
View File
@@ -24,15 +24,28 @@ beforeEach(() => {
})
afterEach(() => vi.resetModules())
function event(session: object | null, pathname = '/api/admin/generate') {
function event(
session: object | null,
pathname = '/api/admin/generate',
{
routeId = pathname,
isDataRequest = false,
method = 'GET'
}: { routeId?: string | null; isDataRequest?: boolean; method?: string } = {}
) {
return {
url: new URL(`http://localhost${pathname}`),
request: new Request(`http://localhost${pathname}${isDataRequest ? '/__data.json' : ''}`, {
method
}),
route: { id: routeId },
isDataRequest,
locals: { auth: vi.fn().mockResolvedValue(session) }
} as unknown as Parameters<Handle>[0]['event']
}
test.each(['/admin', '/admin/pieces', '/api/admin/generate', '/admin/lsp', '/administrator'])(
'the protected prefix %s requires a session before resolving its handler',
test.each(['/admin', '/admin/pieces', '/api/admin', '/api/admin/generate', '/admin/lsp'])(
'the protected route %s requires a session before resolving its handler',
async (pathname) => {
const { handle } = await import('./hooks.server')
const resolve = vi.fn()
@@ -81,6 +94,86 @@ test('an authenticated generation request reaches its handler', async () => {
)
})
const encodedRequests = [
...['', '/page', '/icon', '/share'].flatMap((suffix) =>
[false, true].map((isDataRequest) => ({
pathname: '/%61dmin/piece/books/book.books.md/published' + suffix,
routeId: '/admin/piece/[...path]/published' + suffix,
isDataRequest,
method: 'GET'
}))
),
{
pathname: '/%61dmin/piece/books/book.books.md/source',
routeId: '/admin/piece/[...path]/source',
method: 'POST'
},
{ pathname: '/%61pi/admin/generate', routeId: '/api/admin/generate', method: 'POST' },
{ pathname: '/api/%61dmin/publish', routeId: '/api/admin/publish', method: 'POST' },
{ pathname: '/garden/admin/pieces', routeId: '/admin/pieces', method: 'GET' },
{ pathname: '/public-alias', routeId: '/admin/pieces', method: 'GET' }
]
test.each(encodedRequests)(
'$method $pathname (data=$isDataRequest) requires a session by matched route identity',
async ({ pathname, ...options }) => {
const { handle } = await import('./hooks.server')
const resolve = vi.fn()
const request = event(null, pathname, options)
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
status: 302,
location: `/signin?redirectTo=${pathname}`
})
expect(request.locals.auth).toHaveBeenCalledOnce()
expect(resolve).not.toHaveBeenCalled()
}
)
test.each(encodedRequests)(
'$pathname remains inaccessible with auth disabled, even with a session (data=$isDataRequest)',
async ({ pathname, ...options }) => {
fixture.config.auth = undefined
const { handle } = await import('./hooks.server')
const resolve = vi.fn()
const request = event({ user: { name: 'test' } }, pathname, options)
await expect(handle({ event: request, resolve })).rejects.toMatchObject({
status: 302,
location: '/'
})
expect(request.locals.auth).not.toHaveBeenCalled()
expect(resolve).not.toHaveBeenCalled()
}
)
test.each(encodedRequests)(
'authenticated $pathname resolves normally (data=$isDataRequest)',
async ({ pathname, ...options }) => {
const { handle } = await import('./hooks.server')
const response = new Response('resolved')
const resolve = vi.fn().mockResolvedValue(response)
const request = event({ user: { name: 'test' } }, pathname, options)
await expect(handle({ event: request, resolve })).resolves.toBe(response)
expect(request.locals.auth).toHaveBeenCalledOnce()
expect(resolve).toHaveBeenCalledExactlyOnceWith(request)
}
)
test.each([
{ pathname: '/administrator', routeId: '/administrator' },
{ pathname: '/api/administer', routeId: '/api/administer' },
{ pathname: '/admin-looking-alias', routeId: '/public' },
{ pathname: '/', routeId: '/(root)' },
{ pathname: '/admin/not-a-route', routeId: null }
])('public/unmatched $routeId is not classified by URL spelling', async ({ pathname, routeId }) => {
const { handle } = await import('./hooks.server')
const response = new Response('public or not found')
const resolve = vi.fn().mockResolvedValue(response)
const request = event(null, pathname, { routeId })
await expect(handle({ event: request, resolve })).resolves.toBe(response)
expect(request.locals.auth).not.toHaveBeenCalled()
expect(resolve).toHaveBeenCalledExactlyOnceWith(request)
})
test.each([
{ auth: credentialsAuth, provider: 'credentials' },
{ auth: oidcAuth, provider: 'oidc' }
+8 -3
View File
@@ -57,10 +57,15 @@ const authHandle = auth
const PROTECTED_PREFIXES = ['/admin', '/api/admin']
const guardHandle: Handle = async ({ event, resolve }) => {
const isProtected = PROTECTED_PREFIXES.some((prefix) => event.url.pathname.startsWith(prefix))
function isProtectedRoute(routeId: string | null) {
return (
routeId !== null &&
PROTECTED_PREFIXES.some((prefix) => routeId === prefix || routeId.startsWith(`${prefix}/`))
)
}
if (isProtected) {
const guardHandle: Handle = async ({ event, resolve }) => {
if (isProtectedRoute(event.route.id)) {
if (!auth) {
throw redirect(302, '/')
}
@@ -2,36 +2,24 @@
import { DropdownMenu } from 'bits-ui'
import DotsThreeVertical from 'virtual:icons/ph/dots-three-vertical'
import { goto } from '$app/navigation'
import { editorHref } from '$lib/pieces/navigation'
type Props = {
file: string
currentMode: 'source' | 'preview'
isDirty: boolean
canGenerate: boolean
hasPublicVersion: boolean
isPublicStale: boolean
canPublish: boolean
onDelete: () => void
onAttach?: () => void
onGenerate?: () => void
}
let {
file,
currentMode,
canGenerate,
hasPublicVersion,
isPublicStale,
onDelete,
onAttach,
onGenerate
}: Props = $props()
const previewUrl = `/admin/piece/${file}/preview`
const liveUrl = `/admin/piece/${file}/live`
let { file, currentMode, canGenerate, canPublish, onDelete, onAttach, onGenerate }: Props =
$props()
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="button button-variant-outline">
<DropdownMenu.Trigger class="button" data-variant="outline" aria-label="Piece actions">
<DotsThreeVertical />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
@@ -40,15 +28,7 @@
{#if open}
<div {...wrapperProps}>
<div {...props} class="dropdown-content">
{#if currentMode !== 'preview' && (!hasPublicVersion || isPublicStale)}
<DropdownMenu.Item onSelect={() => goto(previewUrl)}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>preview</div>
{/snippet}
</DropdownMenu.Item>
{/if}
{#if !hasPublicVersion || isPublicStale}
{#if canPublish}
<DropdownMenu.Item onSelect={() => goto('/admin/publish?check=1')}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>publish</div>
@@ -60,7 +40,7 @@
<DropdownMenu.Item
onSelect={() => {
if (currentMode === 'source' && onGenerate) onGenerate()
else goto(`/admin/piece/${file}/source?generate=1`)
else goto(editorHref(file) + '?generate=1')
}}
>
{#snippet child({ props })}
@@ -77,22 +57,6 @@
</DropdownMenu.Item>
{/if}
{#if hasPublicVersion}
<DropdownMenu.Item>
{#snippet child({ props })}
<a
href={liveUrl}
target="_blank"
rel="noopener"
class="dropdown-item"
{...props}
>
live
</a>
{/snippet}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onSelect={onDelete}>
{#snippet child({ props })}
<div class="dropdown-item destructive" {...props}>delete</div>
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import PieceActions from './PieceActions.svelte'
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
const source = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
// SSR covers only the trigger; portal actions below are source contracts,
// not evidence of client selection, keyboard handling or focus behavior.
test('SSR names the actions trigger', () => {
const { body } = render(PieceActions, {
props: {
file: 'books/a #?.md',
currentMode: 'source',
canGenerate: true,
canPublish: true,
onDelete: vi.fn(),
onAttach: vi.fn(),
onGenerate: vi.fn()
}
})
expect(body).toMatch(/<button[^>]*aria-label="Piece actions"[^>]*aria-haspopup="menu"/)
})
test('preserves action gates and callbacks, with encoded generate fallback and no legacy views', () => {
expect(source).toContain('{#if canPublish}')
expect(source).toContain("onSelect={() => goto('/admin/publish?check=1')}")
expect(source).toContain('{#if canGenerate}')
expect(source).toContain("if (currentMode === 'source' && onGenerate) onGenerate()")
expect(source).toContain("else goto(editorHref(file) + '?generate=1')")
expect(source).toContain("{#if currentMode === 'source' && onAttach}")
expect(source).toContain('onSelect={onAttach}')
expect(source).toContain('onSelect={onDelete}')
expect(source).not.toMatch(
/isDirty|hasPublicVersion|isPublicStale|previewUrl|liveUrl|href=|>preview<|>live</
)
})
@@ -0,0 +1,122 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import { goto } from '$app/navigation'
import { publishedHref, previewHref } from '$lib/pieces/navigation'
import { getViewAvailability } from './view-state'
type Props = {
file: string
hasPublishedVersion: boolean
isSaving: boolean
}
let { file, hasPublishedVersion, isSaving }: Props = $props()
const id = $props.id()
const availability = $derived(getViewAvailability(hasPublishedVersion, isSaving))
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="button" data-variant="outline">View</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content sideOffset={8} forceMount>
{#snippet child({ open, props, wrapperProps })}
{#if open}
<div {...wrapperProps}>
<div {...props} class="dropdown-content">
<DropdownMenu.Item
textValue="Published"
disabled={!availability.published}
onSelect={() => goto(publishedHref(file))}
>
{#snippet child({ props })}
<div
{...props}
class="dropdown-item"
aria-labelledby={`${id}-published-label`}
aria-describedby={!availability.published
? `${id}-published-description`
: undefined}
>
<span id={`${id}-published-label`}>Published</span>
{#if !availability.published}
<span id={`${id}-published-description`} class="description">
{#if isSaving}Saving…{:else}Not published yet{/if}
</span>
{/if}
</div>
{/snippet}
</DropdownMenu.Item>
<DropdownMenu.Item
textValue="Preview"
disabled={!availability.preview}
onSelect={() => goto(previewHref(file))}
>
{#snippet child({ props })}
<div
{...props}
class="dropdown-item"
aria-labelledby={`${id}-preview-label`}
aria-describedby={!availability.preview
? `${id}-preview-description`
: undefined}
>
<span id={`${id}-preview-label`}>Preview</span>
{#if !availability.preview}
<span id={`${id}-preview-description`} class="description">Saving…</span>
{/if}
</div>
{/snippet}
</DropdownMenu.Item>
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style>
.dropdown-content {
background-color: var(--color-surface-container-highest);
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
min-width: 160px;
box-shadow: var(--shadow-raised);
z-index: 1000;
}
.dropdown-item {
display: flex;
align-items: center;
padding: var(--space-3);
cursor: pointer;
color: var(--color-on-surface);
gap: var(--space-2);
border-radius: var(--radius-small);
width: 100%;
text-align: left;
text-decoration: none;
background: none;
border: none;
text-transform: uppercase;
}
.dropdown-item:hover,
.dropdown-item[data-highlighted] {
background-color: var(--color-surface-container-low);
outline: none;
}
.dropdown-item:focus-visible {
outline: auto;
}
.dropdown-item[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.description {
font-size: var(--font-size-small);
}
</style>
@@ -0,0 +1,108 @@
import { readFileSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import PieceView from './PieceView.svelte'
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
const source = readFileSync(new URL('./PieceView.svelte', import.meta.url), 'utf8')
// Bits portals do not render menu contents in SSR. These tests establish the
// trigger and source contracts, not popup visibility, clicks, warnings or focus.
test.each([false, true])('SSR keeps View available while saving=%s', (isSaving) => {
const { body } = render(PieceView, {
props: { file: 'books/a #?.md', hasPublishedVersion: false, isSaving }
})
expect(body.replace(/<!--.*?-->/g, '')).toMatch(
/<button[^>]*aria-haspopup="menu"[^>]*>View<\/button>/
)
expect(body).not.toMatch(/<button[^>]*disabled/)
})
test('both literal items use native disabled state and current-prop navigation callbacks', () => {
expect(source).toContain('$derived(getViewAvailability(hasPublishedVersion, isSaving))')
expect(source).toMatch(/class="dropdown-content">\s*<DropdownMenu.Item/)
expect(source).toMatch(/<\/DropdownMenu.Item>\s*<DropdownMenu.Item/)
expect(source.match(/<DropdownMenu.Item\s/g)).toHaveLength(2)
for (const [view, helper] of [
['published', 'publishedHref'],
['preview', 'previewHref']
]) {
expect(source).toContain(`disabled={!availability.${view}}`)
expect(source).toContain(`onSelect={() => goto(${helper}(file))}`)
}
expect(source).not.toMatch(/<a\b|\bhref=|preload|autoSave|bypassWarning|setTimeout|async\b/)
expect(source).not.toMatch(/isDirty|markdownStatus|content_hash|previewUrl|publishedUrl/)
})
test('source editor wires availability and encoded actions without replacing its unsaved guard', () => {
const editor = readFileSync(
new URL('../../../routes/admin/piece/[...path]/source/+page.svelte', import.meta.url),
'utf8'
)
expect(editor).toContain(
'<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />'
)
expect(editor).toContain(
'$derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))'
)
expect(editor).toContain('$derived(editorHref(data.file))')
for (const action of ['save', 'delete'])
expect(editor).toContain(`action="{sourceUrl}?/${action}"`)
expect(editor).toContain('fetch(`${sourceUrl}?/attach`, {')
expect(editor).toContain(
"if (isDirty && !bypassWarning && navigation.type !== 'form' && navigation.to)"
)
expect(editor).toContain('navigation.cancel()')
expect(editor).toContain('onclick={confirmDiscard}')
})
test('View menu source matches the original actions menu presentation', () => {
const original = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
for (const pattern of [
/\.dropdown-content\s*\{([^}]+)\}/,
/\.dropdown-item\s*\{([^}]+)\}/,
/\.dropdown-item\[data-disabled\]\s*\{([^}]+)\}/
]) {
const expected = original.match(pattern)?.[1]
const actual = source.match(pattern)?.[1]
expect(expected).toBeDefined()
expect(actual).toBeDefined()
expect(actual?.trim()).toBe(expected?.trim())
}
const hover = original.match(/\.dropdown-item:hover\s*\{([^}]+)\}/)?.[1]
const highlight = source.match(/\.dropdown-item\[data-highlighted\]\s*\{([^}]+)\}/)?.[1]
expect(hover).toBeDefined()
expect(highlight).toBeDefined()
expect(highlight?.trim()).toBe(hover?.trim())
expect(source).toMatch(/\.dropdown-item:focus-visible\s*\{\s*outline: auto;/)
})
test('menuitem source keeps labels and short reasons only while disabled', () => {
const markup = source.replace(/\s+/g, ' ')
expect(source).toContain('const id = $props.id()')
for (const [view, label] of [
['published', 'Published'],
['preview', 'Preview']
]) {
expect(source).toContain(`<span id={\`\${id}-${view}-label\`}>${label}</span>`)
expect(source).toContain(`aria-labelledby={\`\${id}-${view}-label\`}`)
expect(markup).toContain(
`aria-describedby={!availability.${view} ? \`\${id}-${view}-description\` : undefined}`
)
expect(markup).toContain(
`{#if !availability.${view}} <span id={\`\${id}-${view}-description\`} class="description">`
)
}
expect(source.match(/\{#snippet child\(\{ props \}\)\}\s*<div\s*\{\.\.\.props\}/g)).toHaveLength(
2
)
expect(source.match(/Saving…/g)).toHaveLength(2)
expect(source).toContain('Not published yet')
for (const verbose of [
'Wait for saving to finish.',
'No published version yet.',
'View the published version.',
'Uses saved Markdown.'
])
expect(source).not.toContain(verbose)
})
@@ -1,3 +1,5 @@
@import './token-colors.css';
.cm-editor {
color: var(--editor-foreground, var(--color-on-surface));
background-color: var(--editor-background, var(--color-surface));
@@ -78,62 +80,3 @@
line-height: 1;
white-space: nowrap;
}
/* Keep the spike's role colors and Markdown formatting, now on stable classes. */
.cm-editor .tok-heading {
color: var(--editor-heading, var(--color-on-surface));
font-weight: bold;
}
.cm-editor .tok-strong {
color: var(--editor-strong, var(--color-on-surface));
font-weight: bold;
}
.cm-editor .tok-emphasis {
color: var(--editor-emphasis, var(--color-on-surface));
font-style: italic;
}
.cm-editor .tok-strikethrough {
text-decoration: line-through;
}
.cm-editor .tok-quote {
color: var(--editor-quote, var(--color-on-surface-variant));
}
.cm-editor .tok-monospace {
color: var(--editor-code, var(--color-on-surface));
}
.cm-editor .tok-link {
color: var(--editor-link, var(--color-on-surface));
text-decoration: underline;
}
.cm-editor .tok-url {
color: var(--editor-url, var(--color-on-surface));
text-decoration: underline;
}
.cm-editor .tok-name {
color: var(--editor-key, var(--color-on-surface));
}
.cm-editor :is(.tok-string, .tok-string2) {
color: var(--editor-string, var(--color-on-surface));
}
.cm-editor :is(.tok-number, .tok-bool, .tok-atom, .tok-keyword) {
color: var(--editor-literal, var(--color-on-surface));
}
.cm-editor .tok-comment {
color: var(--editor-comment, var(--color-on-surface-variant));
font-style: italic;
}
.cm-editor :is(.tok-punctuation, .tok-meta, .tok-contentSeparator, .tok-operator) {
color: var(--editor-punctuation, var(--color-on-surface-variant));
}
@@ -88,6 +88,7 @@ test('emits stable classes for other asset languages without another palette', (
test('CSS binds the token classes to the accepted palette instead of fixed colors', () => {
const css = readFileSync(new URL('./configured.css', import.meta.url), 'utf8')
const tokenCss = readFileSync(new URL('./token-colors.css', import.meta.url), 'utf8')
const roles = {
heading: 'heading',
strong: 'strong',
@@ -110,16 +111,31 @@ test('CSS binds the token classes to the accepted palette instead of fixed color
operator: 'punctuation'
}
for (const [token, role] of Object.entries(roles)) {
expect(css).toMatch(
expect(tokenCss).toMatch(
new RegExp(`\\.tok-${token}\\b[^{]*\\{[^}]*color:\\s*var\\(--editor-${role},`)
)
}
expect(css).toMatch(/^\.cm-editor\s*\{/)
expect(css).not.toContain('luzzle-editor')
expect(css).toMatch(/^@import '\.\/token-colors\.css';\s+\.cm-editor\s*\{/)
const tokenSelectors = [...tokenCss.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{/g)].map(
(match) => match[1].trim().replace(/\s+/g, ' ')
)
expect(tokenSelectors).toHaveLength(13)
for (const selector of tokenSelectors) {
expect(selector.startsWith(':is(.cm-editor, .syntax-highlight) ')).toBe(true)
}
expect(tokenCss).toMatch(/\.tok-strikethrough\s*\{\s*text-decoration: line-through;/)
expect(tokenCss).toMatch(/\.tok-heading\s*\{[^}]*font-weight: bold;/)
expect(tokenCss).toMatch(/\.tok-strong\s*\{[^}]*font-weight: bold;/)
expect(tokenCss).toMatch(/\.tok-emphasis\s*\{[^}]*font-style: italic;/)
expect(tokenCss).toMatch(/\.tok-comment\s*\{[^}]*font-style: italic;/)
expect(tokenCss).toMatch(/\.tok-link\s*\{[^}]*text-decoration: underline;/)
expect(tokenCss).toMatch(/\.tok-url\s*\{[^}]*text-decoration: underline;/)
expect(css + tokenCss).not.toContain('luzzle-editor')
expect(css).toContain('--cm-attribute: var(--editor-string,')
expect(css).toContain('--cm-variable: var(--editor-key,')
expect(css).toContain('--cm-link: var(--editor-link,')
expect(css).not.toMatch(/#[\da-f]{3,8}\b/i)
expect(css + tokenCss).not.toMatch(/#[\da-f]{3,8}\b/i)
expect(css).not.toMatch(/\.tok-/)
})
test('theme reconfiguration preserves the document, selection, parser and undo history', () => {
@@ -1,17 +1,9 @@
import { classHighlighter, tagHighlighter, tags as t } from '@lezer/highlight'
import { classHighlighter } from '@lezer/highlight'
import { syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { extraClasses } from './token-classes'
import './configured.css'
// Supplement the standard token classes with the Markdown roles it omits.
const extraClasses = tagHighlighter([
{ tag: t.quote, class: 'tok-quote' },
{ tag: t.monospace, class: 'tok-monospace' },
{ tag: t.strikethrough, class: 'tok-strikethrough' },
{ tag: t.contentSeparator, class: 'tok-contentSeparator' },
{ tag: t.name, class: 'tok-name' }
])
export function configuredTheme(mode: 'light' | 'dark') {
return [
EditorView.darkTheme.of(mode === 'dark'),
@@ -0,0 +1,10 @@
import { tagHighlighter, tags as t } from '@lezer/highlight'
// Supplement the standard token classes with the Markdown roles it omits.
export const extraClasses = tagHighlighter([
{ tag: t.quote, class: 'tok-quote' },
{ tag: t.monospace, class: 'tok-monospace' },
{ tag: t.strikethrough, class: 'tok-strikethrough' },
{ tag: t.contentSeparator, class: 'tok-contentSeparator' },
{ tag: t.name, class: 'tok-name' }
])
@@ -0,0 +1,59 @@
/* Keep the spike's role colors and Markdown formatting, now on stable classes. */
:is(.cm-editor, .syntax-highlight) .tok-heading {
color: var(--editor-heading, var(--color-on-surface));
font-weight: bold;
}
:is(.cm-editor, .syntax-highlight) .tok-strong {
color: var(--editor-strong, var(--color-on-surface));
font-weight: bold;
}
:is(.cm-editor, .syntax-highlight) .tok-emphasis {
color: var(--editor-emphasis, var(--color-on-surface));
font-style: italic;
}
:is(.cm-editor, .syntax-highlight) .tok-strikethrough {
text-decoration: line-through;
}
:is(.cm-editor, .syntax-highlight) .tok-quote {
color: var(--editor-quote, var(--color-on-surface-variant));
}
:is(.cm-editor, .syntax-highlight) .tok-monospace {
color: var(--editor-code, var(--color-on-surface));
}
:is(.cm-editor, .syntax-highlight) .tok-link {
color: var(--editor-link, var(--color-on-surface));
text-decoration: underline;
}
:is(.cm-editor, .syntax-highlight) .tok-url {
color: var(--editor-url, var(--color-on-surface));
text-decoration: underline;
}
:is(.cm-editor, .syntax-highlight) .tok-name {
color: var(--editor-key, var(--color-on-surface));
}
:is(.cm-editor, .syntax-highlight) :is(.tok-string, .tok-string2) {
color: var(--editor-string, var(--color-on-surface));
}
:is(.cm-editor, .syntax-highlight) :is(.tok-number, .tok-bool, .tok-atom, .tok-keyword) {
color: var(--editor-literal, var(--color-on-surface));
}
:is(.cm-editor, .syntax-highlight) .tok-comment {
color: var(--editor-comment, var(--color-on-surface-variant));
font-style: italic;
}
:is(.cm-editor, .syntax-highlight)
:is(.tok-punctuation, .tok-meta, .tok-contentSeparator, .tok-operator) {
color: var(--editor-punctuation, var(--color-on-surface-variant));
}
@@ -0,0 +1,23 @@
import { expect, test } from 'vitest'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
import { getViewAvailability, shouldOfferPublish } from './view-state'
test.each([
[false, false, { published: false, preview: true }],
[true, false, { published: true, preview: true }],
[false, true, { published: false, preview: false }],
[true, true, { published: false, preview: false }]
])('availability: published row=%s, saving=%s', (hasPublishedVersion, isSaving, expected) => {
expect(getViewAvailability(hasPublishedVersion, isSaving)).toEqual(expected)
})
test.each([
[false, MarkdownPublicationStatus.MATCHING, true],
[false, MarkdownPublicationStatus.CHANGED, true],
[false, MarkdownPublicationStatus.UNKNOWN, true],
[true, MarkdownPublicationStatus.MATCHING, false],
[true, MarkdownPublicationStatus.CHANGED, true],
[true, MarkdownPublicationStatus.UNKNOWN, true]
])('publish offer: published row=%s, status=%s', (hasPublishedVersion, status, expected) => {
expect(shouldOfferPublish(hasPublishedVersion, status)).toBe(expected)
})
@@ -0,0 +1,15 @@
import { MarkdownPublicationStatus } from '$lib/pieces/types'
export function getViewAvailability(hasPublishedVersion: boolean, isSaving: boolean) {
return {
published: hasPublishedVersion && !isSaving,
preview: !isSaving
}
}
export function shouldOfferPublish(
hasPublishedVersion: boolean,
status: MarkdownPublicationStatus
): boolean {
return !hasPublishedVersion || status !== MarkdownPublicationStatus.MATCHING
}
@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { WebPieces, WebPiecesAsset } from '@luzzle/web.db'
import { hydrateWithAssets, hydrateWithAssetsInternal } from './assets.server.js'
const { selectFrom, query } = vi.hoisted(() => {
const query = {
selectAll: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
execute: vi.fn()
}
return { selectFrom: vi.fn(() => query), query }
})
vi.mock('$lib/server/database', () => ({ db: { selectFrom } }))
function makePiece(overrides: Partial<WebPieces> = {}) {
return {
id: 'book-id',
key: 'book-key',
title: 'Published book',
slug: 'published-book',
type: 'books',
file_path: 'books/published.books.md',
content_hash: 'completed-source-hash',
last_published_at: 1750000000000,
json_metadata: JSON.stringify({ author: 'Ada', tags: ['fiction'], rating: 5 }),
note: 'Published note',
date_added: 100,
date_updated: 200,
date_consumed: 150,
summary: 'Published summary',
keywords: 'fiction,book',
...overrides
}
}
function makeAsset(overrides: Partial<WebPiecesAsset> = {}): WebPiecesAsset {
return {
piece_file_path: 'books/published.books.md',
piece_key: 'book-key',
piece_asset_path: '.assets/original-cover.jpg',
piece_field_path: 'cover.image',
asset_key: 'cover-key',
transformation: 'image.original',
asset_path: 'cover-key/original.jpg',
mime_type: 'image/jpeg',
is_embedded: 1,
content: 'published asset content',
...overrides
}
}
beforeEach(() => {
vi.clearAllMocks()
query.execute.mockResolvedValue([])
})
describe('hydrateWithAssets', () => {
test('hydrates a single piece with parsed metadata and queries its ordered assets', async () => {
const piece = makePiece()
query.execute.mockResolvedValue([makeAsset()])
const hydrated = await hydrateWithAssets(piece)
expect(Array.isArray(hydrated)).toBe(false)
expect(hydrated).toMatchObject({
id: piece.id,
title: piece.title,
metadata: { author: 'Ada', tags: ['fiction'], rating: 5 },
assets: [{ asset_key: 'cover-key', transformation: 'image.original' }]
})
expect(selectFrom).toHaveBeenCalledExactlyOnceWith('web_pieces_assets')
expect(query.selectAll).toHaveBeenCalledOnce()
expect(query.where).toHaveBeenCalledExactlyOnceWith('piece_file_path', 'in', [piece.file_path])
expect(query.orderBy.mock.calls).toEqual([['piece_asset_path'], ['transformation']])
expect(query.execute).toHaveBeenCalledOnce()
})
test('hydrates an array in input order and groups assets by source path, not piece key', async () => {
const first = makePiece()
const second = makePiece({ id: 'second', file_path: 'books/second.books.md' })
const noAssets = makePiece({ id: 'third', file_path: 'books/third.books.md' })
query.execute.mockResolvedValue([
makeAsset({ piece_file_path: second.file_path, asset_key: 'second-cover' }),
makeAsset({ asset_key: 'first-cover' }),
makeAsset({ asset_key: 'first-palette', transformation: 'palette' })
])
const hydrated = await hydrateWithAssets([first, second, noAssets])
expect(hydrated.map((piece) => piece.id)).toEqual(['book-id', 'second', 'third'])
expect(hydrated.map((piece) => piece.assets.map((asset) => asset.asset_key))).toEqual([
['first-cover', 'first-palette'],
['second-cover'],
[]
])
expect(query.where).toHaveBeenCalledExactlyOnceWith('piece_file_path', 'in', [
first.file_path,
second.file_path,
noAssets.file_path
])
expect(query.execute).toHaveBeenCalledOnce()
})
test('keeps a one-element input array as an array', async () => {
const hydrated = await hydrateWithAssets([makePiece()])
expect(hydrated).toHaveLength(1)
expect(hydrated[0].assets).toEqual([])
})
test('returns an empty array without querying assets', async () => {
expect(await hydrateWithAssets([])).toEqual([])
expect(selectFrom).not.toHaveBeenCalled()
expect(query.execute).not.toHaveBeenCalled()
})
test.each(['', '{}'])(
'uses empty metadata for %j and keeps pieces without assets',
async (json) => {
const hydrated = await hydrateWithAssets(makePiece({ json_metadata: json }))
expect(hydrated.metadata).toEqual({})
expect(hydrated.assets).toEqual([])
}
)
test('projects only public piece and asset fields at runtime and in JSON', async () => {
const piece = makePiece()
const asset = makeAsset()
query.execute.mockResolvedValue([asset])
const hydrated = await hydrateWithAssets(piece)
const expected = {
id: piece.id,
key: piece.key,
title: piece.title,
slug: piece.slug,
type: piece.type,
note: piece.note,
date_added: piece.date_added,
date_updated: piece.date_updated,
date_consumed: piece.date_consumed,
summary: piece.summary,
keywords: piece.keywords,
metadata: JSON.parse(piece.json_metadata),
assets: [
{
asset_key: asset.asset_key,
transformation: asset.transformation,
asset_path: asset.asset_path,
mime_type: asset.mime_type,
is_embedded: asset.is_embedded,
content: asset.content
}
]
}
expect(hydrated).toStrictEqual(expected)
expect(JSON.parse(JSON.stringify(hydrated))).toStrictEqual(expected)
for (const field of ['file_path', 'json_metadata', 'content_hash', 'last_published_at']) {
expect(hydrated).not.toHaveProperty(field)
}
for (const field of ['piece_file_path', 'piece_key', 'piece_asset_path', 'piece_field_path']) {
expect(hydrated.assets[0]).not.toHaveProperty(field)
}
// Public hydration must not remove private fields from the original DB records.
expect(piece.content_hash).toBe('completed-source-hash')
expect(piece.last_published_at).toBe(1750000000000)
expect(asset.piece_asset_path).toBe('.assets/original-cover.jpg')
})
test('strips null completion markers and retains allowed optional asset values', async () => {
const piece = { ...makePiece(), content_hash: null, last_published_at: null }
query.execute.mockResolvedValue([makeAsset({ asset_path: null, is_embedded: 0 })])
const hydrated = await hydrateWithAssets(piece)
expect(hydrated).not.toHaveProperty('content_hash')
expect(hydrated).not.toHaveProperty('last_published_at')
expect(hydrated.assets[0]).toMatchObject({ asset_path: null, is_embedded: 0 })
})
})
describe('hydrateWithAssetsInternal', () => {
test('retains all internal piece and asset fields without parsing metadata', async () => {
const piece = makePiece()
const asset = makeAsset()
query.execute.mockResolvedValue([asset])
const hydrated = await hydrateWithAssetsInternal(piece)
expect(hydrated).toStrictEqual({ ...piece, assets: [asset] })
expect(hydrated).not.toHaveProperty('metadata')
expect(JSON.parse(JSON.stringify(hydrated))).toStrictEqual({ ...piece, assets: [asset] })
expect(query.where).toHaveBeenCalledExactlyOnceWith('piece_file_path', 'in', [piece.file_path])
})
test('groups array assets by source path and retains internal fields, including empty groups', async () => {
const first = makePiece()
const second = makePiece({ id: 'second', file_path: 'books/second.books.md' })
const noAssets = makePiece({ id: 'third', file_path: 'books/third.books.md' })
const firstAsset = makeAsset()
const secondAsset = makeAsset({ piece_file_path: second.file_path, asset_key: 'second-cover' })
query.execute.mockResolvedValue([secondAsset, firstAsset])
expect(await hydrateWithAssetsInternal([first, second, noAssets])).toStrictEqual([
{ ...first, assets: [firstAsset] },
{ ...second, assets: [secondAsset] },
{ ...noAssets, assets: [] }
])
expect(query.execute).toHaveBeenCalledOnce()
})
test('returns an empty array without querying assets', async () => {
expect(await hydrateWithAssetsInternal([])).toEqual([])
expect(selectFrom).not.toHaveBeenCalled()
expect(query.execute).not.toHaveBeenCalled()
})
})
+7 -1
View File
@@ -26,7 +26,13 @@ function toPublicAsset(asset: WebPiecesAsset): PublicWebPieceAsset {
}
function toPublicPiece(piece: WebPieces, assets: WebPiecesAsset[]): PublicWebPiece {
const { file_path: _file_path, json_metadata, ...rest } = piece
const {
file_path: _file_path,
json_metadata,
content_hash: _content_hash,
last_published_at: _last_published_at,
...rest
} = piece
return {
...rest,
metadata: JSON.parse(json_metadata || '{}'),
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from 'vitest'
import { encodeAssetPath, assetPathToUrl } from './assets'
test.each([
null,
undefined,
'',
' ',
'../x',
'a/../x',
'.',
'a/./x',
'/x',
'//host/x',
'a//x',
'https://host/x',
'javascript:x',
'a\\x'
])('invalid recorded path %j remains unlinked', (path) => {
expect(encodeAssetPath(path)).toBeUndefined()
expect(assetPathToUrl(path, '/garden')).toBeUndefined()
})
test.each(['', '/garden/', 'https://cdn.test/prefix/?old=1#fragment'])(
'public URL conversion still preserves base %s and encodes raw segments once',
(base) => {
const path = 'files/雪 #?%2e%2e <file>.pdf'
const encoded = 'files/%E9%9B%AA%20%23%3F%252e%252e%20%3Cfile%3E.pdf'
expect(encodeAssetPath(path)).toBe(encoded)
const prefix = base.startsWith('https:') ? 'https://cdn.test/prefix' : base.replace(/\/$/, '')
expect(assetPathToUrl(path, base)).toBe(`${prefix}/pieces/assets/${encoded}`)
}
)
+27
View File
@@ -0,0 +1,27 @@
export function encodeAssetPath(assetPath: string | null | undefined): string | undefined {
if (!assetPath?.trim() || assetPath.includes('\\') || /^[a-z][a-z\d+.-]*:/i.test(assetPath)) {
return undefined
}
const segments = assetPath.split('/')
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
return undefined
}
return segments.map(encodeURIComponent).join('/')
}
export function assetPathToUrl(
assetPath: string | null | undefined,
base: string
): string | undefined {
const encoded = encodeAssetPath(assetPath)
if (!encoded) return undefined
const path = `pieces/assets/${encoded}`
if (!base || base.startsWith('/')) {
return `${base.replace(/\/+$/, '')}/${path}`
}
const url = new URL(base)
url.pathname = `${url.pathname.replace(/\/+$/, '')}/${path}`
url.search = ''
url.hash = ''
return url.href
}
@@ -28,7 +28,7 @@
const tags = $derived(JSON.parse(piece.keywords || '[]')) as string[]
const width = $derived(size.width)
const height = $derived(size.height ? size.height : (width * 3) / 2)
const helpers = getPieceHelpers(piece)
const helpers = $derived(getPieceHelpers(piece))
async function resolveIcon(type: string): Promise<Component<PieceIconProps>> {
const importer = iconImportMap.get(type)
@@ -42,8 +42,10 @@
}
</script>
{#await resolveIcon(piece.type) then IconComponent}
<IconComponent {piece} {tags} size={{ width, height }} {lazy} {active} {helpers} />
{:catch}
<IconDefault {piece} {tags} size={{ width, height }} {lazy} {active} {helpers} />
{/await}
{#key piece}
{#await resolveIcon(piece.type) then IconComponent}
<IconComponent {piece} {tags} size={{ width, height }} {lazy} {active} {helpers} />
{:catch}
<IconDefault {piece} {tags} size={{ width, height }} {lazy} {active} {helpers} />
{/await}
{/key}
@@ -40,15 +40,17 @@
return OpengraphDefault
}
}
const helpers = getPieceHelpers(piece)
const helpers = $derived(getPieceHelpers(piece))
</script>
{#await resolveOpengraph(piece.type) then Opengraph}
<section style="width:{OpengraphImageWidth}px;height:{OpengraphImageHeight}px;">
<Opengraph {piece} {helpers} />
</section>
{:catch}
<section style="width:{OpengraphImageWidth}px;height:{OpengraphImageHeight}px;">
<OpengraphDefault {piece} {helpers} />
</section>
{/await}
{#key piece}
{#await resolveOpengraph(piece.type) then Opengraph}
<section style="width:{OpengraphImageWidth}px;height:{OpengraphImageHeight}px;">
<Opengraph {piece} {helpers} />
</section>
{:catch}
<section style="width:{OpengraphImageWidth}px;height:{OpengraphImageHeight}px;">
<OpengraphDefault {piece} {helpers} />
</section>
{/await}
{/key}
@@ -26,7 +26,7 @@
}
let { piece, tags }: Props = $props()
const helpers = getPieceHelpers(piece)
const helpers = $derived(getPieceHelpers(piece))
async function resolvePage(type: string): Promise<Component<PiecePageProps>> {
const importer = pageImportMap.get(type)
@@ -40,8 +40,10 @@
}
</script>
{#await resolvePage(piece.type) then PageComponent}
<PageComponent {piece} {tags} {helpers} {components} />
{:catch}
<PageDefault {piece} {tags} {helpers} {components} />
{/await}
{#key piece}
{#await resolvePage(piece.type) then PageComponent}
<PageComponent {piece} {tags} {helpers} {components} />
{:catch}
<PageDefault {piece} {tags} {helpers} {components} />
{/await}
{/key}
+241
View File
@@ -0,0 +1,241 @@
import { beforeEach, expect, test, vi } from 'vitest'
import type { PublicWebPiece } from '@luzzle/web.pieces'
import { getPieceHelpers } from './helpers.js'
const { page, resolve } = vi.hoisted(() => ({
page: {
params: {} as { jobId?: string },
url: new URL('https://app.example/pieces/books/book'),
data: {
preview: undefined as boolean | undefined,
job: undefined as string | undefined,
config: { url: { app: 'https://app.example', luzzle_assets: '' } }
}
},
resolve: vi.fn(() => {
throw new Error('Could not get the request store. This is an internal error.')
})
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({ resolve }))
function piece(path = 'books/cover key/cover.jpg'): PublicWebPiece {
return {
id: 'book',
key: 'book-key',
title: 'Book',
slug: 'a book',
type: 'books',
date_added: 1,
metadata: {},
keywords: '[]',
assets: [
{
asset_key: 'cover',
transformation: 'image.original',
asset_path: path,
mime_type: 'image/jpeg'
},
{
asset_key: 'cover',
transformation: 'palette',
content: '{"accent":"blue"}',
mime_type: 'application/json'
}
]
}
}
beforeEach(() => {
vi.clearAllMocks()
page.url = new URL('https://app.example/pieces/books/book')
page.params = {}
page.data.preview = undefined
page.data.job = undefined
page.data.config.url = { app: 'https://app.example', luzzle_assets: '' }
})
test.each([undefined, false, true])(
'custom-piece helpers keep the same interface when preview is %j',
(preview) => {
page.data.preview = preview
page.data.job = preview ? 'job' : undefined
const data = piece()
data.assets.push({
asset_key: 'book-key',
transformation: 'markdown',
content: 'Rendered body',
mime_type: 'text/markdown'
})
const helpers = getPieceHelpers(data)
expect(helpers.getPieceImageUrl('cover', 500, 'jpg')).toBe(
helpers.getPieceAssetUrl('cover', 'image.original')
)
expect(helpers.getPieceAssetContent('book-key', 'markdown')).toBe('Rendered body')
expect(helpers.getPiecePalette()).toEqual({ accent: 'blue' })
expect(helpers.getPieceUrl()).toBe('https://app.example/pieces/books/a%20book')
}
)
test.each([undefined, false])(
'preview %j uses Published assets with encoded links and app fallback',
(preview) => {
page.data.preview = preview
const helpers = getPieceHelpers(piece())
expect(helpers.getPieceImageUrl('cover', 500, 'jpg')).toBe(
'https://app.example/pieces/assets/books/cover%20key/cover.jpg'
)
expect(helpers.getPieceUrl()).toBe('https://app.example/pieces/books/a%20book')
expect(helpers.getPiecePalette()).toEqual({ accent: 'blue' })
}
)
test('Published is the default even on an admin pathname', () => {
page.url = new URL('https://app.example/admin/piece/book.books.md/published')
page.data.config.url.luzzle_assets = 'https://cdn.example/prefix/'
expect(getPieceHelpers(piece()).getPieceAssetUrl('cover', 'image.original')).toBe(
'https://cdn.example/prefix/pieces/assets/books/cover%20key/cover.jpg'
)
})
test('Preview uses its declared job, not the current route parameter', () => {
page.params.jobId = 'unrelated-route-job'
page.data.preview = true
page.data.job = 'selected job'
expect(getPieceHelpers(piece()).getPieceAssetUrl('cover', 'image.original')).toBe(
'/admin/preview/selected%20job/asset/books/cover%20key/cover.jpg'
)
})
test('Preview renderer asset links need only their declared job/path, even after an await', async () => {
await Promise.resolve()
page.url = new URL('https://app.example/unrelated/location')
page.data.preview = true
page.data.job = 'job #?%'
expect(
getPieceHelpers(piece('books/雪 ?#%.jpg')).getPieceAssetUrl('cover', 'image.original')
).toBe('/admin/preview/job%20%23%3F%25/asset/books/%E9%9B%AA%20%3F%23%25.jpg')
expect(resolve).not.toHaveBeenCalled()
})
test.each([
'../private.jpg',
'/private.jpg',
'books//cover.jpg',
'books/./cover.jpg',
'books\\cover.jpg',
'https://other.example/cover.jpg',
' '
])(
'invalid Preview path %j remains unlinked without changing inline data or source records',
(path) => {
page.data.preview = true
page.data.job = 'job'
const source = piece(path)
const helpers = getPieceHelpers(source)
expect(helpers.getPieceAssetUrl('cover', 'image.original')).toBeUndefined()
expect(helpers.getPiecePalette()).toEqual({ accent: 'blue' })
expect(source.assets[0].asset_path).toBe(path)
expect(resolve).not.toHaveBeenCalled()
}
)
test.each([undefined, ''])('Preview requires a nonempty job, received %j', (job) => {
page.data.preview = true
page.data.job = job
expect(() => getPieceHelpers(piece())).toThrow('Preview rendering requires a job.')
})
test.each([undefined, false])(
'a job by itself does not enable Preview when preview is %j',
(preview) => {
page.data.preview = preview
page.data.job = 'unrelated-job'
expect(getPieceHelpers(piece()).getPieceAssetUrl('cover', 'image.original')).toBe(
'https://app.example/pieces/assets/books/cover%20key/cover.jpg'
)
}
)
test('clearing Preview data returns to Published without changing an existing helper', () => {
page.data.preview = true
page.data.job = 'job'
const preview = getPieceHelpers(piece())
page.data.preview = undefined
page.data.job = undefined
const normal = getPieceHelpers(piece())
expect(preview.getPieceAssetUrl('cover', 'image.original')).toContain('/admin/preview/job/asset/')
expect(normal.getPieceAssetUrl('cover', 'image.original')).toBe(
'https://app.example/pieces/assets/books/cover%20key/cover.jpg'
)
})
test('a preview pathname cannot change the Published default without an explicit flag', () => {
page.url = new URL('https://app.example/admin/piece/book.books.md/preview/job/share')
page.params.jobId = 'job'
expect(getPieceHelpers(piece()).getPieceAssetUrl('cover', 'image.original')).toBe(
'https://app.example/pieces/assets/books/cover%20key/cover.jpg'
)
})
test.each([
'../private.jpg',
'books/../private.jpg',
'/private.jpg',
'books//cover.jpg',
'books/./cover.jpg',
'books\\cover.jpg',
'https://other.example/cover.jpg',
' '
])('invalid published path %j has no URL while keeping inline content and input intact', (path) => {
const source = piece(path)
const helpers = getPieceHelpers(source)
expect(helpers.getPieceAssetUrl('cover', 'image.original')).toBeUndefined()
expect(helpers.getPieceImageUrl('cover', 500, 'jpg')).toBeUndefined()
expect(helpers.getPiecePalette()).toEqual({ accent: 'blue' })
expect(source.assets[0].asset_path).toBe(path)
})
test('invalid published variants fall back to an available original', () => {
const source = piece('original.jpg')
source.assets.push({
asset_key: 'cover',
transformation: 'image.l.jpg',
asset_path: '../private.jpg',
mime_type: 'image/jpeg'
})
expect(getPieceHelpers(source).getPieceImageUrl('cover', 500, 'jpg')).toBe(
'https://app.example/pieces/assets/original.jpg'
)
})
test('published links encode raw Unicode, query, fragment and percent filename characters', () => {
expect(
getPieceHelpers(piece('books/雪 ?#%.jpg')).getPieceAssetUrl('cover', 'image.original')
).toBe('https://app.example/pieces/assets/books/%E9%9B%AA%20%3F%23%25.jpg')
})
test('app and CDN prefixes do not duplicate trailing slashes', () => {
page.data.config.url.app = 'https://app.example/site/'
const helpers = getPieceHelpers(piece())
expect(helpers.getPieceUrl()).toBe('https://app.example/site/pieces/books/a%20book')
expect(helpers.getPieceAssetUrl('cover', 'image.original')).toBe(
'https://app.example/site/pieces/assets/books/cover%20key/cover.jpg'
)
})
test('new helpers follow replacement piece, preview, config and job data', () => {
page.data.preview = true
page.data.job = 'old'
const old = getPieceHelpers(piece('old.jpg'))
page.data.job = 'new'
const next = getPieceHelpers({ ...piece('new.jpg'), slug: 'new-book' })
expect(old.getPieceAssetUrl('cover', 'image.original')).toBe('/admin/preview/old/asset/old.jpg')
expect(next.getPieceAssetUrl('cover', 'image.original')).toBe('/admin/preview/new/asset/new.jpg')
expect(next.getPieceUrl()).toBe('https://app.example/pieces/books/new-book')
page.data.preview = false
page.data.config.url.luzzle_assets = '/cdn'
expect(getPieceHelpers(piece()).getPieceAssetUrl('cover', 'image.original')).toBe(
'/cdn/pieces/assets/books/cover%20key/cover.jpg'
)
})
+15 -14
View File
@@ -3,6 +3,8 @@ import { createPieceHelpers, OpengraphImageWidth, OpengraphImageHeight } from '@
import type { PublicWebPiece, PieceComponentHelpers, PieceIconPalette } from '@luzzle/web.pieces'
import { page } from '$app/state'
import type { Component, Snippet } from 'svelte'
import { assetPathToUrl } from './assets.js'
import { previewOutputUrl } from './preview/output-url.js'
export { OpengraphImageWidth, OpengraphImageHeight }
export type { PieceComponentHelpers, PieceIconPalette }
@@ -55,28 +57,27 @@ export type PiecePageProps = {
components: PieceComponents
}
function detectAssetUrlBuilder(): (path: string) => string {
const jobId = page.params.jobId
const pathname = page.url.pathname
if (jobId && pathname.includes('/preview/')) {
return (path) => `/admin/preview/${jobId}/asset/${path}`
}
if (pathname.startsWith('/admin/')) {
return (path) => `/admin/asset/viewer/${path}`
function createAssetUrlBuilder(preview = false, job?: string): (path: string) => string {
if (preview === true) {
if (!job) throw new Error('Preview rendering requires a job.')
return (path) => previewOutputUrl(path, job) ?? ''
}
const config = page.data.config
const baseUrl = config.url.luzzle_assets || config.url.app
return (path) => `${baseUrl}/pieces/assets/${path}`
return (path) => assetPathToUrl(path, baseUrl) ?? ''
}
export function getPieceHelpers(piece: PublicWebPiece): PieceComponentHelpers {
const config = page.data.config
const buildAssetUrl = createAssetUrlBuilder(page.data.preview, page.data.job)
const assets = piece.assets.map((asset) =>
asset.asset_path && !buildAssetUrl(asset.asset_path) ? { ...asset, asset_path: null } : asset
)
return createPieceHelpers(
piece.assets,
detectAssetUrlBuilder(),
() => `${config.url.app}/pieces/${piece.type}/${piece.slug}`
assets,
buildAssetUrl,
() =>
`${config.url.app.replace(/\/+$/, '')}/pieces/${encodeURIComponent(piece.type)}/${encodeURIComponent(piece.slug)}`
)
}
+108
View File
@@ -0,0 +1,108 @@
import { existsSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { editorHref, getPieceLinks, previewHref, publishedHref } from './navigation'
const { resolve } = vi.hoisted(() => ({
resolve: vi.fn(() => {
throw new Error('Could not get the request store. This is an internal error.')
})
}))
vi.mock('$app/paths', () => ({ resolve }))
const piecePath = 'books/%E6%BA%90%20%23%3F%25%26%22.books.md'
const route = '/admin/piece/[...path]/published'
test('Published replaces Live without keeping a legacy handler or redirect alias', () => {
const routes = new URL('../../routes/admin/piece/[...path]/', import.meta.url)
expect(existsSync(new URL('live/', routes))).toBe(false)
for (const file of [
'published/+layout.server.ts',
'published/+page.svelte',
'published/page/+page.svelte',
'published/icon/+page.svelte',
'published/share/+page.svelte'
])
expect(existsSync(new URL(file, routes)), file).toBe(true)
})
test.each([
'books/源 #?%.books.md',
'folder with spaces/a book.books.md',
'nested/literal%2F.books.md',
'books/📚.books.md'
])(
'editor navigation encodes raw file %s and job segments once without a request store',
(file) => {
const root = `/admin/piece/${file.split('/').map(encodeURIComponent).join('/')}`
const job = 'job #?%'
const links = [editorHref(file), publishedHref(file), previewHref(file), previewHref(file, job)]
expect(links).toEqual([
`${root}/source`,
`${root}/published`,
`${root}/preview`,
`${root}/preview/job%20%23%3F%25`
])
for (const href of links) {
const url = new URL(href, 'https://app.test')
expect(url.search).toBe('')
expect(url.hash).toBe('')
expect(decodeURIComponent(url.pathname)).toContain(file)
}
expect(resolve).not.toHaveBeenCalled()
}
)
test.each(
['', '/garden'].flatMap((mount) =>
['', '/page', '/icon', '/share'].map((suffix) => ({ mount, suffix }))
)
)(
'builds all five links for mount=$mount view=$suffix without a request store',
({ mount, suffix }) => {
const root = `${mount}/admin/piece/${piecePath}`
const url = new URL(`https://app.test${root}/published${suffix}?private=query#fragment`)
const before = url.href
const links = getPieceLinks(url, route + suffix)
expect(links).toEqual({
source: `${root}/source`,
overview: `${root}/published`,
page: `${root}/published/page`,
icon: `${root}/published/icon`,
share: `${root}/published/share`
})
expect(JSON.parse(JSON.stringify(links))).toEqual(links)
expect(url.href).toBe(before)
expect(resolve).not.toHaveBeenCalled()
}
)
test.each(['', '/page', '/icon', '/share'])(
'trailing slash on %s does not change piece identity',
(suffix) => {
const url = new URL(
`https://app.test/admin/piece/films/mars-express.films.md/published${suffix}/`
)
expect(getPieceLinks(url, route + suffix).overview).toBe(
'/admin/piece/films/mars-express.films.md/published'
)
}
)
test('already-encoded literal percent filenames are preserved, not encoded twice or decoded', () => {
const url = new URL('https://app.test/admin/piece/nested/cover%2520name.md/published')
expect(getPieceLinks(url, route).source).toBe('/admin/piece/nested/cover%2520name.md/source')
})
test('nested source folders named after view routes remain part of source identity', () => {
const url = new URL(
'https://app.test/garden/admin/piece/published/page/icon/share/file.md/published/share'
)
expect(getPieceLinks(url, route + '/share').source).toBe(
'/garden/admin/piece/published/page/icon/share/file.md/source'
)
})
test('encoded static route segments stay navigable under the matched route identity', () => {
const url = new URL('https://app.test/%61dmin/piece/books/book.md/published/icon')
expect(getPieceLinks(url, route + '/icon').source).toBe('/%61dmin/piece/books/book.md/source')
})
+32
View File
@@ -0,0 +1,32 @@
function piecePath(file: string): string {
return file.split('/').map(encodeURIComponent).join('/')
}
export function editorHref(file: string): string {
return `/admin/piece/${piecePath(file)}/source`
}
export function publishedHref(file: string): string {
return `/admin/piece/${piecePath(file)}/published`
}
export function previewHref(file: string, job?: string): string {
const href = `/admin/piece/${piecePath(file)}/preview`
return job === undefined ? href : `${href}/${encodeURIComponent(job)}`
}
export function getPieceLinks(url: URL, routeId: string) {
const current = new URL(url)
current.pathname = current.pathname.replace(/\/$/, '')
const published = new URL(routeId.endsWith('/published') ? 'published/' : './', current)
return {
source: new URL('../source', published).pathname,
overview: published.pathname.replace(/\/$/, ''),
page: new URL('page', published).pathname,
icon: new URL('icon', published).pathname,
share: new URL('share', published).pathname
}
}
export type PieceLinks = ReturnType<typeof getPieceLinks>
@@ -0,0 +1,278 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { Config } from '@luzzle/web.config'
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { makeConfig } from '$lib/server/config.fixture.js'
import { assemblePreview, type PreviewWorkerResult } from './assemble.server.js'
const pieceConfig: Config['pieces'][number] = makeConfig({
pieces: [
{
type: 'book',
fields: {
title: 'details.title',
summary: 'details.summary',
date_consumed: 'details.read_on',
tags: 'tags',
media: ['media.cover', 'media.gallery[*].key'],
attachments: ['files']
}
}
]
}).pieces[0]
function workerResult(overrides: Partial<PreviewWorkerResult> = {}): PreviewWorkerResult {
return {
filePath: 'books/example.book.md',
type: 'book',
slug: 'example',
pieceKey: 'piece-key',
sanitizedFrontmatter: {
details: { title: 'Draft title', summary: 'Draft summary', read_on: 123 },
tags: [' Café , Sci Fi ', 'Repeat', '', 'Repeat', 'Hello / World'],
media: {
cover: 'cover-key',
gallery: [{ key: 'gallery-key' }, { key: 'video-key' }, { key: 'missing-key' }, { key: '' }]
},
files: ['pdf-key', 'unknown-key', 'missing-key', '']
},
note: 'Draft **note**',
pathToKey: {
'archive/cover.jpg': 'cover-key',
'archive/gallery.png': 'gallery-key',
'archive/movie.mp4': 'video-key',
'archive/manual.pdf': 'pdf-key',
'archive/data.unrecognized-extension': 'unknown-key'
},
transforms: [],
...overrides
}
}
const passthroughs: PublicWebPieceAsset[] = [
{
asset_key: 'cover-key',
transformation: 'image.original',
asset_path: 'archive/cover.jpg',
mime_type: 'image/jpeg'
},
{
asset_key: 'gallery-key',
transformation: 'image.original',
asset_path: 'archive/gallery.png',
mime_type: 'image/png'
},
{
asset_key: 'pdf-key',
transformation: 'attachment',
asset_path: 'archive/manual.pdf',
mime_type: 'application/pdf'
},
{
asset_key: 'unknown-key',
transformation: 'attachment',
asset_path: 'archive/data.unrecognized-extension',
mime_type: 'application/octet-stream'
}
]
afterEach(() => vi.useRealTimers())
test('projects every actual worker record to only the public output fields', () => {
const privateFields = {
piece_asset_path: 'private/source.jpg',
piece_field_path: 'media.cover',
piece_file_path: 'private/example.book.md',
piece_key: 'private-piece-key',
internal_debug: 'must not be serialized'
}
const result = workerResult({
transforms: [
{
...privateFields,
asset_key: 'cover-key',
transformation: 'image.thumb.webp',
asset_path: 'preview/job/cover.webp',
mime_type: 'image/webp',
is_embedded: 0,
content: ''
},
{
...privateFields,
asset_key: 'piece-key',
transformation: 'custom.summary.html',
asset_path: null,
mime_type: 'text/html',
is_embedded: 1,
content: '<p>Worker content</p>'
}
]
})
const expected: PublicWebPieceAsset[] = [
{
asset_key: 'cover-key',
transformation: 'image.thumb.webp',
asset_path: 'preview/job/cover.webp',
mime_type: 'image/webp',
is_embedded: 0,
content: ''
},
{
asset_key: 'piece-key',
transformation: 'custom.summary.html',
asset_path: null,
mime_type: 'text/html',
is_embedded: 1,
content: '<p>Worker content</p>'
}
]
const assembled = assemblePreview(result, pieceConfig)
expect(assembled.outputs).toStrictEqual(expected)
expect(JSON.parse(JSON.stringify(assembled.outputs))).toStrictEqual(expected)
expect(assembled.piece.assets).toStrictEqual([...expected, ...passthroughs])
for (const output of assembled.outputs) {
expect(Object.keys(output).sort()).toEqual(
['asset_key', 'transformation', 'asset_path', 'mime_type', 'is_embedded', 'content'].sort()
)
}
})
test('keeps actual outputs first and archive image/attachment fallbacks only in piece.assets', () => {
const output: PublicWebPieceAsset = {
asset_key: 'cover-key',
transformation: 'image.original',
asset_path: 'preview/job/original.jpg',
mime_type: 'image/jpeg',
is_embedded: undefined,
content: undefined
}
const assembled = assemblePreview(workerResult({ transforms: [output] }), pieceConfig)
expect(assembled.outputs).toStrictEqual([output])
expect(assembled.piece.assets).toStrictEqual([output, ...passthroughs])
expect(assembled.outputs).not.toBe(assembled.piece.assets)
})
test('preserves duplicate actual worker records and their original ordering', () => {
const output: PublicWebPieceAsset = {
asset_key: 'cover-key',
transformation: 'image.original',
asset_path: 'preview/job/original.jpg',
mime_type: 'image/jpeg',
is_embedded: 0,
content: undefined
}
const other = { ...output, transformation: 'custom.variant', asset_path: 'preview/job/other.jpg' }
const transforms = [output, other, { ...output }]
const assembled = assemblePreview(workerResult({ transforms }), pieceConfig)
expect(assembled.outputs).toStrictEqual(transforms)
expect(assembled.piece.assets).toStrictEqual([...transforms, ...passthroughs])
})
test.each([
{ label: 'missing', path: {} },
{ label: 'undefined', path: { asset_path: undefined } },
{ label: 'null', path: { asset_path: null } }
])('normalizes $label paths to null for inline-only actual outputs', ({ path }) => {
const inline = {
asset_key: 'piece-key',
transformation: 'markdown.html',
mime_type: 'text/html',
is_embedded: 1,
content: '<p>Inline only</p>',
...path
} satisfies PublicWebPieceAsset
const assembled = assemblePreview(workerResult({ transforms: [inline] }), pieceConfig)
expect(assembled.outputs).toStrictEqual([{ ...inline, asset_path: null }])
expect(assembled.piece.assets).toStrictEqual([{ ...inline, asset_path: null }, ...passthroughs])
})
test('does not invent generated outputs when only archive passthroughs exist', () => {
const assembled = assemblePreview(workerResult(), pieceConfig)
expect(assembled.outputs).toStrictEqual([])
expect(assembled.piece.assets).toStrictEqual(passthroughs)
})
test('leaves the worker result and configuration unchanged', () => {
const result = workerResult({
transforms: [
{
asset_key: 'piece-key',
transformation: 'markdown.html',
mime_type: 'text/html',
content: 'Inline',
piece_asset_path: 'private/path',
piece_field_path: 'note'
}
]
})
const originalResult = structuredClone(result)
const originalConfig = structuredClone(pieceConfig)
const assembled = assemblePreview(result, pieceConfig)
expect(result).toStrictEqual(originalResult)
expect(pieceConfig).toStrictEqual(originalConfig)
expect(assembled.outputs[0]).not.toBe(result.transforms[0])
})
test('preserves piece metadata and the existing comma, accent, and duplicate tag normalization', () => {
vi.setSystemTime(new Date('2026-06-22T00:00:00Z'))
const result = workerResult()
const assembled = assemblePreview(result, pieceConfig)
expect(assembled.piece).toStrictEqual({
id: 'preview',
key: 'piece-key',
slug: 'example',
type: 'book',
title: 'Draft title',
summary: 'Draft summary',
note: 'Draft **note**',
keywords: JSON.stringify(['Café', 'Sci Fi', 'Repeat', 'Repeat', 'Hello / World']),
metadata: result.sanitizedFrontmatter,
assets: passthroughs,
date_added: Date.now(),
date_updated: Date.now(),
date_consumed: 123
})
expect(assembled.tags).toStrictEqual([
{ slug: 'cafe', tag: 'Café' },
{ slug: 'sci-fi', tag: 'Sci Fi' },
{ slug: 'repeat', tag: 'Repeat' },
{ slug: 'repeat', tag: 'Repeat' },
{ slug: 'hello-world', tag: 'Hello / World' }
])
})
test('handles missing optional fields without inventing assets or tags and retains slug fallback', () => {
const minimalConfig: Config['pieces'][number] = makeConfig({
pieces: [{ type: 'book', fields: { title: 'title', date_consumed: 'read_on' } }]
}).pieces[0]
const assembled = assemblePreview(
workerResult({ sanitizedFrontmatter: {}, slug: '', filePath: 'Café Draft.book.md' }),
minimalConfig
)
expect(assembled.outputs).toStrictEqual([])
expect(assembled.piece.assets).toStrictEqual([])
expect(assembled.tags).toStrictEqual([])
expect(assembled.piece).toMatchObject({ title: '', slug: 'cafe-draftbookmd' })
expect(assembled.piece.summary).toBeUndefined()
expect(assembled.piece.keywords).toBeUndefined()
expect(assembled.piece.date_consumed).toBeUndefined()
})
test.each([
{ tags: ' Café, Sci Fi, ', expected: ['Café', 'Sci Fi'] },
{ tags: ['', ' , '], expected: [] }
])('keeps existing scalar and empty tag normalization for $tags', ({ tags, expected }) => {
const assembled = assemblePreview(workerResult({ sanitizedFrontmatter: { tags } }), pieceConfig)
expect(assembled.tags.map(({ tag }) => tag)).toStrictEqual(expected)
expect(assembled.piece.keywords).toBe(expected.length ? JSON.stringify(expected) : undefined)
})
@@ -6,6 +6,7 @@ import type { PublicWebPiece, PublicWebPieceAsset } from '@luzzle/web.pieces'
export type AssembledPreview = {
piece: PublicWebPiece
tags: Array<{ slug: string; tag: string }>
outputs: PublicWebPieceAsset[]
}
export type PreviewWorkerAsset = PublicWebPieceAsset & {
@@ -112,15 +113,16 @@ export function assemblePreview(
.filter(Boolean)
: []
const outputs: PublicWebPieceAsset[] = result.transforms.map((r) => ({
asset_key: r.asset_key,
transformation: r.transformation,
asset_path: r.asset_path ?? null,
mime_type: r.mime_type,
is_embedded: r.is_embedded,
content: r.content
}))
const assets: PublicWebPieceAsset[] = [
...result.transforms.map((r) => ({
asset_key: r.asset_key,
transformation: r.transformation,
asset_path: r.asset_path ?? null,
mime_type: r.mime_type,
is_embedded: r.is_embedded,
content: r.content
})),
...outputs,
...imagePassthroughs(fm, pieceConfig.fields.media, keyToPath),
...attachmentPassthroughs(fm, pieceConfig.fields.attachments, keyToPath)
]
@@ -143,5 +145,5 @@ export function assemblePreview(
const tags = tagValues.map((t) => ({ slug: slugify(t), tag: t }))
return { piece, tags }
return { piece, tags, outputs }
}
@@ -0,0 +1,43 @@
import { expect, test } from 'vitest'
import { previewOutputUrl } from './output-url'
test.each(['job', 'selected job', 'job #?%', '仕事', '%2e%2e'])(
'job %j uses the explicit root-mounted Admin asset route',
(job) => {
expect(previewOutputUrl('files/雪 #?%2e%2e.pdf', job)).toBe(
`/admin/preview/${encodeURIComponent(job)}/asset/files/%E9%9B%AA%20%23%3F%252e%252e.pdf`
)
}
)
test.each(['out.png', 'nested/preview/admin/piece/file.txt', '%2e%2e/file%20name.pdf'])(
'output %j is encoded once without interpreting filenames as routes',
(path) => {
expect(previewOutputUrl(path, 'job')).toBe(
`/admin/preview/job/asset/${path.split('/').map(encodeURIComponent).join('/')}`
)
}
)
test.each([
'../x',
'a/../x',
'/x',
'//host/x',
'https://host/x',
'javascript:x',
'x\\y',
'',
' ',
'a//x',
'a/./x'
])('unsafe output path %j never creates a Preview URL', (path) => {
expect(previewOutputUrl(path, 'job')).toBeUndefined()
})
test.each(['', '.', '..', 'a/b', 'a\\b', ' ', 'https://host/job'])(
'invalid job %j cannot alter the asset namespace',
(job) => {
expect(previewOutputUrl('out.png', job)).toBeUndefined()
}
)
@@ -0,0 +1,8 @@
import { encodeAssetPath } from '$lib/pieces/assets'
export function previewOutputUrl(path: string, job: string): string | undefined {
const encoded = encodeAssetPath(path)
const encodedJob = encodeAssetPath(job)
if (!encoded || !encodedJob || job.includes('/')) return undefined
return `/admin/preview/${encodedJob}/asset/${encoded}`
}
@@ -0,0 +1,275 @@
import { createHash } from 'node:crypto'
import { Readable } from 'node:stream'
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'
import {
Piece,
asCoreDatabase,
sql,
type LuzzleStorage,
type PieceFrontmatterSchema,
type PieceFrontmatter
} from '@luzzle/core'
import { db } from '$lib/server/database'
import type { AppDatabase } from '@luzzle/web.db'
import { getPieces } from '$lib/server/pieces'
import { getStorage } from '$lib/server/storage'
import { actions } from '../../routes/admin/piece/[...path]/source/+page.server'
import { getMarkdownPublicationStatus } from './publication.server.js'
import { MarkdownPublicationStatus } from './types.js'
vi.mock('$lib/server/database', async () => {
const { runWebMigrations } = await import('@luzzle/web.db')
const { migrate, getDatabaseClient } = await import('@luzzle/core')
const coreDb = getDatabaseClient(':memory:')
const db = coreDb.withTables<AppDatabase>()
const core = await migrate(coreDb)
if (core.error) throw new Error(`Test core migration failed: ${core.error}`)
const web = await runWebMigrations(db)
if (web.error) throw new Error(`Test web migration failed: ${web.error}`)
return { db }
})
vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() }))
vi.mock('$lib/server/config', async () => {
const { makeConfig } = await import('$lib/server/config.fixture')
return { config: makeConfig() }
})
const file = 'books/雪 ?#%.books.md'
const digest = (bytes: string | Buffer) => createHash('md5').update(bytes).digest('hex')
let savedBytes: Buffer
let modifiedAt: Date
let storage: {
exists: ReturnType<typeof vi.fn>
makeDirectory: ReturnType<typeof vi.fn>
writeFile: ReturnType<typeof vi.fn>
readFile: ReturnType<typeof vi.fn>
createReadStream: ReturnType<typeof vi.fn>
stat: ReturnType<typeof vi.fn>
}
let piece: Piece<PieceFrontmatter>
beforeEach(async () => {
vi.clearAllMocks()
await db.deleteFrom('pieces_cache').execute()
await db.deleteFrom('pieces_items').execute()
await db.deleteFrom('web_pieces').execute()
savedBytes = Buffer.alloc(0)
modifiedAt = new Date()
storage = {
exists: vi.fn(async () => true),
makeDirectory: vi.fn(async () => {}),
writeFile: vi.fn(async (path: string, content: string) => {
expect(path).toBe(file)
savedBytes = Buffer.from(content)
}),
readFile: vi.fn(async () => savedBytes.toString('utf8')),
createReadStream: vi.fn(() => Readable.from([savedBytes])),
stat: vi.fn(async () => ({ last_modified: modifiedAt }))
}
const schema = {
title: 'books',
type: 'object',
properties: { title: { type: 'string' }, cover: { type: 'string', format: 'asset' } },
required: ['title'],
additionalProperties: false
} as PieceFrontmatterSchema<PieceFrontmatter>
piece = new Piece('books', storage as unknown as LuzzleStorage, schema)
vi.mocked(getStorage).mockReturnValue(storage as unknown as ReturnType<typeof getStorage>)
vi.mocked(getPieces).mockReturnValue({
parseFilename: vi.fn(() => ({ type: 'books' })),
getPiece: vi.fn(async () => piece)
} as unknown as ReturnType<typeof getPieces>)
})
afterAll(async () => db.destroy())
function save(content: string) {
const formData = new FormData()
formData.set('content', content)
return actions.save({
params: { path: file },
request: { formData: async () => formData }
} as Parameters<typeof actions.save>[0])
}
async function published(hash: string) {
await db
.insertInto('web_pieces')
.values({
id: 'published-id',
key: 'published-key',
file_path: file,
type: 'books',
title: 'Published title',
slug: 'published-book',
json_metadata: '{"title":"Published title"}',
note: 'Published body',
date_added: 100,
content_hash: hash,
last_published_at: 200
})
.execute()
return db
.selectFrom('web_pieces')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
}
describe('getMarkdownPublicationStatus', () => {
test.each([
['published-hash', MarkdownPublicationStatus.MATCHING],
['indexed-hash', MarkdownPublicationStatus.CHANGED],
[undefined, MarkdownPublicationStatus.UNKNOWN],
[null, MarkdownPublicationStatus.UNKNOWN],
['', MarkdownPublicationStatus.UNKNOWN]
] as const)('compares persisted indexed hash %j: %s', async (hash, status) => {
if (hash !== undefined) {
await sql`INSERT INTO pieces_cache (id, file_path, content_hash) VALUES ('cache-id', ${file}, ${hash})`.execute(
db
)
}
expect(await getMarkdownPublicationStatus(file, 'published-hash')).toBe(status)
expect(getStorage).not.toHaveBeenCalled()
for (const method of Object.values(storage)) expect(method).not.toHaveBeenCalled()
})
test.each([undefined, null, ''])(
'missing published hash %j is unknown, not a match',
async (hash) => {
expect(await getMarkdownPublicationStatus(file, hash)).toBe(MarkdownPublicationStatus.UNKNOWN)
expect(getStorage).not.toHaveBeenCalled()
}
)
})
test('Save creates the archive item/cache before first publication, not a serving row', async () => {
const raw =
'---\r\ntitle: Saved title\r\ncover: .assets/books/cover.png\r\n---\r\nA paragraph.\r\n'
expect(await save(raw)).toEqual({ success: true })
const item = await db
.selectFrom('pieces_items')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
const cache = await db
.selectFrom('pieces_cache')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
expect(JSON.parse(item.frontmatter_json)).toEqual({
title: 'Saved title',
cover: '.assets/books/cover.png'
})
expect(item.note_markdown).toBe('A paragraph.\n')
expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/cover.png'])
expect(cache.content_hash).toBe(digest(savedBytes))
expect(cache.content_hash).not.toBe(digest(raw))
expect(storage.writeFile).toHaveBeenCalledOnce()
expect(storage.createReadStream).toHaveBeenCalledExactlyOnceWith(file)
expect(await db.selectFrom('web_pieces').selectAll().execute()).toEqual([])
})
test('Save updates existing item/cache while retaining serving content, hash and timestamp', async () => {
await save('---\ntitle: Original\n---\nOriginal body')
const originalCache = await db
.selectFrom('pieces_cache')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
const serving = await published(originalCache.content_hash)
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.MATCHING
)
expect(
await save('---\ntitle: Updated\ncover: .assets/books/new.png\n---\nUpdated body')
).toEqual({ success: true })
const cache = await db
.selectFrom('pieces_cache')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
const item = await db
.selectFrom('pieces_items')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
expect(cache.id).toBe(originalCache.id)
expect(cache.content_hash).toBe(digest(savedBytes))
expect(JSON.parse(item.frontmatter_json).title).toBe('Updated')
expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/new.png'])
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.CHANGED
)
expect(
await db
.selectFrom('web_pieces')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
).toEqual(serving)
await db
.updateTable('web_pieces')
.set({ content_hash: cache.content_hash })
.where('file_path', '=', file)
.execute()
expect(await getMarkdownPublicationStatus(file, cache.content_hash)).toBe(
MarkdownPublicationStatus.MATCHING
)
// A no-op Save remains matching; this tracks bytes, not a dirty event flag.
await save('---\ntitle: Updated\ncover: .assets/books/new.png\n---\nUpdated body')
expect(await getMarkdownPublicationStatus(file, cache.content_hash)).toBe(
MarkdownPublicationStatus.MATCHING
)
})
test('Save removing the last asset reference clears the indexed attachment list', async () => {
await save('---\ntitle: Original\ncover: .assets/books/cover.png\n---\nBody')
expect(await save('---\ntitle: Original\n---\nBody')).toEqual({ success: true })
const item = await db
.selectFrom('pieces_items')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
expect(JSON.parse(item.frontmatter_json)).toEqual({ title: 'Original', cover: null })
expect(JSON.parse(item.assets_json_array ?? '[]')).toEqual([])
})
test('external Markdown changes become visible to the same comparison after file sync', async () => {
await save('---\ntitle: Original\n---\nOriginal body')
const cache = await db
.selectFrom('pieces_cache')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
const serving = await published(cache.content_hash)
savedBytes = Buffer.from('---\ntitle: External edit\n---\nExternal body')
modifiedAt = new Date((cache.date_updated ?? cache.date_added) + 1)
// Until sync, the database truthfully reports the last indexed state only.
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.MATCHING
)
const results = []
for await (const result of await piece.sync(asCoreDatabase(db), [file])) results.push(result)
expect(results).toEqual([{ action: 'updated', file }])
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.CHANGED
)
const item = await db
.selectFrom('pieces_items')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
expect(JSON.parse(item.frontmatter_json).title).toBe('External edit')
expect(
await db
.selectFrom('web_pieces')
.selectAll()
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
).toEqual(serving)
})
@@ -0,0 +1,20 @@
import { db } from '$lib/server/database'
import { MarkdownPublicationStatus } from './types.js'
export async function getMarkdownPublicationStatus(
file: string,
publishedHash?: string | null
): Promise<MarkdownPublicationStatus> {
if (!publishedHash) return MarkdownPublicationStatus.UNKNOWN
const cached = await db
.selectFrom('pieces_cache')
.select('content_hash')
.where('file_path', '=', file)
.executeTakeFirst()
if (!cached?.content_hash) return MarkdownPublicationStatus.UNKNOWN
return cached.content_hash === publishedHash
? MarkdownPublicationStatus.MATCHING
: MarkdownPublicationStatus.CHANGED
}
+5
View File
@@ -0,0 +1,5 @@
export enum MarkdownPublicationStatus {
MATCHING = 'matching',
CHANGED = 'changed',
UNKNOWN = 'unknown'
}
@@ -0,0 +1,327 @@
<script lang="ts">
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { groupAssetOutputs, type OutputFile, type OutputValue } from './assets'
import { transformCaption, fileType } from './presentation'
import { toggleDisclosure } from './disclosure'
import SourceContent from './SourceContent.svelte'
let {
assets,
fileUrl,
heading = 'Outputs'
}: {
assets: PublicWebPieceAsset[]
fileUrl: (path: string) => string | undefined
heading?: string
} = $props()
const titleId = $props.id()
const grouped = $derived(groupAssetOutputs(assets, fileUrl))
// Presentation ordinals follow root input order, even for file/value subsets.
const ordinalMap = $derived.by(() => {
const ordinals = new Map<string, number>()
for (const asset of assets) {
if (!ordinals.has(asset.asset_key)) ordinals.set(asset.asset_key, ordinals.size + 1)
}
return ordinals
})
function assetLabel(key: string) {
return `Asset ${ordinalMap.get(key)}`
}
function groupByKey<T extends { key: string }>(items: T[]) {
const groups = new Map<string, T[]>()
for (const item of items) {
const group = groups.get(item.key)
if (group) group.push(item)
else groups.set(item.key, [item])
}
return Array.from(groups, ([key, items]) => ({ key, items })).sort(
(first, second) => ordinalMap.get(first.key)! - ordinalMap.get(second.key)!
)
}
type OutputRow = OutputFile | OutputValue
// Keep projected model identities as row and disclosure keys.
const families = $derived(
grouped.transforms.map((transform) => ({
name: transform.name,
groups: groupByKey<OutputRow>([...transform.files, ...transform.values])
}))
)
let expanded = $state.raw<Set<OutputValue>>(new Set())
$effect(() => {
// A new grouped model replaces the row identities (including replaced preview jobs).
void grouped
expanded = new Set<OutputValue>()
})
function toggle(value: OutputValue) {
expanded = toggleDisclosure(expanded, value)
}
// Explicit roles retain table semantics in Safari when mobile CSS changes display.
const roles = { table: 'table', rowgroup: 'rowgroup', row: 'row' } as const
</script>
{#snippet groupHeading(key: string, columns: number)}
<tr role={roles.row}>
<th scope="rowgroup" role="rowheader" colspan={columns} class="group-heading"
>{assetLabel(key)}</th
>
</tr>
{/snippet}
{#snippet fileValue(file: OutputFile)}
{#if file.path != null}
{#if file.url}
<div class="cell-value">
<a
href={file.url}
aria-label={`View ${file.label}: ${file.path}`}
title={file.path}
target="_blank"
rel="noopener noreferrer">View</a
>
</div>
{:else}
<div class="cell-value">
<span class="truncated" title={`${file.label}: ${file.path}`}>
<bdi dir="ltr">{file.path}</bdi>
</span>
<small class="unavailable" title="No usable file link">Unavailable</small>
</div>
{/if}
{:else}
<span class="cell-value" title={file.label}>Not recorded</span>
{/if}
{/snippet}
<section aria-labelledby={titleId}>
<h2 id={titleId}>{heading}</h2>
{#if !grouped.transforms.length}<span class="empty">None recorded</span>{/if}
{#each families as family, familyIndex (family.name)}
<div class="table-section">
<table role={roles.table}>
<caption>{transformCaption(family.name)}</caption>
<thead role={roles.rowgroup}>
<tr role={roles.row}>
<th scope="col" role="columnheader">Output</th>
<th scope="col" role="columnheader">Field</th>
<th scope="col" role="columnheader">Type</th>
<th scope="col" role="columnheader">Value</th>
</tr>
</thead>
{#each family.groups as group, groupIndex (group.key)}
<tbody role={roles.rowgroup}>
{#if family.groups.length > 1}{@render groupHeading(group.key, 4)}{/if}
{#each group.items as row, rowIndex (row)}
{@const output = 'path' in row ? row.label : row.output}
{@const field = 'path' in row ? 'File' : row.field}
{@const context = `${transformCaption(output)}: ${assetLabel(group.key)}, ${field}`}
{@const sourceId = `${titleId}-source-${familyIndex}-${groupIndex}-${rowIndex}`}
<tr role={roles.row}>
<th scope="row" role="rowheader">
<span class="cell-label" aria-hidden="true">Output</span>
<span class="cell-value truncated" title={output}
><bdi dir="ltr">{output}</bdi></span
>
</th>
<td role="cell">
<span class="cell-label" aria-hidden="true">Field</span>
<span class="cell-value truncated" title={field}
><bdi dir="ltr">{field}</bdi></span
>
</td>
<td role="cell" class="file-type">
<span class="cell-label" aria-hidden="true">Type</span>
<span class="cell-value truncated" title={row.mime}
><bdi dir="ltr">{fileType(row.mime)}</bdi></span
>
</td>
<td role="cell">
<span class="cell-label" aria-hidden="true">Value</span>
{#if 'path' in row}
{@render fileValue(row)}
{:else if row.value === null}
<span class="cell-value" title={row.mime}>Not recorded</span>
{:else if row.value === ''}
<span class="cell-value" title={row.mime}>Empty</span>
{:else}
<button
type="button"
aria-label={`${expanded.has(row) ? 'Hide' : 'Show'} ${context}`}
aria-expanded={expanded.has(row)}
aria-controls={sourceId}
onclick={() => toggle(row)}>{expanded.has(row) ? 'Hide' : 'Show'}</button
>
{/if}
</td>
</tr>
{#if !('path' in row) && row.value !== null && row.value !== ''}
<tr role={roles.row} id={sourceId} class="source-row" hidden={!expanded.has(row)}>
<td role="cell" colspan="4" class="source-cell">
{#if expanded.has(row)}
<SourceContent value={row} label={context} />
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
{/each}
</table>
</div>
{/each}
</section>
<style>
section,
.table-section,
table,
caption,
thead,
tbody,
tr,
th,
td,
.cell-value,
.truncated {
min-width: 0;
box-sizing: border-box;
}
section,
.table-section,
table {
width: 100%;
}
h2 {
font-size: var(--font-size-normal);
margin-block: var(--space-6) var(--space-3);
}
.table-section {
margin-block: var(--space-3);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-medium);
background: var(--color-surface-container-low);
color: var(--color-on-surface);
}
table {
table-layout: fixed;
border-collapse: collapse;
font-size: var(--font-size-xs);
}
caption {
padding: var(--space-2) var(--space-3);
text-align: left;
font-weight: var(--font-weight-bold);
overflow-wrap: anywhere;
}
thead {
background: var(--color-surface-container);
}
th,
td {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-outline-variant);
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
th {
font-weight: var(--font-weight-medium);
}
.group-heading,
.file-type,
.empty,
.unavailable,
.cell-label {
color: var(--color-on-surface-variant);
}
.cell-label {
display: none;
}
.empty {
font-size: var(--font-size-xs);
}
.unavailable {
font-size: inherit;
}
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);
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Override mobile row display too: collapsed IDREF targets remain in the DOM. */
[hidden] {
display: none !important;
}
/* Clip the beginning, but keep the raw value's visual and accessible order. */
.truncated {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
@media (max-width: 600px) {
table,
caption,
tbody {
display: block;
width: 100%;
}
/* Keep the column headers accessible when the rows become field cards. */
thead {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
tbody > tr {
display: block;
width: 100%;
padding: 0;
border-top: 1px solid var(--color-outline-variant);
}
tbody > tr > th,
tbody > tr > td {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: 0;
align-items: start;
}
tbody > tr > td.source-cell {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
}
.cell-label {
display: block;
min-width: 0;
}
tbody > tr > th.group-heading {
display: block;
padding: var(--space-2) var(--space-3);
}
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,126 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import AssetOutputs from './AssetOutputs.svelte'
let {
title,
assets,
fileUrl,
assetsHeading = 'Outputs',
children
}: {
title: string
assets?: PublicWebPieceAsset[]
fileUrl: (path: string) => string | undefined
assetsHeading?: string
children?: Snippet
} = $props()
</script>
<section class="overview" aria-label="Piece overview">
<h1>{title}</h1>
{@render children?.()}
{#if assets !== undefined}<AssetOutputs {assets} {fileUrl} heading={assetsHeading} />{/if}
</section>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
width: 100%;
min-width: 0;
box-sizing: border-box;
max-width: 1100px;
margin-inline: auto;
padding: var(--space-8) var(--space-4);
color: var(--color-on-surface);
}
h1 {
font-size: var(--font-size-large);
font-weight: var(--font-weight-bold);
line-height: 1.3;
overflow-wrap: anywhere;
}
.overview :global(.overview-facts) {
min-width: 0;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-large);
overflow: hidden;
background: var(--color-surface-container-low);
}
.overview :global(.overview-facts table) {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: var(--font-size-xs);
}
.overview :global(.overview-facts caption) {
padding: var(--space-2) var(--space-3);
text-align: left;
font-weight: var(--font-weight-bold);
background: var(--color-surface-container);
}
.overview :global(.overview-facts th),
.overview :global(.overview-facts td) {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-outline-variant);
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.overview :global(.overview-facts th) {
width: 32%;
font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant);
}
.overview :global(.overview-facts .status) {
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);
}
.overview :global(.overview-facts .status[data-status='matching']),
.overview :global(.overview-facts .status[data-status='completed']) {
background: var(--color-primary-container);
color: var(--color-on-primary-container);
}
.overview :global(.overview-facts .status[data-status='changed']),
.overview :global(.overview-facts .status[data-status='running']) {
background: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
.overview :global(.overview-facts .status[data-status='failed']) {
background: var(--color-error-container);
color: var(--color-on-error-container);
}
@media (max-width: 600px) {
.overview {
padding-block: var(--space-4);
}
.overview :global(.overview-facts table),
.overview :global(.overview-facts caption),
.overview :global(.overview-facts tbody) {
display: block;
}
.overview :global(.overview-facts tr) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
border-top: 1px solid var(--color-outline-variant);
padding: 0;
align-items: start;
}
.overview :global(.overview-facts th),
.overview :global(.overview-facts td) {
display: block;
width: auto;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 0;
}
}
</style>
@@ -0,0 +1,97 @@
import { readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
import { render } from 'svelte/server'
import { createRawSnippet } from 'svelte'
import { assetPathToUrl } from '$lib/pieces/assets'
import { asset } from './fixtures/published'
import PieceOverview from './PieceOverview.svelte'
test('shared overview owns title, facts slot and actual output inspector, not a main landmark or publication policy', () => {
const children = createRawSnippet(() => ({
render: () => '<div class="overview-facts"><p>Caller facts fixture</p></div>'
}))
const html = render(PieceOverview, {
props: {
title: 'Piece <title & "text">',
assets: [asset('image.original', 'cover.jpg')],
fileUrl: (path) => assetPathToUrl(path, '/cdn'),
children
}
}).body.replace(/<!--[^]*?-->/g, '')
expect(html).toContain('Piece &lt;title &amp; "text">')
expect(html).toContain('Caller facts fixture')
expect(html).toContain('Image</caption>')
expect(html).toContain('href="/cdn/pieces/assets/cover.jpg"')
expect(html).toContain('Outputs</h2>')
expect(html).not.toContain('<main')
expect(html).not.toContain('Last publish')
expect(html.indexOf('Caller facts fixture')).toBeLessThan(html.indexOf('Image</caption>'))
})
test('shared overview omits not-yet-produced assets and puts caller logs after its facts', () => {
const children = createRawSnippet(() => ({
render: () => '<p>Pending facts fixture</p><section><h2>Preview logs</h2></section>'
}))
const html = render(PieceOverview, {
props: {
title: 'Preview',
fileUrl: () => {
throw new Error('No asset callback expected')
},
children
}
}).body
expect(html).toContain('Pending facts fixture')
expect(html).toContain('Preview logs')
expect(html.indexOf('Pending facts fixture')).toBeLessThan(html.indexOf('Preview logs'))
expect(html).not.toContain('None recorded')
expect(html).not.toContain('Outputs</h2>')
expect(html).not.toContain('<main')
})
test('shared overview places caller facts and logs before actual output tables', () => {
const children = createRawSnippet(() => ({
render: () => '<p>Facts fixture</p><section><h2>Preview logs</h2></section>'
}))
const html = render(PieceOverview, {
props: {
title: 'Preview',
assets: [asset('image.original', 'cover.jpg')],
fileUrl: (path) => assetPathToUrl(path, '/cdn'),
children
}
}).body
expect(html).toContain('Image</caption>')
expect(html.indexOf('Facts fixture')).toBeLessThan(html.indexOf('Preview logs'))
expect(html.indexOf('Preview logs')).toBeLessThan(html.indexOf('Image</caption>'))
})
test('Preview and Published share the same badge styles and success/pending color rules', () => {
const source = readFileSync(new URL('./PieceOverview.svelte', import.meta.url), 'utf8')
const success = source.match(
/\[data-status='matching'\]\),\s*\.overview :global\(\.overview-facts \.status\[data-status='completed'\]\)\s*\{([^}]+)\}/
)?.[1]
expect(success).toContain('background: var(--color-primary-container)')
expect(success).toContain('color: var(--color-on-primary-container)')
const pending = source.match(
/\[data-status='changed'\]\),\s*\.overview :global\(\.overview-facts \.status\[data-status='running'\]\)\s*\{([^}]+)\}/
)?.[1]
expect(pending).toContain('background: var(--color-secondary-container)')
expect(pending).toContain('color: var(--color-on-secondary-container)')
})
test('shared overview can show empty preview outputs without guessing URLs or requiring publication facts', () => {
const html = render(PieceOverview, {
props: {
title: 'Preview title',
assets: [],
fileUrl: () => undefined,
assetsHeading: 'Preview assets'
}
}).body
expect(html).toContain('Preview title')
expect(html).toContain('Preview assets</h2>')
expect(html).toContain('None recorded')
expect(html).not.toContain('<a ')
expect(html).not.toContain('Publication')
})
@@ -0,0 +1,59 @@
<script lang="ts">
import { onMount } from 'svelte'
import type { PublicWebPiece } from '@luzzle/web.pieces'
import Opengraph from '$lib/pieces/components/opengraph.svelte'
import { OpengraphImageWidth, OpengraphImageHeight } from '@luzzle/web.pieces'
let { piece }: { piece: PublicWebPiece } = $props()
let container: HTMLDivElement | undefined = $state()
let scale = $state(1)
onMount(() => {
function updateScale() {
if (container) scale = Math.min(1, container.clientWidth / OpengraphImageWidth)
}
updateScale()
const observer = new ResizeObserver(updateScale)
if (container) observer.observe(container)
return () => observer.disconnect()
})
</script>
<main id="main-content" class="share-viewport" aria-label="Share">
<div
class="scale-container"
bind:this={container}
style:max-width="{OpengraphImageWidth}px"
style:height="{OpengraphImageHeight * scale}px"
>
<div
class="scale-inner"
style:transform="scale({scale})"
style:width="{OpengraphImageWidth}px"
style:height="{OpengraphImageHeight}px"
>
<Opengraph {piece} />
</div>
</div>
</main>
<style>
.share-viewport {
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
background: var(--color-surface-container-low);
color: var(--color-on-surface);
}
.scale-container {
width: 100%;
margin-inline: auto;
overflow: hidden;
box-shadow: var(--shadow-raised);
}
.scale-inner {
transform-origin: top left;
}
</style>
@@ -0,0 +1,56 @@
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import { OpengraphImageWidth, OpengraphImageHeight } from '@luzzle/web.pieces'
import ShareViewport from './ShareViewport.svelte'
import { publishedPiece, asset } from './fixtures/published'
vi.mock('$app/state', () => ({
page: { data: { config: { url: { app: '/garden', luzzle_assets: '/cdn' } } } }
}))
vi.mock('$app/paths', () => ({
resolve: (route: string, params: Record<string, string>) =>
route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name])
}))
// The production wrapper imports renderers asynchronously. This synchronous stub
// establishes viewport markup and helper URLs, not actual renderer/client behavior.
vi.mock('$lib/pieces/components/opengraph.svelte', async () => ({
default: (await import('./fixtures/Composition.svelte')).default
}))
test('shows only the composition with published assets, without explanatory text', () => {
const html = render(ShareViewport, { props: { piece: publishedPiece } }).body
expect(html).toContain('aria-label="Share"')
expect(html).not.toContain('Rendered share composition')
expect(html).not.toContain('Reconstructed with')
expect(html).not.toContain('not the generated PNG')
expect(html).not.toContain('<h1')
expect(html).not.toContain('<p')
expect(html).toContain('data-renderer="composition"')
expect(html).toContain('src="/cdn/pieces/assets/published/cover.jpg"')
expect(html).not.toContain('/admin/asset')
expect(html).not.toContain('/admin/preview')
expect(html).not.toContain('Open generated share image')
expect(html).toContain(`max-width: ${OpengraphImageWidth}px; height: ${OpengraphImageHeight}px`)
expect(html).toContain(`width: ${OpengraphImageWidth}px; height: ${OpengraphImageHeight}px`)
expect(html.match(/<main /g)).toHaveLength(1)
})
test('recorded generated PNG remains separate from the composition', () => {
const piece = {
...publishedPiece,
assets: [...publishedPiece.assets, asset('opengraph', 'actual-share.png', 'piece', 'image/png')]
}
const html = render(ShareViewport, { props: { piece } }).body
expect(html).toContain('data-renderer="composition"')
expect(html).not.toContain('actual-share.png')
expect(html).not.toContain('Open generated share image')
})
test('missing assets still permit reconstruction without claiming a generated file', () => {
const html = render(ShareViewport, { props: { piece: { ...publishedPiece, assets: [] } } }).body
expect(html).toContain('aria-label="Share"')
expect(html).toContain('data-renderer="composition"')
expect(html).not.toContain('Rendered share composition')
expect(html).not.toContain('Open generated share image')
expect(html).not.toContain('/pieces/assets/')
})
@@ -0,0 +1,65 @@
<script lang="ts">
import type { OutputValue } from './assets'
import { formatSource, type SourceToken } from './source'
import '$lib/components/editor/themes/token-colors.css'
let { value, label }: { value: OutputValue; label: string } = $props()
const source = $derived(formatSource(value.value ?? '', value.mime))
let highlighted = $state.raw<{
source: string
mime: string
tokens: SourceToken[]
} | null>(null)
const tokens = $derived(
highlighted?.source === source && highlighted.mime === value.mime
? highlighted.tokens
: [{ text: source, classes: '' }]
)
$effect(() => {
const text = source
const mime = value.mime
let active = true
import('./source-highlight')
.then(({ highlightSource }) => highlightSource(text, mime))
.then((tokens) => {
if (active) highlighted = { source: text, mime, tokens }
})
.catch(() => {
// The full plain source remains available if highlighting cannot load.
})
return () => {
active = false
}
})
</script>
<!-- Keyboard focus makes the bounded read-only source scrollable without a mouse. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<pre
class="syntax-highlight"
title={value.mime}
tabindex="0"
role="region"
aria-label="{label} source"><code
>{#each tokens as token (token)}<span class={token.classes}>{token.text}</span>{/each}</code
></pre>
<style>
pre {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
max-height: 24rem;
margin: 0;
padding: var(--space-3);
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 2;
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xs);
color: var(--editor-foreground, var(--color-on-surface));
background: var(--editor-background, var(--color-surface));
}
</style>
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
import { render } from 'svelte/server'
import SourceContent from './SourceContent.svelte'
function source(content: string, mime = 'text/plain') {
return render(SourceContent, {
props: {
value: {
key: 'private-key',
output: 'custom.output',
field: 'Content',
value: content,
mime
},
label: 'Custom.output: Asset 1, Content'
}
}).body.replace(/<!--[^]*?-->/g, '')
}
function escaped(text: string) {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;')
}
function code(html: string) {
return html.match(/<code[^>]*>([\s\S]*?)<\/code>/)?.[1].replace(/<\/?span\b[^>]*>/g, '')
}
test.each(['text/html', 'image/svg+xml', 'text/css', 'text/javascript', 'unknown'])(
'%s content stays inert and complete instead of executing as markup or CSS',
(mime) => {
const text =
' <script>alert("x")</script>\r\n<iframe src="https://bad.test"></iframe> & <style>body{display:none}</style>\n '
const html = source(text, mime)
expect(code(html)).toBe(escaped(text))
for (const tag of ['<script', '<iframe', '<style', '<svg', '<img'])
expect(html).not.toContain(tag)
expect(html).not.toContain('private-key')
expect(html).toContain('role="region"')
expect(html).toContain('tabindex="0"')
expect(html).toContain('aria-label="Custom.output: Asset 1, Content source"')
}
)
test('JSON source is pretty-printed without changing numeric lexemes, duplicate keys or string escapes', () => {
const html = source(
'{"id":9007199254740993,"id":1e400,"html":"<script>bad()</script>","literal":"\\u0041"}',
'application/json'
)
expect(code(html)).toBe(
escaped(
'{\n "id": 9007199254740993,\n "id": 1e400,\n "html": "<script>bad()</script>",\n "literal": "\\u0041"\n}'
)
)
expect(html).not.toContain('<script>')
})
test('malformed JSON remains the full original text', () => {
const content = ' { broken <script>alert(1)</script>\r\n'
expect(code(source(content, 'application/json'))).toBe(escaped(content))
})
test('large source remains available in full rather than truncating the record', () => {
const content = ' snow 雪 <tag> & \r\n'.repeat(12000)
expect(code(source(content))).toBe(escaped(content))
})
test('source view uses the editor token palette with a bounded keyboard-scrollable viewport', () => {
const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8')
expect(file).toContain("import '$lib/components/editor/themes/token-colors.css'")
expect(file).toContain('class="syntax-highlight"')
expect(file).toContain('max-height: 24rem')
expect(file).toContain('overflow: auto')
expect(file).toContain('var(--editor-foreground, var(--color-on-surface))')
expect(file).toContain('var(--editor-background, var(--color-surface))')
expect(file).not.toContain('{@html')
expect(file).not.toContain('innerHTML')
})
test('highlighting is loaded after expansion/mount with a current-source guard and safe plain fallback', () => {
const file = readFileSync(new URL('./SourceContent.svelte', import.meta.url), 'utf8')
expect(file).toContain("import('./source-highlight')")
expect(file).toContain('highlighted?.source === source && highlighted.mime === value.mime')
expect(file).toContain('if (active) highlighted =')
expect(file).toContain('active = false')
expect(file).toContain('{token.text}')
expect(file).not.toContain('fetch(')
expect(file).not.toContain('clipboard')
})
@@ -0,0 +1,110 @@
.viewer-bar {
position: sticky;
top: 0;
z-index: 100;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: 'back links label';
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background-color: var(--color-tertiary-container);
color: var(--color-on-tertiary-container);
font-size: var(--font-size-small);
}
.viewer-bar .viewer-bar-back {
grid-area: back;
justify-self: start;
padding: var(--space-1);
}
.viewer-bar .viewer-bar-links {
grid-area: links;
justify-self: center;
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
gap: var(--space-3);
overflow-x: auto;
}
.viewer-bar .viewer-bar-back,
.viewer-bar .viewer-bar-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
color: inherit;
text-decoration: none;
white-space: nowrap;
border-radius: var(--radius-small);
}
.viewer-bar .viewer-bar-link {
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
text-transform: uppercase;
font-size: 0.9em;
/* Keep Preview's muted treatment with stronger small-text contrast. */
opacity: 0.85;
}
.viewer-bar .viewer-bar-link:hover {
opacity: 1;
background-color: color-mix(in srgb, var(--color-on-tertiary-container) 10%, transparent);
}
.viewer-bar .viewer-bar-link[aria-current='page'],
.viewer-bar .viewer-bar-link.active {
opacity: 1;
font-weight: var(--font-weight-bold);
text-decoration: underline;
text-underline-offset: 0.2em;
background-color: color-mix(in srgb, var(--color-on-tertiary-container) 20%, transparent);
}
.viewer-bar a:focus-visible {
opacity: 1;
outline: 2px solid currentColor;
outline-offset: -2px;
}
.viewer-bar .viewer-bar-link.disabled {
opacity: 0.35;
pointer-events: none;
cursor: not-allowed;
}
.viewer-bar .viewer-bar-label {
grid-area: label;
justify-self: end;
text-transform: uppercase;
font-weight: var(--font-weight-bold);
letter-spacing: 0.05em;
opacity: 0.85;
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.viewer-bar {
grid-template-columns: minmax(44px, 1fr) minmax(0, auto);
grid-template-areas: 'back label' 'links links';
row-gap: var(--space-1);
}
.viewer-bar .viewer-bar-links {
justify-self: stretch;
flex-wrap: wrap;
gap: var(--space-1);
overflow-x: visible;
}
.viewer-bar .viewer-bar-link {
flex: 1 0 auto;
padding-inline: var(--space-1);
}
}
@@ -0,0 +1,32 @@
<script lang="ts">
import './ViewerBar.css'
import { page } from '$app/state'
import ArrowLeftIcon from 'virtual:icons/ph/arrow-left'
import type { PieceLinks } from '$lib/pieces/navigation'
let { links }: { links: PieceLinks } = $props()
const tabs = $derived(
(['overview', 'page', 'icon', 'share'] as const).map((tab) => ({
label: tab[0].toUpperCase() + tab.slice(1),
href: links[tab]
}))
)
</script>
<header class="viewer-bar">
<a href={links.source} class="viewer-bar-back" aria-label="Back to editor" title="Back to editor">
<ArrowLeftIcon aria-hidden="true" focusable="false" />
</a>
<nav class="viewer-bar-links" aria-label="Published views">
{#each tabs as tab (tab.label)}
<a
class="viewer-bar-link"
href={tab.href}
aria-current={page.url.pathname.replace(/\/$/, '') === new URL(tab.href, page.url).pathname
? 'page'
: undefined}>{tab.label}</a
>
{/each}
</nav>
<span class="viewer-bar-label">Published</span>
</header>
@@ -0,0 +1,176 @@
import { readFileSync } from 'node:fs'
import { beforeEach, expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { PieceLinks } from '$lib/pieces/navigation'
import ViewerHeader from './ViewerHeader.svelte'
const { page, resolve } = vi.hoisted(() => ({
page: { url: new URL('https://app.test/') },
resolve: vi.fn(() => {
throw new Error('Could not get the request store. This is an internal error.')
})
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({ resolve }))
function links(root = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'): PieceLinks {
return {
source: `${root}/source`,
overview: `${root}/published`,
page: `${root}/published/page`,
icon: `${root}/published/icon`,
share: `${root}/published/share`
}
}
beforeEach(() => {
page.url = new URL('https://app.test/')
vi.clearAllMocks()
})
test.each(['overview', 'page', 'icon', 'share'] as const)(
'%s is active using serialized loader links without a render request store',
(tab) => {
const navigation = links()
page.url = new URL(navigation[tab], 'https://app.test')
const html = render(ViewerHeader, { props: { links: navigation } }).body
expect(html).toContain(`href="${navigation.source}"`)
expect(html).toContain('aria-label="Back to editor"')
expect(html).toContain('title="Back to editor"')
expect(html).toContain('aria-label="Published views"')
expect(html).toContain(`href="${navigation[tab]}" aria-current="page"`)
expect(html.match(/aria-current="page"/g)).toHaveLength(1)
for (const label of ['Overview', 'Page', 'Icon', 'Share']) expect(html).toContain(label)
expect(html).not.toContain('Logs')
expect(html).not.toContain('<main')
expect(html).not.toContain('target="_blank"')
expect(resolve).not.toHaveBeenCalled()
}
)
test('mounted loader links preserve active tab and editor destination', () => {
const navigation = links('/garden/admin/piece/books/book.md')
page.url = new URL(navigation.icon, 'https://app.test')
const html = render(ViewerHeader, { props: { links: navigation } }).body
expect(html).toContain(`href="${navigation.icon}" aria-current="page"`)
expect(html).toContain(`href="${navigation.source}"`)
expect(resolve).not.toHaveBeenCalled()
})
test('trailing slash still identifies the active view', () => {
const navigation = links()
page.url = new URL(navigation.overview + '/', 'https://app.test')
const html = render(ViewerHeader, { props: { links: navigation } }).body
expect(html).toContain(`href="${navigation.overview}" aria-current="page"`)
})
test('replacement loader links do not retain a previous source identity', () => {
render(ViewerHeader, { props: { links: links('/admin/piece/first.md') } })
const navigation = links('/admin/piece/second.md')
page.url = new URL(navigation.share, 'https://app.test')
const html = render(ViewerHeader, { props: { links: navigation } }).body
expect(html).toContain('/second.md/published/share')
expect(html).toContain('aria-current="page"')
expect(html).not.toContain('first')
})
test('back link uses the same decorative arrow icon as Preview, not a visible text label', () => {
const navigation = links()
const html = render(ViewerHeader, { props: { links: navigation } }).body
const anchors = html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? []
const back = anchors.find((anchor) => anchor.includes('aria-label="Back to editor"'))!
expect(back).toContain(`href="${navigation.source}"`)
expect(back).toContain('title="Back to editor"')
expect(back).toMatch(/<svg\b[^>]*aria-hidden="true"/)
expect(back).toMatch(/<svg\b[^>]*focusable="false"/)
expect(
back
.replace(/<!--[^]*?-->/g, '')
.replace(/<[^>]*>/g, '')
.trim()
).toBe('')
expect(back).not.toContain('target=')
expect(resolve).not.toHaveBeenCalled()
})
test('Published header consumes the narrowly scoped shared bar stylesheet', () => {
const published = readFileSync(new URL('./ViewerHeader.svelte', import.meta.url), 'utf8')
expect(published).toContain("ViewerBar.css'")
expect(published).toContain("'virtual:icons/ph/arrow-left'")
for (const name of [
'viewer-bar',
'viewer-bar-back',
'viewer-bar-links',
'viewer-bar-link',
'viewer-bar-label'
])
expect(published).toContain(`class="${name}`)
const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8')
for (const declaration of [
'position: sticky',
'top: 0',
'z-index: 100',
'display: grid',
"grid-template-areas: 'back links label'",
'align-items: center',
'gap: var(--space-3)',
'padding: var(--space-2) var(--space-4)',
'font-size: var(--font-size-small)',
'text-transform: uppercase',
'font-size: 0.9em',
'opacity: 0.85',
'letter-spacing: 0.05em'
])
expect(css).toContain(declaration)
expect(published).not.toContain('<style>')
})
test('shared bar stacks mode/back above full-width wrapping tabs on mobile, with comfortable targets', () => {
const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8')
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'))
for (const declaration of [
"grid-template-areas: 'back label' 'links links'",
'justify-self: stretch',
'flex-wrap: wrap',
'flex: 1 0 auto',
'padding-inline: var(--space-1)',
'overflow-x: visible'
])
expect(mobile).toContain(declaration)
expect(css).toContain('min-width: 44px')
expect(css).toContain('min-height: 44px')
expect(mobile).not.toContain('display: none')
expect(mobile).not.toContain('position: absolute')
const html = render(ViewerHeader, { props: { links: links() } }).body
expect(html).toContain('<nav class="viewer-bar-links" aria-label="Published views"')
expect(html).toContain('class="viewer-bar-label">Published</span>')
for (const label of ['Overview', 'Page', 'Icon', 'Share']) expect(html).toContain(`>${label}</a>`)
})
test('shared active/hover/focus states are token-based, visibly distinguish selection and retain Preview disabled styling', () => {
const css = readFileSync(new URL('./ViewerBar.css', import.meta.url), 'utf8')
for (const declaration of [
'color-mix(in srgb, var(--color-on-tertiary-container) 10%, transparent)',
'color-mix(in srgb, var(--color-on-tertiary-container) 20%, transparent)',
"[aria-current='page']",
'.viewer-bar-link.active',
'font-weight: var(--font-weight-bold)',
'text-decoration: underline',
'a:focus-visible',
'outline: 2px solid currentColor',
'outline-offset: -2px',
'.viewer-bar-link.disabled',
'opacity: 0.35',
'pointer-events: none'
])
expect(css).toContain(declaration)
expect(css).not.toContain('var(--color-surface-container)')
expect(css).not.toContain('rgba(')
})
test('rendering after an await still needs no paths API', async () => {
await Promise.resolve()
const html = render(ViewerHeader, { props: { links: links() } }).body
expect(html).toContain('Back to editor')
expect(resolve).not.toHaveBeenCalled()
})
@@ -0,0 +1,548 @@
import { expect, test, vi } from 'vitest'
import { groupAssetOutputs } from './assets'
import { assetPathToUrl } from '$lib/pieces/assets'
import { asset } from './fixtures/published'
function groupAssets(assets: Parameters<typeof groupAssetOutputs>[0], base: string) {
return groupAssetOutputs(assets, (path) => assetPathToUrl(path, base))
}
function transform(records: Parameters<typeof groupAssetOutputs>[0], name: string, base = '') {
const section = groupAssets(records, base).transforms.find((section) => section.name === name)
expect(section).toBeDefined()
return section!
}
test('groups every producer uniformly while retaining keys, originals, variants and attachments as recorded', () => {
const original = asset('image.original', 'first/cover.jpg', 'first')
const records = [
asset('attachment', 'first/cover.jpg', 'first'),
original,
original,
asset('image.s.webp', 'first/s.webp', 'first', 'image/webp'),
asset('image.s.webp', 'first/s.webp', 'first', 'image/webp'),
asset('image.original', 'second/cover.jpg', 'second')
]
const before = structuredClone(records)
const data = groupAssets(records, 'https://cdn.example/prefix/')
expect(records).toEqual(before)
expect(data.transforms.map((section) => section.name)).toEqual(['attachment', 'image'])
expect(transform(records, 'image').files).toMatchObject([
{ key: 'first', path: 'first/cover.jpg', label: 'image.original', mime: 'image/jpeg' },
{ key: 'first', path: 'first/s.webp', label: 'image.s.webp', mime: 'image/webp' },
{ key: 'second', path: 'second/cover.jpg', label: 'image.original', mime: 'image/jpeg' }
])
expect(transform(records, 'image', 'https://cdn.example/prefix/').files[2].url).toBe(
'https://cdn.example/prefix/pieces/assets/second/cover.jpg'
)
expect(transform(records, 'image').files).toHaveLength(3)
expect(transform(records, 'attachment').files).toMatchObject([
{ key: 'first', path: 'first/cover.jpg' }
])
expect(data).not.toHaveProperty('images')
})
test('renamed jobs retain actual file and value outputs in generic producer sections', () => {
const records = [
asset('pictures.original', 'published/cover.jpg', 'cover'),
asset('pictures.s.webp', 'published/s.webp', 'cover', 'image/webp'),
{ ...asset('rendered-note', null, 'piece', 'text/html'), content: '<h1>Serving HTML</h1>' },
{
...asset('colors.v2', null, 'piece', 'application/json'),
content: '{"background":"#123456"}'
},
{ ...asset('new-job', 'published/body.html', 'piece', 'text/html'), content: '<b>both</b>' }
]
const before = structuredClone(records)
const data = groupAssets(records, '/cdn')
expect(records).toEqual(before)
expect(data).not.toHaveProperty('images')
expect(data.transforms.map((section) => section.name)).toEqual([
'pictures',
'rendered-note',
'colors',
'new-job'
])
expect(data.transforms[0].files[0]).toMatchObject({
path: 'published/cover.jpg',
label: 'pictures.original',
url: '/cdn/pieces/assets/published/cover.jpg',
mime: 'image/jpeg'
})
expect(data.transforms[0].files[1]).toMatchObject({
label: 'pictures.s.webp',
mime: 'image/webp',
url: '/cdn/pieces/assets/published/s.webp'
})
expect(data.transforms[1].values[0].value).toBe('<h1>Serving HTML</h1>')
expect(data.transforms[2].values).toEqual([
{
key: 'piece',
output: 'colors.v2',
field: 'Content',
value: records[3].content,
mime: 'application/json'
}
])
expect(data.transforms[3].files[0].url).toBe('/cdn/pieces/assets/published/body.html')
expect(data.transforms[3].values[0]).toMatchObject({ output: 'new-job', value: '<b>both</b>' })
})
test('deduplicates exact projected records, retaining different keys, transforms, MIME and inline values', () => {
const records = [
asset('image.s.webp', 'shared.webp', 'first'),
asset('image.s.webp', 'shared.webp', 'first'),
asset('image.m.webp', 'shared.webp', 'first'),
asset('image.s.webp', 'another.webp', 'first'),
asset('image.s.webp', 'shared.webp', 'second'),
asset('custom', 'export.pdf'),
asset('custom', 'export.pdf'),
asset('custom', 'export.pdf', 'cover', 'application/pdf'),
asset('custom.other', 'export.pdf'),
{ ...asset('text', null, 'piece', 'text/plain'), content: 'one' },
{ ...asset('text', null, 'piece', 'text/plain'), content: 'one' },
{ ...asset('text', null, 'piece', 'text/plain'), content: 'two' }
]
const data = groupAssets(records, '')
expect(
transform(records, 'image').files.map((file) => [file.key, file.label, file.path])
).toEqual([
['first', 'image.s.webp', 'shared.webp'],
['first', 'image.m.webp', 'shared.webp'],
['first', 'image.s.webp', 'another.webp'],
['second', 'image.s.webp', 'shared.webp']
])
expect(
data.transforms.map((section) => [section.name, section.files.length, section.values.length])
).toEqual([
['image', 4, 0],
['custom', 3, 0],
['text', 0, 2]
])
})
test.each(['image.original', 'attachment'])(
'%s retains every distinct missing/usable path without replacing a record',
(name) => {
const records = [
asset(name, null),
asset(name, 'valid.jpg'),
asset(name, 'also-valid.jpg'),
asset(name, 'valid.jpg')
]
const data = groupAssets(records, '')
const files = data.transforms[0].files
expect(files.map((file) => file.path)).toEqual([null, 'valid.jpg', 'also-valid.jpg'])
expect(files[0].url).toBeUndefined()
}
)
test('absent originals remain absent while all size/format variants remain associated with their key', () => {
const variants = ['s', 'm', 'l', 'xl'].flatMap((size) =>
['avif', 'webp'].map((format) => asset(`image.${size}.${format}`, `cover/${size}.${format}`))
)
const data = groupAssets(variants, '/garden')
expect(data.transforms.map((section) => section.name)).toEqual(['image'])
expect(data.transforms[0].files).toHaveLength(8)
expect(data.transforms[0].files.map((file) => file.label)).toEqual(
variants.map((record) => record.transformation)
)
expect(data.transforms[0].files.some((file) => file.label === 'image.original')).toBe(false)
expect(data.transforms[0].files[7].url).toBe('/garden/pieces/assets/cover/xl.webp')
expect(data.transforms[0].files.every((file) => file.key === 'cover')).toBe(true)
})
test('groups all actual downstream transforms including content, unknown outputs and file-plus-inline content', () => {
const records = [
asset('opengraph', 'piece/share.png', 'piece', 'image/png'),
{
...asset('palette', null, 'piece', 'application/json'),
content: '{"background":"#123456","accent":"#abcdef"}'
},
{ ...asset('markdown', null, 'piece', 'text/html'), content: '<h1>inline</h1>' },
{ ...asset('markdown.description', null, 'piece', 'text/html'), content: '' },
{ ...asset('highlight', null), is_embedded: 1 as const },
asset('document.custom', 'piece/export.pdf', 'piece', 'application/pdf'),
asset('image.custom', 'piece/custom.jpg', 'piece'),
{ ...asset('markdown', 'piece/body.html', 'piece', 'text/html'), content: 'inline' }
]
const data = groupAssets(records, '/cdn')
expect(data).not.toHaveProperty('images')
expect(data.transforms.map((section) => section.name)).toEqual([
'opengraph',
'palette',
'markdown',
'highlight',
'document',
'image'
])
expect(transform(records, 'palette').values).toEqual([
{
key: 'piece',
output: 'palette',
field: 'Content',
value: records[1].content,
mime: 'application/json'
}
])
expect(transform(records, 'markdown').values.map((row) => row.value)).toEqual([
'<h1>inline</h1>',
'',
'inline'
])
expect(transform(records, 'markdown', '/cdn').files[0].url).toBe(
'/cdn/pieces/assets/piece/body.html'
)
expect(transform(records, 'highlight').values[0].value).toBeNull()
expect(transform(records, 'markdown').values[1]).toMatchObject({
output: 'markdown.description',
value: ''
})
})
test('retains every actual Opengraph output, not just the first usable PNG', () => {
const section = transform(
[
asset('opengraph', '../bad.png', 'piece', 'image/png'),
asset('opengraph', 'share.jpg', 'piece', 'image/jpeg'),
asset('opengraph', 'valid.png', 'piece', 'image/png'),
asset('opengraph', 'valid.png', 'piece', 'image/png'),
asset('opengraph', 'second.png', 'piece', 'image/png')
],
'opengraph'
)
expect(section.files.map((file) => file.path)).toEqual([
'../bad.png',
'share.jpg',
'valid.png',
'second.png'
])
expect(section.files[0].url).toBeUndefined()
expect(section.files[1].mime).toBe('image/jpeg')
})
test.each(['image/jpeg', 'application/octet-stream', 'text/html'])(
'Opengraph MIME %s is inspectable as recorded, never reclassified by extension',
(mime) => {
const file = transform([asset('opengraph', 'share.png', 'piece', mime)], 'opengraph').files[0]
expect(file).toMatchObject({ mime, path: 'share.png', url: '/pieces/assets/share.png' })
}
)
test.each(['image.original', 'attachment', 'image.s.webp', 'document.custom'])(
'%s with neither content nor path remains a missing file record',
(name) => {
const data = groupAssets([asset(name, null)], '')
const files = data.transforms.flatMap((section) => section.files)
expect(files).toHaveLength(1)
expect(files[0]).toMatchObject({ path: null, url: undefined })
}
)
test('family grouping preserves full output identities, fields, MIME and every file-plus-inline capability', () => {
const records = [
{
...asset('custom.a', 'same/file.json', 'one', 'application/json'),
content: '{"first":"value","second":42}'
},
{ ...asset('custom.b', 'same/file.json', 'one', 'text/plain'), content: 'raw' },
{ ...asset('custom', null, 'two', 'text/plain'), is_embedded: 1 as const },
{ ...asset('other.a', 'same/file.json', 'one', 'text/plain'), content: 'raw' }
]
const before = structuredClone(records)
const grouped = groupAssets([...records, ...records], '/cdn')
expect(records).toEqual(before)
expect(grouped.transforms.map((group) => group.name)).toEqual(['custom', 'other'])
expect(grouped.transforms[0].files.map((file) => [file.label, file.mime, file.path])).toEqual([
['custom.a', 'application/json', 'same/file.json'],
['custom.b', 'text/plain', 'same/file.json']
])
expect(
grouped.transforms[0].values.map((value) => [value.output, value.field, value.value, value.key])
).toEqual([
['custom.a', 'Content', records[0].content, 'one'],
['custom.b', 'Content', 'raw', 'one'],
['custom', 'Content', null, 'two']
])
expect(grouped.transforms[1].files[0].label).toBe('other.a')
expect(grouped.transforms[1].values[0].output).toBe('other.a')
})
test('family grouping uses exact prefixes, first-seen order and Map-safe names rather than a known-producer registry', () => {
const names = [
'custom.more',
'imagewriter.s.webp',
'custom',
'image.original',
'Image.original',
'__proto__.one',
'__proto__.two',
'.leading'
]
const result = groupAssets(
names.map((name) => asset(name, 'file.bin')),
''
)
expect(result.transforms.map((group) => group.name)).toEqual([
'custom',
'imagewriter',
'image',
'Image',
'__proto__',
'.leading'
])
expect(result.transforms[0].files.map((file) => file.label)).toEqual(['custom.more', 'custom'])
expect(result.transforms[4].files.map((file) => file.label)).toEqual([
'__proto__.one',
'__proto__.two'
])
expect(result.transforms.flatMap((group) => group.files)).toHaveLength(names.length)
})
test('empty input invents no downstream transform sections', () => {
expect(groupAssets([], '')).toEqual({ transforms: [] })
})
// Projection is lossless, not a JSON parser or formatter. Exercise distinct raw-data
// risks once, with MIME variations, rather than repeating syntax classes per producer.
test.each([
['application/json', '{}'],
['text/json', '[]'],
['application/example+json', 'null'],
[' APPLICATION/JSON ; charset=utf-8', '{broken'],
['text/plain', '{"html":"<script>alert(1)</script>"}'],
['text/html', ' <h1>raw</h1>\r\n'],
['image/svg+xml', '{"color":"#abc"}'],
['application/json', ''],
[
'application/json',
' \r\n { "__proto__":{"polluted":true}, "constructor":"own", "same":1, "same":2, "escaped":"\\u96ea\\n" } \r\n'
],
['application/json', '"\\u0041\\t\\n"'],
['application/json', '{"id":9007199254740993,"nested":{"value":1e400}}'],
['application/json', '['.repeat(12000) + '0' + ']'.repeat(12000)]
])(
'%s retains exactly one whole Content value with original bytes and metadata',
(mime, content) => {
const record = { ...asset('custom.raw', null, 'piece', mime), content }
const before = structuredClone(record)
const section = transform([record, record], 'custom')
expect(record).toEqual(before)
expect(section.files).toEqual([])
expect(section.values).toEqual([
{ key: 'piece', output: 'custom.raw', field: 'Content', value: content, mime }
])
}
)
test('inline images are inspectable values and path-plus-content images retain both views', () => {
const data = groupAssets(
[
{ ...asset('image.original', null, 'embedded'), is_embedded: 1, content: 'BASE64' },
{ ...asset('image.s.webp', 's.webp', 'cover'), content: 'DATA' }
],
''
)
expect(data).not.toHaveProperty('images')
expect(data.transforms.map((section) => [section.name, section.files.length])).toEqual([
['image', 1]
])
expect(data.transforms[0].values.map((value) => [value.output, value.value])).toEqual([
['image.original', 'BASE64'],
['image.s.webp', 'DATA']
])
})
test.each(['https://cdn.test/prefix/', 'https://app.test/site/', '/prefix/', ''])(
'base %s preserves prefixes and encodes raw paths once',
(base) => {
expect(
transform([asset('attachment', 'files/源 #?%.pdf')], 'attachment', base).files[0].url
).toBe(`${base.replace(/\/+$/, '')}/pieces/assets/files/%E6%BA%90%20%23%3F%25.pdf`)
}
)
test('percent-looking filenames are literal paths, not decoded traversal or pre-encoded URLs', () => {
expect(
transform([asset('attachment', '%2e%2e/file%20name.pdf')], 'attachment', '/cdn').files[0].url
).toBe('/cdn/pieces/assets/%252e%252e/file%2520name.pdf')
})
test('absolute public base preserves prefix but excludes query and fragment', () => {
expect(
transform([asset('attachment', 'file.pdf')], 'attachment', 'https://app.test/site/?q=1#hash')
.files[0].url
).toBe('https://app.test/site/pieces/assets/file.pdf')
})
test.each([
null,
undefined,
'',
' ',
'../private.png',
'piece/../private.png',
'/absolute.png',
'//cdn/file.png',
'piece//share.png',
'piece/./share.png',
'https://evil.test/share.png',
'javascript:alert(1)',
'x\\share.png'
])(
'invalid path %j stays inspectable for every producer without invoking the URL callback',
(path) => {
for (const [output, family] of [
['opengraph', 'opengraph'],
['image.original', 'image'],
['attachment', 'attachment'],
['custom.file', 'custom']
]) {
const record = { ...asset(output, null, 'private-key', 'text/plain'), asset_path: path }
const fileUrl = vi.fn(() => '/must-not-be-used')
const data = groupAssetOutputs([record, record], fileUrl)
expect(fileUrl).not.toHaveBeenCalled()
expect(data).toEqual({
transforms: [
{
name: family,
files: [
{
key: 'private-key',
path: path || null,
label: output,
mime: 'text/plain',
url: undefined
}
],
values: []
}
]
})
}
}
)
test('the URL callback receives each deduplicated raw record path once, never an encoded path', () => {
const path = 'files/源 #?%/%2e%2e/file%20name.json'
const record = {
...asset('unknown.full.output', path, 'private-key', 'application/json'),
content: '{"__proto__":"own","number":42}'
}
const fileUrl = vi.fn((rawPath: string) =>
rawPath === path ? '/admin/piece/book/preview/job/assets/output.json' : undefined
)
const data = groupAssetOutputs([record, record], fileUrl)
expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path)
expect(data).toEqual({
transforms: [
{
name: 'unknown',
files: [
{
key: 'private-key',
path,
label: 'unknown.full.output',
mime: 'application/json',
url: '/admin/piece/book/preview/job/assets/output.json'
}
],
values: [
{
key: 'private-key',
output: 'unknown.full.output',
field: 'Content',
value: record.content,
mime: 'application/json'
}
]
}
]
})
})
test('inline-only and embedded-only outputs never ask for file URLs or invent file rows', () => {
const records = [
{ ...asset('custom.note', null, 'first', 'text/plain'), content: 'inline' },
{ ...asset('custom.empty', '', 'first', 'text/plain'), content: '' },
{ ...asset('custom.embedded', null, 'second', 'text/plain'), is_embedded: 1 as const },
{
...asset('custom.json', null, 'second', 'application/json'),
content: '{"own":"value"}',
is_embedded: 1 as const
}
]
const fileUrl = vi.fn(() => '/bogus-file')
const data = groupAssetOutputs(records, fileUrl)
expect(fileUrl).not.toHaveBeenCalled()
expect(data.transforms).toHaveLength(1)
expect(data.transforms[0].files).toEqual([])
expect(
data.transforms[0].values.map(({ output, field, value }) => [output, field, value])
).toEqual([
['custom.note', 'Content', 'inline'],
['custom.empty', 'Content', ''],
['custom.embedded', 'Content', null],
['custom.json', 'Content', records[3].content]
])
})
test('a valid path with no callback URL retains both its file and inline inspection data', () => {
const path = 'files/recorded.txt'
const fileUrl = vi.fn((_path: string) => undefined)
const data = groupAssetOutputs(
[{ ...asset('custom.full', path, 'private-key', 'text/plain'), content: 'inline' }],
fileUrl
)
expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path)
expect(data.transforms[0].files).toEqual([
{ key: 'private-key', path, label: 'custom.full', mime: 'text/plain', url: undefined }
])
expect(data.transforms[0].values).toEqual([
{
key: 'private-key',
output: 'custom.full',
field: 'Content',
value: 'inline',
mime: 'text/plain'
}
])
})
test('dedup identity preserves delimiter-looking records and missing, empty and literal null content', () => {
const records = [
{ ...asset('custom.a:b', null, 'key', 'application/json'), is_embedded: 1 as const },
{
...asset('custom.a:b', null, 'key', 'application/json'),
is_embedded: 1 as const,
content: ''
},
{
...asset('custom.a:b', null, 'key', 'application/json'),
is_embedded: 1 as const,
content: 'null'
},
{ ...asset('custom.a:b', null, 'key', 'application/json'), content: 'null' },
{ ...asset('custom.a', null, 'key', 'b:application/json'), content: 'null' },
{ ...asset('custom.a:b', null, 'key:', 'application/json'), content: 'null' },
{ ...asset('custom.a:b', null, 'key', 'application/json'), content: 'null ' }
]
const fileUrl = vi.fn(() => '/not-a-file')
const before = structuredClone(records)
const data = groupAssetOutputs([...records, ...records], fileUrl)
expect(records).toEqual(before)
expect(fileUrl).not.toHaveBeenCalled()
expect(data.transforms).toEqual([
{
name: 'custom',
files: [],
values: records.map((record) => ({
key: record.asset_key,
output: record.transformation,
field: 'Content',
value: record.content ?? null,
mime: record.mime_type
}))
}
])
})
+76
View File
@@ -0,0 +1,76 @@
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { encodeAssetPath } from '$lib/pieces/assets'
import { transformFamily } from './presentation'
export type OutputFile = {
key: string
path: string | null
label: string
mime: string
url: string | undefined
}
export type OutputValue = {
key: string
output: string
field: string
value: string | null
mime: string
}
export type OutputFamily = {
name: string
files: OutputFile[]
values: OutputValue[]
}
export function groupAssetOutputs(
assets: PublicWebPieceAsset[],
fileUrl: (path: string) => string | undefined
) {
const transforms = new Map<string, OutputFamily>()
const seen = new Set<string>()
for (const asset of assets) {
const identity = JSON.stringify([
asset.asset_key,
asset.transformation,
asset.asset_path || null,
asset.mime_type,
asset.content ?? null,
Boolean(asset.is_embedded)
])
if (seen.has(identity)) continue
seen.add(identity)
const hasInline = asset.content != null || Boolean(asset.is_embedded)
const hasFile = Boolean(asset.asset_path) || !hasInline
const file: OutputFile = {
key: asset.asset_key,
path: asset.asset_path || null,
label: asset.transformation,
mime: asset.mime_type,
url:
hasFile && encodeAssetPath(asset.asset_path) !== undefined
? fileUrl(asset.asset_path!)
: undefined
}
const family = transformFamily(asset.transformation)
let transform = transforms.get(family)
if (!transform) {
transform = { name: family, files: [], values: [] }
transforms.set(family, transform)
}
if (hasFile) transform.files.push(file)
if (hasInline)
transform.values.push({
key: asset.asset_key,
output: asset.transformation,
field: 'Content',
value: asset.content ?? null,
mime: asset.mime_type
})
}
return { transforms: [...transforms.values()] }
}
@@ -0,0 +1,63 @@
import { expect, test } from 'vitest'
import type { OutputValue } from './assets'
import { toggleDisclosure } from './disclosure'
function value(): OutputValue {
return {
key: 'private',
output: 'custom.note',
field: 'Content',
mime: 'text/plain',
value: 'raw'
}
}
test('opens and closes one row without mutating its input set', () => {
const row = value()
const initial: ReadonlySet<OutputValue> = new Set()
const opened = toggleDisclosure(initial, row)
expect(opened).not.toBe(initial)
expect([...opened]).toEqual([row])
expect(initial.size).toBe(0)
const closed = toggleDisclosure(opened, row)
expect(closed).not.toBe(opened)
expect(closed.size).toBe(0)
expect(opened.has(row)).toBe(true)
})
test('multiple rows stay independently open and close in any order', () => {
const first = value()
const second = value()
const third = value()
const both = toggleDisclosure(toggleDisclosure(new Set(), first), second)
expect(both.size).toBe(2)
const justSecond = toggleDisclosure(both, first)
expect(justSecond.has(second)).toBe(true)
expect(justSecond.has(first)).toBe(false)
expect(both.has(first)).toBe(true)
const secondAndThird = toggleDisclosure(justSecond, third)
expect([...secondAndThird]).toEqual([second, third])
expect([...toggleDisclosure(secondAndThird, second)]).toEqual([third])
})
test('identical fields and raw keys do not merge different projected row identities', () => {
const first = value()
const sameFields = { ...first }
const opened = toggleDisclosure(new Set(), first)
expect(opened.has(sameFields)).toBe(false)
const both = toggleDisclosure(opened, sameFields)
expect(both.size).toBe(2)
expect([...toggleDisclosure(both, first)]).toEqual([sameFields])
})
test('a fresh model starts from an empty set, including replaced rows with identical fields', () => {
// Parent reset wiring is a source contract in AssetOutputs.test.ts, not a mounted effect test.
const previous = value()
const previousState = toggleDisclosure(new Set(), previous)
const replacement = { ...previous }
const reset: ReadonlySet<OutputValue> = new Set()
expect(reset.has(previous)).toBe(false)
expect(reset.has(replacement)).toBe(false)
expect([...toggleDisclosure(reset, replacement)]).toEqual([replacement])
expect([...previousState]).toEqual([previous])
})
@@ -0,0 +1,12 @@
import type { OutputValue } from './assets'
/** Toggle one projected row identity without changing other open rows or the input set. */
export function toggleDisclosure(
expanded: ReadonlySet<OutputValue>,
value: OutputValue
): Set<OutputValue> {
const next = new Set(expanded)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
@@ -0,0 +1,11 @@
<script lang="ts">
import type { PublicWebPiece } from '@luzzle/web.pieces'
import { getPieceHelpers } from '$lib/pieces/helpers'
let { piece }: { piece: PublicWebPiece } = $props()
const helpers = $derived(getPieceHelpers(piece))
</script>
<section data-renderer="composition">
<h2>{piece.title}</h2>
<img src={helpers.getPieceImageUrl('cover', 500, 'jpg')} alt="Piece cover within composition" />
</section>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PublicWebPiece } from '@luzzle/web.pieces'
import { getPieceHelpers } from '$lib/pieces/helpers'
let { piece, size, lazy }: { piece: PublicWebPiece; size: { width: number }; lazy: boolean } =
$props()
const helpers = $derived(getPieceHelpers(piece))
</script>
<img
data-renderer="icon"
data-width={size.width}
src={helpers.getPieceImageUrl('cover', size.width, 'jpg')}
loading={lazy ? 'lazy' : 'eager'}
alt={piece.title}
/>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PublicWebPiece } from '@luzzle/web.pieces'
import { getPieceHelpers } from '$lib/pieces/helpers'
let { piece, tags }: { piece: PublicWebPiece; tags: Array<{ slug: string; tag: string }> } =
$props()
const helpers = $derived(getPieceHelpers(piece))
</script>
<main id="main-content" data-renderer="page">
<h1>{piece.title}</h1>
<img src={helpers.getPieceImageUrl('cover', 500, 'jpg')} alt="Published cover" />
{#each tags as tag (tag.slug)}
<a href="/tags/{tag.slug}">{tag.tag}</a>
{/each}
</main>
@@ -0,0 +1,22 @@
import type { PublicWebPiece, PublicWebPieceAsset } from '@luzzle/web.pieces'
export function asset(
transformation: string,
asset_path: string | null = 'cover.jpg',
asset_key = 'cover',
mime_type = 'image/jpeg'
): PublicWebPieceAsset {
return { asset_key, transformation, asset_path, mime_type }
}
export const publishedPiece: PublicWebPiece = {
id: 'published-id',
key: 'piece-key',
title: 'Published <Book>',
slug: 'published #?%',
type: 'books',
date_added: 1,
date_updated: 2,
metadata: { title: 'Serving metadata only' },
assets: [asset('image.original', 'published/cover.jpg')]
}
@@ -0,0 +1,71 @@
import { expect, test } from 'vitest'
import { fileType, transformCaption, transformFamily } from './presentation'
test.each([
['image', 'image'],
['image.original', 'image'],
['image.s.webp', 'image'],
['markdown.description.nested', 'markdown'],
['pictures.original', 'pictures'],
['colors.v2', 'colors'],
['custom.output', 'custom'],
['imagewriter.s.webp', 'imagewriter'],
['Image.original', 'Image'],
['__proto__.output', '__proto__'],
['constructor.output', 'constructor'],
['image..webp', 'image'],
['image.', 'image'],
['.leading', '.leading'],
['.', '.'],
['', ''],
['no-dot', 'no-dot']
])('output %s uses only its namespace prefix to group into family %s', (name, family) => {
expect(transformFamily(name)).toBe(family)
})
test.each([
['attachment', 'Attachment'],
['opengraph', 'Opengraph'],
['palette', 'Palette'],
['rendered-markdown.note', 'Rendered-markdown.note'],
['pictures.original', 'Pictures.original'],
['image..webp', 'Image..webp'],
['Image.original', 'Image.original'],
['colors.v2', 'Colors.v2'],
['toString', 'ToString'],
['__proto__', '__proto__'],
['', '']
])('caption %s preserves the actual name with generic capitalization', (name, expected) => {
expect(transformCaption(name)).toBe(expected)
})
test.each([
['image/jpeg', 'JPEG'],
['image/png', 'PNG'],
['image/webp', 'WebP'],
['image/avif', 'AVIF'],
['image/svg+xml', 'SVG'],
['application/pdf', 'PDF'],
['application/zip', 'ZIP'],
['text/html', 'HTML'],
['application/json', 'JSON'],
['text/plain', 'Text'],
['text/markdown', 'Markdown'],
['application/octet-stream', 'Binary'],
[' TEXT/HTML; charset=utf-8 ', 'HTML'],
['application/future+json', 'future+json'],
['image/unknown', 'unknown'],
['<svg>', 'File'],
['text/bad subtype', 'File'],
['application/' + 'x'.repeat(25), 'File'],
['__proto__', 'File'],
['constructor', 'File'],
['toString', 'File'],
['', 'File']
])('recorded MIME %s supplies a label or conservative File fallback', (mime, expected) => {
expect(fileType(mime)).toBe(expected)
})
test('absent MIME has the same File fallback', () => {
expect(fileType()).toBe('File')
})
@@ -0,0 +1,33 @@
export function transformFamily(name: string) {
const separator = name.indexOf('.')
return separator > 0 ? name.slice(0, separator) : name
}
export function transformCaption(name: string) {
return name.charAt(0).toUpperCase() + name.slice(1)
}
function normalizedMime(mime: string) {
return mime.split(';')[0].trim().toLowerCase()
}
export function fileType(mime?: string) {
const type = normalizedMime(mime ?? '')
const known: Record<string, string> = {
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/webp': 'WebP',
'image/avif': 'AVIF',
'image/svg+xml': 'SVG',
'application/pdf': 'PDF',
'application/zip': 'ZIP',
'text/html': 'HTML',
'application/json': 'JSON',
'text/plain': 'Text',
'text/markdown': 'Markdown',
'application/octet-stream': 'Binary'
}
return Object.hasOwn(known, type)
? known[type]
: /^[a-z0-9][a-z0-9.+-]*\/([a-z0-9][a-z0-9.+-]{0,23})$/.exec(type)?.[1] || 'File'
}
@@ -0,0 +1,148 @@
import { afterEach, expect, test, vi } from 'vitest'
import { languages } from '@codemirror/language-data'
import { highlightSource } from './source-highlight'
import { formatSource, SOURCE_SIZE_LIMIT, type SourceToken } from './source'
afterEach(() => vi.restoreAllMocks())
function expectReconstruction(tokens: SourceToken[], content: string) {
expect(tokens.map((token) => token.text).join('')).toBe(content)
for (const token of tokens) {
expect(token.classes).toMatch(/^(?:tok-[\w-]+(?: tok-[\w-]+)*)?$/)
expect(Object.keys(token).sort()).toEqual(['classes', 'text'])
}
}
function expectClass(tokens: SourceToken[], text: string, name: string) {
expect(
tokens.some((token) => token.text.includes(text) && token.classes.split(' ').includes(name)),
`${text}: ${name}`
).toBe(true)
}
test('highlights already formatted JSON with existing editor classes and exact reconstruction', async () => {
const content = formatSource(
'{"name":"value","count":9007199254740993,"ok":true,"none":null}',
'application/json'
)
const tokens = await highlightSource(content, ' Application/LD+JSON; charset=UTF-8 ')
expectReconstruction(tokens, content)
expectClass(tokens, '"name"', 'tok-name')
expectClass(tokens, '"value"', 'tok-string')
expectClass(tokens, '9007199254740993', 'tok-number')
expectClass(tokens, 'true', 'tok-bool')
expectClass(tokens, 'null', 'tok-keyword')
expectClass(tokens, '{', 'tok-punctuation')
})
test('highlights HTML tags, attributes, values and comments without rendering markup', async () => {
const content = '<!-- comment -->\r\n<div class="hello">literal &lt;text&gt;</div>\r\n'
const tokens = await highlightSource(content, 'text/html')
expectReconstruction(tokens, content)
expectClass(tokens, 'div', 'tok-name')
expectClass(tokens, 'class', 'tok-name')
expectClass(tokens, '"hello"', 'tok-string')
expectClass(tokens, '<!-- comment -->', 'tok-comment')
})
test.each([
['text/css', 'a { color: red; }\r\n'],
['text/javascript', 'const count = 42;\r\n// comment\r\n'],
['text/xml', '<?xml version="1.0"?>\r\n<root count="1" />\r\n'],
['image/svg+xml', '<svg><path d="M0 0" /></svg>\r\n'],
['text/markdown', '# Heading\r\n\r\n> quote\r\n\r\n`code` and **strong**\r\n'],
['application/yaml', 'title: "value"\r\n# comment\r\n']
])('uses installed %s parser while preserving CRLF and all whitespace', async (mime, content) => {
const tokens = await highlightSource(content, mime)
expectReconstruction(tokens, content)
expect(tokens.some((token) => token.classes)).toBe(true)
})
test('shares the editor extra Markdown classes without importing editor chrome', async () => {
const content = '> quote\n\n`code`\n\n---\n'
const tokens = await highlightSource(content, 'text/markdown')
expectReconstruction(tokens, content)
expectClass(tokens, 'quote', 'tok-quote')
expectClass(tokens, 'code', 'tok-monospace')
expectClass(tokens, '---', 'tok-contentSeparator')
})
test.each([
[
'text/html',
'<img src=x onerror="alert(1)" class="tok-attacker user-css"><script>alert(2)</script>'
],
[
'application/json',
String.raw`{"</script>":"<img src=x onerror=alert(1)>","class":"tok-attacker user-css","escape":"\u003c"}`
]
])(
'keeps XSS-shaped %s payloads literal and never derives classes from content',
async (mime, content) => {
const tokens = await highlightSource(content, mime)
expectReconstruction(tokens, content)
expect(tokens.map((token) => token.classes).join(' ')).not.toMatch(
/attacker|user-css|alert|onerror/
)
}
)
test.each(['', 'text/plain', 'application/octet-stream', 'unknown.html', 'constructor'])(
'returns one complete plaintext token for unknown MIME %j',
async (mime) => {
const content = ' \r\n<div>{"key":1}</div>\t\r\n'
expect(await highlightSource(content, mime)).toEqual([{ text: content, classes: '' }])
}
)
test.each(['', ' \r\n\t', '{"incomplete":', '{"raw":1,"raw":2}'])(
'reconstructs empty, whitespace, malformed and unformatted JSON exactly: %j',
async (content) => {
expectReconstruction(await highlightSource(content, 'application/json'), content)
}
)
test('returns all original plaintext above the highlighting budget without loading a parser', async () => {
const description = languages.find((language) => language.name === 'JSON')!
const load = vi.spyOn(description, 'load')
const content = '{"key":1}'.padEnd(SOURCE_SIZE_LIMIT + 1, ' ') + '\r\n'
expect(await highlightSource(content, 'application/json')).toEqual([
{ text: content, classes: '' }
])
expect(load).not.toHaveBeenCalled()
})
test('can highlight exactly at the input size budget', async () => {
const content = '{"key":1}'.padEnd(SOURCE_SIZE_LIMIT, ' ')
const tokens = await highlightSource(content, 'application/json')
expectReconstruction(tokens, content)
expectClass(tokens, '"key"', 'tok-name')
})
test('falls back to complete plaintext when a known language is unavailable', async () => {
vi.spyOn(languages, 'find').mockReturnValueOnce(undefined)
const content = '{"key":1}\r\n'
expect(await highlightSource(content, 'application/json')).toEqual([
{ text: content, classes: '' }
])
})
test('falls back to complete plaintext when language loading rejects', async () => {
const description = languages.find((language) => language.name === 'JSON')!
vi.spyOn(description, 'load').mockRejectedValueOnce(new Error('language unavailable'))
const content = '{"key":1}\r\n'
expect(await highlightSource(content, 'application/json')).toEqual([
{ text: content, classes: '' }
])
})
test('falls back to complete plaintext when parsing fails', async () => {
const support = await languages.find((language) => language.name === 'JSON')!.load()
vi.spyOn(support.language.parser, 'parse').mockImplementationOnce(() => {
throw new Error('parse failed')
})
const content = '{"key":1}\r\n'
expect(await highlightSource(content, 'application/json')).toEqual([
{ text: content, classes: '' }
])
})
@@ -0,0 +1,28 @@
import { classHighlighter, highlightTree } from '@lezer/highlight'
import { extraClasses } from '../../components/editor/themes/token-classes'
import { SOURCE_SIZE_LIMIT, sourceLanguage, type SourceToken } from './source'
export async function highlightSource(content: string, mime: string): Promise<SourceToken[]> {
const plain = [{ text: content, classes: '' }]
const name = sourceLanguage(mime)
if (!name || content.length > SOURCE_SIZE_LIMIT || !content.length) return plain
try {
const { languages } = await import('@codemirror/language-data')
const description = languages.find((language) => language.name === name)
if (!description) return plain
const support = await description.load()
const tree = support.language.parser.parse(content)
const tokens: SourceToken[] = []
let position = 0
highlightTree(tree, [classHighlighter, extraClasses], (from, to, classes) => {
if (from > position) tokens.push({ text: content.slice(position, from), classes: '' })
tokens.push({ text: content.slice(from, to), classes })
position = to
})
if (position < content.length) tokens.push({ text: content.slice(position), classes: '' })
return tokens.length ? tokens : plain
} catch {
return plain
}
}
@@ -0,0 +1,153 @@
import { expect, test } from 'vitest'
import { formatSource, SOURCE_SIZE_LIMIT, sourceLanguage } from './source'
test.each([
['application/json', 'JSON'],
[' TEXT/JSON ; charset=utf-8 ', 'JSON'],
['application/ld+json', 'JSON'],
['model/gltf+json', 'JSON'],
['text/vendor+json', 'JSON'],
['text/html', 'HTML'],
[' Application/XHTML+XML; charset=UTF-8', 'HTML'],
['text/css', 'CSS'],
['text/javascript', 'JavaScript'],
['application/javascript', 'JavaScript'],
['text/ecmascript', 'JavaScript'],
['application/ecmascript', 'JavaScript'],
['text/xml', 'XML'],
['application/xml', 'XML'],
['application/atom+xml', 'XML'],
['image/svg+xml', 'XML'],
['text/custom+xml', 'XML'],
['text/markdown', 'Markdown'],
['application/yaml', 'YAML'],
['application/x-yaml', 'YAML'],
['text/yaml', 'YAML'],
['text/x-yaml', 'YAML']
])('maps only normalized MIME %s to installed language %s', (mime, language) => {
expect(sourceLanguage(mime)).toBe(language)
})
test.each([
'',
'text/plain',
'application/octet-stream',
'application/json5',
'application/not-json',
'image/json',
'json',
'index.html',
'javascript',
'__proto__',
'constructor',
'text/html/other',
'application/bad type+json'
])('does not guess a language from %s', (mime) => {
expect(sourceLanguage(mime)).toBeUndefined()
expect(formatSource(' {"key":1}\r\n', mime)).toBe(' {"key":1}\r\n')
})
test('pretty prints only JSON, including nested nulls, arrays and empty containers', () => {
expect(
formatSource(' \r\n{"a":[null,{"b":true,"c":false},[],{}],"empty":{ }}\t', 'application/json')
).toBe(`{
"a": [
null,
{
"b": true,
"c": false
},
[],
{}
],
"empty": {}
}`)
})
test('preserves unsafe integer, decimal, exponent and negative-zero lexemes and duplicate keys', () => {
const values = [
'9007199254740993',
'-9007199254740993',
'1.234567890123456789',
'1e+400',
'1E-400',
'-0',
'1.00'
]
const input = `{${values.map((value) => `"duplicate":${value}`).join(',')}}`
expect(formatSource(input, 'application/problem+json')).toBe(
`{\n${values.map((value) => ` "duplicate": ${value}`).join(',\n')}\n}`
)
})
test('retains escaped strings, whitespace inside strings and HTML as literal text', () => {
const input = String.raw`{"\u006b":"\u003cscript\u003e\r\n\t\/\\\"", "k":"a b,:{}[]", "html":"</script><img src=x onerror=alert(1)>"}`
const expected = String.raw`{
"\u006b": "\u003cscript\u003e\r\n\t\/\\\"",
"k": "a b,:{}[]",
"html": "</script><img src=x onerror=alert(1)>"
}`
expect(formatSource(input, 'application/json')).toBe(expected)
})
test.each(['null', 'true', 'false', '42', '-0', '1e400', '"a\\n b"', '[]', '{}'])(
'formats the top-level JSON value %s without changing its lexeme',
(value) => {
expect(formatSource(` \r\n${value}\t `, 'text/json')).toBe(value)
}
)
test.each([
'',
' \r\n\t',
'{',
'{"a":1,}',
'[1,]',
'{"a":undefined}',
'{"a":NaN}',
'{"a":01}',
'{"a":"\\x01"}',
'{"a":"unescaped\nline"}',
'{"a":1} trailing',
'null null',
'/* comment */ {}',
'{"a":1}\u00a0'
])('returns malformed JSON exactly as supplied: %j', (input) => {
expect(formatSource(input, 'application/json')).toBe(input)
})
test.each([
'text/html',
'application/xhtml+xml',
'text/css',
'text/javascript',
'application/xml',
'text/markdown',
'application/yaml',
'text/plain'
])('never formats non-JSON %s, even if its contents look like JSON', (mime) => {
const input = ' \r\n { "a": [1,2] }\r\n\t'
expect(formatSource(input, mime)).toBe(input)
})
test('formats nested JSON iteratively, with a finite depth fallback', () => {
const nested = '['.repeat(30) + 'null' + ']'.repeat(30)
const formatted = formatSource(nested, 'application/json')
expect(formatted).not.toBe(nested)
expect(formatted.replace(/\s/g, '')).toBe(nested)
const deep = '['.repeat(101) + 'null' + ']'.repeat(101)
expect(formatSource(deep, 'application/json')).toBe(deep)
})
test('formats at the input budget, but keeps every original character above it', () => {
const atLimit = '{}'.padEnd(SOURCE_SIZE_LIMIT, ' ')
expect(formatSource(atLimit, 'application/json')).toBe('{}')
const oversized = atLimit + '\r\n'
expect(formatSource(oversized, 'application/json')).toBe(oversized)
})
test('returns the entire original when pretty output would exceed the budget', () => {
const input = '[' + Array(20_000).fill('0').join(',') + ']'
expect(input.length).toBeLessThan(SOURCE_SIZE_LIMIT)
expect(formatSource(input, 'application/json')).toBe(input)
})
+67
View File
@@ -0,0 +1,67 @@
export type SourceToken = { text: string; classes: string }
export const SOURCE_SIZE_LIMIT = 100_000
const JSON_DEPTH_LIMIT = 100
export function sourceLanguage(mime: string): string | undefined {
const type = mime.split(';', 1)[0].trim().toLowerCase()
const suffix = /^[a-z0-9!#$%&'*+.^_`|~-]+\/[a-z0-9!#$%&'*+.^_`|~-]+\+(json|xml)$/.exec(type)?.[1]
if (type === 'application/json' || type === 'text/json' || suffix === 'json') return 'JSON'
if (type === 'text/html' || type === 'application/xhtml+xml') return 'HTML'
if (type === 'application/xml' || type === 'text/xml' || suffix === 'xml') return 'XML'
switch (type) {
case 'text/css':
return 'CSS'
case 'text/javascript':
case 'application/javascript':
case 'text/ecmascript':
case 'application/ecmascript':
return 'JavaScript'
case 'text/markdown':
return 'Markdown'
case 'application/yaml':
case 'application/x-yaml':
case 'text/yaml':
case 'text/x-yaml':
return 'YAML'
}
}
export function formatSource(content: string, mime: string): string {
if (content.length > SOURCE_SIZE_LIMIT || sourceLanguage(mime) !== 'JSON') return content
try {
// Validate only: serializing the parsed value would lose numeric precision and original lexemes.
JSON.parse(content)
return formatJsonTokens(content) ?? content
} catch {
return content
}
}
function formatJsonTokens(content: string): string | undefined {
const tokens = content.match(/"(?:[^"\\]|\\.)*"|[^\s{}[\],:]+|[{}[\],:]/g) ?? []
const output: string[] = []
let depth = 0
let size = 0
const newline = () => '\n' + ' '.repeat(depth)
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index]
let text = token
if (token === '{' || token === '[') {
if (++depth > JSON_DEPTH_LIMIT) return
if (tokens[index + 1] !== (token === '{' ? '}' : ']')) text += newline()
} else if (token === '}' || token === ']') {
depth--
if (tokens[index - 1] !== (token === '}' ? '{' : '[')) text = newline() + token
} else if (token === ',') {
text += newline()
} else if (token === ':') {
text += ' '
}
size += text.length
if (size > SOURCE_SIZE_LIMIT) return
output.push(text)
}
return output.join('')
}
+19
View File
@@ -0,0 +1,19 @@
import path from 'node:path'
export function isSafeRelativePath(value: string): boolean {
return (
!value.includes('\\') &&
!value.includes('\0') &&
value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
)
}
export function isWithinDirectory(directory: string, target: string): boolean {
const relative = path.relative(directory, target)
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
)
}
@@ -10,7 +10,6 @@ export const load: PageServerLoad = async ({ params }) => {
const files = await pieces.getFilesIn(canonicalDir)
return {
mode: 'directory',
files: {
directories: files.directories.map((d) => ({
path: path.join(directory, d),
@@ -0,0 +1,26 @@
import { expect, test, vi } from 'vitest'
import { load } from './+page.server.js'
const { getFilesIn, parseFilename } = vi.hoisted(() => ({
getFilesIn: vi.fn(async () => ({
directories: ['child/'],
pieces: ['one.book.md'],
assets: ['cover.jpg']
})),
parseFilename: vi.fn((file: string) => ({ file }))
}))
vi.mock('$lib/server/pieces', () => ({ getPieces: () => ({ getFilesIn, parseFilename }) }))
test('directory loader keeps browsing data without the unused mode marker', async () => {
const event = { params: { directory: 'books' } } as Parameters<typeof load>[0]
expect(await load(event)).toEqual({
files: {
directories: [{ path: 'books/child/', name: 'child' }],
pieces: [{ file: 'books/one.book.md' }],
assets: [{ path: 'books/cover.jpg', name: 'cover.jpg' }]
},
directory: { parent: '.', current: 'books' }
})
expect(getFilesIn).toHaveBeenCalledWith('books')
expect(parseFilename).toHaveBeenCalledWith('books/one.book.md')
})
@@ -1,17 +0,0 @@
import { error, redirect } from '@sveltejs/kit'
import { db } from '$lib/server/database'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ params }) => {
const piece = await db
.selectFrom('web_pieces')
.select(['type', 'slug'])
.where('file_path', '=', params.path)
.executeTakeFirst()
if (!piece) {
error(404, 'piece is not published')
}
redirect(302, `/pieces/${piece.type}/${piece.slug}`)
}
@@ -4,7 +4,13 @@
import '@luzzle/web.theme/styles/markdown.css'
import '$lib/ui/styles/theme.build.css'
const { children } = $props()
import type { LayoutProps } from './$types'
let { data, children }: LayoutProps = $props()
</script>
<svelte:head>
<link rel="icon" href="{data.config.url.app_assets}/images/favicon.png" />
</svelte:head>
{@render children()}
@@ -3,7 +3,8 @@ import { getPieces } from '$lib/server/pieces'
import { config } from '$lib/server/config'
import { getOpenWorkflow } from '$lib/server/workflow/index.js'
import { previewSpec } from '@luzzle/web.jobs/specs'
import type { PageServerLoad } from '../$types'
import type { PageServerLoad } from './$types'
import { previewHref } from '$lib/pieces/navigation'
export const load: PageServerLoad = async ({ params }) => {
const file = params.path
@@ -22,5 +23,5 @@ export const load: PageServerLoad = async ({ params }) => {
const openWorkflow = getOpenWorkflow()
const handle = await openWorkflow.runWorkflow(previewSpec, { filePath: file })
const runId = handle.workflowRun.id
redirect(303, `/admin/piece/${file}/preview/${runId}`)
redirect(303, previewHref(file, runId))
}
@@ -21,6 +21,7 @@ export const load: LayoutServerLoad = async ({ params }) => {
return error(404, 'Preview not found')
}
const timing = { createdAt: run.created_at, finishedAt: run.finished_at }
let state = 'waiting'
if (run.status === 'running') state = 'running'
if (run.status === 'completed' || run.status === 'succeeded') state = 'completed'
@@ -63,7 +64,15 @@ export const load: LayoutServerLoad = async ({ params }) => {
const completedAt = run.finished_at ? new Date(run.finished_at) : null
const isExpired = completedAt && Date.now() - completedAt.getTime() > retentionMs
if (isExpired) {
return { file: params.path, status: 'expired' as const, jobId: runId, phases, logs }
return {
preview: true,
file: params.path,
status: 'expired' as const,
job: runId,
phases,
logs,
...timing
}
}
const result = JSON.parse(run.output) as PreviewWorkerResult
@@ -73,25 +82,37 @@ export const load: LayoutServerLoad = async ({ params }) => {
}
const assembled = assemblePreview(result, pieceConfig)
return {
preview: true,
file: params.path,
status: 'completed' as const,
jobId: runId,
job: runId,
phases,
logs,
...timing,
...assembled
}
}
if (state === 'failed' || state === 'canceled') {
return {
preview: true,
file: params.path,
status: 'failed' as const,
jobId: runId,
job: runId,
phases,
logs,
...timing,
errorMessage: run.error ?? 'Preview failed'
}
}
return { file: params.path, jobId: runId, status: state as 'waiting' | 'running', phases, logs }
return {
preview: true,
file: params.path,
job: runId,
status: state as 'waiting' | 'running' | 'completed',
phases,
logs,
...timing
}
}
@@ -1,10 +1,14 @@
<script lang="ts">
import '$lib/pieces/viewer/ViewerBar.css'
import { onMount, onDestroy } from 'svelte'
import { page } from '$app/state'
import type { LayoutProps } from './$types'
import { editorHref, previewHref } from '$lib/pieces/navigation'
import ArrowLeftIcon from 'virtual:icons/ph/arrow-left'
import WorkflowConsole from './WorkflowConsole.svelte'
import PieceOverview from '$lib/pieces/viewer/PieceOverview.svelte'
import PreviewFacts from './PreviewFacts.svelte'
import PreviewLogs from './PreviewLogs.svelte'
let { data, children }: LayoutProps = $props()
@@ -45,16 +49,16 @@
let eventSource: EventSource | null = null
const editorUrl = `/admin/piece/${data.file}/source`
const editorUrl = editorHref(data.file)
const logsUrl = `/admin/piece/${data.file}/preview/${data.jobId}`
const pageUrl = `/admin/piece/${data.file}/preview/${data.jobId}/page`
const iconUrl = `/admin/piece/${data.file}/preview/${data.jobId}/icon`
const ogUrl = `/admin/piece/${data.file}/preview/${data.jobId}/og`
const logsUrl = previewHref(data.file, data.job)
const pageUrl = `${logsUrl}/page`
const iconUrl = `${logsUrl}/icon`
const shareUrl = `${logsUrl}/share`
function startWatching() {
if (eventSource) eventSource.close()
eventSource = new EventSource(`/api/admin/preview/${data.jobId}/stream`)
eventSource = new EventSource(`/api/admin/preview/${encodeURIComponent(data.job)}/stream`)
eventSource.addEventListener('state', (e) => {
const d = JSON.parse(e.data)
@@ -111,16 +115,22 @@
})
</script>
<header class="preview-banner">
<a href={editorUrl} class="preview-back">
<ArrowLeftIcon />
<header class="viewer-bar">
<a href={editorUrl} class="viewer-bar-back" aria-label="Back to editor" title="Back to editor">
<ArrowLeftIcon aria-hidden="true" focusable="false" />
</a>
<div class="preview-links">
<a href={logsUrl} class="preview-link" class:active={page.url.pathname === logsUrl}>logs</a>
<nav class="viewer-bar-links" aria-label="Preview views">
<a
href={logsUrl}
class="viewer-bar-link"
class:active={page.url.pathname === logsUrl}
aria-current={page.url.pathname === logsUrl ? 'page' : undefined}>overview</a
>
<a
href={status === 'completed' ? pageUrl : '#'}
class="preview-link"
class="viewer-bar-link"
class:active={page.url.pathname === pageUrl}
aria-current={page.url.pathname === pageUrl ? 'page' : undefined}
class:disabled={status !== 'completed'}
aria-disabled={status !== 'completed'}
>
@@ -128,139 +138,41 @@
</a>
<a
href={status === 'completed' ? iconUrl : '#'}
class="preview-link"
class="viewer-bar-link"
class:active={page.url.pathname === iconUrl}
aria-current={page.url.pathname === iconUrl ? 'page' : undefined}
class:disabled={status !== 'completed'}
aria-disabled={status !== 'completed'}
>
icon
</a>
<a
href={status === 'completed' ? ogUrl : '#'}
class="preview-link"
class:active={page.url.pathname === ogUrl && !page.url.searchParams.has('html')}
href={status === 'completed' ? shareUrl : '#'}
class="viewer-bar-link"
class:active={page.url.pathname === shareUrl}
aria-current={page.url.pathname === shareUrl ? 'page' : undefined}
class:disabled={status !== 'completed'}
aria-disabled={status !== 'completed'}
>
og
share
</a>
{#if status === 'completed' && page.url.pathname === ogUrl && page.url.searchParams.has('html')}
<a href="{ogUrl}?html" class="preview-link active">og (html)</a>
{/if}
</div>
<span class="preview-label">preview</span>
</nav>
<span class="viewer-bar-label">preview</span>
</header>
{#if status === 'completed'}
<main id="main-content">
{@render children()}
</main>
{@render children()}
{:else}
<main id="main-content" class="preview-status">
<header class="preview-status-header">
<h1>Preview</h1>
<div class="meta">{data.file}</div>
</header>
<WorkflowConsole {status} {phases} {logs} {errorMessage} />
<main id="main-content">
<PieceOverview title="Preview" fileUrl={() => undefined}>
<PreviewFacts
file={data.file}
{phases}
{status}
createdAt={data.createdAt}
finishedAt={data.finishedAt}
/>
<PreviewLogs {status} {phases} {logs} {errorMessage} />
</PieceOverview>
</main>
{/if}
<style>
.preview-banner {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-tertiary-container);
color: var(--color-on-tertiary-container);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-small);
}
.preview-label {
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
flex-shrink: 0;
opacity: 0.7;
}
.preview-links {
display: flex;
align-items: center;
gap: var(--space-3);
}
.preview-link {
color: inherit;
text-decoration: none;
text-transform: uppercase;
font-size: 0.9em;
opacity: 0.7;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-small);
}
.preview-link:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.preview-link.active {
opacity: 1;
font-weight: 600;
background-color: rgba(255, 255, 255, 0.2);
}
.preview-link.disabled {
opacity: 0.35;
pointer-events: none;
cursor: not-allowed;
}
.preview-back {
display: flex;
align-items: center;
gap: var(--space-1);
color: inherit;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
}
.preview-back:hover {
text-decoration: underline;
}
.preview-status {
padding: var(--space-8) var(--space-4);
max-width: 860px;
margin: 0 auto;
color: var(--color-on-surface);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.preview-status-header {
margin-bottom: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preview-status-header h1 {
margin: 0;
font-size: var(--font-size-normal);
font-weight: var(--font-weight-bold);
letter-spacing: -0.01em;
}
.preview-status-header .meta {
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
}
</style>
@@ -1,66 +1,43 @@
<script lang="ts">
import WorkflowConsole from './WorkflowConsole.svelte'
import type { ProgressLog } from '$lib/components/progress'
import PieceOverview from '$lib/pieces/viewer/PieceOverview.svelte'
import { previewOutputUrl } from '$lib/pieces/preview/output-url'
import PreviewFacts from './PreviewFacts.svelte'
import PreviewLogs from './PreviewLogs.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
type PhaseLog = {
job_id: string
phase: string
line_number: number
ts: number
level: string
message: string
function fileUrl(path: string) {
return previewOutputUrl(path, data.job)
}
let logsMap = $derived.by(() => {
const map: Record<string, PhaseLog[]> = {}
if (data.logs) {
for (const log of data.logs) {
if (!map[log.phase]) map[log.phase] = []
map[log.phase].push(log)
}
const map: Record<string, ProgressLog[]> = {}
for (const log of data.logs ?? []) {
if (!map[log.phase]) map[log.phase] = []
map[log.phase].push(log)
}
return map
})
</script>
<div class="workflow-tab-container">
<header class="preview-status-header">
<h1>Preview</h1>
<div class="meta">{data.file}</div>
</header>
<WorkflowConsole status="completed" phases={data.phases ?? []} logs={logsMap} />
</div>
<style>
.workflow-tab-container {
padding: var(--space-8) var(--space-4);
max-width: 860px;
margin: 0 auto;
color: var(--color-on-surface);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.preview-status-header {
margin-bottom: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preview-status-header h1 {
margin: 0;
font-size: var(--font-size-normal);
font-weight: var(--font-weight-bold);
letter-spacing: -0.01em;
}
.preview-status-header .meta {
font-family: var(--font-mono);
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
}
</style>
{#if data.status === 'completed'}
<main id="main-content">
<PieceOverview
title={data.piece?.title ?? 'Preview'}
assets={data.outputs}
{fileUrl}
assetsHeading="Preview assets"
>
<PreviewFacts
file={data.file}
phases={data.phases ?? []}
type={data.piece?.type}
status="completed"
createdAt={data.createdAt}
finishedAt={data.finishedAt}
/>
<PreviewLogs status="completed" phases={data.phases ?? []} logs={logsMap} />
</PieceOverview>
</main>
{/if}
@@ -0,0 +1,136 @@
<script lang="ts">
import type { ProgressPhase } from '$lib/components/progress.js'
import { formatDuration, previewDuration } from './duration.js'
interface Props {
file: string
type?: string
status: string
createdAt: string | null
finishedAt: string | null
phases?: ProgressPhase[]
}
let { file, type, status, createdAt, finishedAt, phases = [] }: Props = $props()
const knownStatus = $derived(
status === 'waiting' ||
status === 'enqueued' ||
status === 'running' ||
status === 'completed' ||
status === 'failed' ||
status === 'expired'
)
function parseDate(value: string | null): Date | null {
if (value === null) return null
const date = new Date(value)
return Number.isFinite(date.getTime()) ? date : null
}
const created = $derived(parseDate(createdAt))
const finished = $derived(parseDate(finishedAt))
const duration = $derived(previewDuration(phases, status, finishedAt))
</script>
<div class="overview-facts">
<!-- Explicit roles preserve table semantics when mobile CSS changes display. -->
<!-- svelte-ignore a11y_no_redundant_roles -->
<table role="table">
<caption>Preview</caption>
<tbody role="rowgroup">
<tr role="row">
<th scope="row" role="rowheader">File</th>
<td role="cell"><bdi dir="ltr" class="file" title={file}>{file}</bdi></td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">Type</th>
<td role="cell">
{#if type != null}{type}{:else}Unknown{/if}
</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">State</th>
<td role="cell">
<span class="status" data-status={knownStatus ? status : 'unknown'}>
{#if status === 'waiting' || status === 'enqueued'}
Queued
{:else if status === 'running'}
Building
{:else if status === 'completed'}
Completed
{:else if status === 'failed'}
Failed
{:else if status === 'expired'}
Expired
{:else}
{status}
{/if}
</span>
{#if status === 'enqueued' || status === 'waiting'}
<div class="status-sub">Waiting for worker…</div>
{:else if status === 'expired'}
<div class="status-sub">
Assets are purged after 2 days. Re-run the preview from the editor.
</div>
{/if}
</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">Duration</th>
<td role="cell">
{#if duration !== null}
{formatDuration(duration)}
{#if status === 'running'}
elapsed
{:else if status === 'completed' || status === 'expired'}
total time
{:else if status === 'failed'}
stopped at
{/if}
{:else}
<span class="muted">Unknown</span>
{/if}
</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">Created</th>
<td role="cell">
{#if created}
<time datetime={created.toISOString()}
>{created.toLocaleString('en-US', { timeZone: 'UTC' })} UTC</time
>
{:else}
<span class="muted">Unknown</span>
{/if}
</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">Finished</th>
<td role="cell">
{#if finished}
<time datetime={finished.toISOString()}
>{finished.toLocaleString('en-US', { timeZone: 'UTC' })} UTC</time
>
{:else}
<span class="muted">Unknown</span>
{/if}
</td>
</tr>
</tbody>
</table>
</div>
<style>
.file {
overflow-wrap: anywhere;
}
.muted,
.status-sub {
color: var(--color-on-surface-variant);
}
.status-sub {
margin-top: var(--space-1);
font-size: inherit;
}
</style>
@@ -0,0 +1,319 @@
import { readFileSync } from 'node:fs'
import { expect, test } 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'
function facts(overrides: Partial<ComponentProps<typeof PreviewFacts>> = {}) {
return render(PreviewFacts, {
props: {
file: 'books/a-book.books.md',
type: 'books',
status: 'completed',
createdAt: '2026-01-02T03:04:05.000Z',
finishedAt: '2026-01-02T03:05:06.000Z',
...overrides
}
}).body
}
function rows(html: string) {
return [...html.matchAll(/<th[^>]*>([^<]+)<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/g)].map(
([, label, value]) => [
label,
value
.replace(/<(?:[^>"']|"[^"]*"|'[^']*')*>/g, '')
.replace(/\s+/g, ' ')
.trim()
]
)
}
function phase(overrides: Partial<ProgressPhase> = {}): ProgressPhase {
return {
phase: 'render',
status: 'completed',
started_at: 1000,
finished_at: 3000,
message: null,
...overrides
}
}
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',
'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).toContain('<span class="status" data-status={knownStatus ? status : \'unknown\'}>')
})
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.']
])('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']
])
})
test('missing type is Unknown without inferring any identity', () => {
expect(field(facts({ type: undefined }), 'Type')).toBe('Unknown')
})
test('type content is escaped rather than interpreted as markup', () => {
const html = facts({ type: '<script>books & films</script>' })
expect(html).toContain('&lt;script>books &amp; films&lt;/script>')
expect(html).not.toContain('<script>')
})
test.each([
'future-state',
'constructor',
'toString',
'<script>unknown & state</script>',
'completed" data-status="failed'
])('unknown state %s is preserved as escaped text', (status) => {
const html = facts({ status })
const escaped = status.replace(/&/g, '&amp;').replace(/</g, '&lt;')
expect(field(html, 'State')).toBe(escaped)
expect(html).toContain('class="status" data-status="unknown"')
expect(html).not.toContain('<svg')
expect(field(html, 'Duration')).toBe('Unknown')
expect(html).not.toContain('<script>')
})
test('uses actual metadata ISO times normalized to UTC with published en-US formatting', () => {
const html = facts({
createdAt: '2026-01-02T05:04:05+02:00',
finishedAt: '2026-01-02T05:05:06+02:00'
})
expect(html).toContain('datetime="2026-01-02T03:04:05.000Z"')
expect(html).toContain('datetime="2026-01-02T03:05:06.000Z"')
expect(rows(html).slice(4)).toEqual([
['Created', '1/2/2026, 3:04:05 AM UTC'],
['Finished', '1/2/2026, 3:05:06 AM UTC']
])
})
test.each([null, '', 'not-a-date', '2026-99-99T99:99:99Z', '999999999999999999999'])(
'missing or invalid metadata %j remains Unknown without throwing',
(value) => {
const html = facts({ createdAt: value, finishedAt: value })
expect(rows(html).slice(4)).toEqual([
['Created', 'Unknown'],
['Finished', 'Unknown']
])
expect(html).not.toContain('<time')
}
)
test('epoch zero is a known timestamp in either field', () => {
const html = facts({
createdAt: '1970-01-01T00:00:00.000Z',
finishedAt: '1970-01-01T00:00:00.000Z'
})
expect(html.match(/datetime="1970-01-01T00:00:00.000Z"/g)).toHaveLength(2)
expect(rows(html).slice(4)).toEqual([
['Created', '1/1/1970, 12:00:00 AM UTC'],
['Finished', '1/1/1970, 12:00:00 AM UTC']
])
})
test('date fields are independent: a bad Created value cannot hide an actual Finished value', () => {
const html = facts({ createdAt: 'invalid' })
expect(rows(html).slice(4)).toEqual([
['Created', 'Unknown'],
['Finished', '1/2/2026, 3:05:06 AM UTC']
])
expect(html.match(/<time /g)).toHaveLength(1)
})
test('the entire raw filename is escaped, direction-isolated, and available as its title', () => {
const file = `folder/${'long'.repeat(80)}/<img src=x onerror="bad"> & אבג.books.md`
const html = facts({ file })
expect(field(html, 'File')).toBe(file.replace(/&/g, '&amp;').replace(/</g, '&lt;'))
expect(html).toMatch(/<bdi dir="ltr" class="file[^"]*" title="folder\//)
expect(html).toContain(
'title="' + file.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;') + '"'
)
expect(html.replace(/"[^"]*"/g, '')).not.toContain('<img')
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'],
['failed', '2s stopped at'],
['expired', '2s total time']
])('%s shows the measured duration kind in its own row', (status, expected) => {
const html = facts({ status, phases: [phase()] })
expect(field(html, 'Duration')).toBe(expected)
expect(field(html, 'State')).not.toContain('2s')
})
test.each(['completed', 'running', 'failed', 'expired'])(
'%s without phase timings is honest Unknown rather than a fabricated zero',
(status) => {
const html = facts({ status, phases: [] })
expect(field(html, 'Duration')).toBe('Unknown')
expect(html).not.toContain('0s')
}
)
test('epoch-zero phase timing is measured rather than discarded', () => {
expect(field(facts({ phases: [phase({ started_at: 0, finished_at: 2000 })] }), 'Duration')).toBe(
'2s total time'
)
})
test('invalid phase times remain Unknown and do not alter authoritative metadata dates', () => {
const html = facts({ phases: [phase({ started_at: NaN })] })
expect(field(html, 'Duration')).toBe('Unknown')
expect(rows(html).slice(4)).toEqual(rows(facts()).slice(4))
})
test('valid phase times cannot invent missing metadata dates or use queue time as duration', () => {
const html = facts({ createdAt: null, finishedAt: null, phases: [phase()] })
expect(field(html, 'Duration')).toBe('2s total time')
expect(rows(html).slice(4)).toEqual([
['Created', 'Unknown'],
['Finished', 'Unknown']
])
expect(html).not.toContain('<time')
})
test('terminal missing finish does not display an ever-growing elapsed time', () => {
const html = facts({ finishedAt: null, phases: [phase({ finished_at: null })] })
expect(field(html, 'Duration')).toBe('Unknown')
})
@@ -0,0 +1,32 @@
<script lang="ts">
import type { ProgressPhase, ProgressLog } from '$lib/components/progress'
import WorkflowConsole from './WorkflowConsole.svelte'
interface Props {
status: string
phases: ProgressPhase[]
logs: Record<string, ProgressLog[]>
errorMessage?: string
}
let { status, phases, logs, errorMessage }: Props = $props()
const sectionId = $props.id()
</script>
<section aria-labelledby="{sectionId}-heading">
<h2 id="{sectionId}-heading">Preview logs</h2>
<WorkflowConsole {status} {phases} {logs} {errorMessage} />
</section>
<style>
section {
min-width: 0;
font-size: var(--font-size-xs);
line-height: 1.4;
}
h2 {
font-size: var(--font-size-normal);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-4);
}
</style>
@@ -0,0 +1,187 @@
import { expect, test } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import type { ProgressPhase, ProgressLog } from '$lib/components/progress'
import PreviewLogs from './PreviewLogs.svelte'
import PreviewFacts from './PreviewFacts.svelte'
const started = Date.parse('2026-01-02T03:04:00Z')
const phases: ProgressPhase[] = [
{
phase: 'render',
status: 'completed',
started_at: started,
finished_at: started + 65_000,
message: 'Rendered successfully'
},
{
phase: 'assets',
status: 'failed',
started_at: started + 65_000,
finished_at: started + 90_000,
message: 'Asset failed'
}
]
const logs: Record<string, ProgressLog[]> = {
render: [
{ phase: 'render', line_number: 1, ts: started, level: 'stdout', message: 'Output ready' },
{
phase: 'render',
line_number: 2,
ts: started + 1000,
level: 'info',
message: 'Recorded render detail'
}
],
assets: [
{
phase: 'assets',
line_number: 1,
ts: started + 65_000,
level: 'stderr',
message: 'Recorded asset error'
}
]
}
function props(
overrides: Partial<ComponentProps<typeof PreviewLogs>> = {}
): ComponentProps<typeof PreviewLogs> {
return { status: 'completed', phases, logs, ...overrides }
}
function text(html: string) {
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
// SSR checks delegation and initial controls, not browser scrolling, SSE, or onMount behavior.
test('logs are a labelled section with an instance-scoped heading, not a main or H1', () => {
const first = render(PreviewLogs, { props: props(), idPrefix: 'first' }).body
const second = render(PreviewLogs, { props: props(), idPrefix: 'second' }).body
const headingId = (html: string) => html.match(/<h2[^>]*id="([^"]+)"/)?.[1]
for (const html of [first, second]) {
expect(headingId(html)).toBeTruthy()
expect(html).toContain(`aria-labelledby="${headingId(html)}"`)
expect(html).toMatch(/<h2[^>]*>Preview logs<\/h2>/)
expect(html).not.toContain('<h1')
expect(html).not.toContain('<main')
}
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) => {
const html = render(PreviewLogs, { props: props({ status, phases: [], logs: {} }) }).body
expect(html).not.toContain('status-card')
expect(html).not.toContain('status-title')
expect(html).not.toContain('status-sub')
expect(text(html)).toBe('Preview logs')
}
)
test('preserves native phase groups, timing, log content and initial lock controls', () => {
const html = render(PreviewLogs, { props: props() }).body
const content = text(html)
for (const expected of [
'render',
'assets',
'Rendered successfully',
'Asset failed',
'1m 5s',
'25s',
'2 lines',
'1 lines',
'03:04:00',
'03:04:01',
'03:05:05',
'Output ready',
'Recorded render detail',
'Recorded asset error'
])
expect(content).toContain(expected)
expect(html.match(/class="log-viewport/g)).toHaveLength(2)
expect(html.match(/<button /g)).toHaveLength(2)
expect(html.match(/title="Auto-scroll locked"/g)).toHaveLength(2)
expect(html).toMatch(/class="log-row[^"]*\bis-error\b[^"]*"/)
expect(html).toContain('-log-container-render')
expect(html).toContain('-log-container-assets')
})
test.each(['failed', 'running', 'completed', 'expired'])(
'passes optional errors through with native failed-only visibility: %s',
(status) => {
const html = render(PreviewLogs, {
props: props({ status, errorMessage: '<script>Failure & details</script>' })
}).body
expect(html.includes('error-strip')).toBe(status === 'failed')
expect(html.includes('&lt;script>Failure &amp; details&lt;/script>')).toBe(status === 'failed')
expect(html).not.toContain('<script>')
}
)
test('phase and log content remain escaped through delegation', () => {
const unsafe = '<script>not executable & not hidden</script>'
const html = render(PreviewLogs, {
props: props({
phases: [{ ...phases[0], message: unsafe }],
logs: { render: [{ ...logs.render[0], message: unsafe }] }
})
}).body
expect(html.match(/&lt;script>not executable &amp; not hidden&lt;\/script>/g)).toHaveLength(2)
expect(html).not.toContain('<script>')
})
test('empty queued data does not invent phases, logs or controls', () => {
const html = render(PreviewLogs, {
props: props({ status: 'enqueued', phases: [], logs: {} })
}).body
expect(text(html)).toBe('Preview logs')
expect(html).not.toContain('class="timeline')
expect(html).not.toContain('class="log-console')
expect(html).not.toContain('<button ')
expect(html).not.toContain('error-strip')
})
@@ -1,6 +1,7 @@
<script lang="ts">
import { tick } from 'svelte'
import type { ProgressPhase, ProgressLog } from '$lib/components/progress.js'
import { formatDuration } from './duration'
import CheckCircleFill from 'virtual:icons/ph/check-circle-fill'
import XCircleFill from 'virtual:icons/ph/x-circle-fill'
import CircleNotchBold from 'virtual:icons/ph/circle-notch-bold'
@@ -20,15 +21,6 @@
let scrollLocks = $state<Record<string, boolean>>({})
function formatDuration(ms: number) {
if (ms < 0) return '0s'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const remS = s % 60
return `${m}m ${remS}s`
}
function handleScroll(phaseName: string, event: Event) {
const el = event.currentTarget as HTMLDivElement
if (!el) return
@@ -42,15 +34,6 @@
}
}
let overallDuration = $derived.by(() => {
if (phases.length === 0) return 0
const first = phases[0]
const last = phases[phases.length - 1]
if (!first.started_at) return 0
const end = last.finished_at || Date.now()
return end - first.started_at
})
let logLengths = $derived(
Object.fromEntries(Object.entries(logs).map(([phase, list]) => [phase, list.length]))
)
@@ -68,43 +51,6 @@
})
</script>
<div class="status-card">
<span class="status-icon-wrap status-{status}">
{#if status === 'completed'}
<CheckCircleFill class="status-icon" />
{:else if status === 'running'}
<CircleNotchBold class="status-icon spin" />
{:else if status === 'enqueued'}
<CircleNotchBold class="status-icon" />
{:else if status === 'failed' || status === 'expired'}
<XCircleFill class="status-icon" />
{:else}
<span class="status-dot"></span>
{/if}
</span>
<div class="status-text">
{#if status === 'expired'}
<div class="status-title">Preview expired</div>
<div class="status-sub">
Assets are purged after 2 days. Re-run the preview from the editor.
</div>
{:else if status === 'enqueued'}
<div class="status-title">Enqueued</div>
<div class="status-sub">Waiting for worker…</div>
{:else if status === 'running'}
<div class="status-title">Rendering preview</div>
<div class="status-sub">{formatDuration(overallDuration)} elapsed</div>
{:else if status === 'failed'}
<div class="status-title">Preview failed</div>
<div class="status-sub">{formatDuration(overallDuration)} stopped at</div>
{:else if status === 'completed'}
<div class="status-title">Preview completed</div>
<div class="status-sub">{formatDuration(overallDuration)} total time</div>
{/if}
</div>
</div>
{#if errorMessage && status === 'failed'}
<div class="error-strip">
<XCircleFill class="error-icon" />
@@ -209,66 +155,6 @@
{/if}
<style>
.status-card {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
background-color: var(--color-surface-container-lowest);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-medium);
margin-bottom: var(--space-5);
}
.status-icon-wrap {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-on-surface-variant);
}
.status-icon-wrap :global(.status-icon) {
font-size: 26px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--color-outline-variant);
}
.status-icon-wrap.status-completed {
color: var(--color-primary);
}
.status-icon-wrap.status-running {
color: var(--color-secondary);
}
.status-icon-wrap.status-enqueued {
color: var(--color-tertiary);
}
.status-icon-wrap.status-failed {
color: var(--color-error);
}
.status-title {
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
color: var(--color-on-surface);
}
.status-sub {
margin-top: 2px;
font-size: var(--font-size-xxs);
color: var(--color-on-surface-variant);
}
.error-strip {
display: flex;
align-items: center;
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } 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')
@@ -19,6 +20,19 @@ 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, '')
@@ -33,19 +47,20 @@ afterEach(() => vi.restoreAllMocks())
describe('preview status summary', () => {
test.each([
[
'expired',
'Preview expired',
'Assets are purged after 2 days. Re-run the preview from the editor.'
],
['enqueued', 'Enqueued', 'Waiting for worker…'],
['running', 'Rendering preview', '1m 5s elapsed'],
['failed', 'Preview failed', '1m 5s stopped at'],
['completed', 'Preview completed', '1m 5s total time']
['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 } })
expect(text(result.body)).toContain(`${title} ${subtitle}`)
expect(result.body).toContain(`status-${status}`)
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')
@@ -80,7 +95,8 @@ describe('preview phases and logs', () => {
logs
}
})
expect(text(result.body)).toContain('Preview completed 1m 5s total time')
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')
})
@@ -123,13 +139,26 @@ describe('preview phases and logs', () => {
logs: {}
}
})
expect(text(result.body)).toContain('Preview completed 1m 30s total time')
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([
[[], '0s'],
[[{ ...phases[0], started_at: 0 }], '0s'],
[[], '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']
@@ -141,7 +170,11 @@ describe('preview phases and logs', () => {
logs: {}
}
})
expect(text(result.body)).toContain(`Rendering preview ${duration} elapsed`)
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', () => {
@@ -0,0 +1,138 @@
import { expect, test } from 'vitest'
import type { ProgressPhase } from '$lib/components/progress.js'
import { formatDuration, previewDuration } from './duration.js'
function phase(overrides: Partial<ProgressPhase> = {}): ProgressPhase {
return {
phase: 'render',
status: 'completed',
started_at: 1000,
finished_at: 3000,
message: null,
...overrides
}
}
test.each([
[-1001, '0s'],
[-1, '0s'],
[0, '0s'],
[999, '0s'],
[1000, '1s'],
[1999, '1s'],
[59999, '59s'],
[60000, '1m 0s'],
[61999, '1m 1s'],
[120000, '2m 0s'],
[3600000, '60m 0s']
])('formatDuration(%s) preserves the console format %s', (ms, expected) => {
expect(formatDuration(ms)).toBe(expected)
})
test('duration spans the first phase start through the last phase end, not a sum or sorted list', () => {
const phases = [
phase({ phase: 'first', started_at: 1000, finished_at: 2000 }),
phase({ phase: 'middle', started_at: 500, finished_at: 20000 }),
phase({ phase: 'last', started_at: 5000, finished_at: 7000 })
]
expect(previewDuration(phases, 'completed', '2026-01-02T03:05:06Z', 90000)).toBe(6000)
})
test('running duration uses explicit now only when the last phase has not finished', () => {
expect(previewDuration([phase({ finished_at: null })], 'running', null, 4500)).toBe(3500)
})
test('running duration preserves the old console end when the last phase already finished', () => {
expect(previewDuration([phase()], 'running', '2026-01-02T03:05:06Z', 90000)).toBe(2000)
})
test.each(['completed', 'failed', 'expired'])(
'%s uses phase finish in preference to metadata finish or now',
(status) => {
expect(previewDuration([phase()], status, '1970-01-01T00:00:09Z', 90000)).toBe(2000)
}
)
test.each(['completed', 'failed', 'expired'])(
'%s can fall back to authoritative metadata finish when the last phase has no finish',
(status) => {
expect(
previewDuration([phase({ finished_at: null })], status, '1970-01-01T02:00:04+02:00', 90000)
).toBe(3000)
}
)
test.each(['completed', 'failed', 'expired'])(
'%s missing both finishes is Unknown regardless of now',
(status) => {
expect(previewDuration([phase({ finished_at: null })], status, null, 90000)).toBeNull()
}
)
test.each([null, '', 'not-a-date', '2026-99-99T99:99:99Z'])(
'invalid terminal metadata finish %j cannot invent a duration',
(finishedAt) => {
expect(
previewDuration([phase({ finished_at: null })], 'completed', finishedAt, 90000)
).toBeNull()
}
)
test.each(['running', 'completed', 'failed', 'expired', 'enqueued', 'unknown'])(
'%s with no phases remains Unknown',
(status) => {
expect(previewDuration([], status, '1970-01-01T00:00:09Z', 90000)).toBeNull()
}
)
test.each(['enqueued', 'waiting', 'unknown', 'constructor', 'toString'])(
'%s does not infer a measurement even from phase values',
(status) => {
expect(previewDuration([phase()], status, '1970-01-01T00:00:09Z', 90000)).toBeNull()
}
)
test.each([NaN, Infinity, -Infinity])('nonfinite phase start %s is Unknown', (started_at) => {
expect(previewDuration([phase({ started_at })], 'completed', null, 90000)).toBeNull()
})
test.each([NaN, Infinity, -Infinity])('nonfinite phase finish %s is Unknown', (finished_at) => {
expect(
previewDuration([phase({ finished_at })], 'completed', '1970-01-01T00:00:09Z', 90000)
).toBeNull()
})
test.each([NaN, Infinity, -Infinity])('nonfinite running now %s is Unknown', (now) => {
expect(previewDuration([phase({ finished_at: null })], 'running', null, now)).toBeNull()
})
test('zero start and finish are both known, including metadata epoch zero', () => {
expect(
previewDuration([phase({ started_at: 0, finished_at: 2000 })], 'completed', null, 90000)
).toBe(2000)
expect(previewDuration([phase({ started_at: 0, finished_at: 0 })], 'running', null, 90000)).toBe(
0
)
expect(
previewDuration(
[phase({ started_at: 0, finished_at: null })],
'completed',
'1970-01-01T00:00:00Z',
90000
)
).toBe(0)
})
test('negative spans clamp to zero', () => {
expect(previewDuration([phase({ started_at: 4000 })], 'failed', null, 90000)).toBe(0)
expect(previewDuration([phase({ finished_at: null })], 'running', null, 500)).toBe(0)
})
test('measurement never changes the input phases', () => {
const phases = [phase({ phase: 'first' }), phase({ phase: 'last', finished_at: null })]
const before = structuredClone(phases)
phases.forEach(Object.freeze)
Object.freeze(phases)
expect(previewDuration(phases, 'running', null, 5000)).toBe(4000)
expect(phases).toEqual(before)
})
@@ -0,0 +1,30 @@
import type { ProgressPhase } from '$lib/components/progress.js'
export function formatDuration(ms: number): string {
if (ms < 0) return '0s'
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
return `${minutes}m ${seconds % 60}s`
}
/** Measure rendering phases, never time spent waiting in the queue. */
export function previewDuration(
phases: ProgressPhase[],
status: string,
finishedAt: string | null,
now = Date.now()
): number | null {
if (phases.length === 0) return null
const terminal = status === 'completed' || status === 'failed' || status === 'expired'
if (status !== 'running' && !terminal) return null
const start = phases[0].started_at
const last = phases[phases.length - 1]
// Preserve the console's phase-span end, including a known epoch-zero finish.
const end =
last.finished_at ??
(status === 'running' ? now : finishedAt === null ? NaN : Date.parse(finishedAt))
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
return Math.max(0, end - start)
}
@@ -6,9 +6,9 @@
</script>
{#if data.status === 'completed' && data.piece}
<div class="icon-preview-viewport">
<main id="main-content" class="icon-preview-viewport" aria-label="Icon">
<PieceIcon piece={data.piece} size={{ width: 500 }} lazy={false} />
</div>
</main>
{/if}
<style>
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { getWorkflowRun, getStepAttempts, type WorkflowRunRow } from '@luzzle/web.jobs'
import type { AssembledPreview } from '$lib/pieces/preview/assemble.server.js'
import { publishedPiece } from '$lib/pieces/viewer/fixtures/published.js'
import { load } from './+layout.server.js'
const { assemblePreview, query } = vi.hoisted(() => ({
assemblePreview: vi.fn(() => ({ html: 'rendered-preview' })),
assemblePreview: vi.fn<() => AssembledPreview>(),
query: {
selectAll: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
@@ -24,6 +26,36 @@ vi.mock('$lib/server/workflow/index.js', () => ({ getOpenWorkflowBackend: () =>
vi.mock('$lib/pieces/preview/assemble.server.js', () => ({ assemblePreview }))
vi.mock('@luzzle/web.jobs', () => ({ getWorkflowRun: vi.fn(), getStepAttempts: vi.fn() }))
const assembled: AssembledPreview = {
piece: {
...publishedPiece,
id: 'preview',
title: 'Draft',
assets: [
{
asset_key: 'piece-key',
transformation: 'markdown.html',
asset_path: null,
mime_type: 'text/html',
is_embedded: 1,
content: '<p>Draft</p>'
},
...publishedPiece.assets
]
},
tags: [{ slug: 'draft-tag', tag: 'Draft tag' }],
outputs: [
{
asset_key: 'piece-key',
transformation: 'markdown.html',
asset_path: null,
mime_type: 'text/html',
is_embedded: 1,
content: '<p>Draft</p>'
}
]
}
const preview = {
filePath: 'example.book.md',
type: 'book',
@@ -53,6 +85,7 @@ function event() {
beforeEach(() => {
vi.clearAllMocks()
assemblePreview.mockReturnValue(assembled)
vi.setSystemTime(new Date('2026-06-22T00:00:00Z'))
query.execute.mockResolvedValue([])
vi.mocked(getWorkflowRun).mockResolvedValue(run())
@@ -61,31 +94,151 @@ beforeEach(() => {
afterEach(() => vi.useRealTimers())
test('awaits SDK-backed metadata and preserves completed preview assembly', async () => {
expect(await load(event())).toMatchObject({
const data = await load(event())
expect(data).toStrictEqual({
preview: true,
file: 'example.book.md',
status: 'completed',
jobId: 'preview-id',
job: 'preview-id',
phases: [],
logs: [],
html: 'rendered-preview'
createdAt: '2026-06-20T00:00:00Z',
finishedAt: '2026-06-21T00:00:00Z',
...assembled
})
expect(assemblePreview).toHaveBeenCalledWith(preview, expect.objectContaining({ type: 'book' }))
expect(data).toHaveProperty('outputs', assembled.outputs)
expect(data).not.toHaveProperty('html')
expect(assembled.outputs).not.toEqual(assembled.piece.assets)
expect(assemblePreview).toHaveBeenCalledExactlyOnceWith(
preview,
expect.objectContaining({ type: 'book' })
)
})
test.each(['pending', 'running', 'failed', 'canceled', 'completed'])(
'forwards real run creation/finish timestamps for %s without using page-load time',
async (status) => {
vi.mocked(getWorkflowRun).mockResolvedValue(
run({ status, created_at: '1970-01-01T00:00:00.000Z', finished_at: null })
)
const data = await load(event())
expect(data).toMatchObject({ createdAt: '1970-01-01T00:00:00.000Z', finishedAt: null })
expect(data).not.toHaveProperty('startedAt')
}
)
test('expired runs retain their actual creation and finish times without assembling assets', async () => {
vi.mocked(getWorkflowRun).mockResolvedValue(
run({ created_at: '2026-06-18T00:00:00Z', finished_at: '2026-06-19T00:00:00Z' })
)
const data = await load(event())
expect(data).toMatchObject({
status: 'expired',
createdAt: '2026-06-18T00:00:00Z',
finishedAt: '2026-06-19T00:00:00Z'
})
expect(assemblePreview).not.toHaveBeenCalled()
})
test('preserves failed preview error data', async () => {
vi.mocked(getWorkflowRun).mockResolvedValue(run({ status: 'failed', error: 'preview failed' }))
expect(await load(event())).toMatchObject({ status: 'failed', errorMessage: 'preview failed' })
const data = await load(event())
expect(data).toMatchObject({
preview: true,
job: 'preview-id',
status: 'failed',
errorMessage: 'preview failed'
})
expect(data).not.toHaveProperty('outputs')
expect(data).not.toHaveProperty('piece')
expect(assemblePreview).not.toHaveBeenCalled()
})
test('preserves expiration based on the serialized SDK finish time', async () => {
vi.mocked(getWorkflowRun).mockResolvedValue(run({ finished_at: '2026-06-19T00:00:00Z' }))
expect(await load(event())).toMatchObject({ status: 'expired', jobId: 'preview-id' })
const data = await load(event())
expect(data).toMatchObject({
preview: true,
status: 'expired',
job: 'preview-id'
})
expect(data).not.toHaveProperty('outputs')
expect(data).not.toHaveProperty('piece')
expect(assemblePreview).not.toHaveBeenCalled()
})
test.each([
{ status: 'pending', expected: 'waiting' },
{ status: 'running', expected: 'running' },
{ status: 'canceled', expected: 'failed' },
{ status: 'completed', expected: 'completed' }
])(
'declares preview and job for $status without assembled output',
async ({ status, expected }) => {
vi.mocked(getWorkflowRun).mockResolvedValue(run({ status, output: null }))
const data = await load(event())
expect(data).toMatchObject({
preview: true,
job: 'preview-id',
status: expected
})
expect(data).not.toHaveProperty('outputs')
expect(data).not.toHaveProperty('piece')
expect(assemblePreview).not.toHaveBeenCalled()
}
)
test('replacement layout data declares the newly requested Preview job', async () => {
const nextEvent = {
params: { jobId: 'next-job', path: 'next.book.md' }
} as Parameters<typeof load>[0]
expect(await load(nextEvent)).toMatchObject({
preview: true,
job: 'next-job'
})
expect(getWorkflowRun).toHaveBeenLastCalledWith({}, 'next-job')
})
test('keeps missing SDK runs as a 404', async () => {
vi.mocked(getWorkflowRun).mockResolvedValue(null)
await expect(load(event())).rejects.toMatchObject({ status: 404 })
expect(getStepAttempts).not.toHaveBeenCalled()
expect(assemblePreview).not.toHaveBeenCalled()
})
test.each([
{ status: 'pending', expected: 'waiting' },
{ status: 'running', expected: 'running' },
{ status: 'canceled', expected: 'failed' }
])(
'does not assemble or expose outputs for $status even with a stored result',
async ({ status, expected }) => {
vi.mocked(getWorkflowRun).mockResolvedValue(run({ status }))
const data = await load(event())
expect(data).toMatchObject({ preview: true, job: 'preview-id', status: expected })
expect(data).not.toHaveProperty('outputs')
expect(data).not.toHaveProperty('piece')
expect(assemblePreview).not.toHaveBeenCalled()
}
)
test('preserves an empty completed outputs DTO without falling back to piece assets', async () => {
const passthroughOnly: AssembledPreview = {
...assembled,
piece: { ...assembled.piece, assets: publishedPiece.assets },
outputs: []
}
assemblePreview.mockReturnValue(passthroughOnly)
const data = await load(event())
expect(data).toMatchObject({
status: 'completed',
piece: passthroughOnly.piece,
tags: passthroughOnly.tags,
outputs: []
})
expect(assemblePreview).toHaveBeenCalledOnce()
})
@@ -0,0 +1,190 @@
import { existsSync, readFileSync } from 'node:fs'
import { beforeEach, afterEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { makeConfig } from '$lib/server/config.fixture'
import { publishedPiece } from '$lib/pieces/viewer/fixtures/published'
import PreviewLayout from './+layout.svelte'
import PreviewResetLayout from '../+layout@.svelte'
const { page, EventSource } = vi.hoisted(() => ({
page: { url: new URL('https://app.test/') },
EventSource: vi.fn()
}))
vi.mock('$app/state', () => ({ page }))
const file = 'books/book.books.md'
const root = `/admin/piece/${file}/preview/preview-id`
const children = createRawSnippet(() => ({ render: () => '<p>Preview child fixture</p>' }))
type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status): ComponentProps<typeof PreviewLayout>['data'] {
const common = {
config: makeConfig(),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
file,
job: 'preview-id',
createdAt: '2026-06-20T00:00:00Z',
finishedAt:
status === 'completed' || status === 'failed' || status === 'expired'
? '2026-06-21T00:00:00Z'
: null,
phases: [],
logs: []
}
if (status === 'completed')
return { ...common, status, piece: publishedPiece, tags: [], outputs: publishedPiece.assets }
if (status === 'failed') return { ...common, status, errorMessage: 'Preview failed fixture' }
return { ...common, status }
}
function body(status: Status = 'completed') {
return render(PreviewLayout, {
props: { data: data(status), params: { path: file, jobId: 'preview-id' }, children }
}).body.replace(/<!--[^]*?-->/g, '')
}
function anchors(html: string) {
return html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? []
}
beforeEach(() => {
page.url = new URL(root, 'https://app.test')
vi.clearAllMocks()
vi.stubGlobal('EventSource', EventSource)
})
afterEach(() => vi.unstubAllGlobals())
test.each(['/static', 'https://cdn.test/site-assets', ''])(
'Preview reset layout adds only the configured site favicon for base %j in every job state',
(base) => {
for (const status of ['waiting', 'running', 'completed', 'failed', 'expired'] as const) {
const props = data(status)
props.config.url.app_assets = base
const result = render(PreviewResetLayout, {
props: { data: props, params: { path: file }, children }
})
expect(result.head).toContain(`rel="icon" href="${base}/images/favicon.png"`)
expect(result.head.match(/rel="icon"/g)).toHaveLength(1)
expect(result.head).not.toContain('<title>')
expect(result.head).not.toContain('property="og:')
expect(result.head).not.toContain('name="description"')
expect(result.head).not.toContain('name="robots"')
expect(result.body).toContain('Preview child fixture')
expect(EventSource).not.toHaveBeenCalled()
}
}
)
test('Preview consumes the shared viewer bar instead of keeping separate banner styles', () => {
const source = readFileSync(new URL('./+layout.svelte', import.meta.url), 'utf8')
expect(source).toContain("ViewerBar.css'")
expect(source).toContain("'virtual:icons/ph/arrow-left'")
for (const name of [
'viewer-bar',
'viewer-bar-back',
'viewer-bar-links',
'viewer-bar-link',
'viewer-bar-label'
])
expect(source).toContain(`class="${name}`)
expect(source).not.toContain('.preview-banner {')
})
test('Preview registers Share without retaining an OG route or redirect alias', () => {
expect(existsSync(new URL('./share/+page.svelte', import.meta.url))).toBe(true)
expect(existsSync(new URL('./og/', import.meta.url))).toBe(false)
})
// SSR establishes markup/state contracts, not mount/SSE, CSS layout or client replacement.
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 header = html.slice(0, html.indexOf('</header>') + '</header>'.length)
const links = anchors(header)
expect(links).toHaveLength(5)
expect(header).toContain('class="viewer-bar"')
expect(header).toContain('class="viewer-bar-links" aria-label="Preview views"')
expect(header).toContain('class="viewer-bar-label">preview</span>')
expect(links[0]).toContain(`href="/admin/piece/${file}/source"`)
expect(links[0]).toContain('aria-label="Back to editor"')
expect(links[0]).toContain('title="Back to editor"')
expect(links[0]).toMatch(/<svg\b[^>]*aria-hidden="true"/)
expect(links[0]).toMatch(/<svg\b[^>]*focusable="false"/)
expect(links[1]).toContain(`href="${root}"`)
expect(links[1]).toContain('aria-current="page"')
expect(links[1]).toContain('>overview</a>')
expect(header).not.toContain('>logs</a>')
expect(header).not.toContain(`${root}/og`)
expect(links[1]).not.toContain('aria-disabled="true"')
for (const [index, leaf] of ['page', 'icon', 'share'].entries()) {
expect(links[index + 2]).toContain(
`href="${status === 'completed' ? `${root}/${leaf}` : '#'}"`
)
expect(links[index + 2]).toContain(`aria-disabled="${status !== 'completed'}"`)
expect(
links[index + 2]
.match(/class="([^"]*)"/)?.[1]
.split(/\s+/)
.includes('disabled')
).toBe(status !== 'completed')
}
expect(header.match(/aria-current="page"/g)).toHaveLength(1)
expect(links[4]).toMatch(/>\s*share\s*<\/a>/)
expect(header).not.toMatch(/>\s*og(?: \(html\))?\s*<\/a>/)
expect(header).not.toContain('share (html)')
expect(html.includes('Preview child fixture')).toBe(status === 'completed')
expect(EventSource).not.toHaveBeenCalled()
}
)
test.each(['', '/page', '/icon', '/share'])(
'completed Preview %s uses the existing active-view conditions with native current-page semantics',
(leaf) => {
page.url = new URL(root + leaf, 'https://app.test')
const links = anchors(body())
const selected = links.filter((link) => link.includes('aria-current="page"'))
expect(selected).toHaveLength(1)
expect(selected[0]).toContain(`href="${root}${leaf}"`)
expect(selected[0]).toContain('active')
expect(EventSource).not.toHaveBeenCalled()
}
)
test('Preview header retains encoded file and job identity reached through View', () => {
const props = data('completed')
props.file = 'books/源 #?%.books.md'
props.job = 'job #?%'
const encodedRoot = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md'
const preview = `${encodedRoot}/preview/job%20%23%3F%25`
page.url = new URL(`${preview}/share?html`, 'https://app.test')
const html = render(PreviewLayout, {
props: { data: props, params: { path: props.file, jobId: props.job }, children }
}).body
const links = anchors(html.slice(0, html.indexOf('</header>')))
expect(links[0]).toContain(`href="${encodedRoot}/source"`)
expect(links).toHaveLength(5)
for (const [index, leaf] of ['', '/page', '/icon', '/share'].entries())
expect(links[index + 1]).toContain(`href="${preview}${leaf}"`)
expect(links.filter((link) => link.includes('aria-current="page"'))).toHaveLength(1)
expect(links.at(-1)).toContain('aria-current="page"')
expect(EventSource).not.toHaveBeenCalled()
})
test('obsolete html query does not create a mode, extra tab or different active view', () => {
page.url = new URL(`${root}/share?html`, 'https://app.test')
const withQuery = body()
const links = anchors(withQuery)
expect(links).toHaveLength(5)
expect(links.filter((link) => link.includes('aria-current="page"'))).toHaveLength(1)
expect(links[4]).toContain(`href="${root}/share"`)
expect(links[4]).toMatch(/>\s*share\s*<\/a>/)
expect(links[4]).toContain('aria-current="page"')
expect(withQuery).not.toContain('?html')
expect(withQuery).not.toContain('share (html)')
page.url = new URL(`${root}/share`, 'https://app.test')
expect(body()).toBe(withQuery)
expect(EventSource).not.toHaveBeenCalled()
})
@@ -1,74 +0,0 @@
<script lang="ts">
import Opengraph from '$lib/pieces/components/opengraph.svelte'
import { page } from '$app/state'
import { onMount } from 'svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
const isHtml = $derived(page.url.searchParams.has('html'))
const ogAsset = $derived(data.piece?.assets.find((a) => a.transformation === 'opengraph'))
const ogPngUrl = $derived(
ogAsset ? `/admin/preview/${data.jobId}/asset/${ogAsset.asset_path}` : ''
)
let ogScale = $state(1)
let scaleContainer: HTMLDivElement | undefined = $state()
function updateScale() {
if (!scaleContainer) return
const available = scaleContainer.clientWidth
ogScale = Math.min(1, available / 1200)
}
onMount(() => {
updateScale()
window.addEventListener('resize', updateScale)
return () => window.removeEventListener('resize', updateScale)
})
</script>
{#if data.status === 'completed' && data.piece}
<div class="og-preview-viewport">
{#if isHtml}
<div class="og-scale-container" bind:this={scaleContainer}>
<div class="og-scale-inner" style:transform="scale({ogScale})" style:width="1200px">
<Opengraph piece={data.piece} />
</div>
</div>
{:else if ogPngUrl}
<img src={ogPngUrl} alt="Open Graph PNG Preview" />
{:else}
<div class="error">Open Graph PNG not generated yet.</div>
{/if}
</div>
{/if}
<style>
.og-preview-viewport {
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
background-color: var(--color-surface-container-low);
}
.og-scale-container {
width: 100%;
max-width: 1200px;
}
.og-scale-inner {
transform-origin: top left;
}
img {
box-shadow: var(--shadow-raised);
max-width: 1200px;
width: 100%;
height: auto;
border: 1px solid var(--color-outline-variant);
}
.error {
color: var(--color-error);
font-weight: var(--font-weight-semibold);
}
</style>
@@ -0,0 +1,268 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { makeConfig } from '$lib/server/config.fixture'
import { publishedPiece, asset } from '$lib/pieces/viewer/fixtures/published'
import Overview from './+page.svelte'
import PreviewLayout from './+layout.svelte'
const { page, resolve } = vi.hoisted(() => ({
page: { url: new URL('https://app.test/admin/piece/books/book.md/preview/job') },
resolve: vi.fn(() => {
throw new Error('Could not get the request store. This is an internal error.')
})
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({ resolve }))
type Data = ComponentProps<typeof Overview>['data']
type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status = 'completed'): Data {
const common = {
config: makeConfig({
url: {
app: 'https://public.test/site',
app_assets: '/static',
luzzle_assets: 'https://cdn.test/published'
}
}),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
file: 'books/book.md',
job: 'job',
createdAt: '1970-01-01T00:00:00.000Z',
finishedAt:
status === 'completed' || status === 'failed' || status === 'expired'
? '1970-01-01T00:00:02.000Z'
: null,
phases: [
{
job_id: 'job',
phase: 'transform',
status: 'completed',
started_at: 1000,
finished_at: 2000,
message: null
}
],
logs: [
{
job_id: 'job',
phase: 'transform',
line_number: 1,
ts: 1000,
level: 'info',
message: 'Preview log <script>escaped()</script>'
}
]
}
if (status === 'failed') return { ...common, status, errorMessage: 'Preview failed fixture' }
if (status !== 'completed') return { ...common, status }
const outputs = [
asset('image.original', 'generated/cover.jpg', 'image'),
asset('image.s.webp', 'generated/s.webp', 'image', 'image/webp'),
{
...asset('markdown', 'generated/body.html', 'piece', 'text/html'),
content: '<script>privateBody()</script><h1>Body</h1>'
},
{
...asset('markdown.description', null, 'piece', 'text/html'),
content: '<p>privateDescription()</p>'
},
{
...asset('palette', null, 'piece', 'application/json'),
content: '{"color":"#abc","count":42}'
},
asset('custom.invalid', '../private.pdf', 'piece', 'application/pdf'),
asset('custom.missing', null, 'piece', 'application/pdf')
]
return {
...common,
status,
outputs,
piece: {
...publishedPiece,
title: 'Preview <Draft>',
assets: [...outputs, asset('attachment', 'archive/manual.pdf')]
},
tags: []
}
}
function tree(props: Data) {
const children = createRawSnippet(() => ({
render: () =>
render(Overview, { props: { data: props, params: { path: props.file, jobId: props.job } } })
.body
}))
return render(PreviewLayout, {
props: { data: props, params: { path: props.file, jobId: props.job }, children }
}).body.replace(/<!--[^]*?-->/g, '')
}
beforeEach(() => {
page.url = new URL('https://app.test/admin/piece/books/book.md/preview/job')
vi.clearAllMocks()
})
test('completed Preview reads title, consolidated facts, Preview logs, then assets with a single main/title', () => {
const html = tree(data())
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html.match(/<h1\b/g)).toHaveLength(1)
expect(html).toContain('Preview &lt;Draft>')
expect(html).toContain('Preview assets</h2>')
expect(html).toContain('Preview logs</h2>')
expect(html).toContain('Preview log &lt;script>escaped()&lt;/script>')
expect(html.indexOf('Preview &lt;Draft>')).toBeLessThan(html.indexOf('Preview</caption>'))
expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>'))
expect(html.indexOf('Preview logs</h2>')).toBeLessThan(html.indexOf('Preview assets</h2>'))
const facts = html.slice(html.indexOf('<caption'), html.indexOf('</table>'))
const logSection = html.slice(
html.indexOf('Preview logs</h2>'),
html.indexOf('Preview assets</h2>')
)
expect(facts).toContain('books/book.md</bdi>')
expect(facts).toContain('data-status="completed"')
expect(facts).toContain('>Completed</span>')
expect(facts).toContain('1s total time')
expect(facts).not.toContain('<svg')
expect(logSection).not.toContain('books/book.md')
expect(logSection).not.toContain('Preview completed')
expect(logSection).not.toContain('total time')
expect(html).not.toContain('status-card')
expect(html.indexOf('Preview logs</h2>')).toBeLessThan(html.indexOf('Preview log &lt;script>'))
expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"')
expect(html).toContain('datetime="1970-01-01T00:00:02.000Z"')
for (const caption of ['Preview', 'Image', 'Markdown', 'Palette', 'Custom'])
expect(html).toContain(`${caption}</caption>`)
expect(html.match(/Image<\/caption>/g)).toHaveLength(1)
expect(html.match(/Markdown<\/caption>/g)).toHaveLength(1)
for (const output of ['image.original', 'image.s.webp', 'markdown.description'])
expect(html).toContain(`${output}</bdi>`)
for (const path of ['generated/cover.jpg', 'generated/s.webp', 'generated/body.html'])
expect(html).toContain(`href="/admin/preview/job/asset/${path}"`)
expect(html).not.toMatch(/ download(?:[= >])/)
expect(html).toContain('aria-label="Show Markdown: Asset 2, Content"')
expect(html).toContain('aria-label="Show Markdown.description: Asset 2, Content"')
expect(html).toContain('aria-label="Show Palette: Asset 2, Content"')
expect(html).toContain('aria-expanded="false"')
expect(html).not.toContain('{"color":"#abc","count":42}')
expect(html).not.toContain('color</bdi>')
expect(html).not.toContain('count</bdi>')
expect(html).toContain('Unavailable</small>')
expect(html).toContain('Not recorded')
for (const absent of [
'privateBody()',
'privateDescription()',
'<script>',
'<iframe',
'archive/manual.pdf',
'Attachment</caption>',
'Last publish',
'Publication</caption>',
'Matches publish',
'https://public.test',
'https://cdn.test',
'/pieces/assets/',
'/admin/asset/',
'background-color: #abc'
])
expect(html).not.toContain(absent)
expect(resolve).not.toHaveBeenCalled()
})
test.each(['waiting', 'running', 'failed', 'expired'] as const)(
'%s Preview puts current facts above logs without inventing assets or publication facts',
(status) => {
const props = data(status)
const html = tree(props)
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html).toContain('Piece overview')
expect(html).toContain('Preview</caption>')
expect(html).toContain('Preview logs</h2>')
const label = {
waiting: 'Queued',
running: 'Building',
failed: 'Failed',
expired: 'Expired'
}[status]
expect(html).toContain(`>${label}</span>`)
expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"')
if (status === 'waiting' || status === 'running')
expect(html).not.toContain('datetime="1970-01-01T00:00:02.000Z"')
expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>'))
expect(html).not.toContain('Preview assets')
expect(html).not.toContain('Preview outputs')
expect(html).not.toContain('Publication</caption>')
expect(html).not.toContain('Preview &lt;Draft>')
expect(html).not.toContain('/asset/generated/')
expect(resolve).not.toHaveBeenCalled()
}
)
test('completed workflow without an assembled result keeps facts/logs and omits assets rather than crashing', () => {
const running = data('running')
if (running.status !== 'running') throw new Error('Expected running fixture')
const props = { ...running, status: 'completed' as const }
const html = tree(props)
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html.match(/<h1\b/g)).toHaveLength(1)
expect(html).toContain('Preview</h1>')
expect(html).toContain('Preview</caption>')
expect(html).toContain('>Completed</span>')
expect(html).toContain('Preview logs</h2>')
expect(html).toContain('Preview log &lt;script>escaped()&lt;/script>')
expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>'))
expect(html).not.toContain('Preview assets')
expect(html).not.toContain('None recorded')
expect(html).not.toContain('/asset/generated/')
expect(resolve).not.toHaveBeenCalled()
})
test('recorded generated Share PNG remains a View link in Overview', () => {
const props = data()
if (props.status !== 'completed' || !props.outputs) throw new Error('Expected assembled fixture')
props.outputs = [
...props.outputs,
asset('opengraph', 'generated/share.png', 'piece', 'image/png')
]
const html = tree(props)
const png = html
.match(/<a\b[^>]*>/g)
?.find((tag) => tag.includes('href="/admin/preview/job/asset/generated/share.png"'))
expect(png).toBeDefined()
expect(png).toContain('aria-label="View opengraph: generated/share.png"')
expect(png).toContain('target="_blank"')
expect(png).toContain('rel="noopener noreferrer"')
expect(png).not.toMatch(/\bdownload(?:=|\s|>)/)
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(resolve).not.toHaveBeenCalled()
})
test('empty generated outputs do not turn SDK passthroughs into downloadable preview artifacts', () => {
const props = data()
if (props.status !== 'completed') throw new Error('Expected completed fixture')
props.outputs = []
const html = tree(props)
expect(html).toContain('Preview assets</h2>')
expect(html).toContain('None recorded')
expect(html).not.toContain('Image</caption>')
expect(html).not.toContain('archive/manual.pdf')
expect(html).not.toContain('/asset/generated/')
})
test('replacement completed snapshots use only the new job namespace and metadata without a request store', () => {
tree(data())
const props = data()
if (props.status !== 'completed' || !props.piece) throw new Error('Expected assembled fixture')
props.job = 'new-job'
props.piece = { ...props.piece, title: 'New result' }
props.outputs = [asset('custom', 'new.txt', 'new', 'text/plain')]
page.url = new URL('https://app.test/admin/piece/books/book.md/preview/new-job')
const html = tree(props)
expect(html).toContain('New result')
expect(html).toContain('href="/admin/preview/new-job/asset/new.txt"')
expect(html).not.toContain('/preview/job/asset/')
expect(html).not.toContain('generated/cover.jpg')
expect(resolve).not.toHaveBeenCalled()
})
@@ -0,0 +1,10 @@
<script lang="ts">
import ShareViewport from '$lib/pieces/viewer/ShareViewport.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
{#if data.status === 'completed' && data.piece}
<ShareViewport piece={data.piece} />
{/if}
@@ -0,0 +1,184 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { makeConfig } from '$lib/server/config.fixture'
import { publishedPiece, asset } from '$lib/pieces/viewer/fixtures/published'
import PreviewLayout from '../+layout.svelte'
import Page from '../page/+page.svelte'
import Icon from '../icon/+page.svelte'
import SharePage from './+page.svelte'
const { page, resolve } = vi.hoisted(() => ({
page: {
url: new URL('https://app.test/admin/piece/books/book.md/preview/job/share'),
data: {
preview: true,
job: 'job',
config: { url: { app: 'https://public.test/site', luzzle_assets: 'https://cdn.test' } }
}
},
resolve: vi.fn(() => {
throw new Error('Could not get the request store. This is an internal error.')
})
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({ resolve }))
// Only async renderer wrappers are replaced: the real routes, layout, ShareViewport
// and getPieceHelpers run. SSR cannot prove custom async renderers, client SSE,
// or instance ResizeObserver mounting/cleanup and responsive scaling behavior.
vi.mock('$lib/pieces/components/opengraph.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/Composition.svelte')).default
}))
vi.mock('$lib/pieces/components/page.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/PageRenderer.svelte')).default
}))
vi.mock('$lib/pieces/components/icon.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/IconRenderer.svelte')).default
}))
type Data = ComponentProps<typeof SharePage>['data']
type Status = 'waiting' | 'running' | 'completed' | 'failed' | 'expired'
function data(status: Status = 'completed', png = true): Data {
const common = {
config: makeConfig({
url: {
app: 'https://public.test/site',
app_assets: '/static',
luzzle_assets: 'https://cdn.test/published'
}
}),
meta: { title: 'Garden', description: 'Description' },
preview: true as const,
file: 'books/book.md',
job: 'job',
createdAt: '1970-01-01T00:00:00.000Z',
finishedAt: ['completed', 'failed', 'expired'].includes(status)
? '1970-01-01T00:00:02.000Z'
: null,
phases: [
{
job_id: 'job',
phase: 'transform',
status: 'completed',
started_at: 1000,
finished_at: 2000,
message: null
}
],
logs: [
{
job_id: 'job',
phase: 'transform',
line_number: 1,
ts: 1000,
level: 'info',
message: 'Preview log <draft>'
}
]
}
if (status === 'failed') return { ...common, status, errorMessage: 'Preview failed fixture' }
if (status !== 'completed') return { ...common, status }
const outputs = [
asset('image.original', 'generated/cover.jpg', 'cover'),
...(png ? [asset('opengraph', 'generated/share.png', 'piece', 'image/png')] : [])
]
return {
...common,
status,
outputs,
tags: [],
piece: { ...publishedPiece, title: 'Preview <Draft>', assets: outputs }
}
}
const routes = { share: SharePage, page: Page, icon: Icon }
function tree(props: Data, view: keyof typeof routes = 'share', query = '') {
page.data = props
page.url = new URL(
`https://app.test/admin/piece/${props.file}/preview/${props.job}/${view}${query}`
)
const params = { path: props.file, jobId: props.job }
const children = createRawSnippet(() => ({
render: () => render(routes[view], { props: { data: props, params } }).body
}))
return render(PreviewLayout, { props: { data: props, params, children } }).body.replace(
/<!--[^]*?-->/g,
''
)
}
beforeEach(() => vi.clearAllMocks())
test.each([true, false])('Share reconstructs composition with recorded PNG=%s', (png) => {
const html = tree(data('completed', png))
expect(html).toContain('aria-label="Share"')
expect(html).toContain('data-renderer="composition"')
expect(html).toContain('Preview &lt;Draft>')
expect(html).toContain('src="/admin/preview/job/asset/generated/cover.jpg"')
expect(html.match(/<img\b/g)).toHaveLength(1)
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html).toContain('id="main-content"')
expect(html).not.toMatch(
/generated\/share\.png|not (?:been )?generated|Open generated share image|share \(html\)|<button/i
)
expect(html).not.toMatch(
/https:\/\/(?:public|cdn)\.test|\/pieces\/assets\/|\/admin\/asset\/|published\/cover\.jpg/
)
expect(resolve).not.toHaveBeenCalled()
})
test('?html is ignored: identical composition and four tabs plus Back, only Share active', () => {
const props = data()
const html = tree(props)
expect(tree(props, 'share', '?html')).toBe(html)
const header = html.slice(html.indexOf('<header'), html.indexOf('</header>'))
expect(header.match(/<a\b/g)).toHaveLength(5)
expect(header.match(/aria-current="page"/g)).toHaveLength(1)
expect(header).toContain('aria-label="Back to editor"')
for (const view of ['overview', 'page', 'icon', 'share'])
expect(header).toMatch(new RegExp(`>\\s*${view}\\s*</a>`))
expect(header).toMatch(/<a[^>]*href="[^"]*\/share"[^>]*aria-current="page"[^>]*>\s*share\s*<\/a>/)
expect(header).not.toContain('?html')
expect(header).not.toContain('share (html)')
})
test.each(['share', 'page', 'icon'] as const)(
'completed %s owns exactly one main inside the real layout',
(view) => {
const html = tree(data(), view)
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html.match(/id="main-content"/g)).toHaveLength(1)
expect(html).toContain(`data-renderer="${view === 'share' ? 'composition' : view}"`)
expect(html).toContain('src="/admin/preview/job/asset/generated/cover.jpg"')
expect(resolve).not.toHaveBeenCalled()
}
)
test.each(['waiting', 'running', 'failed', 'expired'] as const)(
'%s Share keeps facts and logs under the layout main, not composition or PNG UI',
(status) => {
const html = tree(data(status))
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html).toContain('id="main-content"')
expect(html).toContain('Preview</caption>')
expect(html).toContain('books/book.md</bdi>')
expect(html).toContain('Preview logs</h2>')
expect(html).toContain('Preview log &lt;draft>')
expect(html.indexOf('Preview</caption>')).toBeLessThan(html.indexOf('Preview logs</h2>'))
expect(html).not.toMatch(
/data-renderer="composition"|<img|generated\/share\.png|Open generated share image|\?html|Preview assets/
)
expect(resolve).not.toHaveBeenCalled()
}
)
test('completed without an assembled piece is safe and invents no composition or artifacts', () => {
const pending = data('running')
if (pending.status !== 'running') throw new Error('Expected running fixture')
const html = tree({ ...pending, status: 'completed' })
expect(html).toContain('Back to editor')
expect(html).not.toMatch(
/data-renderer=|<img|\/asset\/|Open generated share image|not (?:been )?generated/i
)
expect(resolve).not.toHaveBeenCalled()
})
@@ -29,5 +29,26 @@ test('the UI queues its preview through the web client and redirects without the
status: 303,
location: '/admin/piece/example.book.md/preview/preview-id'
})
expect(runWorkflow).toHaveBeenCalledWith(previewSpec, { filePath: 'example.book.md' })
expect(runWorkflow).toHaveBeenCalledExactlyOnceWith(previewSpec, { filePath: 'example.book.md' })
})
test.each([
[
'books/源 #?%.book.md',
'job #?%',
'/admin/piece/books/%E6%BA%90%20%23%3F%25.book.md/preview/job%20%23%3F%25'
],
[
'nested/literal%2F.book.md',
'preview-id',
'/admin/piece/nested/literal%252F.book.md/preview/preview-id'
]
])(
'Preview starts once from raw file %s and redirects with encoded segments',
async (file, job, location) => {
runWorkflow.mockResolvedValue({ workflowRun: { id: job } })
const event = { params: { path: file } } as Parameters<typeof load>[0]
await expect(load(event)).rejects.toMatchObject({ status: 303, location })
expect(runWorkflow).toHaveBeenCalledExactlyOnceWith(previewSpec, { filePath: file })
}
)
@@ -0,0 +1,38 @@
import { error } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
import { db } from '$lib/server/database'
import { config } from '$lib/server/config'
import { getWebPiece } from '$lib/server/pieces'
import { hydrateWithAssets } from '$lib/pieces/assets.server'
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
import { getPieceLinks } from '$lib/pieces/navigation'
export const load: LayoutServerLoad = async ({ params, url, route }) => {
const webPiece = await getWebPiece(db, params.path)
if (!webPiece) error(404, 'Piece is not published.')
const piece = await hydrateWithAssets(webPiece)
const tags = await db
.selectFrom('web_pieces_tags')
.select(['slug', 'tag'])
.distinct()
.where('piece_id', '=', piece.id)
.execute()
const publishedUrl = new URL(config.url.app || '/', url.origin)
publishedUrl.pathname = `${publishedUrl.pathname.replace(/\/+$/, '')}/pieces/${encodeURIComponent(piece.type)}/${encodeURIComponent(piece.slug)}`
publishedUrl.search = ''
publishedUrl.hash = ''
return {
file: params.path,
piece,
tags,
publication: {
lastPublishedAt: webPiece.last_published_at ?? null,
markdownStatus: await getMarkdownPublicationStatus(params.path, webPiece.content_hash)
},
publishedUrl: publishedUrl.href,
links: getPieceLinks(url, route.id)
}
}
@@ -0,0 +1,19 @@
<script lang="ts">
import '@luzzle/web.theme/styles/reset.css'
import '@luzzle/web.theme/styles/base.css'
import '@luzzle/web.theme/styles/markdown.css'
import '$lib/ui/styles/theme.build.css'
import type { LayoutProps } from './$types'
import ViewerHeader from '$lib/pieces/viewer/ViewerHeader.svelte'
let { data, children }: LayoutProps = $props()
</script>
<svelte:head>
<title>Published: {data.piece.title}</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="{data.config.url.app_assets}/images/favicon.png" />
</svelte:head>
<ViewerHeader links={data.links} />
{@render children()}
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PageProps } from './$types'
import PieceOverview from '$lib/pieces/viewer/PieceOverview.svelte'
import { assetPathToUrl } from '$lib/pieces/assets'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
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)
)
</script>
<main id="main-content">
<PieceOverview
title={data.piece.title}
assets={data.piece.assets}
{fileUrl}
assetsHeading="Published assets"
>
<div class="overview-facts">
<!-- Explicit roles preserve table semantics when mobile CSS changes display. -->
<!-- svelte-ignore a11y_no_redundant_roles -->
<table role="table">
<caption>Publication</caption>
<tbody role="rowgroup">
<tr role="row">
<th scope="row" role="rowheader">Type</th>
<td role="cell">{data.piece.type}</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">Last publish</th>
<td role="cell">
{#if publishedAt}
<time datetime={publishedAt.toISOString()}
>{publishedAt.toLocaleString('en-US', { timeZone: 'UTC' })} UTC</time
>
{:else}
<span class="muted">Unknown</span>
{/if}
</td>
</tr>
<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
>
</td>
</tr>
<tr role="row">
<th scope="row" role="rowheader">URL</th>
<td role="cell">
<a
href={data.publishedUrl}
target="_blank"
rel="noopener noreferrer"
class="truncated"
title={data.publishedUrl}><bdi dir="ltr">{data.publishedUrl}</bdi></a
>
</td>
</tr>
</tbody>
</table>
</div>
</PieceOverview>
</main>
<style>
.truncated {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
a {
color: var(--color-primary);
}
.muted {
color: var(--color-on-surface-variant);
}
</style>
@@ -0,0 +1,28 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import Layout from '../+layout@.svelte'
import Overview from '../+page.svelte'
import Page from '../page/+page.svelte'
import Icon from '../icon/+page.svelte'
import Share from '../share/+page.svelte'
let {
data,
view = 'overview'
}: {
data: ComponentProps<typeof Overview>['data']
view?: 'overview' | 'page' | 'icon' | 'share'
} = $props()
const params = $derived({ path: data.file })
</script>
<Layout {data} {params}>
{#if view === 'page'}
<Page {data} {params} />
{:else if view === 'icon'}
<Icon {data} {params} />
{:else if view === 'share'}
<Share {data} {params} />
{:else}
<Overview {data} {params} />
{/if}
</Layout>
@@ -0,0 +1,29 @@
<script lang="ts">
import PieceIcon from '$lib/pieces/components/icon.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<main id="main-content" class="icon-viewport" aria-label="Piece icon">
<div class="icon-frame">
<PieceIcon piece={data.piece} size={{ width: 500 }} lazy={false} />
</div>
</main>
<style>
.icon-viewport {
display: flex;
justify-content: flex-start;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
overflow-x: auto;
background: var(--color-surface-container-low);
}
.icon-frame {
margin-inline: auto;
flex-shrink: 0;
box-shadow: var(--shadow-raised);
border: 1px solid var(--color-outline-variant);
}
</style>
@@ -0,0 +1,275 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
import { load } from './+layout.server'
const {
getWebPiece,
getPieces,
getStorage,
createReadStream,
write,
sync,
runWorkflow,
config,
query,
db
} = vi.hoisted(() => {
const query = {
selectAll: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
distinct: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
execute: vi.fn(),
executeTakeFirst: vi.fn()
}
return {
query,
db: { selectFrom: vi.fn((_table: string) => query) },
getWebPiece: vi.fn(),
getPieces: vi.fn(),
getStorage: vi.fn(),
createReadStream: vi.fn(),
write: vi.fn(),
sync: vi.fn(),
runWorkflow: vi.fn(),
config: {
url: { app: 'https://app.example/garden/' },
ai: { secret: 'private-ai' },
auth: { secret: 'private-auth' },
pieces: []
}
}
})
vi.mock('$app/paths', () => ({
resolve: () => {
throw new Error('Could not get the request store. This is an internal error.')
}
}))
vi.mock('$lib/server/database', () => ({ db }))
vi.mock('$lib/server/config', () => ({ config }))
vi.mock('$lib/server/pieces', () => ({ getWebPiece, getPieces }))
vi.mock('$lib/server/storage', () => ({ getStorage }))
vi.mock('$lib/server/workflow/index.js', () => ({ getOpenWorkflow: () => ({ runWorkflow }) }))
const file = 'old type/源 #?%.books.md'
const serving = {
id: 'serving-id',
key: 'serving-key',
file_path: file,
title: 'Published title',
type: 'removed-schema',
slug: 'public #?%',
date_added: 10,
date_updated: 20,
json_metadata: JSON.stringify({ title: 'Published metadata' }),
content_hash: 'completed-hash',
last_published_at: 1234
}
const tags = [{ slug: 'fiction', tag: 'Fiction' }]
function event() {
return {
params: { path: file },
url: new URL(
'https://admin.example/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published'
),
route: { id: '/admin/piece/[...path]/published' }
} as Parameters<typeof load>[0]
}
beforeEach(() => {
vi.clearAllMocks()
config.url.app = 'https://app.example/garden/'
getWebPiece.mockResolvedValue({ ...serving })
getStorage.mockReturnValue({ createReadStream, write, sync })
query.executeTakeFirst.mockResolvedValue({ content_hash: serving.content_hash })
query.execute.mockImplementation(() =>
Promise.resolve(
db.selectFrom.mock.lastCall?.[0] === 'web_pieces_assets'
? [
{
piece_file_path: file,
piece_asset_path: '.assets/private-draft.jpg',
piece_field_path: 'draft.cover',
asset_key: 'published-cover',
transformation: 'image.original',
asset_path: 'public/cover.jpg',
mime_type: 'image/jpeg'
}
]
: tags
)
)
})
test('loads only serving data/public assets, distinct tags and actual public URL without draft or current schema', async () => {
const data = await load(event())
expect(data).toMatchObject({
file,
piece: {
title: 'Published title',
metadata: { title: 'Published metadata' },
assets: [{ asset_path: 'public/cover.jpg' }]
},
tags,
publication: { lastPublishedAt: 1234, markdownStatus: MarkdownPublicationStatus.MATCHING },
publishedUrl: 'https://app.example/garden/pieces/removed-schema/public%20%23%3F%25',
links: {
source: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/source',
overview: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published',
page: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/page',
icon: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/icon',
share: '/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/share'
}
})
expect(Object.keys(data!).sort()).toEqual([
'file',
'links',
'piece',
'publication',
'publishedUrl',
'tags'
])
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
expect(db.selectFrom.mock.calls).toEqual([
['web_pieces_assets'],
['web_pieces_tags'],
['pieces_cache']
])
expect(query.select).toHaveBeenCalledWith(['slug', 'tag'])
expect(query.distinct).toHaveBeenCalledOnce()
expect(query.where).toHaveBeenCalledWith('piece_id', '=', serving.id)
expect(query.where).toHaveBeenCalledWith('file_path', '=', file)
expect(query.executeTakeFirst).toHaveBeenCalledOnce()
const json = JSON.stringify(data)
for (const privateValue of [
'content_hash',
serving.content_hash,
'last_published_at',
'json_metadata',
'file_path',
'piece_asset_path',
'piece_field_path',
'private-draft',
'private-ai',
'private-auth'
])
expect(json).not.toContain(privateValue)
for (const forbidden of [getPieces, getStorage, createReadStream, write, sync, runWorkflow])
expect(forbidden).not.toHaveBeenCalled()
})
test('404 before hydration/status queries when no serving row exists', async () => {
getWebPiece.mockResolvedValue(undefined)
await expect(load(event())).rejects.toMatchObject({
status: 404,
body: { message: 'Piece is not published.' }
})
expect(db.selectFrom).not.toHaveBeenCalled()
expect(getStorage).not.toHaveBeenCalled()
expect(runWorkflow).not.toHaveBeenCalled()
})
test.each([
[serving.content_hash, MarkdownPublicationStatus.MATCHING],
['different-indexed-hash', MarkdownPublicationStatus.CHANGED],
[null, MarkdownPublicationStatus.UNKNOWN],
['', MarkdownPublicationStatus.UNKNOWN],
[undefined, MarkdownPublicationStatus.UNKNOWN]
] as const)(
'indexed hash %s reports %s without replacing serving content',
async (hash, status) => {
query.executeTakeFirst.mockResolvedValue(
hash === undefined ? undefined : { content_hash: hash }
)
expect(await load(event())).toMatchObject({
piece: { title: serving.title },
publication: { markdownStatus: status, lastPublishedAt: 1234 }
})
expect(query.executeTakeFirst).toHaveBeenCalledOnce()
expect(getStorage).not.toHaveBeenCalled()
expect(getPieces).not.toHaveBeenCalled()
expect(runWorkflow).not.toHaveBeenCalled()
}
)
test.each([null, undefined, ''])(
'missing completed hash %s gives Unknown without a cache read',
async (hash) => {
getWebPiece.mockResolvedValue({ ...serving, content_hash: hash })
expect(await load(event())).toMatchObject({
publication: { markdownStatus: MarkdownPublicationStatus.UNKNOWN }
})
expect(query.executeTakeFirst).not.toHaveBeenCalled()
}
)
test.each([null, undefined])(
'legacy timestamp %s is not replaced by source dates',
async (time) => {
getWebPiece.mockResolvedValue({ ...serving, last_published_at: time })
expect(await load(event())).toMatchObject({ publication: { lastPublishedAt: null } })
}
)
test('source and current schema can be unavailable; existing indexed facts still render', async () => {
getStorage.mockImplementation(() => {
throw new Error('Source unavailable')
})
getPieces.mockImplementation(() => {
throw new Error('Schema removed')
})
expect(await load(event())).toMatchObject({
piece: { title: serving.title },
publication: { markdownStatus: MarkdownPublicationStatus.MATCHING }
})
expect(getStorage).not.toHaveBeenCalled()
expect(getPieces).not.toHaveBeenCalled()
})
test.each([
['https://public.example/prefix/?old=1#old', 'https://public.example/prefix'],
['/garden/', 'https://admin.example/garden'],
['', 'https://admin.example']
])(
'configured app base %s supplies public URL, not the inspector pathname',
async (base, expected) => {
config.url.app = base
expect(await load(event())).toMatchObject({
publishedUrl: `${expected}/pieces/removed-schema/public%20%23%3F%25`
})
}
)
test('public URL encodes both serving type and slug, including literal percent characters', async () => {
getWebPiece.mockResolvedValue({ ...serving, type: '旧 #?%/type', slug: 'slug%20/"' })
expect(await load(event())).toMatchObject({
publishedUrl: 'https://app.example/garden/pieces/%E6%97%A7%20%23%3F%25%2Ftype/slug%2520%2F%22'
})
})
test.each(['', '/page', '/icon', '/share'])(
'loader serializes mounted navigation on %s without a Kit request store',
async (suffix) => {
const request = {
...event(),
url: new URL(
`https://admin.example/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published${suffix}/?x=1`
),
route: { id: '/admin/piece/[...path]/published' + suffix }
} as Parameters<typeof load>[0]
expect(await load(request)).toMatchObject({
links: {
source: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/source',
overview: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published',
share: '/garden/admin/piece/old%20type/%E6%BA%90%20%23%3F%25.books.md/published/share'
}
})
}
)
test('cache database failures follow normal loader failure semantics', async () => {
query.executeTakeFirst.mockRejectedValue(new Error('Database unavailable'))
await expect(load(event())).rejects.toThrow('Database unavailable')
expect(runWorkflow).not.toHaveBeenCalled()
})
@@ -0,0 +1,545 @@
import { readFileSync } from 'node:fs'
import { beforeEach, expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import type { ComponentProps } from 'svelte'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
import { publishedPiece, asset } from '$lib/pieces/viewer/fixtures/published'
import Overview from './+page.svelte'
import PublishedTree from './fixtures/PublishedTree.svelte'
const { page } = vi.hoisted(() => ({
page: {
url: new URL('https://app.test/admin/piece/books/book.md/published'),
params: {},
data: { config: { url: { app: '/garden', luzzle_assets: '/cdn' } } }
}
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({
resolve: (route: string, params: Record<string, string>) =>
route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name])
}))
// These synchronous renderer stubs establish route props, URLs and landmarks;
// they do not exercise the wrappers' asynchronous imports or client replacement.
vi.mock('$lib/pieces/components/page.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/PageRenderer.svelte')).default
}))
vi.mock('$lib/pieces/components/icon.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/IconRenderer.svelte')).default
}))
vi.mock('$lib/pieces/components/opengraph.svelte', async () => ({
default: (await import('$lib/pieces/viewer/fixtures/Composition.svelte')).default
}))
function data(
status: MarkdownPublicationStatus = MarkdownPublicationStatus.MATCHING,
time: number | null = 1234
): ComponentProps<typeof Overview>['data'] {
return {
file: 'books/<draft #?%>.books.md',
piece: publishedPiece,
tags: [{ slug: 'fiction', tag: 'Fiction' }],
publication: { lastPublishedAt: time, markdownStatus: status },
publishedUrl: 'https://public.test/garden/pieces/books/published%20%23%3F%25',
links: {
source: '/admin/piece/books/book.md/source',
overview: '/admin/piece/books/book.md/published',
page: '/admin/piece/books/book.md/published/page',
icon: '/admin/piece/books/book.md/published/icon',
share: '/admin/piece/books/book.md/published/share'
},
config: {
content: { text: { title: 'Garden', description: 'Description' } },
url: { app: '/garden', app_assets: '/static', luzzle_assets: '/cdn' }
},
meta: { title: 'Garden', description: 'Description' }
}
}
beforeEach(() => {
page.url = new URL('https://app.test/admin/piece/books/book.md/published')
})
test('Overview shows escaped published identity, actual completion time and external public link', () => {
const html = render(Overview, { props: { data: data(), params: { path: data().file } } }).body
expect(html).toContain('Published &lt;Book>')
expect(html).not.toContain('books/&lt;draft #?%>.books.md')
expect(html).toContain('Type')
expect(html).toContain('books')
expect(html).toContain('<caption')
expect(html).toContain('Publication</caption>')
expect(html).toMatch(/<th[^>]*scope="row"[^>]*>Last publish<\/th>/)
expect(html).not.toMatch(/<th[^>]*scope="row"[^>]*>Source<\/th>/)
expect(html).not.toContain('Excludes')
expect(html).not.toContain('Unsaved edits · attachment bytes')
expect(html).not.toContain('Status covers indexed Markdown')
expect(html).toContain('datetime="1970-01-01T00:00:01.234Z"')
expect(html).toContain('UTC')
expect(html).toContain(
'href="https://public.test/garden/pieces/books/published%20%23%3F%25" target="_blank" rel="noopener noreferrer"'
)
expect(html).toContain(`<bdi dir="ltr">${data().publishedUrl}</bdi>`)
expect(html).not.toContain('href="/admin/piece/books/book.md/source"')
expect(html).not.toContain('>Open<')
expect(html).not.toContain('<details')
expect(html).toContain('href="/cdn/pieces/assets/published/cover.jpg"')
expect(html.match(/<main /g)).toHaveLength(1)
expect(html).not.toContain('<draft')
expect(html).not.toContain('Serving metadata only')
expect(html).not.toContain('/admin/asset')
})
test('only the public URL remains a linked field value; long source paths are absent', () => {
const props = data()
props.piece = { ...publishedPiece, assets: [] }
props.file = `${'nested folder/'.repeat(24)}雪 <long & "draft">.books.md`
props.publishedUrl = `https://public.test/${'long-prefix/'.repeat(24)}published`
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
const links = html.match(/<a\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? []
expect(links).toHaveLength(1)
for (const link of links) {
expect(link).toMatch(/class="truncated[^"]*"/)
expect(link).toMatch(/<bdi[^>]*dir="ltr"[^>]*>/)
expect(link).not.toContain('aria-label=')
}
expect(html).not.toContain(props.links.source)
expect(html).not.toContain('nested folder/')
expect(links[0]).toContain(`href="${props.publishedUrl}"`)
expect(links[0]).toContain(`>${props.publishedUrl}</bdi>`)
expect(links[0]).toContain('target="_blank" rel="noopener noreferrer"')
for (const excluded of ['<details', '<summary', '>Open<', '<long'])
expect(html).not.toContain(excluded)
})
test('publication rows retain explicit table semantics when styled as narrow-screen fields', () => {
const props = data()
props.piece = { ...publishedPiece, assets: [] }
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
const publication = html.slice(0, html.indexOf('</table>') + '</table>'.length)
expect(publication).toMatch(/<table[^>]*role="table"/)
expect(publication).toMatch(/<tbody[^>]*role="rowgroup"/)
expect(publication.match(/<tr[^>]*role="row"/g)).toHaveLength(4)
expect(publication.match(/<th[^>]*scope="row"[^>]*role="rowheader"/g)).toHaveLength(4)
expect(publication.match(/<td[^>]*role="cell"/g)).toHaveLength(4)
})
test('publication and asset tables use the same caption, cell padding and metadata size', () => {
const sources = [
readFileSync(
new URL('../../../../../lib/pieces/viewer/PieceOverview.svelte', import.meta.url),
'utf8'
),
readFileSync(
new URL('../../../../../lib/pieces/viewer/AssetOutputs.svelte', import.meta.url),
'utf8'
)
]
for (const source of sources) {
const styles = source.slice(source.indexOf('<style>'))
expect(styles.match(/\bcaption\)?\s*\{([^}]+)\}/)?.[1]).toContain(
'padding: var(--space-2) var(--space-3)'
)
expect(
styles.match(/\bth\)?,\s*(?:\.overview :global\(\.overview-facts )?td\)?\s*\{([^}]+)\}/)?.[1]
).toContain('padding: var(--space-2) var(--space-3)')
const tableRules = [...styles.matchAll(/\btable\)?\s*\{([^}]+)\}/g)]
expect(tableRules.some((rule) => rule[1].includes('font-size: var(--font-size-xs)'))).toBe(true)
expect(styles.slice(styles.indexOf('@media'))).toContain(
'padding: var(--space-2) var(--space-3)'
)
}
})
test('epoch-zero completion is a recorded time, not an Unknown state', () => {
const props = data(MarkdownPublicationStatus.MATCHING, 0)
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
expect(html).toContain('datetime="1970-01-01T00:00:00.000Z"')
expect(html).toContain('data-status="matching"')
})
test.each([
[MarkdownPublicationStatus.MATCHING, 'Matches publish'],
[MarkdownPublicationStatus.CHANGED, 'Unpublished changes'],
[MarkdownPublicationStatus.UNKNOWN, 'Unknown']
] as const)(
'status %s and Unknown timestamp are truthful and independent of source dates',
(status, copy) => {
const html = render(Overview, {
props: { data: data(status, null), params: { path: data().file } }
}).body
expect(html).toMatch(new RegExp(`data-status="${status}"[^>]*>${copy}</span>`))
expect(html).toMatch(/<th[^>]*scope="row"[^>]*>State<\/th>/)
expect(html.replace(/<!--[^]*?-->/g, '').replace(/\s+/g, ' ')).toMatch(
/Last publish<\/th>\s*<td[^>]*>\s*<span[^>]*>Unknown<\/span>/
)
expect(html).not.toContain('Unsaved edits · attachment bytes')
expect(html).not.toContain('Indexed Markdown')
expect(html).not.toContain('<time')
}
)
test.each(['https://cdn.test/prefix/', '/cdn/prefix/', ''])(
'Overview selects public CDN/app fallback for base %s',
(base) => {
const props = data()
props.config.url.luzzle_assets = base
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
expect(html).toContain(
`href="${(base || '/garden').replace(/\/+$/, '')}/pieces/assets/published/cover.jpg"`
)
}
)
test('Overview, not Share, links only the actual recorded generated output', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [asset('opengraph', 'piece/generated.png', 'piece', 'image/png')]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
const generatedLink = html.match(
/<a[^>]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>/
)?.[0]
expect(generatedLink).toContain('target="_blank"')
expect(generatedLink).toContain('rel="noopener noreferrer"')
expect(html).toMatch(/<a[^>]*href="\/cdn\/pieces\/assets\/piece\/generated.png"[^>]*>View<\/a>/)
expect(html).not.toContain('>Open<')
expect(html).not.toContain('<details')
expect(html).not.toContain('<img')
expect(html).not.toContain('Rendered share composition')
})
test('Overview keeps JSON and HTML collapsed behind Show without exposing or executing either', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [
{
...asset('palette', null, 'piece', 'application/json'),
content: '{"background":"#123456","accent":"#abcdef"}'
},
{
...asset('markdown', null, 'piece', 'text/html'),
content: '<script>alert(1)</script><h1>Serving body</h1>'
},
asset('custom.output', 'published/output.pdf', 'piece', 'application/pdf')
]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
expect(html).toContain('Palette</caption>')
expect(html).toContain('aria-label="Show Palette: Asset 1, Content"')
expect(html).not.toContain('{"background":"#123456","accent":"#abcdef"}')
expect(html).not.toContain('background</bdi>')
expect(html).not.toContain('accent</bdi>')
expect(html).not.toContain('style=')
expect(html).toContain('Markdown</caption>')
expect(html).toMatch(/<button\b[^>]*>[\s\S]*?Show[\s\S]*?<\/button>/)
expect(html).toContain('aria-expanded="false"')
expect(html).not.toContain('alert(1)')
expect(html).not.toContain('Serving body')
expect(html).not.toContain('<script>')
expect(html).not.toContain('<h1>Serving body')
expect(html).toContain('href="/cdn/pieces/assets/published/output.pdf"')
expect(html).not.toContain('Other published files')
expect(html.match(/<main /g)).toHaveLength(1)
})
test('Overview retains every recorded Opengraph output for inspection, including invalid and non-PNG rows', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [
asset('opengraph', 'piece/image.png', 'piece', 'image/png'),
asset('opengraph', 'piece/image.jpg', 'piece', 'image/jpeg'),
asset('opengraph', '../unsafe.png', 'piece', 'image/png')
]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
expect(html).toContain('href="/cdn/pieces/assets/piece/image.png"')
expect(html).toContain('href="/cdn/pieces/assets/piece/image.jpg"')
expect(html).toContain('../unsafe.png</bdi>')
expect(html).not.toContain('href="/cdn/pieces/assets/..')
expect(html).toContain('Unavailable</small>')
})
test('Overview hides internal asset identifiers and preserves related outputs with readable groups', () => {
const props = data()
const first = `internal-asset-${'a'.repeat(64)}`
const second = `internal-asset-${'b'.repeat(64)}`
const piece = `internal-piece-${'c'.repeat(64)}`
props.piece = {
...publishedPiece,
assets: [
asset('image.original', 'one/cover.jpg', first),
asset('image.s.webp', 'one/s.webp', first, 'image/webp'),
asset('attachment', 'one/doc.pdf', first, 'application/pdf'),
{ ...asset('highlight', null, first, 'text/html'), content: '<code>one</code>' },
asset('image.original', 'two/cover.jpg', second),
asset('image.s.webp', 'two/s.webp', second, 'image/webp'),
asset('attachment', 'two/doc.pdf', second, 'application/pdf'),
{ ...asset('highlight', null, second, 'text/html'), content: '<code>two</code>' },
{ ...asset('palette', null, piece, 'application/json'), content: '{"background":"#123456"}' }
]
}
const html = render(Overview, {
props: { data: props, params: { path: props.file } }
}).body.replace(/<!--[^]*?-->/g, '')
for (const key of [first, second, piece]) expect(html).not.toContain(key)
for (const excluded of ['>Key<', 'Technical details', '<details'])
expect(html).not.toContain(excluded)
expect(html).toContain('Asset 1')
expect(html).toContain('Asset 2')
for (const path of [
'one/cover.jpg',
'one/s.webp',
'one/doc.pdf',
'two/cover.jpg',
'two/s.webp',
'two/doc.pdf'
]) {
expect(html).toContain(`href="/cdn/pieces/assets/${path}"`)
}
expect(html).toContain('aria-label="Show Highlight: Asset 1, Content"')
expect(html).toContain('aria-label="Show Highlight: Asset 2, Content"')
expect(html).not.toContain('&lt;code>one&lt;/code>')
expect(html).not.toContain('&lt;code>two&lt;/code>')
expect(html).toContain('aria-label="Show Palette: Asset 3, Content"')
expect(html).not.toContain('{"background":"#123456"}')
expect(html).not.toContain('style=')
expect(html.match(/Image<\/caption>/g)).toHaveLength(1)
expect(html).toContain('image.original</bdi>')
expect(html).toContain('image.s.webp</bdi>')
expect(html).not.toContain('>Variant<')
expect(html.match(/<main /g)).toHaveLength(1)
})
test('every transform uses View or inline Show with no color or image roles', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [
asset('pictures.original', 'published/cover.jpg', 'cover'),
asset('pictures.s.webp', 'published/s.webp', 'cover', 'image/webp'),
{
...asset('rendered-note', null, 'piece', 'text/html'),
content: '<script>bad()</script><h1>Serving HTML</h1>'
},
{
...asset('colors.v2', null, 'piece', 'application/json'),
content: '{"background":"#123456","swatches":["#fff","#abc"]}'
},
{
...asset('custom.output', 'published/body.html', 'piece', 'text/html'),
content: '<b>File and inline</b>'
}
]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
for (const caption of ['Pictures', 'Rendered-note', 'Colors', 'Custom'])
expect(html).toContain(`${caption}</caption>`)
for (const path of ['published/cover.jpg', 'published/s.webp', 'published/body.html'])
expect(html).toContain(`href="/cdn/pieces/assets/${path}"`)
expect(html).toContain('aria-label="Show Rendered-note: Asset 2, Content"')
expect(html).toContain('aria-label="Show Colors.v2: Asset 2, Content"')
expect(html).not.toContain('{"background":"#123456","swatches":["#fff","#abc"]}')
expect(html).toContain('aria-label="Show Custom.output: Asset 2, Content"')
expect(html).not.toContain('bad()')
expect(html).not.toContain('File and inline')
expect(html).not.toContain('background</bdi>')
expect(html).not.toContain('swatches</bdi>')
expect(html).not.toContain('style=')
expect(html.match(/<button /g)).toHaveLength(3)
expect(html).not.toMatch(/ download(?:[= >])/)
expect(html).not.toContain('<script>')
expect(html).not.toContain('<h1>Serving HTML')
expect(html).not.toContain('Colors (palette)</caption>')
expect(html.match(/<main /g)).toHaveLength(1)
})
test('inline Show controls keep complete long JSON and text out of the collapsed page', () => {
const props = data()
const big = ' 雪😀\n'.repeat(300)
props.piece = {
...publishedPiece,
assets: [
{ ...asset('future-text-job', null, 'piece', 'text/plain'), content: big },
{
...asset('markdown.description', null, 'piece', 'application/json'),
content: JSON.stringify({ count: 42, label: 'short', nested: { long: big } })
},
asset('future-file-job', 'published/download.zip', 'piece', 'application/zip')
]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
expect(html).toContain('aria-label="Show Future-text-job: Asset 1, Content"')
expect(html).toContain('aria-label="Show Markdown.description: Asset 1, Content"')
expect(html).not.toContain('42</code>')
expect(html).not.toContain('short</code>')
expect(html).not.toContain('Show Markdown.description: Asset 1, count')
expect(html).not.toContain('Show Markdown.description: Asset 1, label')
expect(html).not.toContain(big)
expect(html).toContain('href="/cdn/pieces/assets/published/download.zip"')
expect(html).toContain('aria-label="View future-file-job: published/download.zip"')
expect(html).not.toMatch(/ download(?:[= >])/)
expect(html).not.toContain('<textarea')
expect(html).not.toContain('<script>')
})
test('original image and palette names receive the same ordinary presentation as renamed jobs', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [
asset('image.original', 'published/a.jpg', 'piece'),
asset('image.s.webp', 'published/a.webp', 'piece', 'image/webp'),
{
...asset('palette', null, 'piece', 'application/json'),
content: '{"color":"#abc","swatches":["#abc","#fff"]}'
},
{
...asset('colors.v2', null, 'piece', 'application/json'),
content: '{"color":"#abc","swatches":["#abc","#fff"]}'
}
]
}
const html = render(Overview, { props: { data: props, params: { path: props.file } } }).body
for (const caption of ['Image', 'Palette', 'Colors'])
expect(html).toContain(`${caption}</caption>`)
for (const excluded of [
'>Images</caption>',
'>Variant<',
'Colors (palette)',
'background-color',
'class="swatch',
'None recorded'
])
expect(html).not.toContain(excluded)
expect(html).toContain('aria-label="Show Palette: Asset 1, Content"')
expect(html).toContain('aria-label="Show Colors.v2: Asset 1, Content"')
expect(html.match(/<button /g)).toHaveLength(2)
expect(html).not.toContain('{"color":"#abc","swatches":["#abc","#fff"]}')
expect(html).not.toContain('color</bdi>')
expect(html).not.toContain('swatches</bdi>')
for (const path of ['published/a.jpg', 'published/a.webp'])
expect(html).toContain(`href="/cdn/pieces/assets/${path}"`)
expect(html).not.toMatch(/ download(?:[= >])/)
})
test('Overview renders one table per family with every image and Markdown output retained in an Output column', () => {
const props = data()
props.piece = {
...publishedPiece,
assets: [
asset('image.original', 'published/original.jpg', 'image'),
asset('image.s.webp', 'published/s.webp', 'image', 'image/webp'),
asset('image.m.avif', 'published/m.avif', 'image', 'image/avif'),
{
...asset('markdown', 'published/body.html', 'piece', 'text/html'),
content: '<h1>Body</h1>'
},
{
...asset('markdown.description', null, 'piece', 'text/html'),
content: '<p>Description</p>'
},
{
...asset('markdown.metadata', null, 'piece', 'application/json'),
content: '{"title":"Recorded title","count":42}'
}
]
}
const html = render(Overview, {
props: { data: props, params: { path: props.file } }
}).body.replace(/<!--[^]*?-->/g, '')
expect([...html.matchAll(/<caption[^>]*>([^<]*)<\/caption>/g)].map((match) => match[1])).toEqual([
'Publication',
'Image',
'Markdown'
])
for (const output of [
'image.original',
'image.s.webp',
'image.m.avif',
'markdown',
'markdown.description',
'markdown.metadata'
])
expect(html).toContain(`${output}</bdi>`)
expect(html.match(/scope="col"[^>]*>Output<\/th>/g)).toHaveLength(2)
for (const path of [
'published/original.jpg',
'published/s.webp',
'published/m.avif',
'published/body.html'
])
expect(html).toContain(`href="/cdn/pieces/assets/${path}"`)
expect(html).not.toMatch(/ download(?:[= >])/)
expect(html.match(/<button /g)).toHaveLength(3)
expect(html).toContain('aria-label="Show Markdown.description: Asset 2, Content"')
expect(html).toContain('aria-label="Show Markdown.metadata: Asset 2, Content"')
expect(html).not.toContain('{"title":"Recorded title","count":42}')
expect(html).not.toContain('title</bdi>')
expect(html).not.toContain('count</bdi>')
for (const excluded of [
'Markdown values</caption>',
'Body</h1>',
'Description</p>',
'>Variant<',
'style=',
'None recorded'
])
expect(html).not.toContain(excluded)
})
test.each(['/static', 'https://cdn.test/site-assets', ''])(
'Published reset layout adds only the configured site favicon for asset base %j',
(base) => {
const props = data()
props.config.url.app_assets = base
const result = render(PublishedTree, { props: { data: props } })
expect(result.head).toContain(`rel="icon" href="${base}/images/favicon.png"`)
expect(result.head.match(/rel="icon"/g)).toHaveLength(1)
expect(result.head).toContain('Published: Published &lt;Book>')
expect(result.head).toContain('name="robots" content="noindex"')
expect(result.head).not.toContain('property="og:')
expect(result.head).not.toContain('name="description"')
expect(result.head).not.toContain('/cdn/images/favicon.png')
expect(result.body.match(/<main\b/g)).toHaveLength(1)
}
)
test('reset layout keeps its inspector title/noindex and adds only the favicon, not public SEO', () => {
const result = render(PublishedTree, { props: { data: data() } })
expect(result.head).toContain('Published: Published &lt;Book>')
expect(result.head).toContain('name="robots" content="noindex"')
expect(result.head).not.toContain('og:')
expect(result.body).toContain('Back to editor')
expect(result.body).toContain(`href="${data().links.source}"`)
expect(result.body).not.toMatch(/<th[^>]*>Source<\/th>/)
expect(result.body.match(/<main /g)).toHaveLength(1)
})
test.each(['page', 'icon', 'share'] as const)(
'Published %s delegates serving data and uses published assets without nested main',
(view) => {
const result = render(PublishedTree, { props: { data: data(), view } })
expect(result.body.match(/<main /g)).toHaveLength(1)
expect(result.body).toContain('src="/cdn/pieces/assets/published/cover.jpg"')
expect(result.body).not.toContain('/admin/asset')
expect(result.body).not.toContain('/admin/preview')
if (view === 'page') {
expect(result.body).toContain('data-renderer="page"')
expect(result.body).toContain('href="/tags/fiction"')
expect(result.body).toContain('Fiction')
}
if (view === 'icon') {
expect(result.body).toContain('aria-label="Piece icon"')
expect(result.body).toContain('data-width="500"')
expect(result.body).toContain('loading="eager"')
}
if (view === 'share') {
expect(result.body).toContain('aria-label="Share"')
expect(result.body).toContain('data-renderer="composition"')
expect(result.body).not.toContain('Rendered share composition')
expect(result.body).not.toContain('not the generated PNG')
expect(result.body).not.toContain('Open generated share image')
}
}
)
@@ -0,0 +1,7 @@
<script lang="ts">
import PiecePage from '$lib/pieces/components/page.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<PiecePage piece={data.piece} tags={data.tags} />
@@ -0,0 +1,7 @@
<script lang="ts">
import ShareViewport from '$lib/pieces/viewer/ShareViewport.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<ShareViewport piece={data.piece} />
@@ -6,6 +6,7 @@ import { db } from '$lib/server/database'
import path from 'path'
import { config } from '$lib/server/config'
import {
asCoreDatabase,
extractFullMarkdown,
makePieceMarkdown,
type PieceFrontmatter,
@@ -18,6 +19,7 @@ import {
} from '@luzzle/core'
import { Readable } from 'stream'
import { normalizeLineEndings, normalizeMarkdown } from '$lib/server/markdown'
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
async function resolveAssetUrls(
piece: Piece<PieceFrontmatter>,
@@ -87,9 +89,8 @@ export const load: PageServerLoad = async ({ params }) => {
}
const storage = getStorage()
const [rawContent, fileStat, webPiece] = await Promise.all([
const [rawContent, webPiece] = await Promise.all([
storage.readFile(file, 'text') as Promise<string>,
storage.stat(file),
getWebPiece(db, file)
])
@@ -102,8 +103,8 @@ export const load: PageServerLoad = async ({ params }) => {
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: assetPaths,
publishedAt: webPiece ? (webPiece.date_updated ?? webPiece.date_added) : null,
fileUpdatedAt: fileStat.last_modified.getTime()
hasPublishedVersion: Boolean(webPiece),
savedMarkdown: await getMarkdownPublicationStatus(file, webPiece?.content_hash)
}
}
@@ -125,20 +126,20 @@ export const actions = {
const piece = await pieces.getPiece(type)
let markdown: PieceMarkdown<PieceFrontmatter>
try {
const normalized = normalizeLineEndings(content)
const data = await extractFullMarkdown(normalized)
const note = await normalizeMarkdown(data.markdown)
const markdown = makePieceMarkdown(file, type, note, data.frontmatter as PieceFrontmatter)
markdown = makePieceMarkdown(file, type, note, data.frontmatter as PieceFrontmatter)
const assetPaths = filterFrontmatterFields(piece.fields, (f) => f.format === 'asset')
if (assetPaths.length > 0) {
const result = await resolveAssetUrls(piece, markdown, content, assetPaths)
if ('error' in result) return result.error
await piece.write(result.markdown)
} else {
await piece.write(markdown)
markdown = result.markdown
}
await piece.write(markdown)
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e))
console.error('Save raw action error:', error)
@@ -150,6 +151,20 @@ export const actions = {
})
}
try {
await piece.syncMarkdown(asCoreDatabase(db), markdown)
} catch (e) {
console.error('Sync after Save failed:', e)
return fail(500, {
error: {
message: 'Markdown was saved, but its database index could not be updated.'
},
rawContent: content,
fields: undefined,
note: undefined
})
}
return { success: true }
},
attach: async (event) => {
@@ -3,6 +3,9 @@
import MarkdownEditor from '$lib/components/editor/MarkdownEditor.svelte'
import Button from '$lib/components/ui/Button.svelte'
import PieceActions from '$lib/components/editor/PieceActions.svelte'
import PieceView from '$lib/components/editor/PieceView.svelte'
import { shouldOfferPublish } from '$lib/components/editor/view-state'
import { editorHref } from '$lib/pieces/navigation'
import GenerationDialog from '$lib/components/editor/GenerationDialog.svelte'
import type { PageProps } from './$types'
import { Dialog } from 'bits-ui'
@@ -63,8 +66,8 @@
const hasChanges = $derived(rawContent !== (data.rawContent ?? ''))
const isDirty = $derived(!!form?.error || (hasChanges && !isSaving))
const hasPublicVersion = $derived(data.publishedAt !== null)
const isPublicStale = $derived(data.publishedAt !== null && data.fileUpdatedAt > data.publishedAt)
const sourceUrl = $derived(editorHref(data.file))
const canPublish = $derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))
$effect(() => {
if (form && 'rawContent' in form && form.rawContent) {
@@ -167,7 +170,7 @@
}
try {
const response = await fetch(`/admin/piece/${data.file}/source?/attach`, {
const response = await fetch(`${sourceUrl}?/attach`, {
method: 'POST',
body: formData
})
@@ -366,7 +369,7 @@
<dialog bind:this={dialog}>
<form
method="post"
action="/admin/piece/{data.file}/source?/delete"
action="{sourceUrl}?/delete"
use:enhance={() => {
bypassWarning = true
return async ({ update }) => {
@@ -420,7 +423,7 @@
<div style="display:flex; gap: var(--space-2);">
<form
method="post"
action="/admin/piece/{data.file}/source?/save"
action="{sourceUrl}?/save"
use:enhance={() => {
isSaving = true
bypassWarning = true
@@ -456,13 +459,12 @@
<Button variant="outline">cancel</Button>
</a>
</div>
<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />
<PieceActions
file={data.file}
currentMode="source"
{isDirty}
canGenerate={data.canGenerate && !isSaving}
{hasPublicVersion}
{isPublicStale}
{canPublish}
onDelete={() => dialog.showModal()}
onGenerate={() => {
editorError = ''
@@ -527,6 +529,8 @@
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-2);
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more