From 8e45a6476ab28dcca50a102ace63a0156bb4c55a Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 8 Oct 2026 07:09:26 -0700 Subject: [PATCH 01/17] [web.db] add per-piece publication timestamp schema --- apps/web/src/lib/pieces/assets.server.test.ts | 220 ++++++++++++++++++ apps/web/src/lib/pieces/assets.server.ts | 8 +- .../web/db/src/migrations.integration.test.ts | 120 +++++++++- ...00:00Z-add-web-pieces-last-published-at.ts | 11 + packages/web/db/src/types.ts | 6 +- packages/web/worker/test/db.sql | 2 +- 6 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/lib/pieces/assets.server.test.ts create mode 100644 packages/web/db/src/migrations/2026-09-16T00:00:00Z-add-web-pieces-last-published-at.ts diff --git a/apps/web/src/lib/pieces/assets.server.test.ts b/apps/web/src/lib/pieces/assets.server.test.ts new file mode 100644 index 00000000..75880339 --- /dev/null +++ b/apps/web/src/lib/pieces/assets.server.test.ts @@ -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 = {}) { + 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 { + 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() + }) +}) diff --git a/apps/web/src/lib/pieces/assets.server.ts b/apps/web/src/lib/pieces/assets.server.ts index f1a1f18b..ed1f46e3 100644 --- a/apps/web/src/lib/pieces/assets.server.ts +++ b/apps/web/src/lib/pieces/assets.server.ts @@ -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 || '{}'), diff --git a/packages/web/db/src/migrations.integration.test.ts b/packages/web/db/src/migrations.integration.test.ts index ca048d79..144d669e 100644 --- a/packages/web/db/src/migrations.integration.test.ts +++ b/packages/web/db/src/migrations.integration.test.ts @@ -1,13 +1,46 @@ -import { describe, expect, test } from 'vitest' -import { sql } from 'kysely' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { Migrator, sql } from 'kysely' import { createAppDb } from './client.js' import { runWebMigrations } from './migrations.js' +import { migrations } from './migrations/index.js' + +let db: ReturnType + +beforeEach(() => { + db = createAppDb(':memory:') +}) + +afterEach(async () => { + await db.destroy() +}) + +function migrator() { + return new Migrator({ + db, + provider: { getMigrations: async () => migrations }, + migrationTableName: 'kysely_web_migrations', + migrationLockTableName: 'kysely_web_migrations_lock' + }) +} + +function piece(id: string) { + return { + id, + key: `key-${id}`, + title: id, + slug: id, + type: 'book', + file_path: `${id}.book.md`, + json_metadata: '{}', + date_added: 1000, + date_updated: 2000, + content_hash: `hash-${id}` + } +} describe('web_pieces_date_added_index', () => { test('is created by migrating an app db to latest', async () => { - const db = createAppDb(':memory:') - - await runWebMigrations(db) + expect((await runWebMigrations(db)).error).toBeUndefined() const rows = await sql<{ name: string }>` SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'web_pieces_date_added_index' @@ -16,3 +49,80 @@ describe('web_pieces_date_added_index', () => { expect(rows.rows).toHaveLength(1) }) }) + +describe('web_pieces.last_published_at', () => { + test('fresh schema accepts completion timestamps and leaves unset timestamps null', async () => { + expect((await runWebMigrations(db)).error).toBeUndefined() + await db + .insertInto('web_pieces') + .values([ + { ...piece('published'), last_published_at: 1780000000123 }, + piece('legacy'), + { ...piece('explicit-null'), last_published_at: null } + ]) + .execute() + + const rows = await db + .selectFrom('web_pieces') + .select(['id', 'last_published_at']) + .orderBy('id') + .execute() + + expect(rows).toEqual([ + { id: 'explicit-null', last_published_at: null }, + { id: 'legacy', last_published_at: null }, + { id: 'published', last_published_at: 1780000000123 } + ]) + }) + + test('existing rows are not backfilled or changed when upgrading', async () => { + expect( + (await migrator().migrateTo('2026-09-15T00:00:00Z-add-web-pieces-content-hash')).error + ).toBeUndefined() + await db.insertInto('web_pieces').values(piece('existing')).execute() + const before = await db.selectFrom('web_pieces').selectAll().executeTakeFirstOrThrow() + + expect((await runWebMigrations(db)).error).toBeUndefined() + const after = await db.selectFrom('web_pieces').selectAll().executeTakeFirstOrThrow() + + expect(after).toEqual({ ...before, last_published_at: null }) + + await db + .updateTable('web_pieces') + .set({ last_published_at: 1780000000123 }) + .where('id', '=', 'existing') + .execute() + expect((await runWebMigrations(db)).error).toBeUndefined() + expect(await db.selectFrom('web_pieces').selectAll().executeTakeFirstOrThrow()).toEqual({ + ...before, + last_published_at: 1780000000123 + }) + }) + + test('down removes only the publication timestamp and preserves the row and indexes', async () => { + expect((await runWebMigrations(db)).error).toBeUndefined() + await db + .insertInto('web_pieces') + .values({ ...piece('existing'), last_published_at: 1780000000123 }) + .execute() + const before = await db.introspection.getTables() + const columns = before.find((table) => table.name === 'web_pieces')!.columns + const indexesBefore = await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name + `.execute(db) + + expect((await migrator().migrateDown()).error).toBeUndefined() + const after = await db.introspection.getTables() + expect(after.find((table) => table.name === 'web_pieces')!.columns).toEqual( + columns.filter((column) => column.name !== 'last_published_at') + ) + expect(await db.selectFrom('web_pieces').selectAll().executeTakeFirstOrThrow()).toMatchObject( + piece('existing') + ) + expect( + await sql<{ name: string }>` + SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name + `.execute(db) + ).toEqual(indexesBefore) + }) +}) diff --git a/packages/web/db/src/migrations/2026-09-16T00:00:00Z-add-web-pieces-last-published-at.ts b/packages/web/db/src/migrations/2026-09-16T00:00:00Z-add-web-pieces-last-published-at.ts new file mode 100644 index 00000000..95b34798 --- /dev/null +++ b/packages/web/db/src/migrations/2026-09-16T00:00:00Z-add-web-pieces-last-published-at.ts @@ -0,0 +1,11 @@ +import type { Kysely } from 'kysely' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + await db.schema.alterTable('web_pieces').addColumn('last_published_at', 'integer').execute() +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function down(db: Kysely): Promise { + await db.schema.alterTable('web_pieces').dropColumn('last_published_at').execute() +} diff --git a/packages/web/db/src/types.ts b/packages/web/db/src/types.ts index 60ca3990..f3af631e 100644 --- a/packages/web/db/src/types.ts +++ b/packages/web/db/src/types.ts @@ -7,6 +7,7 @@ export interface WebPieces { slug: string file_path: string content_hash?: string | null + last_published_at?: number | null note?: string date_updated?: number date_added: number @@ -72,7 +73,10 @@ export type PublicWebPieceAsset = Pick< 'asset_key' | 'transformation' | 'asset_path' | 'mime_type' | 'is_embedded' | 'content' > -export type PublicWebPiece = Omit & { +export type PublicWebPiece = Omit< + WebPieces, + 'file_path' | 'json_metadata' | 'content_hash' | 'last_published_at' +> & { metadata: PieceFrontmatter assets: PublicWebPieceAsset[] } diff --git a/packages/web/worker/test/db.sql b/packages/web/worker/test/db.sql index 806c4556..b1ee7b52 100644 --- a/packages/web/worker/test/db.sql +++ b/packages/web/worker/test/db.sql @@ -21,7 +21,7 @@ CREATE TABLE "pieces_manager" ("id" text not null primary key, "date_added" date -- statement-breakpoint -CREATE TABLE "web_pieces" ("id" text not null primary key, "key" text not null, "slug" text not null, "type" text not null, "file_path" text not null, "title" text, "summary" text, "note" text, "keywords" text, "json_metadata" text, "date_added" datetime, "date_updated" datetime, "date_consumed" datetime, "content_hash" text, constraint "slug-type" unique ("slug", "type")); +CREATE TABLE "web_pieces" ("id" text not null primary key, "key" text not null, "slug" text not null, "type" text not null, "file_path" text not null, "title" text, "summary" text, "note" text, "keywords" text, "json_metadata" text, "date_added" datetime, "date_updated" datetime, "date_consumed" datetime, "content_hash" text, "last_published_at" integer, constraint "slug-type" unique ("slug", "type")); -- statement-breakpoint -- 2.54.0 From 714602a0a588b1a47c9bccb87f2dcd5ec1601f6e Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 8 Oct 2026 09:12:31 -0700 Subject: [PATCH 02/17] [web.worker] record per-piece publication completion time --- .../worker/src/services/publication.test.ts | 178 ++++++++++++++++-- .../web/worker/src/steps/publish-complete.ts | 6 +- .../src/workflows/publish-recovery.test.ts | 36 +++- .../web/worker/src/workflows/publish.test.ts | 38 ++++ 4 files changed, 238 insertions(+), 20 deletions(-) diff --git a/packages/web/worker/src/services/publication.test.ts b/packages/web/worker/src/services/publication.test.ts index 86bda99e..9d1d392f 100644 --- a/packages/web/worker/src/services/publication.test.ts +++ b/packages/web/worker/src/services/publication.test.ts @@ -12,8 +12,10 @@ import { emptyPiecesDiff } from '../workflows/pieces-diff.js' let db: Kysely let ctx: WorkerContext +const completionTime = 1_800_000_000_000 beforeEach(async () => { + vi.spyOn(Date, 'now').mockReturnValue(completionTime) db = (await setupDatabase()).withTables() ctx = { db, @@ -34,6 +36,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() await teardownDatabase(db) }) @@ -61,22 +64,29 @@ async function indexPiece(filePath: string, contentHash?: string, type = 'books' } } -async function publishMetadata(filePath: string, contentHash: string | null) { +async function publishMetadata( + filePath: string, + contentHash: string | null, + lastPublishedAt: number | null = null +) { await webSyncStep.run({ filePaths: [filePath] }, ctx) await db .updateTable('web_pieces') - .set({ content_hash: contentHash }) + .set({ content_hash: contentHash, last_published_at: lastPublishedAt }) .where('file_path', '=', filePath) .execute() } -async function publishedHash(filePath: string) { - const piece = await db +async function publicationState(filePath: string) { + return db .selectFrom('web_pieces') - .select('content_hash') + .select(['content_hash', 'last_published_at']) .where('file_path', '=', filePath) .executeTakeFirstOrThrow() - return piece.content_hash +} + +async function publishedHash(filePath: string) { + return (await publicationState(filePath)).content_hash } describe('publication', () => { @@ -128,15 +138,21 @@ describe('publication', () => { expect(await publishedHash('uncached.books.md')).toBeNull() }) - test('metadata refresh invalidates success before output changes; new metadata starts with no success hash', async () => { + test('metadata refresh invalidates the hash but retains the time; new metadata has no completion markers', async () => { await indexPiece('existing.books.md', 'new-hash') await indexPiece('new.books.md', 'new-hash') - await publishMetadata('existing.books.md', 'old-hash') + await publishMetadata('existing.books.md', 'old-hash', 123) await webSyncStep.run({ filePaths: ['existing.books.md', 'new.books.md'] }, ctx) - expect(await publishedHash('existing.books.md')).toBeNull() - expect(await publishedHash('new.books.md')).toBeNull() + expect(await publicationState('existing.books.md')).toEqual({ + content_hash: null, + last_published_at: 123, + }) + expect(await publicationState('new.books.md')).toEqual({ + content_hash: null, + last_published_at: null, + }) }) test('prepare captures pending hashes and includes recovery paths in the report', async () => { @@ -167,8 +183,10 @@ describe('publication', () => { test('complete advances healthy pieces only, using their captured hashes', async () => { await indexPiece('healthy.books.md', 'even-newer-hash') await indexPiece('failed.books.md', 'failed-hash') - await publishMetadata('healthy.books.md', 'old-healthy-hash') - await publishMetadata('failed.books.md', 'old-failed-hash') + await indexPiece('untouched.books.md', 'untouched-hash') + await publishMetadata('healthy.books.md', 'old-healthy-hash', 123) + await publishMetadata('failed.books.md', 'old-failed-hash', 456) + await publishMetadata('untouched.books.md', 'untouched-hash', 789) await publishCompleteStep.run( { @@ -181,12 +199,144 @@ describe('publication', () => { ctx ) - expect(await publishedHash('healthy.books.md')).toBe('processed-hash') - expect(await publishedHash('failed.books.md')).toBe('old-failed-hash') + expect(await publicationState('healthy.books.md')).toEqual({ + content_hash: 'processed-hash', + last_published_at: completionTime, + }) + expect(await publicationState('failed.books.md')).toEqual({ + content_hash: 'old-failed-hash', + last_published_at: 456, + }) + expect(await publicationState('untouched.books.md')).toEqual({ + content_hash: 'untouched-hash', + last_published_at: 789, + }) const pending = await getPendingPublication(db, ctx.config) expect(pending.diff.updated).toEqual(['failed.books.md', 'healthy.books.md']) }) + test('first completion and a new captured hash record the current time atomically', async () => { + await indexPiece('new.books.md', 'indexed-hash') + await webSyncStep.run({ filePaths: ['new.books.md'] }, ctx) + const input = { + pieces: [{ filePath: 'new.books.md', contentHash: "captured'hash" }], + failedPieces: [], + } + + await publishCompleteStep.run(input, ctx) + expect(await publicationState('new.books.md')).toEqual({ + content_hash: "captured'hash", + last_published_at: completionTime, + }) + + vi.mocked(Date.now).mockReturnValue(completionTime + 1000) + input.pieces[0]!.contentHash = 'next-captured-hash' + await publishCompleteStep.run(input, ctx) + expect(await publicationState('new.books.md')).toEqual({ + content_hash: 'next-captured-hash', + last_published_at: completionTime + 1000, + }) + }) + + test('same-hash retries record the latest successful completion time', async () => { + await indexPiece('retry.books.md', 'same-hash') + await publishMetadata('retry.books.md', 'same-hash', 123) + const input = { + pieces: [{ filePath: 'retry.books.md', contentHash: 'same-hash' }], + failedPieces: [], + } + + await publishCompleteStep.run(input, ctx) + expect(await publicationState('retry.books.md')).toEqual({ + content_hash: 'same-hash', + last_published_at: completionTime, + }) + vi.mocked(Date.now).mockReturnValue(completionTime + 1000) + await publishCompleteStep.run(input, ctx) + expect(await publicationState('retry.books.md')).toEqual({ + content_hash: 'same-hash', + last_published_at: completionTime + 1000, + }) + }) + + test('legacy null times are populated only for successful completion, even with the same hash', async () => { + for (const filePath of ['legacy.books.md', 'failed.books.md']) { + await indexPiece(filePath, 'same-hash') + await publishMetadata(filePath, 'same-hash') + } + await publishCompleteStep.run( + { + pieces: ['legacy.books.md', 'failed.books.md'].map((filePath) => ({ + filePath, + contentHash: 'same-hash', + })), + failedPieces: [{ filePath: 'failed.books.md', message: 'transform failed' }], + }, + ctx + ) + expect(await publicationState('legacy.books.md')).toEqual({ + content_hash: 'same-hash', + last_published_at: completionTime, + }) + expect(await publicationState('failed.books.md')).toEqual({ + content_hash: 'same-hash', + last_published_at: null, + }) + }) + + test('all successful pieces share one completion timestamp', async () => { + const filePaths = ['first.books.md', 'second.books.md'] + for (const filePath of filePaths) { + await indexPiece(filePath, 'new-hash') + await publishMetadata(filePath, 'old-hash', 123) + } + vi.mocked(Date.now) + .mockClear() + .mockReturnValueOnce(completionTime) + .mockReturnValue(completionTime + 1000) + + await publishCompleteStep.run( + { + pieces: filePaths.map((filePath) => ({ filePath, contentHash: 'new-hash' })), + failedPieces: [], + }, + ctx + ) + + expect(Date.now).toHaveBeenCalledOnce() + for (const filePath of filePaths) { + expect(await publicationState(filePath)).toEqual({ + content_hash: 'new-hash', + last_published_at: completionTime, + }) + } + }) + + test('empty plans leave existing markers untouched and completion does not recreate removed rows', async () => { + await indexPiece('untouched.books.md', 'same-hash') + await publishMetadata('untouched.books.md', 'same-hash', 123) + await indexPiece('removed.books.md', 'removed-hash') + await publishMetadata('removed.books.md', 'old-hash', 456) + await db.deleteFrom('web_pieces').where('file_path', '=', 'removed.books.md').execute() + + await publishCompleteStep.run({ pieces: [], failedPieces: [] }, ctx) + await publishCompleteStep.run( + { + pieces: [{ filePath: 'removed.books.md', contentHash: 'removed-hash' }], + failedPieces: [], + }, + ctx + ) + expect( + await db + .selectFrom('web_pieces') + .select(['file_path', 'content_hash', 'last_published_at']) + .execute() + ).toEqual([ + { file_path: 'untouched.books.md', content_hash: 'same-hash', last_published_at: 123 }, + ]) + }) + test('complete and prepare accept an empty archive', async () => { expect(await publishPrepareStep.run(emptyPiecesDiff(), ctx)).toEqual({ status: 'completed', diff --git a/packages/web/worker/src/steps/publish-complete.ts b/packages/web/worker/src/steps/publish-complete.ts index 04f85e52..11183c51 100644 --- a/packages/web/worker/src/steps/publish-complete.ts +++ b/packages/web/worker/src/steps/publish-complete.ts @@ -10,6 +10,7 @@ export interface PublishCompleteInput { export const publishCompleteStep: Step = { name: 'publish.complete', async run({ pieces, failedPieces }, { db, logger }) { + const publishedAt = Date.now() const failedPaths = new Set(failedPieces.map((piece) => piece.filePath)) let count = 0 @@ -18,7 +19,10 @@ export const publishCompleteStep: Step = { await db .updateTable('web_pieces') - .set({ content_hash: piece.contentHash }) + .set({ + content_hash: piece.contentHash, + last_published_at: publishedAt, + }) .where('file_path', '=', piece.filePath) .execute() count += 1 diff --git a/packages/web/worker/src/workflows/publish-recovery.test.ts b/packages/web/worker/src/workflows/publish-recovery.test.ts index c591bfd5..63729307 100644 --- a/packages/web/worker/src/workflows/publish-recovery.test.ts +++ b/packages/web/worker/src/workflows/publish-recovery.test.ts @@ -50,6 +50,7 @@ vi.mock('../steps/cache-purge.js', () => ({ const healthy = 'healthy.books.md' const broken = 'broken.books.md' const batch = [broken, healthy] +const completionTime = 1_900_000_000_000 function valueOf(result: StepResult): T { expect(result.status).toBe('completed') @@ -64,6 +65,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { beforeEach(async () => { vi.resetAllMocks() + vi.spyOn(Date, 'now').mockReturnValue(completionTime) mocks.assets.mockResolvedValue(completed({ failedPieces: [] })) mocks.cdn.mockResolvedValue(completed(undefined)) mocks.cache.mockResolvedValue(completed(undefined)) @@ -115,6 +117,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { }) afterEach(async () => { + vi.restoreAllMocks() await ctx?.db.destroy() await rm(directory, { recursive: true, force: true }) }) @@ -165,13 +168,14 @@ describe('publish hash recovery with a real archive and SQLite', () => { .executeTakeFirstOrThrow() const published = await ctx.db .selectFrom('web_pieces') - .select(['content_hash', 'title']) + .select(['content_hash', 'title', 'last_published_at']) .where('file_path', '=', filePath) .executeTakeFirstOrThrow() return { indexed: indexed.content_hash, published: published.content_hash, title: published.title, + lastPublishedAt: published.last_published_at, } } @@ -233,7 +237,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { const initialDiff = valueOf(await luzzleSyncStep.run(undefined, ctx)) const initialPlan = valueOf(await publishPrepareStep.run(initialDiff, ctx)) await webSyncStep.run({ filePaths: initialPlan.pieces.map((piece) => piece.filePath) }, ctx) - expect((await hashes(healthy)).published).toBeNull() + expect(await hashes(healthy)).toMatchObject({ published: null, lastPublishedAt: null }) expect((await getPendingPublication(ctx.db, ctx.config)).pieces).toEqual(initialPlan.pieces) await publish('baseline') @@ -246,6 +250,8 @@ describe('publish hash recovery with a real archive and SQLite', () => { expect(state.title).toBe('Metadata changed') expect(state.indexed).not.toBe(original.indexed) expect(state.published).toBeNull() + expect(original.lastPublishedAt).toBe(completionTime) + expect(state.lastPublishedAt).toBe(original.lastPublishedAt) expect((await getPendingPublication(ctx.db, ctx.config)).pieces).toEqual(plan.pieces) expect((await audit()).pieces).toEqual({ added: [], updated: [healthy], pruned: [] }) }) @@ -255,6 +261,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { await writePiece(broken, 'Original broken') await publish('baseline') const oldBroken = await hashes(broken) + vi.mocked(Date.now).mockReturnValue(completionTime + 1000) await writePiece(healthy, 'Changed healthy') await writePiece(broken, 'Changed broken') const failedPieces = [{ filePath: broken, message: 'markdown transform failed' }] @@ -264,20 +271,28 @@ describe('publish hash recovery with a real archive and SQLite', () => { expect(partial.result.failedPieces).toEqual(failedPieces) const healthyState = await hashes(healthy) expect(healthyState.published).toBe(healthyState.indexed) + expect(healthyState.lastPublishedAt).toBe(completionTime + 1000) const brokenState = await hashes(broken) expect(brokenState.title).toBe('Changed broken') expect(oldBroken.published).toBe(oldBroken.indexed) expect(brokenState.published).toBeNull() expect(brokenState.published).not.toBe(brokenState.indexed) + expect(oldBroken.lastPublishedAt).toBe(completionTime) + expect(brokenState.lastPublishedAt).toBe(oldBroken.lastPublishedAt) expect((await audit()).pieces).toEqual({ added: [], updated: [broken], pruned: [] }) mocks.assets.mockClear() + vi.mocked(Date.now).mockReturnValue(completionTime + 2000) const retry = await publish('fresh-retry') expect(retry.phaseResults.get('luzzle.sync')).toEqual(emptyPiecesDiff()) expect(mocks.assets).toHaveBeenCalledExactlyOnceWith({ filePaths: [broken] }, ctx) expect(retry.result.pieces).toEqual({ added: [], updated: [broken], pruned: [] }) expect(retry.result.failedPieces).toEqual([]) - expect((await hashes(broken)).published).toBe(brokenState.indexed) + expect(await hashes(broken)).toMatchObject({ + published: brokenState.indexed, + lastPublishedAt: completionTime + 2000, + }) + expect((await hashes(healthy)).lastPublishedAt).toBe(healthyState.lastPublishedAt) expect(await audit()).toEqual(emptyPiecesDiff()) }) @@ -289,6 +304,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { await publish('baseline') const originalHealthy = await hashes(healthy) const originalBroken = await hashes(broken) + vi.mocked(Date.now).mockReturnValue(completionTime + 1000) await writePiece(healthy, 'Changed healthy') await writePiece(broken, 'Changed broken') const error = new Error(`${phase} unavailable`) @@ -298,8 +314,14 @@ describe('publish hash recovery with a real archive and SQLite', () => { await expect(publish('global-failure')).rejects.toBe(error) expect(originalHealthy.published).toBe(originalHealthy.indexed) expect(originalBroken.published).toBe(originalBroken.indexed) - expect((await hashes(healthy)).published).toBeNull() - expect((await hashes(broken)).published).toBeNull() + expect(await hashes(healthy)).toMatchObject({ + published: null, + lastPublishedAt: originalHealthy.lastPublishedAt, + }) + expect(await hashes(broken)).toMatchObject({ + published: null, + lastPublishedAt: originalBroken.lastPublishedAt, + }) const completion = await ctx.db .selectFrom('job_progress') .selectAll() @@ -310,6 +332,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { expect((await audit()).pieces).toEqual({ added: [], updated: batch, pruned: [] }) mocks.assets.mockClear() + vi.mocked(Date.now).mockReturnValue(completionTime + 2000) const retry = await publish('fresh-global-retry') expect(retry.phaseResults.get('luzzle.sync')).toEqual(emptyPiecesDiff()) expect(mocks.assets).toHaveBeenCalledExactlyOnceWith({ filePaths: batch }, ctx) @@ -317,6 +340,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { for (const filePath of batch) { const state = await hashes(filePath) expect(state.published).toBe(state.indexed) + expect(state.lastPublishedAt).toBe(completionTime + 2000) } expect(await audit()).toEqual(emptyPiecesDiff()) } @@ -354,6 +378,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { indexed: state.indexed, published: state.indexed, title: 'Revision three', + lastPublishedAt: completionTime, }) expect(await audit()).toEqual(emptyPiecesDiff()) }) @@ -384,6 +409,7 @@ describe('publish hash recovery with a real archive and SQLite', () => { indexed: original.indexed, published: original.indexed, title: 'Revision A', + lastPublishedAt: completionTime, }) expect(await audit()).toEqual(emptyPiecesDiff()) } diff --git a/packages/web/worker/src/workflows/publish.test.ts b/packages/web/worker/src/workflows/publish.test.ts index 1ce79d94..6faebf12 100644 --- a/packages/web/worker/src/workflows/publish.test.ts +++ b/packages/web/worker/src/workflows/publish.test.ts @@ -161,6 +161,16 @@ describe('workflows/publish', () => { { filePaths: ['healthy.books.md', 'broken.books.md'] }, expect.anything() ) + expect(mocks.publishComplete.run).toHaveBeenCalledExactlyOnceWith( + { + pieces: [ + { filePath: 'healthy.books.md', contentHash: 'healthy-hash' }, + { filePath: 'broken.books.md', contentHash: 'broken-hash' }, + ], + failedPieces, + }, + expect.anything() + ) expect(JSON.parse(JSON.stringify(result))).toEqual({ ...summary, failedPieces }) expect(result.pieces.updated).toContain('broken.books.md') expect(summary).not.toHaveProperty('failedPieces') @@ -197,11 +207,35 @@ describe('workflows/publish', () => { test('returns successful reports and keeps the normal completion log', async () => { const { result } = await runPublish() expect(result).toEqual({ ...summary, failedPieces: [] }) + expect(mocks.publishComplete.run).toHaveBeenCalledExactlyOnceWith( + { + pieces: [ + { filePath: 'healthy.books.md', contentHash: 'healthy-hash' }, + { filePath: 'broken.books.md', contentHash: 'broken-hash' }, + ], + failedPieces: [], + }, + expect.anything() + ) expect(mocks.archiveSync.run).not.toHaveBeenCalled() expectHealthyPhasesComplete() expectNormalCompletion() }) + test('still completes after configured delivery steps are skipped', async () => { + mocks.cdnSync.run.mockResolvedValue({ status: 'skipped', message: 'no remote' }) + mocks.cachePurge.run.mockResolvedValue({ status: 'skipped', message: 'no proxy' }) + + const { result, phases } = await runPublish() + + expect(phases.slice(-3)).toEqual(['cdn.sync', 'cache.purge', 'publish.complete']) + expect(mocks.progress.skip).toHaveBeenCalledWith('publish-1', 'cdn.sync', 'no remote') + expect(mocks.progress.skip).toHaveBeenCalledWith('publish-1', 'cache.purge', 'no proxy') + expect(mocks.publishComplete.run).toHaveBeenCalledOnce() + expect(result).toEqual({ ...summary, failedPieces: [] }) + expectNormalCompletion() + }) + test('runs optional archive bisync before the remaining publish phases', async () => { const { result, phases } = await runPublish({ bisync: true }) expect(phases).toEqual([ @@ -248,6 +282,10 @@ describe('workflows/publish', () => { const failedPieces = [{ filePath: 'broken.books.md', message: 'cached failure' }] const { result } = await runPublish({}, new Map([['assets.generate', { failedPieces }]])) expect(result).toEqual({ ...summary, failedPieces }) + expect(mocks.publishComplete.run).toHaveBeenCalledWith( + expect.objectContaining({ failedPieces }), + expect.anything() + ) expect(mocks.assetsGenerate.run).not.toHaveBeenCalled() expectHealthyPhasesComplete() expect(mocks.logger.warn).toHaveBeenCalledWith('openworkflow publish complete with failures', { -- 2.54.0 From af6db1c310ef6e6c2f44a5ff20d159aed980c6f2 Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 8 Oct 2026 17:57:56 -0700 Subject: [PATCH 03/17] [web] sync saved Markdown and compare publication hashes --- apps/core/src/database/client.scope.test.ts | 40 +++ apps/core/src/database/client.ts | 8 +- apps/core/src/index.test.ts | 1 + apps/core/src/index.ts | 3 +- apps/core/src/pieces/item.test.ts | 14 + apps/core/src/pieces/item.ts | 2 +- .../src/lib/pieces/publication.server.test.ts | 275 ++++++++++++++++++ apps/web/src/lib/pieces/publication.server.ts | 20 ++ apps/web/src/lib/pieces/types.ts | 5 + .../piece/[...path]/source/+page.server.ts | 23 +- .../[...path]/source/page.server.test.ts | 60 ++++ 11 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 apps/core/src/database/client.scope.test.ts create mode 100644 apps/web/src/lib/pieces/publication.server.test.ts create mode 100644 apps/web/src/lib/pieces/publication.server.ts create mode 100644 apps/web/src/lib/pieces/types.ts diff --git a/apps/core/src/database/client.scope.test.ts b/apps/core/src/database/client.scope.test.ts new file mode 100644 index 00000000..13df920b --- /dev/null +++ b/apps/core/src/database/client.scope.test.ts @@ -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() + // @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() + 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() + } +}) diff --git a/apps/core/src/database/client.ts b/apps/core/src/database/client.ts index bcfb2430..146149c7 100644 --- a/apps/core/src/database/client.ts +++ b/apps/core/src/database/client.ts @@ -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: Kysely): LuzzleDatabase { + return db as unknown as LuzzleDatabase +} + +export { getDatabaseClient, asCoreDatabase } diff --git a/apps/core/src/index.test.ts b/apps/core/src/index.test.ts index 68e15d11..31b91e8f 100644 --- a/apps/core/src/index.test.ts +++ b/apps/core/src/index.test.ts @@ -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', () => { diff --git a/apps/core/src/index.ts b/apps/core/src/index.ts index 35a50dc5..0f8a3e06 100644 --- a/apps/core/src/index.ts +++ b/apps/core/src/index.ts @@ -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, diff --git a/apps/core/src/pieces/item.test.ts b/apps/core/src/pieces/item.test.ts index 66dede0b..b72230d2 100644 --- a/apps/core/src/pieces/item.test.ts +++ b/apps/core/src/pieces/item.test.ts @@ -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' diff --git a/apps/core/src/pieces/item.ts b/apps/core/src/pieces/item.ts index 5bf7440a..63421d6e 100644 --- a/apps/core/src/pieces/item.ts +++ b/apps/core/src/pieces/item.ts @@ -116,7 +116,7 @@ function makePieceItemUpdatable( } if (force || data.assets_json_array !== assetString) { - update.assets_json_array = assetString !== '[]' ? assetString : undefined + update.assets_json_array = assetString } return update diff --git a/apps/web/src/lib/pieces/publication.server.test.ts b/apps/web/src/lib/pieces/publication.server.test.ts new file mode 100644 index 00000000..87a49774 --- /dev/null +++ b/apps/web/src/lib/pieces/publication.server.test.ts @@ -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() + 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 + makeDirectory: ReturnType + writeFile: ReturnType + readFile: ReturnType + createReadStream: ReturnType + stat: ReturnType +} +let piece: Piece + +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 + piece = new Piece('books', storage as unknown as LuzzleStorage, schema) + vi.mocked(getStorage).mockReturnValue(storage as unknown as ReturnType) + vi.mocked(getPieces).mockReturnValue({ + parseFilename: vi.fn(() => ({ type: 'books' })), + getPiece: vi.fn(async () => piece) + } as unknown as ReturnType) +}) + +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[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) +}) diff --git a/apps/web/src/lib/pieces/publication.server.ts b/apps/web/src/lib/pieces/publication.server.ts new file mode 100644 index 00000000..2e0e0dbf --- /dev/null +++ b/apps/web/src/lib/pieces/publication.server.ts @@ -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 { + 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 +} diff --git a/apps/web/src/lib/pieces/types.ts b/apps/web/src/lib/pieces/types.ts new file mode 100644 index 00000000..122ba14e --- /dev/null +++ b/apps/web/src/lib/pieces/types.ts @@ -0,0 +1,5 @@ +export enum MarkdownPublicationStatus { + MATCHING = 'matching', + CHANGED = 'changed', + UNKNOWN = 'unknown' +} diff --git a/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts b/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts index 8157cbea..a339cd45 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts +++ b/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts @@ -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, @@ -125,20 +126,20 @@ export const actions = { const piece = await pieces.getPiece(type) + let markdown: PieceMarkdown 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) => { diff --git a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts index beff6fa8..cfd01691 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts @@ -14,6 +14,7 @@ import { getPieces } from '$lib/server/pieces' import { getStorage } from '$lib/server/storage' import { actions, load } from './+page.server' import { config } from '$lib/server/config' +import { db } from '$lib/server/database' vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() })) vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() })) @@ -40,6 +41,7 @@ function setup(content: string, fields: PieceFrontmatterSchemaField[] = []) { type: 'books', fields, write: vi.fn().mockResolvedValue(undefined), + syncMarkdown: vi.fn().mockResolvedValue(undefined), setField: vi.fn( async (markdown: PieceMarkdown, field: string, value: string) => { const updated = structuredClone(markdown) @@ -82,6 +84,10 @@ test('Save normalizes line endings and markdown and overwrites the existing piec frontmatter: { title: 'Reviewed' }, note: '# Heading\n\nA paragraph.\n' }) + expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0]) + expect(piece.write.mock.invocationCallOrder[0]).toBeLessThan( + piece.syncMarkdown.mock.invocationCallOrder[0] + ) expect(savePieceAsset).not.toHaveBeenCalled() }) @@ -119,6 +125,7 @@ Body`, } }) ) + expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0]) }) test('Save returns the exact raw draft on malformed frontmatter without writing', async () => { @@ -129,6 +136,7 @@ test('Save returns the exact raw draft on malformed frontmatter without writing' data: { rawContent, error: { message: expect.stringContaining('failed to save raw piece:') } } }) expect(piece.write).not.toHaveBeenCalled() + expect(piece.syncMarkdown).not.toHaveBeenCalled() }) test('Save preserves the exact draft and stops on a failed URL download', async () => { @@ -151,6 +159,7 @@ test('Save preserves the exact draft and stops on a failed URL download', async } }) expect(piece.write).not.toHaveBeenCalled() + expect(piece.syncMarkdown).not.toHaveBeenCalled() expect(storage.delete).not.toHaveBeenCalled() }) @@ -169,9 +178,53 @@ test('Save returns the exact draft on write failure and does not delete download } }) expect(piece.write).toHaveBeenCalledTimes(1) + expect(piece.syncMarkdown).not.toHaveBeenCalled() expect(storage.delete).not.toHaveBeenCalled() }) +test('Save waits for sync before reporting success', async () => { + const { piece, event } = setup('---\ntitle: Reviewed\n---\nBody') + let finishSync!: () => void + const started = new Promise((resolve) => { + piece.syncMarkdown.mockImplementationOnce(() => { + resolve() + return new Promise((finish) => { + finishSync = finish + }) + }) + }) + let settled = false + const saving = actions.save(event).then((result) => { + settled = true + return result + }) + await started + expect(settled).toBe(false) + finishSync() + expect(await saving).toEqual({ success: true }) +}) + +test('Save reports sync failure truthfully without retrying or rolling back the file', async () => { + const rawContent = '---\r\ntitle: Reviewed\r\n---\r\nBody' + const { piece, storage, event } = setup(rawContent) + const error = new Error('private database error') + piece.syncMarkdown.mockRejectedValueOnce(error) + + expect(await actions.save(event)).toMatchObject({ + status: 500, + data: { + rawContent, + fields: undefined, + note: undefined, + error: { message: 'Markdown was saved, but its database index could not be updated.' } + } + }) + expect(piece.write).toHaveBeenCalledOnce() + expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0]) + expect(storage.delete).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith('Sync after Save failed:', error) +}) + test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])( 'Save converts %s to a local asset before strict validation and writing', async (url) => { @@ -189,6 +242,7 @@ test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])( additionalProperties: false } as PieceFrontmatterSchema const piece = new Piece('books', targetStorage as unknown as LuzzleStorage, schema) + const sync = vi.spyOn(piece, 'syncMarkdown').mockResolvedValue(undefined) vi.spyOn(getPieces(), 'getPiece').mockResolvedValue(piece) vi.mocked(savePieceAsset).mockResolvedValue('.assets/books/example.books/cover.png') @@ -200,6 +254,12 @@ test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])( 'books/example.books.md', expect.stringContaining('cover: .assets/books/example.books/cover.png') ) + expect(sync).toHaveBeenCalledExactlyOnceWith( + db, + expect.objectContaining({ + frontmatter: { title: 'Reviewed', cover: '.assets/books/example.books/cover.png' } + }) + ) } ) -- 2.54.0 From 6e4085f15a52b5d6912a9f0d6688a6a6b47e65c6 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 9 Oct 2026 06:32:22 -0700 Subject: [PATCH 04/17] [web] isolate preview assets in piece renderers --- apps/web/src/app.d.ts | 2 + apps/web/src/lib/pieces/assets.ts | 21 ++ .../web/src/lib/pieces/components/icon.svelte | 14 +- .../lib/pieces/components/opengraph.svelte | 22 +- .../web/src/lib/pieces/components/page.svelte | 14 +- apps/web/src/lib/pieces/helpers.test.ts | 207 ++++++++++++++++++ apps/web/src/lib/pieces/helpers.ts | 33 +-- .../directory/[...directory]/+page.server.ts | 1 - .../[...directory]/page.server.test.ts | 26 +++ .../preview/[jobId]/+layout.server.ts | 24 +- .../[...path]/preview/[jobId]/+layout.svelte | 10 +- .../preview/[jobId]/layout.server.test.ts | 45 +++- .../[...path]/preview/[jobId]/og/+page.svelte | 4 +- .../[piece=pieceType]/[slug]/+page.server.ts | 3 +- .../[slug]/page.server.test.ts | 73 ++++++ 15 files changed, 446 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/lib/pieces/assets.ts create mode 100644 apps/web/src/lib/pieces/helpers.test.ts create mode 100644 apps/web/src/routes/admin/directory/[...directory]/page.server.test.ts create mode 100644 apps/web/src/routes/pieces/[piece=pieceType]/[slug]/page.server.test.ts diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index c5a6eff0..28171e84 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -14,6 +14,8 @@ declare global { interface PageData { session: Session | null config: AppConfigPublic + preview?: boolean + job?: string meta: { title?: string description?: string diff --git a/apps/web/src/lib/pieces/assets.ts b/apps/web/src/lib/pieces/assets.ts new file mode 100644 index 00000000..07588bbe --- /dev/null +++ b/apps/web/src/lib/pieces/assets.ts @@ -0,0 +1,21 @@ +export function assetPathToUrl( + assetPath: string | null | undefined, + base: string +): 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 + } + const path = `pieces/assets/${segments.map(encodeURIComponent).join('/')}` + 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 +} diff --git a/apps/web/src/lib/pieces/components/icon.svelte b/apps/web/src/lib/pieces/components/icon.svelte index f906482a..f64b5557 100644 --- a/apps/web/src/lib/pieces/components/icon.svelte +++ b/apps/web/src/lib/pieces/components/icon.svelte @@ -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> { const importer = iconImportMap.get(type) @@ -42,8 +42,10 @@ } -{#await resolveIcon(piece.type) then IconComponent} - -{:catch} - -{/await} +{#key piece} + {#await resolveIcon(piece.type) then IconComponent} + + {:catch} + + {/await} +{/key} diff --git a/apps/web/src/lib/pieces/components/opengraph.svelte b/apps/web/src/lib/pieces/components/opengraph.svelte index 60ecfccf..85429d23 100644 --- a/apps/web/src/lib/pieces/components/opengraph.svelte +++ b/apps/web/src/lib/pieces/components/opengraph.svelte @@ -40,15 +40,17 @@ return OpengraphDefault } } - const helpers = getPieceHelpers(piece) + const helpers = $derived(getPieceHelpers(piece)) -{#await resolveOpengraph(piece.type) then Opengraph} -
- -
-{:catch} -
- -
-{/await} +{#key piece} + {#await resolveOpengraph(piece.type) then Opengraph} +
+ +
+ {:catch} +
+ +
+ {/await} +{/key} diff --git a/apps/web/src/lib/pieces/components/page.svelte b/apps/web/src/lib/pieces/components/page.svelte index 04fd2b50..db9089d7 100644 --- a/apps/web/src/lib/pieces/components/page.svelte +++ b/apps/web/src/lib/pieces/components/page.svelte @@ -26,7 +26,7 @@ } let { piece, tags }: Props = $props() - const helpers = getPieceHelpers(piece) + const helpers = $derived(getPieceHelpers(piece)) async function resolvePage(type: string): Promise> { const importer = pageImportMap.get(type) @@ -40,8 +40,10 @@ } -{#await resolvePage(piece.type) then PageComponent} - -{:catch} - -{/await} +{#key piece} + {#await resolvePage(piece.type) then PageComponent} + + {:catch} + + {/await} +{/key} diff --git a/apps/web/src/lib/pieces/helpers.test.ts b/apps/web/src/lib/pieces/helpers.test.ts new file mode 100644 index 00000000..f0121abd --- /dev/null +++ b/apps/web/src/lib/pieces/helpers.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import type { PublicWebPiece } from '@luzzle/web.pieces' +import { getPieceHelpers } from './helpers.js' + +const { page } = 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: '' } } + } + } +})) +vi.mock('$app/state', () => ({ page })) +vi.mock('$app/paths', () => ({ + resolve: (route: string, params: Record) => + route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name]) +})) + +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(() => { + 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.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' + ) +}) diff --git a/apps/web/src/lib/pieces/helpers.ts b/apps/web/src/lib/pieces/helpers.ts index 57321751..9c0ec9dc 100644 --- a/apps/web/src/lib/pieces/helpers.ts +++ b/apps/web/src/lib/pieces/helpers.ts @@ -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 { resolve } from '$app/paths' +import { assetPathToUrl } from './assets.js' export { OpengraphImageWidth, OpengraphImageHeight } export type { PieceComponentHelpers, PieceIconPalette } @@ -55,28 +57,31 @@ 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) => + resolve('/admin/preview/[jobId]/asset/[...asset]', { + jobId: encodeURIComponent(job), + asset: path.split('/').map(encodeURIComponent).join('/') + }) } 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)}` ) } diff --git a/apps/web/src/routes/admin/directory/[...directory]/+page.server.ts b/apps/web/src/routes/admin/directory/[...directory]/+page.server.ts index 43c8d7a2..81836467 100644 --- a/apps/web/src/routes/admin/directory/[...directory]/+page.server.ts +++ b/apps/web/src/routes/admin/directory/[...directory]/+page.server.ts @@ -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), diff --git a/apps/web/src/routes/admin/directory/[...directory]/page.server.test.ts b/apps/web/src/routes/admin/directory/[...directory]/page.server.test.ts new file mode 100644 index 00000000..02798e8e --- /dev/null +++ b/apps/web/src/routes/admin/directory/[...directory]/page.server.test.ts @@ -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[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') +}) diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.server.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.server.ts index 2b421dc3..4ac826d4 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.server.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.server.ts @@ -63,7 +63,14 @@ 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 + } } const result = JSON.parse(run.output) as PreviewWorkerResult @@ -73,9 +80,10 @@ 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, ...assembled @@ -84,14 +92,22 @@ export const load: LayoutServerLoad = async ({ params }) => { if (state === 'failed' || state === 'canceled') { return { + preview: true, file: params.path, status: 'failed' as const, - jobId: runId, + job: runId, phases, logs, 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', + phases, + logs + } } diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte index 382c382e..d00a1c44 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte @@ -47,14 +47,14 @@ const editorUrl = `/admin/piece/${data.file}/source` - 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 = `/admin/piece/${data.file}/preview/${data.job}` + const pageUrl = `/admin/piece/${data.file}/preview/${data.job}/page` + const iconUrl = `/admin/piece/${data.file}/preview/${data.job}/icon` + const ogUrl = `/admin/piece/${data.file}/preview/${data.job}/og` function startWatching() { if (eventSource) eventSource.close() - eventSource = new EventSource(`/api/admin/preview/${data.jobId}/stream`) + eventSource = new EventSource(`/api/admin/preview/${data.job}/stream`) eventSource.addEventListener('state', (e) => { const d = JSON.parse(e.data) diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.server.test.ts index a26bee92..b6e5cd1e 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.server.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.server.test.ts @@ -62,9 +62,10 @@ afterEach(() => vi.useRealTimers()) test('awaits SDK-backed metadata and preserves completed preview assembly', async () => { expect(await load(event())).toMatchObject({ + preview: true, file: 'example.book.md', status: 'completed', - jobId: 'preview-id', + job: 'preview-id', phases: [], logs: [], html: 'rendered-preview' @@ -74,16 +75,54 @@ test('awaits SDK-backed metadata and preserves completed preview assembly', asyn 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' }) + expect(await load(event())).toMatchObject({ + preview: true, + job: 'preview-id', + status: 'failed', + errorMessage: 'preview failed' + }) 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' }) + expect(await load(event())).toMatchObject({ + preview: true, + status: 'expired', + job: 'preview-id' + }) 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 })) + expect(await load(event())).toMatchObject({ + preview: true, + job: 'preview-id', + status: expected + }) + 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[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 }) diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/og/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/og/+page.svelte index 36cd337f..28582f45 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/og/+page.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/og/+page.svelte @@ -8,9 +8,7 @@ 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}` : '' - ) + const ogPngUrl = $derived(ogAsset ? `/admin/preview/${data.job}/asset/${ogAsset.asset_path}` : '') let ogScale = $state(1) let scaleContainer: HTMLDivElement | undefined = $state() diff --git a/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/+page.server.ts b/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/+page.server.ts index c3ccf3d2..e9b2539d 100644 --- a/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/+page.server.ts +++ b/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/+page.server.ts @@ -3,6 +3,7 @@ import type { PageServerLoad } from './$types' import { db } from '$lib/server/database' import { config } from '$lib/server/config' import { hydrateWithAssets } from '$lib/pieces/assets.server' +import { assetPathToUrl } from '$lib/pieces/assets.js' export const load: PageServerLoad = async (page) => { const type = page.params.piece @@ -36,7 +37,7 @@ export const load: PageServerLoad = async (page) => { title: `${piece.title} | ${config.content.text.title}`, type: piece.type, description: piece.summary, - image: ogAsset ? `${config.url.luzzle_assets}/pieces/assets/${ogAsset}` : undefined + image: assetPathToUrl(ogAsset, config.url.luzzle_assets || config.url.app) } } } diff --git a/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/page.server.test.ts b/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/page.server.test.ts new file mode 100644 index 00000000..c4ea54f2 --- /dev/null +++ b/apps/web/src/routes/pieces/[piece=pieceType]/[slug]/page.server.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { load } from './+page.server.js' + +const { query, hydrateWithAssets, config } = vi.hoisted(() => ({ + query: { + selectAll: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + distinct: vi.fn().mockReturnThis(), + executeTakeFirst: vi.fn(), + execute: vi.fn() + }, + hydrateWithAssets: vi.fn(), + config: { + url: { app: 'https://app.example', luzzle_assets: '' }, + content: { text: { title: 'Garden' } } + } +})) +vi.mock('$lib/server/database', () => ({ db: { selectFrom: () => query } })) +vi.mock('$lib/server/config', () => ({ config })) +vi.mock('$lib/pieces/assets.server', () => ({ hydrateWithAssets })) + +function event() { + return { params: { piece: 'books', slug: 'book' } } as Parameters[0] +} + +beforeEach(() => { + vi.clearAllMocks() + config.url.luzzle_assets = '' + query.executeTakeFirst.mockResolvedValue({ id: 'book', file_path: 'book.books.md' }) + query.execute.mockResolvedValue([{ slug: 'fiction', tag: 'Fiction' }]) + hydrateWithAssets.mockResolvedValue({ + id: 'book', + title: 'Book', + type: 'books', + summary: 'Summary', + assets: [{ transformation: 'opengraph', asset_path: 'books/key/share image.png' }] + }) +}) + +test('public page meta uses the app base when CDN is not configured and retains tags', async () => { + const data = await load(event()) + expect(data).toMatchObject({ + tags: [{ slug: 'fiction', tag: 'Fiction' }], + meta: { + image: 'https://app.example/pieces/assets/books/key/share%20image.png', + title: 'Book | Garden' + } + }) + expect(query.where).toHaveBeenCalledWith('piece_id', '=', 'book') + expect(hydrateWithAssets).toHaveBeenCalledOnce() +}) + +test('public meta uses the configured CDN prefix', async () => { + config.url.luzzle_assets = 'https://cdn.example/prefix/' + expect(await load(event())).toMatchObject({ + meta: { image: 'https://cdn.example/prefix/pieces/assets/books/key/share%20image.png' } + }) +}) + +test.each([ + { assets: [] }, + { assets: [{ transformation: 'opengraph', asset_path: '../private.png' }] } +])('missing or invalid recorded images leave site fallback intact', async ({ assets }) => { + hydrateWithAssets.mockResolvedValue({ id: 'book', title: 'Book', type: 'books', assets }) + expect(await load(event())).toMatchObject({ meta: { image: undefined } }) +}) + +test('missing public pieces remain a 404', async () => { + query.executeTakeFirst.mockResolvedValue(undefined) + await expect(load(event())).rejects.toMatchObject({ status: 404 }) + expect(hydrateWithAssets).not.toHaveBeenCalled() +}) -- 2.54.0 From 34b6eda6f84845b6a556234d423dd52be85be589 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 11:37:00 -0700 Subject: [PATCH 05/17] [web] protect admin routes using matched route IDs --- apps/web/src/hooks.server.test.ts | 99 ++++++++++++++++++++++++++++++- apps/web/src/hooks.server.ts | 11 +++- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 2ca28c99..ce6f59bb 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -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[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' } diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index f804d769..b82b01e5 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -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.54.0 From 2d1796c6e9ba15dda32143d481a9d3c8afd6a75a Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 11:39:07 -0700 Subject: [PATCH 06/17] [web] add shared asset source inspector --- .../components/editor/themes/configured.css | 61 +- .../editor/themes/configured.test.ts | 24 +- .../components/editor/themes/configured.ts | 12 +- .../components/editor/themes/token-classes.ts | 10 + .../components/editor/themes/token-colors.css | 59 + apps/web/src/lib/pieces/assets.test.ts | 33 + apps/web/src/lib/pieces/assets.ts | 16 +- apps/web/src/lib/pieces/helpers.test.ts | 46 +- apps/web/src/lib/pieces/helpers.ts | 8 +- .../src/lib/pieces/preview/output-url.test.ts | 43 + apps/web/src/lib/pieces/preview/output-url.ts | 8 + .../src/lib/pieces/viewer/AssetOutputs.svelte | 327 ++++ .../lib/pieces/viewer/AssetOutputs.test.ts | 1550 +++++++++++++++++ .../lib/pieces/viewer/PieceOverview.svelte | 126 ++ .../lib/pieces/viewer/PieceOverview.test.ts | 97 ++ .../lib/pieces/viewer/SourceContent.svelte | 65 + .../lib/pieces/viewer/SourceContent.test.ts | 89 + apps/web/src/lib/pieces/viewer/assets.test.ts | 548 ++++++ apps/web/src/lib/pieces/viewer/assets.ts | 76 + .../src/lib/pieces/viewer/disclosure.test.ts | 63 + apps/web/src/lib/pieces/viewer/disclosure.ts | 12 + .../lib/pieces/viewer/fixtures/published.ts | 22 + .../lib/pieces/viewer/presentation.test.ts | 71 + .../web/src/lib/pieces/viewer/presentation.ts | 33 + .../pieces/viewer/source-highlight.test.ts | 148 ++ .../src/lib/pieces/viewer/source-highlight.ts | 28 + apps/web/src/lib/pieces/viewer/source.test.ts | 153 ++ apps/web/src/lib/pieces/viewer/source.ts | 67 + 28 files changed, 3705 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/lib/components/editor/themes/token-classes.ts create mode 100644 apps/web/src/lib/components/editor/themes/token-colors.css create mode 100644 apps/web/src/lib/pieces/assets.test.ts create mode 100644 apps/web/src/lib/pieces/preview/output-url.test.ts create mode 100644 apps/web/src/lib/pieces/preview/output-url.ts create mode 100644 apps/web/src/lib/pieces/viewer/AssetOutputs.svelte create mode 100644 apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/PieceOverview.svelte create mode 100644 apps/web/src/lib/pieces/viewer/PieceOverview.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/SourceContent.svelte create mode 100644 apps/web/src/lib/pieces/viewer/SourceContent.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/assets.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/assets.ts create mode 100644 apps/web/src/lib/pieces/viewer/disclosure.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/disclosure.ts create mode 100644 apps/web/src/lib/pieces/viewer/fixtures/published.ts create mode 100644 apps/web/src/lib/pieces/viewer/presentation.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/presentation.ts create mode 100644 apps/web/src/lib/pieces/viewer/source-highlight.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/source-highlight.ts create mode 100644 apps/web/src/lib/pieces/viewer/source.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/source.ts diff --git a/apps/web/src/lib/components/editor/themes/configured.css b/apps/web/src/lib/components/editor/themes/configured.css index 65a58b34..39ebc0ad 100644 --- a/apps/web/src/lib/components/editor/themes/configured.css +++ b/apps/web/src/lib/components/editor/themes/configured.css @@ -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)); -} diff --git a/apps/web/src/lib/components/editor/themes/configured.test.ts b/apps/web/src/lib/components/editor/themes/configured.test.ts index 47bb4648..6e58677d 100644 --- a/apps/web/src/lib/components/editor/themes/configured.test.ts +++ b/apps/web/src/lib/components/editor/themes/configured.test.ts @@ -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', () => { diff --git a/apps/web/src/lib/components/editor/themes/configured.ts b/apps/web/src/lib/components/editor/themes/configured.ts index 531047c8..421ab4d0 100644 --- a/apps/web/src/lib/components/editor/themes/configured.ts +++ b/apps/web/src/lib/components/editor/themes/configured.ts @@ -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'), diff --git a/apps/web/src/lib/components/editor/themes/token-classes.ts b/apps/web/src/lib/components/editor/themes/token-classes.ts new file mode 100644 index 00000000..29c9b99f --- /dev/null +++ b/apps/web/src/lib/components/editor/themes/token-classes.ts @@ -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' } +]) diff --git a/apps/web/src/lib/components/editor/themes/token-colors.css b/apps/web/src/lib/components/editor/themes/token-colors.css new file mode 100644 index 00000000..67684f75 --- /dev/null +++ b/apps/web/src/lib/components/editor/themes/token-colors.css @@ -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)); +} diff --git a/apps/web/src/lib/pieces/assets.test.ts b/apps/web/src/lib/pieces/assets.test.ts new file mode 100644 index 00000000..70904394 --- /dev/null +++ b/apps/web/src/lib/pieces/assets.test.ts @@ -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 .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}`) + } +) diff --git a/apps/web/src/lib/pieces/assets.ts b/apps/web/src/lib/pieces/assets.ts index 07588bbe..69506232 100644 --- a/apps/web/src/lib/pieces/assets.ts +++ b/apps/web/src/lib/pieces/assets.ts @@ -1,7 +1,4 @@ -export function assetPathToUrl( - assetPath: string | null | undefined, - base: string -): string | undefined { +export function encodeAssetPath(assetPath: string | null | undefined): string | undefined { if (!assetPath?.trim() || assetPath.includes('\\') || /^[a-z][a-z\d+.-]*:/i.test(assetPath)) { return undefined } @@ -9,7 +6,16 @@ export function assetPathToUrl( if (segments.some((segment) => !segment || segment === '.' || segment === '..')) { return undefined } - const path = `pieces/assets/${segments.map(encodeURIComponent).join('/')}` + 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}` } diff --git a/apps/web/src/lib/pieces/helpers.test.ts b/apps/web/src/lib/pieces/helpers.test.ts index f0121abd..a2d507a3 100644 --- a/apps/web/src/lib/pieces/helpers.test.ts +++ b/apps/web/src/lib/pieces/helpers.test.ts @@ -2,7 +2,7 @@ import { beforeEach, expect, test, vi } from 'vitest' import type { PublicWebPiece } from '@luzzle/web.pieces' import { getPieceHelpers } from './helpers.js' -const { page } = vi.hoisted(() => ({ +const { page, resolve } = vi.hoisted(() => ({ page: { params: {} as { jobId?: string }, url: new URL('https://app.example/pieces/books/book'), @@ -11,13 +11,13 @@ const { page } = vi.hoisted(() => ({ 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: (route: string, params: Record) => - route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name]) -})) +vi.mock('$app/paths', () => ({ resolve })) function piece(path = 'books/cover key/cover.jpg'): PublicWebPiece { return { @@ -47,6 +47,7 @@ function piece(path = 'books/cover key/cover.jpg'): PublicWebPiece { } beforeEach(() => { + vi.clearAllMocks() page.url = new URL('https://app.example/pieces/books/book') page.params = {} page.data.preview = undefined @@ -106,6 +107,39 @@ test('Preview uses its declared job, not the current route parameter', () => { ) }) +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 diff --git a/apps/web/src/lib/pieces/helpers.ts b/apps/web/src/lib/pieces/helpers.ts index 9c0ec9dc..96af3788 100644 --- a/apps/web/src/lib/pieces/helpers.ts +++ b/apps/web/src/lib/pieces/helpers.ts @@ -3,8 +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 { resolve } from '$app/paths' import { assetPathToUrl } from './assets.js' +import { previewOutputUrl } from './preview/output-url.js' export { OpengraphImageWidth, OpengraphImageHeight } export type { PieceComponentHelpers, PieceIconPalette } @@ -60,11 +60,7 @@ export type PiecePageProps = { function createAssetUrlBuilder(preview = false, job?: string): (path: string) => string { if (preview === true) { if (!job) throw new Error('Preview rendering requires a job.') - return (path) => - resolve('/admin/preview/[jobId]/asset/[...asset]', { - jobId: encodeURIComponent(job), - asset: path.split('/').map(encodeURIComponent).join('/') - }) + return (path) => previewOutputUrl(path, job) ?? '' } const config = page.data.config diff --git a/apps/web/src/lib/pieces/preview/output-url.test.ts b/apps/web/src/lib/pieces/preview/output-url.test.ts new file mode 100644 index 00000000..3c2fc302 --- /dev/null +++ b/apps/web/src/lib/pieces/preview/output-url.test.ts @@ -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() + } +) diff --git a/apps/web/src/lib/pieces/preview/output-url.ts b/apps/web/src/lib/pieces/preview/output-url.ts new file mode 100644 index 00000000..a2b928de --- /dev/null +++ b/apps/web/src/lib/pieces/preview/output-url.ts @@ -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}` +} diff --git a/apps/web/src/lib/pieces/viewer/AssetOutputs.svelte b/apps/web/src/lib/pieces/viewer/AssetOutputs.svelte new file mode 100644 index 00000000..42a82666 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/AssetOutputs.svelte @@ -0,0 +1,327 @@ + + +{#snippet groupHeading(key: string, columns: number)} + + {assetLabel(key)} + +{/snippet} + +{#snippet fileValue(file: OutputFile)} + {#if file.path != null} + {#if file.url} +
+ View +
+ {:else} +
+ + {file.path} + + Unavailable +
+ {/if} + {:else} + Not recorded + {/if} +{/snippet} + +
+

{heading}

+ {#if !grouped.transforms.length}None recorded{/if} + {#each families as family, familyIndex (family.name)} +
+ + + + + + + + + + + {#each family.groups as group, groupIndex (group.key)} + + {#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}`} + + + + + + + {#if !('path' in row) && row.value !== null && row.value !== ''} + + + + {/if} + {/each} + + {/each} +
{transformCaption(family.name)}
OutputFieldTypeValue
+ + {output} + + + {field} + + + {fileType(row.mime)} + + + {#if 'path' in row} + {@render fileValue(row)} + {:else if row.value === null} + Not recorded + {:else if row.value === ''} + Empty + {:else} + + {/if} +
+
+ {/each} +
+ + diff --git a/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts b/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts new file mode 100644 index 00000000..fbd1ebe0 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/AssetOutputs.test.ts @@ -0,0 +1,1550 @@ +import { readFileSync } from 'node:fs' +import { expect, test, vi } from 'vitest' +import { render } from 'svelte/server' +import type { PublicWebPieceAsset } from '@luzzle/web.pieces' +import AssetOutputs from './AssetOutputs.svelte' +import { asset } from './fixtures/published' +import { groupAssetOutputs } from './assets' +import { assetPathToUrl } from '$lib/pieces/assets' +import { fileType, transformCaption, transformFamily } from './presentation' + +function groupAssets(assets: Parameters[0], base: string) { + return groupAssetOutputs(assets, (path) => assetPathToUrl(path, base)) +} + +// Quoted attributes can contain literal > characters from recorded paths. +const tagPattern = /<\/?[a-z][\w-]*\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi + +function elements(html: string, tag: string) { + const stack: number[] = [] + const matches: { start: number; html: string }[] = [] + for (const match of html.matchAll(tagPattern)) { + if (!new RegExp(`^ a.start - b.start).map((match) => match.html) +} + +function cellValue(html: string) { + return html + .replace(/]*class="cell-label[^"]*"[^>]*>[\s\S]*?<\/span>/g, '') + .replace(/"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g, (link) => + (link.match(/\btitle="([^"]*)"/)?.[1] ?? '').replaceAll('"', '"') + ) + .replace(tagPattern, '') + .trim() +} + +function table(html: string, caption: string) { + const found = elements(html, 'table').find( + (candidate) => cellValue(elements(candidate, 'caption')[0] ?? '') === caption + ) + expect(found, `table caption: ${caption}`).toBeDefined() + return found! +} + +function rows(html: string) { + // Metadata projections only; expectDisclosureRows separately checks EVERY detail row. + return elements(html, 'tr') + .filter((row) => !isSourceRow(row)) + .map((row) => + elements(row, 'th') + .concat(elements(row, 'td')) + .map((cell) => cellValue(cell)) + ) +} + +function allLinks(html: string) { + return elements(html, 'a') +} + +// Existing counts describe recorded files (View), not secondary actions. +function fileLinks(html: string) { + return allLinks(html).filter((link) => link.endsWith('>View')) +} + +function escapedText(value: string) { + return value.replaceAll('&', '&').replaceAll('<', '<') +} + +function escapedAttribute(value: string) { + return escapedText(value).replaceAll('"', '"') +} + +const columns = ['Output', 'Field', 'Type', 'Value'] + +function valueRow(output: string, field: string, mime: string, value: string) { + return [escapedText(output), escapedText(field), fileType(mime), value] +} + +function expectShowValue(html: string, output: string, content: string, ordinal = 1) { + const label = escapedAttribute(`Show ${transformCaption(output)}: Asset ${ordinal}, Content`) + const buttons = elements(html, 'button').filter((button) => + button.includes(`aria-label="${label}"`) + ) + expect(buttons).toHaveLength(1) + expect(buttons[0]).toContain('type="button"') + expect(buttons[0]).toMatch(/>Show<\/button>$/) + expect(buttons[0]).not.toContain('disabled') + expect(buttons[0]).toContain('aria-expanded="false"') + expect(buttons[0]).toContain('aria-controls=') + // Text-only comparison avoids mistaking aria-expanded="false" for JSON payload. + expect(cellValue(html)).not.toContain(escapedText(content)) +} + +function isSourceRow(row: string) { + return /^]*class="source-row(?:[ "\s])/.test(row) +} + +function expectDisclosureRows(html: string, assets: PublicWebPieceAsset[]) { + const expected = groupAssets(assets, '/cdn').transforms.flatMap((family) => + family.values.filter((value) => value.value !== null && value.value !== '') + ) + const detailRows = elements(html, 'tr').filter(isSourceRow) + const buttons = elements(html, 'button') + expect(detailRows).toHaveLength(expected.length) + expect(buttons).toHaveLength(expected.length) + const ids: string[] = [] + for (const body of elements(html, 'tbody')) { + const allRows = elements(body, 'tr') + for (const [index, row] of allRows.entries()) { + if (!isSourceRow(row)) continue + const opening = row.match(tagPattern)![0] + const id = opening.match(/\bid="([^"]+)"/)?.[1] + expect(id).toMatch(/-source-\d+-\d+-\d+$/) + ids.push(id!) + expect(opening).toContain('role="row"') + expect(opening).toMatch(/\shidden(?:="")?(?:\s|>)/) + expect(elements(row, 'th')).toHaveLength(0) + const cells = elements(row, 'td') + expect(cells).toHaveLength(1) + expect(cells[0]).toContain('role="cell"') + expect(cells[0]).toContain('colspan="4"') + expect(cells[0]).toMatch(/class="source-cell(?:[ "\s])/) + expect(cellValue(cells[0])).toBe('') + expect(cells[0]).not.toContain('cell-label') + expect(elements(row, 'table')).toHaveLength(0) + const trigger = elements(allRows[index - 1] ?? '', 'button') + expect(trigger).toHaveLength(1) + expect(trigger[0]).toContain(`aria-controls="${id}"`) + expect(trigger[0]).toContain('aria-expanded="false"') + expect(trigger[0]).toMatch(/aria-label="Show [^"]+: Asset \d+, Content"/) + expect(trigger[0]).toMatch(/>Show<\/button>$/) + } + } + expect(new Set(ids).size).toBe(ids.length) + for (const button of buttons) { + const id = button.match(/aria-controls="([^"]+)"/)?.[1] + expect(ids.filter((candidate) => candidate === id)).toHaveLength(1) + } + expect(html).not.toMatch(/\bdata-(?:source|value|payload)=/) + for (const tag of ['pre', 'code', 'script', 'style', 'iframe']) + expect(elements(html, tag)).toHaveLength(0) +} + +function fileRow(output: string, path: string, type: string) { + return [escapedText(output), 'File', type, path] +} + +function expectFileRow(html: string, path: string, type: string, output: string) { + expect(rows(html)).toContainEqual(fileRow(output, path, type)) +} + +function expectUnavailableRow(html: string, path: string, type: string, output: string) { + const row = elements(html, 'tr').find((row) => + elements(row, 'td').some((cell) => cell.includes(`${path}`)) + ) + expect(row).toBeDefined() + expectFileRow(row!.replace(/]*>Unavailable<\/small>/g, ''), path, type, output) + expect(allLinks(row!)).toHaveLength(0) + expect(elements(row!, 'small').map(cellValue)).toEqual(['Unavailable']) + expect(row).toContain('title="No usable file link"') +} + +function expectFileViews(html: string) { + const views = fileLinks(html) + expect(allLinks(html)).toHaveLength(views.length) + expect(html).not.toMatch(/\sdownload(?:\s|=|>)/) + for (const link of views) { + expect(link).toMatch(/>View<\/a>$/) + expect(link).toContain('aria-label="View ') + expect(link).toContain('title=') + expect(link).not.toContain('truncated') + expect(link).toContain('target="_blank"') + expect(link).toContain('rel="noopener noreferrer"') + expect(link).not.toMatch(/\sdownload(?:\s|=|>)/) + const href = link.match(/\bhref="([^"]*)"/)?.[1] + expect(href).toBeDefined() + } +} + +function renderAssets(assets: PublicWebPieceAsset[], base = '/cdn') { + const html = render(AssetOutputs, { + props: { + assets, + fileUrl: (path) => assetPathToUrl(path, base), + heading: 'Published assets' + } + }).body.replace(//g, '') + expect(html).toMatch(/]*>Published assets<\/h2>/) + expect(elements(html, 'caption').map(cellValue)).toEqual( + groupAssets(assets, base).transforms.map((family) => escapedText(transformCaption(family.name))) + ) + for (const candidate of elements(html, 'table')) { + const head = elements(candidate, 'thead')[0] + const headers = elements(head, 'th') + expect(headers.map(cellValue)).toEqual(columns) + for (const header of headers) expect(header).toContain('scope="col"') + const bodies = elements(candidate, 'tbody') + for (const body of bodies) { + const groupHeads = elements(body, 'th').filter((cell) => cell.includes('scope="rowgroup"')) + expect(groupHeads).toHaveLength(bodies.length > 1 ? 1 : 0) + for (const row of elements(body, 'tr')) { + const headers = elements(row, 'th') + const cells = elements(row, 'td') + if (isSourceRow(row)) { + expect(headers).toHaveLength(0) + expect(cells).toHaveLength(1) + expect(cells[0]).toContain('colspan="4"') + continue + } + expect(headers).toHaveLength(1) + if (headers[0].includes('scope="rowgroup"')) expect(cells).toHaveLength(0) + else { + expect(headers[0]).toContain('scope="row"') + expect(cells).toHaveLength(3) + } + } + for (const groupHead of groupHeads) { + expect(groupHead).toContain('role="rowheader"') + expect(groupHead).toContain(`colspan="${headers.length}"`) + expect(groupHead).not.toContain('cell-label') + expect(cellValue(groupHead)).toMatch(/^Asset \d+$/) + } + } + expect(elements(candidate, 'table')).toHaveLength(1) + } + for (const excluded of [ + 'Technical details', + '>Key<', + 'Role<', + '>Open<', + '>MIME<', + '>Status<', + '>Recorded<', + 'Original assets', + 'Generated files', + 'Image variants', + '>Variant<', + '>Images<', + 'Colors (palette)', + 'class="swatch', + ' style=', + 'Open recorded file', + '

Available<' + ]) + expect(html).not.toContain(excluded) + expectFileViews(html) + expectDisclosureRows(html, assets) + return html +} + +test('every recorded family has one generic caption and retains each full output', () => { + const html = renderAssets( + [ + asset('image.original', 'cover #?.jpg'), + asset('attachment', 'cover.pdf', 'cover', 'application/pdf'), + asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), + asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), + asset('image.m.avif', 'cover/m.avif', 'cover', 'image/avif'), + asset('opengraph', 'piece/share.png', 'piece', 'image/png'), + asset('custom', 'files/other.zip', 'other', 'application/zip'), + { ...asset('markdown', null, 'cover', 'text/html'), content: '

PRIVATE INLINE

' }, + { ...asset('palette', null), content: 'PRIVATE PALETTE' }, + { ...asset('highlight', null), content: 'PRIVATE HIGHLIGHT', is_embedded: 1 } + ], + 'https://cdn.test/prefix/' + ) + for (const [output, path, type] of [ + ['image.original', 'cover #?.jpg', 'JPEG'], + ['image.s.webp', 'cover/s.webp', 'WebP'], + ['image.m.avif', 'cover/m.avif', 'AVIF'] + ]) { + const files = table(html, 'Image') + expectFileRow(files, path, type, output) + expect(elements(files, 'tbody')).toHaveLength(1) + expect(fileLinks(files)).toHaveLength(3) + expect(files).not.toContain('colspan=') + expect(files).not.toContain('cover.pdf') + } + expectFileRow(table(html, 'Attachment'), 'cover.pdf', 'PDF', 'attachment') + expectFileRow(table(html, 'Custom'), 'files/other.zip', 'ZIP', 'custom') + expect(html).toContain('title="files/other.zip"') + expectFileRow(table(html, 'Opengraph'), 'piece/share.png', 'PNG', 'opengraph') + expect(html).toContain('https://cdn.test/prefix/pieces/assets/cover%20%23%3F.jpg') + expect(fileLinks(html)).toHaveLength(6) + expect(rows(table(html, 'Markdown'))).toContainEqual( + valueRow('markdown', 'Content', 'text/html', 'Show') + ) + expect(html).not.toContain('PRIVATE INLINE') + expect(rows(table(html, 'Palette'))).toContainEqual( + valueRow('palette', 'Content', 'image/jpeg', 'Show') + ) + expect(rows(table(html, 'Highlight'))).toContainEqual( + valueRow('highlight', 'Content', 'image/jpeg', 'Show') + ) + expect(elements(html, 'caption').map(cellValue)).toEqual([ + 'Image', + 'Attachment', + 'Opengraph', + 'Custom', + 'Markdown', + 'Palette', + 'Highlight' + ]) + for (const excluded of ['/admin/asset', '/admin/preview', ' { + const html = renderAssets( + [ + asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp'), + asset('attachment', '../private.pdf', 'attachment', 'application/pdf') + ], + '' + ) + expectFileRow(table(html, 'Image'), 'cover/s.webp', 'WebP', 'image.s.webp') + expect(elements(html, 'table')).toHaveLength(2) + expect(html).not.toContain('Image.original') + expect(html).not.toContain('Not recorded') + expectUnavailableRow(table(html, 'Attachment'), '../private.pdf', 'PDF', 'attachment') + expect(html).not.toContain('href="/pieces/assets/..') + expect(fileLinks(html)).toHaveLength(1) + expect(html).not.toContain('Other published files') +}) + +test('no records renders only a simple None recorded marker without invented tables', () => { + const html = renderAssets([]) + expect(elements(html, 'table')).toHaveLength(0) + expect(elements(html, 'caption')).toHaveLength(0) + expect(elements(html, 'p')).toHaveLength(0) + expect(html).toMatch(/]*class="empty[^" ]*[^"]*"[^>]*>None recorded<\/span>/) + expect(html.match(/None recorded/g)).toHaveLength(1) + for (const caption of ['Attachment', 'Opengraph', 'Output', 'Images']) + expect(html).not.toContain(`>${caption}<`) + expect(fileLinks(html)).toHaveLength(0) +}) + +test.each([null, '', '../private.png', '/private.png', 'https://evil.test/file.png'])( + 'missing or unsafe PNG %s remains an actual Opengraph record without a link', + (path) => { + const html = renderAssets([asset('opengraph', path, 'piece', 'image/png')]) + if (path) expectUnavailableRow(table(html, 'Opengraph'), path, 'PNG', 'opengraph') + else expectFileRow(table(html, 'Opengraph'), 'Not recorded', 'PNG', 'opengraph') + expect(html).not.toContain('None recorded') + expect(fileLinks(html)).toHaveLength(0) + expect(html).not.toContain('Other published files') + } +) + +test('unknown transformation, file and raw MIME are escaped; malformed MIME displays File', () => { + const transformation = '' + const path = '.pdf' + const html = renderAssets([asset(transformation, path, 'other', '')]) + expectFileRow(table(html, escapedText(transformation)), escapedText(path), 'File', transformation) + expect(html).toContain(`aria-label="${escapedAttribute(`View ${transformation}: ${path}`)}"`) + expect(html).toContain(`title="${escapedAttribute(path)}"`) + expect(html).toContain('title="<svg>"') + for (const excluded of ['']) expect(html).not.toContain(excluded) +}) + +test('attachment and image variant records stay independent without invented originals', () => { + const html = renderAssets([ + asset('attachment', 'cover.pdf', 'cover', 'application/pdf'), + asset('image.s.webp', 'cover/s.webp', 'cover', 'image/webp') + ]) + expect(html).not.toContain('Image.original') + expect(html).not.toContain('Not recorded') + expectFileRow(table(html, 'Image'), 'cover/s.webp', 'WebP', 'image.s.webp') + expectFileRow(table(html, 'Attachment'), 'cover.pdf', 'PDF', 'attachment') +}) + +test('image.original and deduplicated same-path attachment have independent actual captions', () => { + const original = asset('image.original', 'shared.jpg') + const attachment = asset('attachment', 'shared.jpg', 'cover', 'application/octet-stream') + const html = renderAssets([attachment, original, original, attachment], '/app') + for (const caption of ['Image', 'Attachment']) { + const candidate = table(html, caption) + expectFileRow( + candidate, + 'shared.jpg', + caption === 'Image' ? 'JPEG' : 'Binary', + caption === 'Image' ? 'image.original' : 'attachment' + ) + expect(fileLinks(candidate)).toHaveLength(1) + } + expect(html.match(/href="\/app\/pieces\/assets\/shared.jpg"/g)).toHaveLength(2) + expect(html).not.toContain('Other published files') + expect(rows(html).filter((row) => row[0] === 'None recorded')).toHaveLength(0) +}) + +test('same-basename keys retain per-key bodies in one Image family table', () => { + const names = [ + 'image.original', + ...['s', 'm', 'l', 'xl'].flatMap((size) => + ['avif', 'webp'].map((format) => `image.${size}.${format}`) + ) + ] + const assets = ['first', 'second'].flatMap((key) => + names.map((name) => { + const format = name === 'image.original' ? 'jpeg' : name.split('.')[2] + return asset(name, `${key}/${name}`, key, `image/${format}`) + }) + ) + const html = renderAssets(assets) + expect(fileLinks(html)).toHaveLength(18) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Image']) + const bodies = elements(table(html, 'Image'), 'tbody') + expect(bodies).toHaveLength(2) + for (const [index, key] of ['first', 'second'].entries()) { + const expected = names.map((name) => { + const type = name === 'image.original' ? 'JPEG' : name.endsWith('avif') ? 'AVIF' : 'WebP' + expectFileRow(bodies[index], `${key}/${name}`, type, name) + return fileRow(name, `${key}/${name}`, type) + }) + expect(rows(bodies[index])).toEqual([[`Asset ${index + 1}`], ...expected]) + expect(fileLinks(bodies[index])).toHaveLength(9) + expect(bodies[index]).not.toContain(' { + const html = renderAssets( + ['image.original', 'attachment', 'image.s.webp', 'document.custom'].map((transformation) => + asset(transformation, null) + ) + ) + expectFileRow(table(html, 'Image'), 'Not recorded', 'JPEG', 'image.original') + // The recorded MIME, not the transformation's format, determines Type. + expectFileRow(table(html, 'Image'), 'Not recorded', 'JPEG', 'image.s.webp') + expectFileRow(table(html, 'Attachment'), 'Not recorded', 'JPEG', 'attachment') + expectFileRow(table(html, 'Document'), 'Not recorded', 'JPEG', 'document.custom') + expect(html).toContain('title="document.custom"') + expect(rows(html).filter((row) => row[0] === 'None recorded')).toHaveLength(0) + expect(html).not.toContain('Unavailable') + expect(fileLinks(html)).toHaveLength(0) +}) + +test('unsafe paths are not archive, preview, external or guessed links; actual non-PNG Opengraph is shown', () => { + const html = renderAssets( + [ + asset('image.original', '../archive/cover.jpg'), + asset('attachment', '/admin/asset/private.html', 'attachment', 'text/html'), + asset('image.s.webp', 'https://evil.test/cover.webp', 'cover', 'image/webp'), + asset('unknown', 'bad\\path.txt', 'other', 'text/plain'), + asset('opengraph', 'share.jpg', 'piece', 'image/jpeg') + ], + 'https://app.test/site/' + ) + expectUnavailableRow(table(html, 'Image'), '../archive/cover.jpg', 'JPEG', 'image.original') + expectUnavailableRow(table(html, 'Attachment'), '/admin/asset/private.html', 'HTML', 'attachment') + expectUnavailableRow(table(html, 'Image'), 'https://evil.test/cover.webp', 'WebP', 'image.s.webp') + expectUnavailableRow(table(html, 'Unknown'), 'bad\\path.txt', 'Text', 'unknown') + expect(html.match(/>Unavailable { + const html = renderAssets( + [ + asset('attachment', 'files/' + ], + [ + 'highlight', + 'text/html', + '' + ], + ['markdown.description', 'text/plain', 'javascript:alert(1) data:text/html,'], + ['custom', 'text/css', 'body{background:url(javascript:alert(1));} @import "https://evil.test";'], + [ + 'palette', + 'application/json', + '{"":"","style":"background:url(javascript:bad)"}' + ] +])( + 'untrusted %s values stay absent while collapsed, never active markup, CSS or links', + (name, mime, content) => { + const html = renderAssets([{ ...asset(name, null, 'unsafe', mime), content }]) + const caption = transformCaption(transformFamily(name)) + const values = table(html, caption) + expect(rows(values)).toContainEqual(valueRow(name, 'Content', mime, 'Show')) + expectShowValue(values, name, content) + for (const tag of ['script', 'img', 'iframe', 'svg', 'style', 'input', 'textarea']) + expect(html).not.toMatch(new RegExp(`<${tag}\\b`, 'i')) + expect(html).not.toMatch(/<(?:[^>"']|"[^"]*"|'[^']*')*\s(?:style|onload|onerror)=/i) + expect(fileLinks(values)).toHaveLength(0) + } +) + +test('huge Unicode JSON becomes Show without DOM payload or exposing metadata field names', () => { + const key = `${'key'.repeat(80)}雪 "&` + const field = `${'field'.repeat(80)}雪 "&` + const value = ` ${'雪😀 &\n'.repeat(4000)} ` + const html = renderAssets([ + { + ...asset('palette', null, key, 'application/json'), + content: JSON.stringify({ [field]: value }) + } + ]) + const palette = table(html, 'Palette') + expect(elements(palette, 'button')).toHaveLength(1) + expect(palette).toContain(`aria-label="${escapedAttribute(`Show Palette: Asset 1, Content`)}"`) + expect(palette).not.toContain(escapedText(value)) + expect(palette).not.toContain('raw</script>') + expect(html.length).toBeLessThan(12000) + const projected = groupAssets( + [ + { + ...asset('palette', null, key, 'application/json'), + content: JSON.stringify({ [field]: value }) + } + ], + '/cdn' + ) + expect(projected.transforms[0].values[0].value).toBe(JSON.stringify({ [field]: value })) + expect(palette).not.toContain(escapedText(key)) + expect(palette).not.toContain(escapedAttribute(key)) + expect(palette).not.toContain(escapedText(field)) + expect(palette).not.toContain(escapedAttribute(field)) +}) + +test('Markdown huge source, short JSON and plain content all use Show without SSR payload', () => { + const source = ` ${'雪😀 &\n'.repeat(4000)} ` + const html = renderAssets([ + asset('attachment', 'other.pdf', 'first', 'application/pdf'), + { ...asset('markdown', null, 'private-key', 'text/html; charset=utf-8'), content: source }, + { + ...asset('markdown.description', null, 'third', 'application/json'), + content: JSON.stringify({ description: '雪 & raw', count: 42 }) + }, + { ...asset('markdown.description', null, 'private-key', 'text/plain'), content: 'different' } + ]) + const description = table(html, 'Markdown') + expect(elements(html, 'caption').map(cellValue)).toEqual(['Attachment', 'Markdown']) + expect(rows(description)).toEqual([ + columns, + ['Asset 2'], + valueRow('markdown', 'Content', 'text/html; charset=utf-8', 'Show'), + valueRow('markdown.description', 'Content', 'text/plain', 'Show'), + ['Asset 3'], + valueRow('markdown.description', 'Content', 'application/json', 'Show') + ]) + const buttons = elements(html, 'button') + expectShowValue( + description, + 'markdown.description', + JSON.stringify({ description: '雪 & raw', count: 42 }), + 3 + ) + expect(buttons).toHaveLength(3) + expect(buttons.map((button) => button.match(/aria-label="([^"]*)"/)?.[1])).toEqual([ + 'Show Markdown: Asset 2, Content', + 'Show Markdown.description: Asset 2, Content', + 'Show Markdown.description: Asset 3, Content' + ]) + for (const button of buttons) { + expect(button).toContain('type="button"') + expect(button).toMatch(/>Show<\/button>$/) + expect(button).not.toContain('disabled') + } + expect(html).not.toContain('aria-live=') + expect(html.length).toBeLessThan(12000) + for (const excluded of ['data-', 'private-key', 'bad()', '<script>', ' { + const html = renderAssets([ + { ...asset('markdown', null, 'null', 'text/html'), is_embedded: 1 }, + { ...asset('markdown', null, 'empty', 'text/html'), content: '' }, + asset('markdown', 'actual/page.html', 'file', 'text/html'), + { ...asset('markdown.description', null, 'null', 'text/plain'), is_embedded: 1 }, + { ...asset('markdown.description', null, 'empty', 'text/plain'), content: '' } + ]) + expectFileRow(table(html, 'Markdown'), 'actual/page.html', 'HTML', 'markdown') + const values = table(html, 'Markdown') + expect(elements(html, 'caption').map(cellValue)).toEqual(['Markdown']) + for (const output of ['markdown', 'markdown.description']) { + const mime = output === 'markdown' ? 'text/html' : 'text/plain' + expect(rows(values)).toContainEqual(valueRow(output, 'Content', mime, 'Not recorded')) + expect(rows(values)).toContainEqual(valueRow(output, 'Content', mime, 'Empty')) + } + expect(elements(values, 'button')).toHaveLength(0) + expect(fileLinks(html)).toHaveLength(1) +}) + +test('worker-shaped palette retains one whole value without field or color projection', () => { + const record = { + ...asset('palette', null, 'cover', 'application/json; charset=utf-8'), + content: JSON.stringify({ + background: '#123456', + bodyText: '#fff', + titleText: '#000F', + accent: '#ABCDEF', + muted: '#12345678', + swatches: ['#abc', 'red; background:url(javascript:bad)', ''], + nested: { color: '#fff', values: [['#abc']] }, + empty: [], + missing: null, + blank: '', + active: false, + count: 0 + }) + } + const palette = table(renderAssets([record]), 'Palette') + expect(rows(palette)).toEqual([columns, valueRow('palette', 'Content', record.mime_type, 'Show')]) + expectShowValue(palette, 'palette', record.content) + expect(groupAssets([record], '/cdn').transforms[0].values).toEqual([ + { + key: 'cover', + output: 'palette', + field: 'Content', + value: record.content, + mime: record.mime_type + } + ]) + expect(palette).toContain('title="application/json; charset=utf-8"') + expect(fileLinks(palette)).toHaveLength(0) + expect(palette).not.toContain('Not recorded') +}) + +test('parent owns identity-keyed disclosure state and lazily delegates only opened source', () => { + // Source contract only: Node SSR cannot click or run the client effect. + const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') + expect(source).toContain("import { transformCaption, fileType } from './presentation'") + expect(source).toContain("import { toggleDisclosure } from './disclosure'") + expect(source).toContain('let expanded = $state.raw>(new Set())') + expect(source).toContain('expanded = toggleDisclosure(expanded, value)') + const effect = source.match(/\$effect\(\(\) => \{([\s\S]*?)\}\)/)?.[1] ?? '' + expect(effect).toContain('void grouped') + expect(effect).toContain('expanded = new Set()') + expect(effect).not.toContain('expanded.') + expect(effect).not.toContain('toggleDisclosure') + expect(source).toContain('{#each group.items as row, rowIndex (row)}') + expect(source).toContain('groupByKey([...transform.files, ...transform.values])') + expect(source).toContain('`${titleId}-source-${familyIndex}-${groupIndex}-${rowIndex}`') + expect(source).toContain('`${transformCaption(output)}: ${assetLabel(group.key)}, ${field}`') + expect(source).toContain('onclick={() => toggle(row)}') + expect(source).toMatch( + /\{#if expanded.has\(row\)\}\s*\s*\{\/if\}/ + ) + expect(source).toContain('hidden={!expanded.has(row)}') + for (const forbidden of [ + 'AssetValue', + 'CopyValue', + 'copyText', + 'copyFeedback', + 'fileActions', + 'download', + 'valuePresentation', + 'initialExpanded', + 'style:', + 'literal\n\tsecond line '], + [' TEXT/HTML ; charset=UTF-8', '

雪 & raw

'], + ['text/css', 'body { background: url(javascript:bad); }'], + ['application/javascript', 'alert("raw")'], + ['application/xml', 'raw'], + ['image/svg+xml', ''], + ['text/x-python', 'print("raw")'], + ['text/markdown', '# Recorded source'], + ['text/json', '{broken'], + ['application/problem+json', 'null'], + [' TEXT/Custom+JSON ; charset=utf-8 ', '{ "id":9007199254740993,"same":1,"same":2 }'], + ['invalid', ' \r\n '] +])('nonempty %s content uses contextual Show without SSR payload', (mime, content) => { + const output = 'custom.source"&' + const values = table( + renderAssets([{ ...asset(output, null, 'private-key', mime), content }]), + 'Custom' + ) + expect(rows(values)).toEqual([columns, valueRow(output, 'Content', mime, 'Show')]) + expectShowValue(values, output, content) + expect(values).toContain(`title="${escapedAttribute(mime)}"`) + expect(allLinks(values)).toHaveLength(0) + expect(values).not.toMatch(/<(?:script|style|svg|h1|output)\b/) + expect(values).not.toContain('private-key') +}) + +test.each(['/app', '', 'https://app.test/site/', '//cdn.test/prefix/'])( + 'file actions for base %s retain actual View URLs and never offer Download', + (base) => { + const path = 'files/雪 #?% "&.html' + const html = renderAssets( + [ + asset('image.original', 'original.jpg'), + asset('image.s.webp', 'variants/s.webp', 'cover', 'image/webp'), + asset('attachment', path, 'one', 'text/html'), + asset('opengraph', 'actual/share.webp', 'piece', 'image/webp'), + asset('renamed', 'custom.bin', 'other', 'application/octet-stream'), + asset('unsafe', '../private.html', 'unsafe', 'text/html'), + asset('missing', null, 'missing', 'text/plain') + ], + base + ) + expect(fileLinks(html)).toHaveLength(5) + expect(allLinks(html)).toHaveLength(5) + const prefix = base.replace(/\/$/, '') + const recordedPaths = [ + 'original.jpg', + 'variants/s.webp', + path, + 'actual/share.webp', + 'custom.bin' + ] + const expectedHrefs = recordedPaths.map( + (recordedPath) => + `${prefix}/pieces/assets/${recordedPath.split('/').map(encodeURIComponent).join('/')}` + ) + expect(fileLinks(html).map((link) => link.match(/\bhref="([^"]*)"/)![1])).toEqual(expectedHrefs) + const href = `${prefix}/pieces/assets/${path.split('/').map(encodeURIComponent).join('/')}` + const files = table(html, 'Attachment') + expect(fileLinks(files)[0]).toContain(`href="${href}"`) + expect(allLinks(table(html, 'Unsafe'))).toHaveLength(0) + expect(allLinks(table(html, 'Missing'))).toHaveLength(0) + } +) + +test.each([ + 'palette', + 'image.original', + 'image.s.webp', + 'palette.renamed', + 'image.renamed', + 'markdown-renamed', + 'Markdown', + 'custom.', + 'constructor', + '__proto__' +])('%s retains native View and whole inline values independently of producer identity', (name) => { + const path = 'files/雪 #? .bin' + const key = 'raw-private-key' + for (const [mime, content, marker] of [ + ['text/plain', '#AbC', 'Show'], + ['image/png', 'BASE64', 'Show'], + ['application/json', '{ "__proto__":"own","same":1,"same":2,"escaped":"\\u96ea" }', 'Show'], + ['text/html', '', 'Empty'], + ['text/html', null, 'Not recorded'] + ] as const) { + const record = { + ...asset(name, path, key, mime), + content: content ?? undefined, + is_embedded: 1 as const + } + const html = renderAssets([record, record], '/app') + const caption = escapedText(transformCaption(transformFamily(name))) + const outputs = table(html, caption) + expect(elements(html, 'caption').map(cellValue)).toEqual([caption]) + expect(rows(outputs)).toEqual([ + columns, + fileRow(name, escapedText(path), fileType(mime)), + valueRow(name, 'Content', mime, marker) + ]) + // Count all links independently: an extra action must not hide behind a View filter. + expect(allLinks(outputs)).toHaveLength(1) + expect(fileLinks(outputs)).toHaveLength(1) + const view = fileLinks(outputs)[0] + expect(view).toContain('href="/app/pieces/assets/files/%E9%9B%AA%20%23%3F%20%3Cactual%3E.bin"') + expect(view).toContain(`aria-label="${escapedAttribute(`View ${name}: ${path}`)}"`) + expect(view).toContain(`title="${escapedAttribute(path)}"`) + expect(outputs).toContain(`title="${escapedAttribute(mime)}"`) + expect(elements(outputs, 'button')).toHaveLength(marker === 'Show' ? 1 : 0) + if (marker === 'Show') expectShowValue(outputs, name, content!) + expect(groupAssets([record, record], '/app').transforms[0].values).toEqual([ + { key, output: name, field: 'Content', value: content, mime } + ]) + expect(html).not.toContain(key) + for (const tag of ['img', 'svg', 'style', 'script', 'iframe']) + expect(html).not.toMatch(new RegExp(`<${tag}\\b`, 'i')) + } +}) + +test.each(['image.original', 'image.s.webp', 'palette', 'renamed'])( + 'actual %s records retain MIME, content and embedding differences; only exact repeats disappear', + (name) => { + const first = { ...asset(name, 'same/path.bin', 'first', 'text/plain'), content: '#abc' } + const secondKey = { ...first, asset_key: 'second' } + const html = renderAssets([ + first, + first, + { ...first, content: '#fff' }, + { ...first, mime_type: 'image/png' }, + { ...first, is_embedded: 1 }, + secondKey, + secondKey + ]) + const caption = transformCaption(transformFamily(name)) + const files = table(html, caption) + expect(fileLinks(files)).toHaveLength(5) + expect(elements(files, 'tbody')).toHaveLength(2) + expect(elements(html, 'caption').map(cellValue)).toEqual([caption]) + expect(rows(elements(files, 'tbody')[0])).toEqual([ + ['Asset 1'], + fileRow(name, 'same/path.bin', 'Text'), + fileRow(name, 'same/path.bin', 'Text'), + fileRow(name, 'same/path.bin', 'PNG'), + fileRow(name, 'same/path.bin', 'Text'), + valueRow(name, 'Content', 'text/plain', 'Show'), + valueRow(name, 'Content', 'text/plain', 'Show'), + valueRow(name, 'Content', 'image/png', 'Show'), + valueRow(name, 'Content', 'text/plain', 'Show') + ]) + expect(rows(elements(files, 'tbody')[1])).toEqual([ + ['Asset 2'], + fileRow(name, 'same/path.bin', 'Text'), + valueRow(name, 'Content', 'text/plain', 'Show') + ]) + expect(elements(files, 'button')).toHaveLength(5) + expect(html).not.toContain('style=') + } +) + +test('one Image family table retains file and inline capabilities for every full output', () => { + const original = asset('image.original', 'same/file.bin', 'cover', 'image/jpeg') + const variant = asset('image.s.webp', 'same/file.bin', 'cover', 'image/webp') + const note = { + ...asset('image.note', 'notes.txt', 'cover', 'text/plain'), + content: 'actual inline note' + } + const html = renderAssets([original, variant, original, note, note]) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Image']) + const images = table(html, 'Image') + expect(rows(images)).toEqual([ + columns, + fileRow('image.original', 'same/file.bin', 'JPEG'), + fileRow('image.s.webp', 'same/file.bin', 'WebP'), + fileRow('image.note', 'notes.txt', 'Text'), + valueRow('image.note', 'Content', 'text/plain', 'Show') + ]) + expect(fileLinks(images)).toHaveLength(3) + expect(elements(images, 'tbody')).toHaveLength(1) + expect(elements(images, 'tr').filter(isSourceRow)).toHaveLength(1) + expectShowValue(images, 'image.note', 'actual inline note') + for (const output of ['image.original', 'image.s.webp', 'image.note']) { + expect(images).toContain(`title="${output}"`) + expect(images).toContain(`${output}`) + } +}) + +test('Markdown file, note and schema outputs share a table but keep distinct Show context', () => { + const html = renderAssets([ + asset('markdown', 'page.html', 'piece', 'text/html'), + { ...asset('markdown.description', null, 'piece', 'text/html'), content: '

private A

' }, + { ...asset('markdown.note', null, 'piece', 'text/html'), content: '

private B

' }, + { + ...asset('markdown.schema', null, 'piece', 'application/json'), + content: '{"description":"short text","count":2}' + } + ]) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Markdown']) + const markdown = table(html, 'Markdown') + expect(rows(markdown)).toEqual([ + columns, + fileRow('markdown', 'page.html', 'HTML'), + valueRow('markdown.description', 'Content', 'text/html', 'Show'), + valueRow('markdown.note', 'Content', 'text/html', 'Show'), + valueRow('markdown.schema', 'Content', 'application/json', 'Show') + ]) + expect( + elements(markdown, 'button').map((button) => button.match(/aria-label="([^"]*)"/)![1]) + ).toEqual([ + 'Show Markdown.description: Asset 1, Content', + 'Show Markdown.note: Asset 1, Content', + 'Show Markdown.schema: Asset 1, Content' + ]) + expectShowValue(markdown, 'markdown.schema', '{"description":"short text","count":2}') + expect(fileLinks(markdown)).toHaveLength(1) + expect(markdown).not.toContain('private A') + expect(markdown).not.toContain('private B') +}) + +test('unfamiliar namespace collisions retain full outputs and whole own-key JSON records', () => { + const content = '{"__proto__":"own","constructor":"also own","color":"#AbC"}' + const first = { ...asset('custom.FIRST', 'same.json', 'one', 'application/json'), content } + const second = { ...first, transformation: 'custom.Second' } + const html = renderAssets([first, second, first, second]) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom']) + const custom = table(html, 'Custom') + expect(rows(custom)).toEqual([ + columns, + fileRow('custom.FIRST', 'same.json', 'JSON'), + fileRow('custom.Second', 'same.json', 'JSON'), + ...['custom.FIRST', 'custom.Second'].map((output) => + valueRow(output, 'Content', 'application/json', 'Show') + ) + ]) + expect(fileLinks(custom)).toHaveLength(2) + for (const output of ['custom.FIRST', 'custom.Second']) { + expect(custom).toContain(`title="${output}"`) + expectShowValue(custom, output, content) + } + expect(elements(custom, 'button')).toHaveLength(2) +}) + +test('combined file-only and inline-only keys share family bodies with root-order aliases', () => { + const html = renderAssets([ + asset('attachment', 'first.pdf', 'root-first', 'application/pdf'), + { ...asset('custom.note', null, 'inline-second', 'text/plain'), content: 'inline only' }, + asset('custom.file', 'third.txt', 'file-third', 'text/plain'), + { ...asset('custom.missing', null, 'embedded-fourth', 'text/plain'), is_embedded: 1 } + ]) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Attachment', 'Custom']) + const custom = table(html, 'Custom') + expect(rows(custom)).toEqual([ + columns, + ['Asset 2'], + valueRow('custom.note', 'Content', 'text/plain', 'Show'), + ['Asset 3'], + fileRow('custom.file', 'third.txt', 'Text'), + ['Asset 4'], + valueRow('custom.missing', 'Content', 'text/plain', 'Not recorded') + ]) + expect(elements(custom, 'tbody')).toHaveLength(3) + for (const body of elements(custom, 'tbody')) { + const heading = elements(body, 'th')[0] + expect(heading).toContain('scope="rowgroup"') + expect(heading).toContain('colspan="4"') + } + expectShowValue(custom, 'custom.note', 'inline only', 2) + expect(fileLinks(custom)).toHaveLength(1) + for (const key of ['root-first', 'inline-second', 'file-third', 'embedded-fourth']) + expect(html).not.toContain(key) +}) + +test('same displayed family caption does not merge case-distinct namespaces or raw output labels', () => { + const output = `Custom.${'long-output-'.repeat(30)}雪 "&` + const html = renderAssets([ + { ...asset('custom.note', null, 'one', 'text/plain'), content: 'lower namespace' }, + { ...asset(output, null, 'one', 'text/plain'), content: 'upper namespace' } + ]) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom', 'Custom']) + const tables = elements(html, 'table') + expect(rows(tables[0])).toEqual([ + columns, + valueRow('custom.note', 'Content', 'text/plain', 'Show') + ]) + expect(rows(tables[1])).toEqual([columns, valueRow(output, 'Content', 'text/plain', 'Show')]) + const header = elements(elements(tables[1], 'tbody')[0], 'th')[0] + expect(header).toContain('scope="row"') + expect(header).toContain(`title="${escapedAttribute(output)}"`) + expect(header).toContain(`${escapedText(output)}`) + expectShowValue(tables[1], output, 'upper namespace') + expect(html).not.toContain('') +}) + +test.each([undefined, 'Published assets', 'Preview outputs', ' & "text"'])( + 'heading %j labels only its own section without exposing record keys', + (heading) => { + const html = render(AssetOutputs, { + props: { + assets: [asset('custom.file', 'file.txt', 'secret-key', 'text/plain')], + fileUrl: () => undefined, + ...(heading === undefined ? {} : { heading }) + } + }).body.replace(//g, '') + const section = elements(html, 'section')[0] + const title = elements(section, 'h2')[0] + const titleId = title.match(/\bid="([^"]+)"/)?.[1] + expect(titleId).toBeDefined() + expect(titleId).not.toBe('published-assets-title') + expect(section.match(/\baria-labelledby="([^"]+)"/)?.[1]).toBe(titleId) + expect(cellValue(title)).toBe(escapedText(heading ?? 'Outputs')) + expect(html.match(new RegExp(`id="${titleId}"`, 'g'))).toHaveLength(1) + expect(html).not.toContain('secret-key') + if (heading === undefined) { + expect(html).not.toContain('Published') + expect(html).not.toContain('Preview') + } + } +) + +test('section title IDs come from Svelte instance IDs, not headings or record keys', () => { + const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') + expect(source).toContain('const titleId = $props.id()') + expect(source).toContain('
') + expect(source).toContain('

{heading}

') + expect(source).not.toContain('published-assets-title') +}) + +test('generic preview-shaped callback URLs retain only native View, full outputs and inline family rows', () => { + const path = 'files/源 #?%/file%20name.json' + const url = + '/admin/piece/book/preview/job-id/assets/files/%E6%BA%90%20%23%3F%25/file%2520name.json' + const record = { + ...asset('custom.full.output', path, 'secret-key', 'application/json'), + content: '{"__proto__":"own","value":"#abc"}' + } + const fileUrl = vi.fn((rawPath: string) => (rawPath === path ? url : undefined)) + const html = render(AssetOutputs, { + props: { + assets: [ + record, + record, + { ...asset('custom.note', null, 'secret-key', 'text/plain'), content: 'inline only' } + ], + fileUrl, + heading: 'Preview outputs' + } + }).body.replace(//g, '') + const custom = table(html, 'Custom') + expectFileViews(html) + expect(fileUrl).toHaveBeenCalledExactlyOnceWith(path) + expect(elements(html, 'h2').map(cellValue)).toEqual(['Preview outputs']) + expect(elements(html, 'caption').map(cellValue)).toEqual(['Custom']) + expect(rows(custom)).toEqual([ + columns, + fileRow('custom.full.output', path, 'JSON'), + valueRow('custom.full.output', 'Content', 'application/json', 'Show'), + valueRow('custom.note', 'Content', 'text/plain', 'Show') + ]) + expectShowValue(custom, 'custom.full.output', record.content) + expect(fileLinks(custom)).toHaveLength(1) + expect(allLinks(custom)).toHaveLength(1) + expectDisclosureRows(html, [ + record, + record, + { ...asset('custom.note', null, 'secret-key', 'text/plain'), content: 'inline only' } + ]) + const view = fileLinks(custom)[0] + for (const link of [view]) { + expect(link).toContain(`href="${url}"`) + expect(link).toContain(`title="${escapedAttribute(path)}"`) + expect(link).toContain('rel="noopener noreferrer"') + } + expect(view).toContain(`aria-label="${escapedAttribute(`View custom.full.output: ${path}`)}"`) + expect(view).toContain('target="_blank"') + expect(view).not.toMatch(/\sdownload(?:\s|=|>)/) + expect(html).not.toContain('secret-key') + expect(html).not.toContain('/pieces/assets/') +}) + +test('invalid and callback-unavailable files stay visible alongside embedded values without bogus links', () => { + const fileUrl = vi.fn((_path: string) => undefined) + const html = render(AssetOutputs, { + props: { + assets: [ + asset('custom.unsafe', '../bad.txt', 'secret-key', 'text/plain'), + { + ...asset('custom.file', 'valid.txt', 'secret-key', 'text/plain'), + content: 'inline value' + }, + asset('custom.missing', null, 'secret-key', 'text/plain'), + { ...asset('custom.embedded', null, 'secret-key', 'text/plain'), is_embedded: 1 as const } + ], + fileUrl + } + }).body.replace(//g, '') + const custom = table(html, 'Custom') + expectFileViews(html) + expect(fileUrl).toHaveBeenCalledExactlyOnceWith('valid.txt') + expectUnavailableRow(custom, '../bad.txt', 'Text', 'custom.unsafe') + expectUnavailableRow(custom, 'valid.txt', 'Text', 'custom.file') + expect(rows(custom)).toContainEqual(valueRow('custom.file', 'Content', 'text/plain', 'Show')) + expect(rows(custom)).toContainEqual(fileRow('custom.missing', 'Not recorded', 'Text')) + expect(rows(custom)).toContainEqual( + valueRow('custom.embedded', 'Content', 'text/plain', 'Not recorded') + ) + expect(fileLinks(custom)).toHaveLength(0) + expect(allLinks(custom)).toHaveLength(0) + expect(html).not.toContain('No usable published link') + expect(html).not.toContain('secret-key') +}) + +test('detail rows are native full-width siblings only for nonempty inline values', () => { + const html = renderAssets([ + asset('custom.file', 'actual.txt', 'same-key', 'text/plain'), + { ...asset('custom.empty', null, 'same-key', 'text/plain'), content: '' }, + { ...asset('custom.missing', null, 'same-key', 'text/plain'), is_embedded: 1 }, + { ...asset('custom.note', null, 'same-key', 'text/plain'), content: 'raw private note' }, + { ...asset('custom.json', null, 'same-key', 'application/json'), content: '{ "raw": 1 }' } + ]) + const custom = table(html, 'Custom') + // Count ALL native rows before checking the metadata projection. + expect(elements(custom, 'tr')).toHaveLength(8) // header + 5 metadata + 2 detail + expect(elements(custom, 'tr').filter(isSourceRow)).toHaveLength(2) + expect(rows(custom)).toHaveLength(6) + expect(elements(custom, 'td')).toHaveLength(17) // 5 * 3 metadata + 2 colspan cells + expect(elements(custom, 'button').map(cellValue)).toEqual(['Show', 'Show']) + expectShowValue(custom, 'custom.note', 'raw private note') + expectShowValue(custom, 'custom.json', '{ "raw": 1 }') + expect(elements(custom, 'table')).toHaveLength(1) +}) + +test('disclosure IDs use instance and family/group/row ordinals even for equal labels and values', () => { + const first = { + ...asset('custom.same', 'first.txt', 'private-key-first', 'text/plain'), + content: 'private repeated bytes' + } + const html = renderAssets([ + first, + first, // Exact full records still deduplicate. + { ...first, mime_type: 'image/png' }, + { ...first, asset_key: 'private-key-second' }, + { ...first, transformation: 'other.same' } + ]) + const instanceId = elements(html, 'h2')[0].match(/\bid="([^"]+)"/)![1] + const ids = elements(html, 'tr') + .filter(isSourceRow) + .map((row) => row.match(/\bid="([^"]+)"/)![1]) + expect(ids).toEqual([ + `${instanceId}-source-0-0-2`, + `${instanceId}-source-0-0-3`, + `${instanceId}-source-0-1-1`, + `${instanceId}-source-1-0-1` + ]) + expect( + elements(html, 'button').map((button) => button.match(/aria-controls="([^"]+)"/)![1]) + ).toEqual(ids) + expect(html).not.toContain('private-key-first') + expect(html).not.toContain('private-key-second') + expect(html).not.toContain('private repeated bytes') + // All rows, including two group headings and four detail rows, remain visible to this count. + expect(elements(html, 'tr')).toHaveLength(16) // 2 headers + 2 group headings + 8 metadata + 4 detail +}) + +test('mobile source cells override the label grid and collapsed rows override mobile display', () => { + // CSS/source contract only; this does not claim browser layout or focus verification. + const source = readFileSync(new URL('./AssetOutputs.svelte', import.meta.url), 'utf8') + const mobile = source.slice(source.indexOf('@media')) + const cell = mobile.match(/tbody > tr > td\.source-cell\s*\{([^}]+)\}/)?.[1] ?? '' + for (const rule of ['display: block', 'width: 100%', 'padding: var(--space-2) var(--space-3)']) + expect(cell).toContain(rule) + for (const forbidden of [ + 'grid-template-columns', + 'margin', + 'padding-left', + 'padding-inline-start' + ]) + expect(cell).not.toContain(forbidden) + expect(mobile.indexOf('td.source-cell')).toBeGreaterThan(mobile.indexOf('tbody > tr > td {')) + expect(source.match(/\[hidden\]\s*\{([^}]+)\}/)?.[1]).toContain('display: none !important') + const button = source.match(/\n\tbutton\s*\{([^}]+)\}/)?.[1] ?? '' + for (const rule of [ + 'font: inherit', + 'font-weight: var(--font-weight-normal)', + 'var(--color-primary)', + 'var(--color-surface-container)' + ]) + expect(button).toContain(rule) + expect(source).toContain('button:focus-visible') + expect(source).toContain("expanded.has(row) ? 'Hide' : 'Show'") + expect(source).not.toContain('svelte-ignore') +}) diff --git a/apps/web/src/lib/pieces/viewer/PieceOverview.svelte b/apps/web/src/lib/pieces/viewer/PieceOverview.svelte new file mode 100644 index 00000000..f8a16095 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/PieceOverview.svelte @@ -0,0 +1,126 @@ + + +
+

{title}

+ {@render children?.()} + {#if assets !== undefined}{/if} +
+ + diff --git a/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts b/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts new file mode 100644 index 00000000..8ca38f18 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/PieceOverview.test.ts @@ -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: () => '

Caller facts fixture

' + })) + const html = render(PieceOverview, { + props: { + title: 'Piece ', + 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') +}) diff --git a/apps/web/src/lib/pieces/viewer/SourceContent.svelte b/apps/web/src/lib/pieces/viewer/SourceContent.svelte new file mode 100644 index 00000000..06666f24 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/SourceContent.svelte @@ -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> diff --git a/apps/web/src/lib/pieces/viewer/SourceContent.test.ts b/apps/web/src/lib/pieces/viewer/SourceContent.test.ts new file mode 100644 index 00000000..97d4ca13 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/SourceContent.test.ts @@ -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') +}) diff --git a/apps/web/src/lib/pieces/viewer/assets.test.ts b/apps/web/src/lib/pieces/viewer/assets.test.ts new file mode 100644 index 00000000..5abb2e91 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/assets.test.ts @@ -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 + })) + } + ]) +}) diff --git a/apps/web/src/lib/pieces/viewer/assets.ts b/apps/web/src/lib/pieces/viewer/assets.ts new file mode 100644 index 00000000..d65f66d1 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/assets.ts @@ -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()] } +} diff --git a/apps/web/src/lib/pieces/viewer/disclosure.test.ts b/apps/web/src/lib/pieces/viewer/disclosure.test.ts new file mode 100644 index 00000000..0e02517a --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/disclosure.test.ts @@ -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]) +}) diff --git a/apps/web/src/lib/pieces/viewer/disclosure.ts b/apps/web/src/lib/pieces/viewer/disclosure.ts new file mode 100644 index 00000000..558d1ba2 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/disclosure.ts @@ -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 +} diff --git a/apps/web/src/lib/pieces/viewer/fixtures/published.ts b/apps/web/src/lib/pieces/viewer/fixtures/published.ts new file mode 100644 index 00000000..9b4786c1 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/fixtures/published.ts @@ -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')] +} diff --git a/apps/web/src/lib/pieces/viewer/presentation.test.ts b/apps/web/src/lib/pieces/viewer/presentation.test.ts new file mode 100644 index 00000000..d3a7d7f4 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/presentation.test.ts @@ -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') +}) diff --git a/apps/web/src/lib/pieces/viewer/presentation.ts b/apps/web/src/lib/pieces/viewer/presentation.ts new file mode 100644 index 00000000..8725841b --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/presentation.ts @@ -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' +} diff --git a/apps/web/src/lib/pieces/viewer/source-highlight.test.ts b/apps/web/src/lib/pieces/viewer/source-highlight.test.ts new file mode 100644 index 00000000..85fb9ebc --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/source-highlight.test.ts @@ -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: '' } + ]) +}) diff --git a/apps/web/src/lib/pieces/viewer/source-highlight.ts b/apps/web/src/lib/pieces/viewer/source-highlight.ts new file mode 100644 index 00000000..bb74831f --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/source-highlight.ts @@ -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 + } +} diff --git a/apps/web/src/lib/pieces/viewer/source.test.ts b/apps/web/src/lib/pieces/viewer/source.test.ts new file mode 100644 index 00000000..73981d74 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/source.test.ts @@ -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) +}) diff --git a/apps/web/src/lib/pieces/viewer/source.ts b/apps/web/src/lib/pieces/viewer/source.ts new file mode 100644 index 00000000..9f33e239 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/source.ts @@ -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('') +} -- 2.54.0 From a20e08640c3abfdd769e64ff22d52117ccb09ea2 Mon Sep 17 00:00:00 2001 From: eleith <eleith@noreply.git.eleith.com> Date: Fri, 16 Oct 2026 11:40:08 -0700 Subject: [PATCH 07/17] [web] add published piece viewer --- apps/web/src/lib/pieces/navigation.test.ts | 67 +++ apps/web/src/lib/pieces/navigation.ts | 15 + .../lib/pieces/viewer/ShareViewport.svelte | 59 ++ .../lib/pieces/viewer/ShareViewport.test.ts | 56 ++ apps/web/src/lib/pieces/viewer/ViewerBar.css | 110 ++++ .../src/lib/pieces/viewer/ViewerHeader.svelte | 32 + .../lib/pieces/viewer/ViewerHeader.test.ts | 176 ++++++ .../pieces/viewer/fixtures/Composition.svelte | 11 + .../viewer/fixtures/IconRenderer.svelte | 15 + .../viewer/fixtures/PageRenderer.svelte | 15 + .../[...path]/published/+layout.server.ts | 38 ++ .../piece/[...path]/published/+layout@.svelte | 19 + .../piece/[...path]/published/+page.svelte | 92 +++ .../published/fixtures/PublishedTree.svelte | 28 + .../[...path]/published/icon/+page.svelte | 29 + .../[...path]/published/layout.server.test.ts | 275 +++++++++ .../piece/[...path]/published/page.test.ts | 545 ++++++++++++++++++ .../[...path]/published/page/+page.svelte | 7 + .../[...path]/published/share/+page.svelte | 7 + 19 files changed, 1596 insertions(+) create mode 100644 apps/web/src/lib/pieces/navigation.test.ts create mode 100644 apps/web/src/lib/pieces/navigation.ts create mode 100644 apps/web/src/lib/pieces/viewer/ShareViewport.svelte create mode 100644 apps/web/src/lib/pieces/viewer/ShareViewport.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/ViewerBar.css create mode 100644 apps/web/src/lib/pieces/viewer/ViewerHeader.svelte create mode 100644 apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts create mode 100644 apps/web/src/lib/pieces/viewer/fixtures/Composition.svelte create mode 100644 apps/web/src/lib/pieces/viewer/fixtures/IconRenderer.svelte create mode 100644 apps/web/src/lib/pieces/viewer/fixtures/PageRenderer.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/+layout.server.ts create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/+layout@.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/+page.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/fixtures/PublishedTree.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/icon/+page.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/layout.server.test.ts create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/page.test.ts create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/page/+page.svelte create mode 100644 apps/web/src/routes/admin/piece/[...path]/published/share/+page.svelte diff --git a/apps/web/src/lib/pieces/navigation.test.ts b/apps/web/src/lib/pieces/navigation.test.ts new file mode 100644 index 00000000..281fa64b --- /dev/null +++ b/apps/web/src/lib/pieces/navigation.test.ts @@ -0,0 +1,67 @@ +import { expect, test, vi } from 'vitest' +import { getPieceLinks } 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.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') +}) diff --git a/apps/web/src/lib/pieces/navigation.ts b/apps/web/src/lib/pieces/navigation.ts new file mode 100644 index 00000000..e9bbd6d7 --- /dev/null +++ b/apps/web/src/lib/pieces/navigation.ts @@ -0,0 +1,15 @@ +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> diff --git a/apps/web/src/lib/pieces/viewer/ShareViewport.svelte b/apps/web/src/lib/pieces/viewer/ShareViewport.svelte new file mode 100644 index 00000000..99f30839 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/ShareViewport.svelte @@ -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> diff --git a/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts b/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts new file mode 100644 index 00000000..462bc9e9 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/ShareViewport.test.ts @@ -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/') +}) diff --git a/apps/web/src/lib/pieces/viewer/ViewerBar.css b/apps/web/src/lib/pieces/viewer/ViewerBar.css new file mode 100644 index 00000000..d3bd7eae --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/ViewerBar.css @@ -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); + } +} diff --git a/apps/web/src/lib/pieces/viewer/ViewerHeader.svelte b/apps/web/src/lib/pieces/viewer/ViewerHeader.svelte new file mode 100644 index 00000000..18c8d424 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/ViewerHeader.svelte @@ -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> diff --git a/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts new file mode 100644 index 00000000..d1c252d4 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/ViewerHeader.test.ts @@ -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() +}) diff --git a/apps/web/src/lib/pieces/viewer/fixtures/Composition.svelte b/apps/web/src/lib/pieces/viewer/fixtures/Composition.svelte new file mode 100644 index 00000000..b395c1aa --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/fixtures/Composition.svelte @@ -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> diff --git a/apps/web/src/lib/pieces/viewer/fixtures/IconRenderer.svelte b/apps/web/src/lib/pieces/viewer/fixtures/IconRenderer.svelte new file mode 100644 index 00000000..ac5b2a38 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/fixtures/IconRenderer.svelte @@ -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} +/> diff --git a/apps/web/src/lib/pieces/viewer/fixtures/PageRenderer.svelte b/apps/web/src/lib/pieces/viewer/fixtures/PageRenderer.svelte new file mode 100644 index 00000000..d28a28b3 --- /dev/null +++ b/apps/web/src/lib/pieces/viewer/fixtures/PageRenderer.svelte @@ -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> diff --git a/apps/web/src/routes/admin/piece/[...path]/published/+layout.server.ts b/apps/web/src/routes/admin/piece/[...path]/published/+layout.server.ts new file mode 100644 index 00000000..1f13835e --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/+layout.server.ts @@ -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) + } +} diff --git a/apps/web/src/routes/admin/piece/[...path]/published/+layout@.svelte b/apps/web/src/routes/admin/piece/[...path]/published/+layout@.svelte new file mode 100644 index 00000000..bbd87f81 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/+layout@.svelte @@ -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} + + + + + +{@render children()} diff --git a/apps/web/src/routes/admin/piece/[...path]/published/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/published/+page.svelte new file mode 100644 index 00000000..7c0fb3c8 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/+page.svelte @@ -0,0 +1,92 @@ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
Publication
Type{data.piece.type}
Last publish + {#if publishedAt} + + {:else} + Unknown + {/if} +
State + {statusLabels[data.publication.markdownStatus]} +
URL + {data.publishedUrl} +
+
+
+
+ + diff --git a/apps/web/src/routes/admin/piece/[...path]/published/fixtures/PublishedTree.svelte b/apps/web/src/routes/admin/piece/[...path]/published/fixtures/PublishedTree.svelte new file mode 100644 index 00000000..f0bf761c --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/fixtures/PublishedTree.svelte @@ -0,0 +1,28 @@ + + + + {#if view === 'page'} + + {:else if view === 'icon'} + + {:else if view === 'share'} + + {:else} + + {/if} + diff --git a/apps/web/src/routes/admin/piece/[...path]/published/icon/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/published/icon/+page.svelte new file mode 100644 index 00000000..08cd6c18 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/icon/+page.svelte @@ -0,0 +1,29 @@ + + +
+
+ +
+
+ + diff --git a/apps/web/src/routes/admin/piece/[...path]/published/layout.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/published/layout.server.test.ts new file mode 100644 index 00000000..b1f72f1a --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/layout.server.test.ts @@ -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[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[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() +}) diff --git a/apps/web/src/routes/admin/piece/[...path]/published/page.test.ts b/apps/web/src/routes/admin/piece/[...path]/published/page.test.ts new file mode 100644 index 00000000..66728000 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/published/page.test.ts @@ -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) => + 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['data'] { + return { + file: 'books/.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('') + expect(html).toMatch(/]*scope="row"[^>]*>Last publish<\/th>/) + expect(html).not.toMatch(/]*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(`${data().publishedUrl}`) + expect(html).not.toContain('href="/admin/piece/books/book.md/source"') + expect(html).not.toContain('>Open<') + expect(html).not.toContain(' { + const props = data() + props.piece = { ...publishedPiece, assets: [] } + props.file = `${'nested folder/'.repeat(24)}雪 .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(/"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/a>/g) ?? [] + expect(links).toHaveLength(1) + for (const link of links) { + expect(link).toMatch(/class="truncated[^"]*"/) + expect(link).toMatch(/]*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}`) + expect(links[0]).toContain('target="_blank" rel="noopener noreferrer"') + for (const excluded of ['Open<', ' { + 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('') + ''.length) + expect(publication).toMatch(/]*role="table"/) + expect(publication).toMatch(/]*role="rowgroup"/) + expect(publication.match(/]*role="row"/g)).toHaveLength(4) + expect(publication.match(/]*scope="row"[^>]*role="rowheader"/g)).toHaveLength(4) + expect(publication.match(/]*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(' diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte index 78b8838f..7d72b74e 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte @@ -1,66 +1,41 @@ -
-
-

Preview

-
{data.file}
-
- - -
- - +{#if data.status === 'completed'} + + + + +{/if} diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.svelte new file mode 100644 index 00000000..b9f976cd --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.svelte @@ -0,0 +1,136 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Preview
File{file}
Type + {#if type != null}{type}{:else}Unknown{/if} +
State + + {#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} + + {#if status === 'enqueued' || status === 'waiting'} +
Waiting for worker…
+ {:else if status === 'expired'} +
+ Assets are purged after 2 days. Re-run the preview from the editor. +
+ {/if} +
Duration + {#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} + Unknown + {/if} +
Created + {#if created} + + {:else} + Unknown + {/if} +
Finished + {#if finished} + + {:else} + Unknown + {/if} +
+
+ + diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.test.ts new file mode 100644 index 00000000..5244af70 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewFacts.test.ts @@ -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> = {}) { + 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>\s*]*>([\s\S]*?)<\/td>/g)].map( + ([, label, value]) => [ + label, + value + .replace(/<(?:[^>"']|"[^"]*"|'[^']*')*>/g, '') + .replace(/\s+/g, ' ') + .trim() + ] + ) +} + +function phase(overrides: Partial = {}): 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(/ diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts new file mode 100644 index 00000000..678a46da --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/PreviewLogs.test.ts @@ -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 = { + 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 { + 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(/]*id="([^"]+)"/)?.[1] + for (const html of [first, second]) { + expect(headingId(html)).toBeTruthy() + expect(html).toContain(`aria-labelledby="${headingId(html)}"`) + expect(html).toMatch(/]*>Preview logs<\/h2>/) + expect(html).not.toContain(' { + const file = `${'nested folder/'.repeat(24)}雪 & #?%.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(/') + expect(facts).not.toContain(' { + const html = render(PreviewLogs, { + props: props({ status, errorMessage: '' }) + }).body + expect(html.includes('error-strip')).toBe(status === 'failed') + expect(html.includes('<script>Failure & details</script>')).toBe(status === 'failed') + expect(html).not.toContain('' + 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(' -
- - {#if status === 'completed'} - - {:else if status === 'running'} - - {:else if status === 'enqueued'} - - {:else if status === 'failed' || status === 'expired'} - - {:else} - - {/if} - - -
- {#if status === 'expired'} -
Preview expired
-
- Assets are purged after 2 days. Re-run the preview from the editor. -
- {:else if status === 'enqueued'} -
Enqueued
-
Waiting for worker…
- {:else if status === 'running'} -
Rendering preview
-
{formatDuration(overallDuration)} elapsed
- {:else if status === 'failed'} -
Preview failed
-
{formatDuration(overallDuration)} stopped at
- {:else if status === 'completed'} -
Preview completed
-
{formatDuration(overallDuration)} total time
- {/if} -
-
- {#if errorMessage && status === 'failed'}
@@ -209,66 +155,6 @@ {/if} diff --git a/apps/web/src/lib/components/editor/PieceView.test.ts b/apps/web/src/lib/components/editor/PieceView.test.ts new file mode 100644 index 00000000..85b8dd61 --- /dev/null +++ b/apps/web/src/lib/components/editor/PieceView.test.ts @@ -0,0 +1,76 @@ +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( + /]*aria-haspopup="menu"[^>]*>View<\/button>/ + ) + expect(body).not.toMatch(/]*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*\s* goto(${helper}(file))}`) + } + expect(source).not.toMatch(/ { + const editor = readFileSync( + new URL('../../../routes/admin/piece/[...path]/source/+page.svelte', import.meta.url), + 'utf8' + ) + expect(editor).toContain( + '' + ) + 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('real child menuitems connect instance-unique literal labels and visible descriptions', () => { + expect(source).toContain('const id = $props.id()') + for (const [view, label] of [ + ['published', 'Published'], + ['preview', 'Preview'] + ]) { + expect(source).toContain(`${label}`) + expect(source).toContain(`id={\`\${id}-${view}-description\`} class="description"`) + expect(source).toContain(`aria-labelledby={\`\${id}-${view}-label\`}`) + expect(source).toContain(`aria-describedby={\`\${id}-${view}-description\`}`) + } + expect(source.match(/\{#snippet child\(\{ props \}\)\}\s* { + 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) +}) diff --git a/apps/web/src/lib/components/editor/view-state.ts b/apps/web/src/lib/components/editor/view-state.ts new file mode 100644 index 00000000..9007c4ac --- /dev/null +++ b/apps/web/src/lib/components/editor/view-state.ts @@ -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 +} diff --git a/apps/web/src/lib/pieces/navigation.test.ts b/apps/web/src/lib/pieces/navigation.test.ts index 281fa64b..a61df63e 100644 --- a/apps/web/src/lib/pieces/navigation.test.ts +++ b/apps/web/src/lib/pieces/navigation.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vitest' -import { getPieceLinks } from './navigation' +import { editorHref, getPieceLinks, previewHref, publishedHref } from './navigation' const { resolve } = vi.hoisted(() => ({ resolve: vi.fn(() => { @@ -11,6 +11,33 @@ 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.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 })) diff --git a/apps/web/src/lib/pieces/navigation.ts b/apps/web/src/lib/pieces/navigation.ts index e9bbd6d7..bc52746e 100644 --- a/apps/web/src/lib/pieces/navigation.ts +++ b/apps/web/src/lib/pieces/navigation.ts @@ -1,3 +1,20 @@ +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(/\/$/, '') diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/+page.server.ts b/apps/web/src/routes/admin/piece/[...path]/preview/+page.server.ts index 34306094..f6e53835 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/+page.server.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/+page.server.ts @@ -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)) } diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte index 5d396a2e..c619f67b 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte @@ -3,6 +3,7 @@ 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 PieceOverview from '$lib/pieces/viewer/PieceOverview.svelte' @@ -48,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.job}` - const pageUrl = `/admin/piece/${data.file}/preview/${data.job}/page` - const iconUrl = `/admin/piece/${data.file}/preview/${data.job}/icon` - const shareUrl = `/admin/piece/${data.file}/preview/${data.job}/share` + 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.job}/stream`) + eventSource = new EventSource(`/api/admin/preview/${encodeURIComponent(data.job)}/stream`) eventSource.addEventListener('state', (e) => { const d = JSON.parse(e.data) diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts index b555cbe7..49ab9582 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/layout.test.ts @@ -153,6 +153,25 @@ test.each(['', '/page', '/icon', '/share'])( } ) +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(''))) + expect(links[0]).toContain(`href="${encodedRoot}/source"`) + for (const [index, leaf] of ['', '/page', '/icon', '/share', '/share?html'].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('Share HTML label keeps the renamed alternate destination available and solely active', () => { page.url = new URL(`${root}/share?html`, 'https://app.test') const links = anchors(body()) diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/page.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/page.server.test.ts index 8ecb0ad7..123370f8 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/page.server.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/preview/page.server.test.ts @@ -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[0] + await expect(load(event)).rejects.toMatchObject({ status: 303, location }) + expect(runWorkflow).toHaveBeenCalledExactlyOnceWith(previewSpec, { filePath: file }) + } +) diff --git a/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts b/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts index a339cd45..d25afc44 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts +++ b/apps/web/src/routes/admin/piece/[...path]/source/+page.server.ts @@ -19,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, @@ -88,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, - storage.stat(file), getWebPiece(db, file) ]) @@ -103,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) } } diff --git a/apps/web/src/routes/admin/piece/[...path]/source/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/source/+page.svelte index debbac7d..879a203d 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/+page.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/source/+page.svelte @@ -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 @@
{ bypassWarning = true return async ({ update }) => { @@ -420,7 +423,7 @@ + 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); diff --git a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts index cfd01691..fd9673f9 100644 --- a/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts +++ b/apps/web/src/routes/admin/piece/[...path]/source/page.server.test.ts @@ -10,14 +10,18 @@ import { type PieceMarkdown, type PieceFrontmatter } from '@luzzle/core' -import { getPieces } from '$lib/server/pieces' +import { getPieces, getWebPiece } from '$lib/server/pieces' import { getStorage } from '$lib/server/storage' import { actions, load } from './+page.server' import { config } from '$lib/server/config' import { db } from '$lib/server/database' +import type { WebPieces } from '@luzzle/web.db' +import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server' +import { MarkdownPublicationStatus } from '$lib/pieces/types' vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() })) vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() })) +vi.mock('$lib/pieces/publication.server', () => ({ getMarkdownPublicationStatus: vi.fn() })) vi.mock('$lib/server/database', () => ({ db: {} })) vi.mock('$lib/server/config', async () => { const { makeConfig } = await import('$lib/server/config.fixture') @@ -263,31 +267,119 @@ test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])( } ) +const file = 'books/example.books.md' +const rawSource = '---\r\ntitle: Saved\r\n---\r\n untouched \r\n' +const publishedPiece: WebPieces = { + id: 'published-id', + key: 'published-key', + title: 'Published title', + slug: 'published-book', + file_path: file, + type: 'books', + json_metadata: '{"title":"Published title"}', + date_added: 0, + content_hash: 'completed-publication-hash', + last_published_at: null +} + +function setupLoad() { + const saved = setup('') + const piece = Object.assign(saved.piece, { + get: vi.fn().mockResolvedValue({ filePath: file }), + schema: { type: 'object', properties: {} } + }) + const storage = Object.assign(saved.storage, { + readFile: vi.fn().mockResolvedValue(rawSource), + stat: vi.fn(), + writeFile: vi.fn() + }) + vi.mocked(getWebPiece).mockResolvedValue(undefined) + vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(MarkdownPublicationStatus.UNKNOWN) + const event = { params: { path: file } } as Parameters[0] + return { piece, storage, event } +} + test.each([false, true])( 'source loader exposes only generation availability (configured: %s)', async (configured) => { config.ai = configured ? { provider: 'google', api_key: 'private-ai-key' } : undefined - const { piece, storage } = setup('') - Object.assign(piece, { - get: vi.fn().mockResolvedValue({ filePath: 'books/example.books.md' }), - schema: { type: 'object', properties: {} } - }) - Object.assign(storage, { - readFile: vi.fn().mockResolvedValue('Unsaved source'), - stat: vi.fn().mockResolvedValue({ last_modified: new Date(0) }) - }) - const result = await load({ params: { path: 'books/example.books.md' } } as Parameters< - typeof load - >[0]) - expect(result).toEqual({ - file: 'books/example.books.md', + const { event } = setupLoad() + expect(await load(event)).toEqual({ + file, type: 'books', - rawContent: 'Unsaved source', + rawContent: rawSource, schema: { type: 'object', properties: {} }, canGenerate: configured, assetFields: [], - publishedAt: null, - fileUpdatedAt: 0 + hasPublishedVersion: false, + savedMarkdown: MarkdownPublicationStatus.UNKNOWN }) } ) + +test.each([ + { name: 'no serving row', row: undefined, status: MarkdownPublicationStatus.UNKNOWN }, + { + name: 'serving row without a hash or publication time', + row: { ...publishedPiece, content_hash: null }, + status: MarkdownPublicationStatus.UNKNOWN + }, + { + name: 'matching saved Markdown', + row: publishedPiece, + status: MarkdownPublicationStatus.MATCHING + }, + { + name: 'changed saved Markdown', + row: publishedPiece, + status: MarkdownPublicationStatus.CHANGED + }, + { name: 'unknown saved Markdown', row: publishedPiece, status: MarkdownPublicationStatus.UNKNOWN } +])( + 'source loader reports $name without mutations or a status-only stat', + async ({ row, status }) => { + const { piece, storage, event } = setupLoad() + vi.mocked(getWebPiece).mockResolvedValue(row) + vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(status) + + expect(await load(event)).toMatchObject({ + rawContent: rawSource, + hasPublishedVersion: Boolean(row), + savedMarkdown: status + }) + expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file) + expect(getMarkdownPublicationStatus).toHaveBeenCalledExactlyOnceWith(file, row?.content_hash) + expect(storage.readFile).toHaveBeenCalledExactlyOnceWith(file, 'text') + expect(storage.stat).not.toHaveBeenCalled() + expect(storage.writeFile).not.toHaveBeenCalled() + expect(storage.delete).not.toHaveBeenCalled() + expect(piece.write).not.toHaveBeenCalled() + expect(piece.syncMarkdown).not.toHaveBeenCalled() + expect(savePieceAsset).not.toHaveBeenCalled() + } +) + +test.each(['serving database', 'publication database', 'raw source'])( + 'source loader propagates %s failures rather than claiming an unpublished or unknown state', + async (failure) => { + const { piece, storage, event } = setupLoad() + const error = new Error(`${failure} failed`) + if (failure === 'serving database') vi.mocked(getWebPiece).mockRejectedValueOnce(error) + if (failure === 'publication database') { + vi.mocked(getWebPiece).mockResolvedValueOnce(publishedPiece) + vi.mocked(getMarkdownPublicationStatus).mockRejectedValueOnce(error) + } + if (failure === 'raw source') storage.readFile.mockRejectedValueOnce(error) + + await expect(load(event)).rejects.toBe(error) + if (failure !== 'publication database') { + expect(getMarkdownPublicationStatus).not.toHaveBeenCalled() + } + expect(storage.stat).not.toHaveBeenCalled() + expect(storage.writeFile).not.toHaveBeenCalled() + expect(storage.delete).not.toHaveBeenCalled() + expect(piece.write).not.toHaveBeenCalled() + expect(piece.syncMarkdown).not.toHaveBeenCalled() + expect(savePieceAsset).not.toHaveBeenCalled() + } +) diff --git a/apps/web/src/routes/admin/publish/report.ts b/apps/web/src/routes/admin/publish/report.ts index 7a763abf..8205bf2c 100644 --- a/apps/web/src/routes/admin/publish/report.ts +++ b/apps/web/src/routes/admin/publish/report.ts @@ -1,3 +1,5 @@ +export { editorHref, publishedHref } from '$lib/pieces/navigation' + export type ActiveKind = 'audit' | 'publish' export function getRunKind(workflowName: unknown, fallback: ActiveKind | null): ActiveKind | null { @@ -13,18 +15,3 @@ export function latestPhases(rows: T[]): T[] { } return Array.from(latestByPhase.values()) } - -function piecePath(file: string): string { - return file - .split('/') - .map((segment) => encodeURIComponent(segment)) - .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` -} -- 2.54.0 From b0405947dab8528c20a59c7276ec2ebe4a1407c1 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 15:03:27 -0700 Subject: [PATCH 14/17] [web] simplify editor view menu presentation --- .../lib/components/editor/PieceView.svelte | 59 ++++++++++--------- .../lib/components/editor/PieceView.test.ts | 44 ++++++++++++-- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/apps/web/src/lib/components/editor/PieceView.svelte b/apps/web/src/lib/components/editor/PieceView.svelte index 713692d6..8eaeaeef 100644 --- a/apps/web/src/lib/components/editor/PieceView.svelte +++ b/apps/web/src/lib/components/editor/PieceView.svelte @@ -33,18 +33,16 @@ {...props} class="dropdown-item" aria-labelledby={`${id}-published-label`} - aria-describedby={`${id}-published-description`} + aria-describedby={!availability.published + ? `${id}-published-description` + : undefined} > Published - - {#if isSaving} - Wait for saving to finish. - {:else if !hasPublishedVersion} - No published version yet. - {:else} - View the published version. - {/if} - + {#if !availability.published} + + {#if isSaving}Saving…{:else}Not published yet{/if} + + {/if}
{/snippet} @@ -58,16 +56,14 @@ {...props} class="dropdown-item" aria-labelledby={`${id}-preview-label`} - aria-describedby={`${id}-preview-description`} + aria-describedby={!availability.preview + ? `${id}-preview-description` + : undefined} > Preview - - {#if isSaving} - Wait for saving to finish. - {:else} - Uses saved Markdown. - {/if} - + {#if !availability.preview} + Saving… + {/if} {/snippet} @@ -84,26 +80,35 @@ 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); - padding: var(--space-1); z-index: 1000; } .dropdown-item { display: flex; - flex-direction: column; - gap: var(--space-1); + align-items: center; padding: var(--space-3); - border-radius: var(--radius-small); - color: var(--color-on-surface); 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[data-highlighted], - .dropdown-item:focus-visible { + .dropdown-item:hover, + .dropdown-item[data-highlighted] { background-color: var(--color-surface-container-low); - outline: 2px solid var(--color-primary); - outline-offset: -2px; + outline: none; + } + + .dropdown-item:focus-visible { + outline: auto; } .dropdown-item[data-disabled] { diff --git a/apps/web/src/lib/components/editor/PieceView.test.ts b/apps/web/src/lib/components/editor/PieceView.test.ts index 85b8dd61..e5f43cf6 100644 --- a/apps/web/src/lib/components/editor/PieceView.test.ts +++ b/apps/web/src/lib/components/editor/PieceView.test.ts @@ -56,21 +56,53 @@ test('source editor wires availability and encoded actions without replacing its expect(editor).toContain('onclick={confirmDiscard}') }) -test('real child menuitems connect instance-unique literal labels and visible descriptions', () => { +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(`${label}`) - expect(source).toContain(`id={\`\${id}-${view}-description\`} class="description"`) expect(source).toContain(`aria-labelledby={\`\${id}-${view}-label\`}`) - expect(source).toContain(`aria-describedby={\`\${id}-${view}-description\`}`) + expect(markup).toContain( + `aria-describedby={!availability.${view} ? \`\${id}-${view}-description\` : undefined}` + ) + expect(markup).toContain( + `{#if !availability.${view}} ` + ) } expect(source.match(/\{#snippet child\(\{ props \}\)\}\s* Date: Fri, 16 Oct 2026 15:10:34 -0700 Subject: [PATCH 15/17] [web] remove unused live piece redirect --- apps/web/src/lib/pieces/navigation.test.ts | 14 ++++++++++++++ .../admin/piece/[...path]/live/+server.ts | 17 ----------------- 2 files changed, 14 insertions(+), 17 deletions(-) delete mode 100644 apps/web/src/routes/admin/piece/[...path]/live/+server.ts diff --git a/apps/web/src/lib/pieces/navigation.test.ts b/apps/web/src/lib/pieces/navigation.test.ts index a61df63e..acf9ddf8 100644 --- a/apps/web/src/lib/pieces/navigation.test.ts +++ b/apps/web/src/lib/pieces/navigation.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { expect, test, vi } from 'vitest' import { editorHref, getPieceLinks, previewHref, publishedHref } from './navigation' @@ -11,6 +12,19 @@ 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', diff --git a/apps/web/src/routes/admin/piece/[...path]/live/+server.ts b/apps/web/src/routes/admin/piece/[...path]/live/+server.ts deleted file mode 100644 index abbb28b2..00000000 --- a/apps/web/src/routes/admin/piece/[...path]/live/+server.ts +++ /dev/null @@ -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}`) -} -- 2.54.0 From 34c24f7e4678d9102606de47562ada1bfc16c3d0 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 16 Oct 2026 15:26:02 -0700 Subject: [PATCH 16/17] [web] align preview share with published composition --- .../[...path]/preview/[jobId]/+layout.svelte | 13 +- .../[...path]/preview/[jobId]/+page.svelte | 34 ++-- .../preview/[jobId]/icon/+page.svelte | 4 +- .../[...path]/preview/[jobId]/layout.test.ts | 21 +- .../[...path]/preview/[jobId]/page.test.ts | 20 ++ .../preview/[jobId]/share/+page.svelte | 66 +------ .../preview/[jobId]/share/page.test.ts | 184 ++++++++++++++++++ 7 files changed, 241 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/share/page.test.ts diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte index c619f67b..52613b7c 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+layout.svelte @@ -149,26 +149,19 @@ share - {#if status === 'completed' && page.url.pathname === shareUrl && page.url.searchParams.has('html')} - share (html) - {/if} preview {#if status === 'completed'} -
- {@render children()} -
+ {@render children()} {:else}
undefined}> diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte index 7d72b74e..4566c417 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/+page.svelte @@ -22,20 +22,22 @@ {#if data.status === 'completed'} - - - - +
+ + + + +
{/if} diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte index 5b1b72ed..606d1fdd 100644 --- a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/icon/+page.svelte @@ -6,9 +6,9 @@ {#if data.status === 'completed' && data.piece} -
+
-
+
{/if} diff --git a/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/share/page.test.ts b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/share/page.test.ts new file mode 100644 index 00000000..7a9b00a9 --- /dev/null +++ b/apps/web/src/routes/admin/piece/[...path]/preview/[jobId]/share/page.test.ts @@ -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['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 ' + } + ] + } + 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 ', 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(/ { + const props = data() + const html = tree(props) + expect(tree(props, 'share', '?html')).toBe(html) + const header = html.slice(html.indexOf('')) + expect(header.match(/\\s*${view}\\s*`)) + expect(header).toMatch(/]*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(/ { + const html = tree(data(status)) + expect(html.match(/') + expect(html).toContain('books/book.md') + expect(html).toContain('Preview logs') + expect(html).toContain('Preview log <draft>') + expect(html.indexOf('Preview')).toBeLessThan(html.indexOf('Preview logs')) + expect(html).not.toMatch( + /data-renderer="composition"| { + 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=| Date: Fri, 16 Oct 2026 15:52:53 -0700 Subject: [PATCH 17/17] [web] add piece viewer changeset --- .changeset/piece-viewer.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/piece-viewer.md diff --git a/.changeset/piece-viewer.md b/.changeset/piece-viewer.md new file mode 100644 index 00000000..d2bd42cb --- /dev/null +++ b/.changeset/piece-viewer.md @@ -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. -- 2.54.0