Resolve registered Markdown asset links #198

Merged
eleith merged 2 commits from web/markdown-assets into main 2026-11-07 14:48:31 +00:00
26 changed files with 336 additions and 81 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@luzzle/web.worker": patch
"@luzzle/web": patch
---
Resolve registered asset links and images in Markdown to preview or public original-file URLs.
-34
View File
@@ -1,34 +0,0 @@
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',
'images/mark\0.svg',
'a\\x'
])('invalid recorded path %j remains unlinked', (path) => {
expect(encodeAssetPath(path)).toBeUndefined()
expect(assetPathToUrl(path, '/garden')).toBeUndefined()
})
test.each(['', '/garden/', 'https://cdn.test/prefix/?old=1#fragment'])(
'public URL conversion still preserves base %s and encodes raw segments once',
(base) => {
const path = 'files/雪 #?%2e%2e <file>.pdf'
const encoded = 'files/%E9%9B%AA%20%23%3F%252e%252e%20%3Cfile%3E.pdf'
expect(encodeAssetPath(path)).toBe(encoded)
const prefix = base.startsWith('https:') ? 'https://cdn.test/prefix' : base.replace(/\/$/, '')
expect(assetPathToUrl(path, base)).toBe(`${prefix}/pieces/assets/${encoded}`)
}
)
+7 -3
View File
@@ -1,8 +1,12 @@
import { createPieceHelpers, OpengraphImageWidth, OpengraphImageHeight } from '@luzzle/web.pieces'
import {
createPieceHelpers,
OpengraphImageWidth,
OpengraphImageHeight,
assetPathToUrl,
previewOutputUrl
} from '@luzzle/web.pieces'
import type { PublicWebPiece, PieceComponentHelpers, PieceIconPalette } from '@luzzle/web.pieces'
import { page } from '$app/state'
import { assetPathToUrl } from './assets.js'
import { previewOutputUrl } from './preview/output-url.js'
export { OpengraphImageWidth, OpengraphImageHeight }
export type { PieceComponentHelpers, PieceIconPalette }
@@ -1,10 +1,9 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { TemplateConfig as Config } from '@luzzle/web.templates/config'
import type { PreviewResult } from '@luzzle/web.jobs'
import { createPieceHelpers, type PublicWebPieceAsset } from '@luzzle/web.pieces'
import { createPieceHelpers, previewOutputUrl, type PublicWebPieceAsset } from '@luzzle/web.pieces'
import { makeConfig } from '#lib/server/config.fixture.js'
import { assemblePreview, type PreviewWorkerResult } from './assemble.server.js'
import { previewOutputUrl } from './output-url.js'
const pieceConfig: Config['pieces'][number] = {
...makeConfig({
@@ -1,8 +0,0 @@
import { encodeAssetPath } from '#lib/pieces/assets.js'
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}`
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { encodeAssetPath } from '#lib/pieces/assets.js'
import { encodeAssetPath } from '@luzzle/web.pieces'
import { transformFamily } from './presentation'
export type OutputFile = {
@@ -1,7 +1,7 @@
<script lang="ts">
import { mergePhaseLogs } from '#lib/components/progress.js'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
import { previewOutputUrl } from '#lib/pieces/preview/output-url.js'
import { previewOutputUrl } from '@luzzle/web.pieces'
import PreviewFacts from './PreviewFacts.svelte'
import PreviewLogs from './PreviewLogs.svelte'
import type { PageProps } from './$types'
@@ -1,7 +1,7 @@
<script lang="ts">
import type { PageProps } from './$types'
import PieceOverview from '#lib/pieces/viewer/PieceOverview.svelte'
import { assetPathToUrl } from '#lib/pieces/assets.js'
import { assetPathToUrl } from '@luzzle/web.pieces'
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
let { data }: PageProps = $props()
function fileUrl(path: string) {
@@ -3,7 +3,7 @@ import type { PageServerLoad } from './$types'
import { db } from '#lib/server/database/index.js'
import { config } from '#lib/server/config.js'
import { hydrateWithAssets } from '#lib/pieces/assets.server.js'
import { assetPathToUrl } from '#lib/pieces/assets.js'
import { assetPathToUrl } from '@luzzle/web.pieces'
import { isConfiguredPieceType } from '#lib/pieces/types.server.js'
export const load: PageServerLoad = async (page) => {
@@ -1,5 +1,37 @@
import { expect, test } from 'vitest'
import { previewOutputUrl } from './output-url'
import { encodeAssetPath, assetPathToUrl, previewOutputUrl } from './asset-urls.js'
test.each([
null,
undefined,
'',
' ',
'../x',
'a/../x',
'.',
'a/./x',
'/x',
'//host/x',
'a//x',
'https://host/x',
'javascript:x',
'images/mark\0.svg',
'a\\x'
])('invalid recorded path %j remains unlinked', (path) => {
expect(encodeAssetPath(path)).toBeUndefined()
expect(assetPathToUrl(path, '/garden')).toBeUndefined()
})
test.each(['', '/garden/', 'https://cdn.test/prefix/?old=1#fragment'])(
'public URL conversion still preserves base %s and encodes raw segments once',
(base) => {
const path = 'files/雪 #?%2e%2e <file>.pdf'
const encoded = 'files/%E9%9B%AA%20%23%3F%252e%252e%20%3Cfile%3E.pdf'
expect(encodeAssetPath(path)).toBe(encoded)
const prefix = base.startsWith('https:') ? 'https://cdn.test/prefix' : base.replace(/\/$/, '')
expect(assetPathToUrl(path, base)).toBe(`${prefix}/pieces/assets/${encoded}`)
}
)
test.each(['job', 'selected job', 'job #?%', '仕事', '%2e%2e'])(
'job %j uses the explicit root-mounted Admin asset route',
@@ -23,6 +23,13 @@ export function assetPathToUrl(
return joinAssetUrl(`pieces/assets/${encoded}`, base)
}
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}`
}
function joinAssetUrl(path: string, base: string): string {
if (!base || base.startsWith('/')) {
return `${base.replace(/\/+$/, '')}/${path}`
+1
View File
@@ -11,5 +11,6 @@ export {
OpengraphImageWidth,
OpengraphImageHeight
} from './helpers.js'
export { encodeAssetPath, assetPathToUrl, previewOutputUrl } from './asset-urls.js'
export type { PieceComponentHelpers, PieceIconPalette } from './helpers.js'
export type { PublicWebPiece, PublicWebPieceAsset } from '@luzzle/web.db'
+1
View File
@@ -26,6 +26,7 @@
"devDependencies": {
"@eslint/js": "catalog:",
"@types/eslint": "catalog:",
"@types/hast": "3.0.4",
"@types/mdast": "4.0.4",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
@@ -31,7 +31,10 @@ function makeCtx(): WorkerContext {
const parsed: ParsedPreview = {
type: 'books',
slug: 'my-book',
webPiece: { file_path: 'books/my-book.books.md', key: 'k' } as unknown as ParsedPreview['webPiece'],
webPiece: {
file_path: 'books/my-book.books.md',
key: 'k',
} as unknown as ParsedPreview['webPiece'],
pathToKey: new Map([['images/foo.jpg', 'k1']]),
keyToPath: new Map([['k1', 'images/foo.jpg']]),
sanitizedFrontmatter: { title: 'Hello' },
@@ -48,7 +51,7 @@ describe('previewTransformStep', () => {
mocks.getTransforms.mockReturnValue(new Map([['palette', palette]]))
const result = await previewTransformStep('palette').run(
{ parsed, pieces: {} as Pieces, outDir: 'temp-out' },
{ jobId: 'job-one', parsed, pieces: {} as Pieces, outDir: 'temp-out' },
makeCtx()
)
@@ -60,6 +63,7 @@ describe('previewTransformStep', () => {
expect.objectContaining({
webPiece: parsed.webPiece,
assetKeyToPath: parsed.keyToPath,
buildAssetUrl: expect.any(Function),
})
)
})
@@ -68,7 +72,7 @@ describe('previewTransformStep', () => {
mocks.getTransforms.mockReturnValue(new Map())
const result = await previewTransformStep('markdown').run(
{ parsed, pieces: {} as Pieces, outDir: 'temp-out' },
{ jobId: 'job-one', parsed, pieces: {} as Pieces, outDir: 'temp-out' },
makeCtx()
)
expect(result.status).toBe('completed')
@@ -1,22 +1,23 @@
import type { Pieces } from '@luzzle/core'
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { previewOutputUrl, type PublicWebPieceAsset } from '@luzzle/web.pieces'
import { completed, type Step, type StepResult } from '../core/step.js'
import { getTransforms } from '../transforms/index.js'
import type { AssetRecord } from '../transforms/utils/types.js'
import type { ParsedPreview } from './preview-parse.js'
// order matters: opengraph depends on image assets written by earlier transforms
// Markdown needs generated originals; opengraph needs the preceding transform outputs.
export const PREVIEW_TRANSFORM_NAMES = [
'markdown',
'highlight',
'palette',
'image',
'attachment',
'image',
'palette',
'highlight',
'markdown',
'opengraph',
] as const
export type PreviewTransformName = (typeof PREVIEW_TRANSFORM_NAMES)[number]
export interface PreviewTransformInput {
jobId: string
parsed: ParsedPreview
pieces: Pieces
outDir: string
@@ -28,7 +29,10 @@ export function previewTransformStep(
): Step<PreviewTransformInput, AssetRecord[]> {
return {
name,
async run({ parsed, pieces, outDir, priorAssets }, ctx): Promise<StepResult<AssetRecord[]>> {
async run(
{ jobId, parsed, pieces, outDir, priorAssets },
ctx
): Promise<StepResult<AssetRecord[]>> {
const { config, logger } = ctx
const transform = getTransforms().get(name)
if (!transform) {
@@ -43,6 +47,7 @@ export function previewTransformStep(
assetKeyToPath: parsed.keyToPath,
logger,
priorAssets,
buildAssetUrl: (path) => previewOutputUrl(path, jobId),
})
if (records.length === 0) {
@@ -0,0 +1,124 @@
import { expect, test, vi } from 'vitest'
import type { Pieces } from '@luzzle/core'
import { validateConfig } from '@luzzle/web.config'
import { assetPathToUrl } from '@luzzle/web.pieces'
import type { TransformInput } from '../utils/types.js'
import { makeLogger } from '../../../test/logger.js'
import { run } from './index.js'
function makeInput(overrides: Partial<TransformInput> = {}): TransformInput {
return {
webPiece: {
id: 'essay',
key: 'piece-key',
type: 'words',
slug: 'essay',
title: 'Essay',
file_path: 'essay.words.md',
date_added: 1,
json_metadata: '{}',
},
config: validateConfig(
{
pieces: [{ type: 'words', fields: { title: 'title', date_consumed: 'date' } }],
},
{}
),
outDir: '/out',
pieces: {
getPiece: vi.fn().mockResolvedValue({
fields: [{ name: 'description', type: 'string', format: 'markdown' }],
}),
} as unknown as Pieces,
assetKeyToPath: new Map(),
buildAssetUrl: (path) => assetPathToUrl(path, 'https://cdn.test/garden/'),
logger: makeLogger(),
...overrides,
}
}
test('renders registered originals in body and metadata Markdown using public URLs', async () => {
const input = makeInput({
assetKeyToPath: new Map([
['diagram', '.assets/essay/diagram 雪.png'],
['manual', '.assets/essay/C++.pdf'],
]),
priorAssets: [
{
asset_key: 'diagram',
transformation: 'image.xl.jpg',
asset_path: 'words/piece-key/diagram.xl.jpg',
mime_type: 'image/jpeg',
},
{
asset_key: 'diagram',
transformation: 'image.original',
asset_path: 'words/piece-key/diagram 雪.png',
mime_type: 'image/png',
},
{
asset_key: 'manual',
transformation: 'attachment',
asset_path: 'words/piece-key/C++.pdf',
mime_type: 'application/pdf',
},
],
})
input.webPiece.note = [
'![Diagram](<.assets/essay/diagram 雪.png>)',
'[Download][manual]',
'[manual]: .assets/essay/C++.pdf',
'[Elsewhere](https://example.com)',
'`[Manual](.assets/essay/C++.pdf)`',
].join('\n\n')
input.webPiece.json_metadata = JSON.stringify({
description: '[Manual](.assets/essay/C++.pdf)',
})
const records = await run(input)
const body = records.find((record) => record.transformation === 'markdown')?.content
expect(body).toContain(
'src="https://cdn.test/garden/pieces/assets/words/piece-key/diagram%20%E9%9B%AA.png"'
)
expect(body).toContain('href="https://cdn.test/garden/pieces/assets/words/piece-key/C%2B%2B.pdf"')
expect(body).toContain('href="https://example.com"')
expect(body).toContain('<code>[Manual](.assets/essay/C++.pdf)</code>')
expect(
records.find((record) => record.transformation === 'markdown.description')?.content
).toContain('href="https://cdn.test/garden/pieces/assets/words/piece-key/C%2B%2B.pdf"')
expect(input.logger.warn).not.toHaveBeenCalled()
})
test('warns and preserves destinations without a registered, generated original', async () => {
const input = makeInput({
assetKeyToPath: new Map([['variant', '.assets/variant-only.png']]),
priorAssets: [
{
asset_key: 'variant',
transformation: 'image.xl.jpg',
asset_path: 'words/piece-key/variant-only.xl.jpg',
mime_type: 'image/jpeg',
},
{
asset_key: 'foreign',
transformation: 'attachment',
asset_path: 'words/another-piece/foreign.pdf',
mime_type: 'application/pdf',
},
],
})
const destinations = ['.assets/missing.png', '.assets/variant-only.png', '.assets/foreign.pdf']
input.webPiece.note = destinations.map((path) => `[File](${path})`).join('\n\n')
const records = await run(input)
for (const destination of destinations) {
expect(records[0].content).toContain(`href="${destination}"`)
expect(input.logger.warn).toHaveBeenCalledWith(
'Markdown asset has no generated original file',
{ file: 'essay.words.md', asset: destination }
)
}
expect(input.logger.warn).toHaveBeenCalledTimes(destinations.length)
})
@@ -6,6 +6,7 @@ import remarkGemoji from 'remark-gemoji'
import remarkDirective from 'remark-directive'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import { rehypeAssetUrls, type AssetUrls } from './rehype-asset-urls.js'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
import rehypePrettyCode from 'rehype-pretty-code'
@@ -14,7 +15,11 @@ import type { Config } from '@luzzle/web.config'
export type CodeThemes = Config['theme']['markdown']['code']
export async function processMarkdown(markdown: string, themes: CodeThemes): Promise<string> {
export async function processMarkdown(
markdown: string,
themes: CodeThemes,
assets?: AssetUrls
): Promise<string> {
const processor = unified()
.use(remarkGfm, { plugins: { footnote: false } })
.use(remarkSidenote)
@@ -30,6 +35,7 @@ export async function processMarkdown(markdown: string, themes: CodeThemes): Pro
})
.use(rehypePrettyCode, { theme: themes })
.use(rehypeRaw)
.use(rehypeAssetUrls, assets)
.use(rehypeSlug)
.use(rehypeAutolinkHeadings)
.use(rehypeStringify, { allowDangerousHtml: true })
@@ -58,7 +58,7 @@ describe('transforms/markdown', () => {
logger: makeLogger(),
})
expect(processMarkdown).toHaveBeenCalledWith('# Hello\n\nWorld', themes)
expect(processMarkdown).toHaveBeenCalledWith('# Hello\n\nWorld', themes, expect.any(Object))
expect(records).toEqual([
expect.objectContaining({
transformation: 'markdown',
@@ -112,7 +112,7 @@ describe('transforms/markdown', () => {
logger: makeLogger(),
})
expect(processMarkdown).toHaveBeenCalledWith('A **great** book', themes)
expect(processMarkdown).toHaveBeenCalledWith('A **great** book', themes, expect.any(Object))
expect(records).toEqual([
expect.objectContaining({
transformation: 'markdown.description',
@@ -174,8 +174,8 @@ describe('transforms/markdown', () => {
})
expect(processMarkdown).toHaveBeenCalledTimes(2)
expect(processMarkdown).toHaveBeenNthCalledWith(1, '# First', themes)
expect(processMarkdown).toHaveBeenNthCalledWith(2, '# Second', themes)
expect(processMarkdown).toHaveBeenNthCalledWith(1, '# First', themes, expect.any(Object))
expect(processMarkdown).toHaveBeenNthCalledWith(2, '# Second', themes, expect.any(Object))
expect(records).toEqual([
expect.objectContaining({
transformation: 'markdown.sections.0',
@@ -234,7 +234,7 @@ describe('transforms/markdown', () => {
logger: makeLogger(),
})
expect(processMarkdown).toHaveBeenCalledWith('**nested**', themes)
expect(processMarkdown).toHaveBeenCalledWith('**nested**', themes, expect.any(Object))
expect(records).toEqual([
expect.objectContaining({
transformation: 'markdown.meta.bio',
@@ -275,8 +275,8 @@ describe('transforms/markdown', () => {
})
expect(processMarkdown).toHaveBeenCalledTimes(2)
expect(processMarkdown).toHaveBeenNthCalledWith(1, '# First', themes)
expect(processMarkdown).toHaveBeenNthCalledWith(2, '# Second', themes)
expect(processMarkdown).toHaveBeenNthCalledWith(1, '# First', themes, expect.any(Object))
expect(processMarkdown).toHaveBeenNthCalledWith(2, '# Second', themes, expect.any(Object))
expect(records).toEqual([
expect.objectContaining({
transformation: 'markdown.chapters.0.body',
@@ -1,20 +1,20 @@
import { filterFrontmatterFields, getFrontmatterValues, resolveFieldPaths } from '@luzzle/core'
import type { PieceFrontmatterProperty } from '@luzzle/core'
import { encodeAssetPath } from '@luzzle/web.pieces'
import { processMarkdown } from './engine.js'
import type { AssetUrls } from './rehype-asset-urls.js'
import type { TransformInput, AssetRecord } from '../utils/types.js'
const isMarkdown = (f: PieceFrontmatterProperty) =>
f.type === 'string' && f.format === 'markdown'
const isMarkdown = (f: PieceFrontmatterProperty) => f.type === 'string' && f.format === 'markdown'
export async function run({ webPiece, config, pieces }: TransformInput): Promise<AssetRecord[]> {
export async function run(input: TransformInput): Promise<AssetRecord[]> {
const { webPiece, config } = input
const assets = createMarkdownAssets(input)
const render = (markdown: string) => processMarkdown(markdown, config.theme.markdown.code, assets)
const records: AssetRecord[] = []
const themes = {
light: config.theme.markdown.code.light,
dark: config.theme.markdown.code.dark,
}
if (webPiece.note) {
const html = await processMarkdown(webPiece.note, themes)
const html = await render(webPiece.note)
records.push({
transformation: 'markdown',
piece_asset_path: null,
@@ -25,6 +25,43 @@ export async function run({ webPiece, config, pieces }: TransformInput): Promise
})
}
records.push(...(await renderMarkdownFields(input, render)))
return records
}
function createMarkdownAssets({
webPiece,
assetKeyToPath,
priorAssets = [],
buildAssetUrl,
logger,
}: TransformInput): AssetUrls {
const urls = new Map<string, string>()
for (const asset of priorAssets) {
if (asset.transformation !== 'attachment' && asset.transformation !== 'image.original') continue
const source = assetKeyToPath.get(asset.asset_key)
if (!source || !asset.asset_path || !encodeAssetPath(source)) continue
// Markdown preserves URI punctuation such as '+'; output URLs still encode each segment.
const destination = encodeURI(source).replace(/[?#]/g, encodeURIComponent)
const url = buildAssetUrl?.(asset.asset_path)
if (destination && url) urls.set(destination, url)
}
return {
urls,
warn: (destination) =>
logger.warn('Markdown asset has no generated original file', {
file: webPiece.file_path,
asset: destination,
}),
}
}
async function renderMarkdownFields(
{ webPiece, pieces }: TransformInput,
render: (markdown: string) => Promise<string>
): Promise<AssetRecord[]> {
const records: AssetRecord[] = []
const piece = await pieces.getPiece(webPiece.type)
const schemaPaths = filterFrontmatterFields(piece.fields, isMarkdown)
const frontmatter = JSON.parse(webPiece.json_metadata || '{}')
@@ -37,7 +74,7 @@ export async function run({ webPiece, config, pieces }: TransformInput): Promise
const value = values[0]
if (typeof value !== 'string' || !value) continue
const html = await processMarkdown(value, themes)
const html = await render(value)
records.push({
transformation: `markdown.${fieldPath}`,
piece_asset_path: null,
@@ -0,0 +1,28 @@
import type { Root } from 'hast'
import { visit } from 'unist-util-visit'
export type AssetUrls = {
urls: ReadonlyMap<string, string>
warn: (destination: string) => void
}
export function rehypeAssetUrls(assets?: AssetUrls) {
return (tree: Root) => {
if (!assets) return
visit(tree, 'element', (node) => {
if (node.tagName !== 'img' && node.tagName !== 'a') return
const property = node.tagName === 'img' ? 'src' : 'href'
const destination = node.properties[property]
if (typeof destination !== 'string' || !destination.startsWith('.assets/')) return
const url = assets.urls.get(destination)
if (url) {
node.properties[property] = url
} else {
assets.warn(destination)
}
})
}
}
@@ -10,6 +10,7 @@ import type { Pieces } from '@luzzle/core'
import type { Logger } from '../services/logger.js'
import { generateAssetKey } from '../assets/key.js'
import { getTransforms } from './index.js'
import type { TransformInput } from './utils/types.js'
vi.mock('../assets/key.js')
vi.mock('./index.js')
@@ -26,6 +27,7 @@ const mocks = {
}
const config = {
url: { app: 'https://garden.test', luzzle_assets: 'https://cdn.test/garden' },
paths: { database: 'db.sqlite' },
assets: { salt: 'test-salt' },
} as unknown as Config
@@ -92,6 +94,10 @@ describe('transforms/runner', () => {
expect(transforms.attachment.run).toHaveBeenCalledOnce()
expect(transforms.palette.run).toHaveBeenCalledOnce()
expect(transforms.opengraph.run).toHaveBeenCalledOnce()
const { buildAssetUrl } = transforms.attachment.run.mock.calls[0][0] as TransformInput
expect(buildAssetUrl?.('books/key/file.pdf')).toBe(
'https://cdn.test/garden/pieces/assets/books/key/file.pdf'
)
})
test('filters to only the specified transform when typeFilter is given', async () => {
+4 -1
View File
@@ -1,7 +1,7 @@
import type { Kysely } from 'kysely'
import type { Pieces } from '@luzzle/core'
import type { Config } from '@luzzle/web.config'
import type { PublicWebPieceAsset } from '@luzzle/web.pieces'
import { assetPathToUrl, type PublicWebPieceAsset } from '@luzzle/web.pieces'
import { generateAssetKey } from '../assets/key.js'
import { getTransforms } from './index.js'
import type { WebPieces, WebDatabase } from '../services/db.js'
@@ -34,6 +34,8 @@ export async function produceTransformsForPiece(
const produced: ProducedTransform[] = []
const priorAssets: PublicWebPieceAsset[] = []
const buildAssetUrl = (path: string) =>
assetPathToUrl(path, config.url.luzzle_assets || config.url.app)
for (const [name, transform] of selected) {
try {
@@ -45,6 +47,7 @@ export async function produceTransformsForPiece(
assetKeyToPath,
logger,
priorAssets,
buildAssetUrl,
})
produced.push({ name, records })
@@ -15,4 +15,5 @@ export type TransformInput = {
assetKeyToPath: Map<string, string>
logger: Logger
priorAssets?: PublicWebPieceAsset[]
buildAssetUrl?: (path: string) => string | undefined
}
@@ -9,6 +9,7 @@ import { getWorkerContext, type WorkerContext } from '../services/context.js'
import { previewParseStep, type ParsedPreview } from '../steps/preview-parse.js'
import { getTransforms } from '../transforms/index.js'
import { run as attachment } from '../transforms/attachment.js'
import { run as markdown } from '../transforms/markdown/index.js'
import { generateAssetKey } from '../assets/key.js'
import { completed } from '../core/step.js'
import type { DurableStepApi } from '../core/run-progress-phase.js'
@@ -32,7 +33,7 @@ type PreviewWorkflow = (args: {
}) => Promise<PreviewResult>
// Keep this independent of PREVIEW_TRANSFORM_NAMES so omission from production is caught.
const phases = ['markdown', 'highlight', 'palette', 'image', 'attachment', 'opengraph']
const phases = ['attachment', 'image', 'palette', 'highlight', 'markdown', 'opengraph']
describe('preview attachment production', () => {
let directory: string
@@ -156,6 +157,34 @@ describe('preview attachment production', () => {
).toEqual([{ phase: 'attachment', status: 'completed', message: '1 record(s)' }])
})
test('renders Markdown against the attachment generated for this preview job', async () => {
await mkdir(path.join(directory, '.luzzle', 'schemas'), { recursive: true })
await writeFile(
path.join(directory, '.luzzle', 'schemas', 'snippets.json'),
JSON.stringify({
title: 'snippets',
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
})
)
parsed.note = '[Source](.assets/fibonacci.py)'
parsed.webPiece.note = parsed.note
getTransforms().set('markdown', { run: markdown })
const result = await workflow({
input: { filePath: parsed.webPiece.file_path },
run: { id: 'job-markdown' },
step,
})
const persisted: PreviewResult = JSON.parse(JSON.stringify(result))
expect(
persisted.transforms.find((asset) => asset.transformation === 'markdown')?.content
).toContain('href="/admin/preview/job-markdown/asset/snippets/piece-key/fibonacci.py"')
expect(ctx.logger.warn).not.toHaveBeenCalled()
})
test('does not report a generated attachment when the source cannot be copied', async () => {
await rm(path.join(directory, '.assets/fibonacci.py'))
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -79,6 +79,7 @@ export function registerPreviewWorkflow(): void {
try {
const res = await previewTransformStep(name).run(
{
jobId,
parsed,
pieces,
outDir,
+3
View File
@@ -925,6 +925,9 @@ importers:
'@types/eslint':
specifier: 'catalog:'
version: 9.6.1
'@types/hast':
specifier: 3.0.4
version: 3.0.4
'@types/mdast':
specifier: 4.0.4
version: 4.0.4