Resolve registered Markdown asset links #198
No files matched your search
@@ -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.
|
||||
@@ -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}`)
|
||||
}
|
||||
)
|
||||
@@ -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,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) => {
|
||||
|
||||
+33
-1
@@ -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}`
|
||||
@@ -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'
|
||||
@@ -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 = [
|
||||
'',
|
||||
'[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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user