Piece viewer — incremental review #115
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+2
@@ -14,6 +14,8 @@ declare global {
|
||||
interface PageData {
|
||||
session: Session | null
|
||||
config: AppConfigPublic
|
||||
preview?: boolean
|
||||
job?: string
|
||||
meta: {
|
||||
title?: string
|
||||
description?: string
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 || '{}'),
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <title & "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('&', '&').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
])
|
||||
})
|
||||
@@ -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 <text></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)
|
||||
})
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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('<script>books & films</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, '&').replace(/</g, '<')
|
||||
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, '&').replace(/</g, '<'))
|
||||
expect(html).toMatch(/<bdi dir="ltr" class="file[^"]*" title="folder\//)
|
||||
expect(html).toContain(
|
||||
'title="' + file.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"') + '"'
|
||||
)
|
||||
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, '&').replace(/</g, '<'))
|
||||
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('<script>Failure & details</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(/<script>not executable & not hidden<\/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 <Draft>')
|
||||
expect(html).toContain('Preview assets</h2>')
|
||||
expect(html).toContain('Preview logs</h2>')
|
||||
expect(html).toContain('Preview log <script>escaped()</script>')
|
||||
expect(html.indexOf('Preview <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 <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 <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 <script>escaped()</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 <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 <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 <Book>')
|
||||
expect(html).not.toContain('books/<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('<code>one</code>')
|
||||
expect(html).not.toContain('<code>two</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 <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 <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
Reference in new issue
Block a user