fix: handle assets rendering

scarlett
NGPixel 1 month ago
parent 7f53b60dfb
commit 45d2287ab1
No known key found for this signature in database

@ -1,9 +1,7 @@
import type { FastifyInstance, FastifyRequest } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
import { decodeTreePath } from '../helpers/common.ts' import { decodeTreePath } from '../helpers/common.ts'
import { INLINE_EXTS } from '../models/assets.ts'
/** Extensions a browser may render inline. Everything else is sent as a download. */
const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'])
const assetIdParam = { const assetIdParam = {
type: 'object', type: 'object',

@ -0,0 +1,76 @@
import { INLINE_EXTS } from '../models/assets.ts'
import type { FastifyInstance } from 'fastify'
/**
* How long a browser may keep a file before asking again.
*
* Short, and revalidated: unlike a thumbnail, what sits at a path is not fixed deleting a file and
* uploading another under the same name puts different bytes behind the same URL. `private`, because
* the reply depends on who asked: a shared cache holding one reader's copy of a file the rules put
* behind an account would hand it to the next reader along.
*/
const FILE_CACHE = 'private, max-age=600, must-revalidate'
/**
* _files Routes
*
* How a page's content points at an uploaded file: `/_files/<folder>/<name.ext>`, which is the path
* the file manager shows and what the editors write into a page.
*
* Addressed by path rather than by ID so that what an author reads in their own markdown is the file
* they picked, and so that content carries nothing instance-specific. The cost is the other half of
* that bargain: renaming or moving a file leaves the pages that pointed at it pointing at nothing.
*
* Public in the sense that `_site` and `_thumb` are no session is required but not unguarded:
* assets are addressed by the same rules as the pages they sit among, so every request is judged
* against `read:assets` for the path it asked for.
*/
async function routes(app: FastifyInstance) {
app.get<{ Params: { '*': string } }>('/*', async (req, reply) => {
const site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
if (!site) {
return reply.notFound('Site not found')
}
const asset = await WIKI.models.assets.getAssetByPath(site.id, req.params['*'] ?? '')
// -> Not readable is answered as not there, so the URL cannot be used to probe for files
if (
!asset ||
!WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), 'read:assets', {
path: asset.folderPath ? `${asset.folderPath}/${asset.fileName}` : asset.fileName,
locale: asset.locale
})
) {
return reply.notFound('File not found')
}
/*
The ID and the timestamp together, because either one alone lies: a file replaced at the same
path is a different asset under the same URL, and one edited in place keeps its ID.
*/
const etag = `"${asset.id}-${asset.updatedAt.getTime()}"`
reply.header('ETag', etag)
reply.header('Cache-Control', FILE_CACHE)
// -> The bytes came from a user, so the browser must take the type at its word rather than
// looking for something more interesting in them
reply.header('X-Content-Type-Options', 'nosniff')
if (req.headers['if-none-match'] === etag) {
return reply.code(304).send()
}
const content = await WIKI.models.assets.getContent(asset.id)
if (!content) {
return reply.notFound('File not found')
}
if (WIKI.config.security?.forceAssetDownload || !INLINE_EXTS.has(asset.fileExt)) {
reply.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(asset.fileName)}"`
)
}
return reply.type(content.mimeType).send(content.data)
})
}
export default routes

@ -610,6 +610,7 @@ async function initHTTPServer() {
app.register(import('./api/index.ts'), { prefix: '/_api' }) app.register(import('./api/index.ts'), { prefix: '/_api' })
app.register(import('./controllers/collab.ts'), { prefix: '/_collab' }) app.register(import('./controllers/collab.ts'), { prefix: '/_collab' })
app.register(import('./controllers/files.ts'), { prefix: '/_files' })
app.register(import('./controllers/site.ts'), { prefix: '/_site' }) app.register(import('./controllers/site.ts'), { prefix: '/_site' })
app.register(import('./controllers/icons.ts'), { prefix: '/_icons' }) app.register(import('./controllers/icons.ts'), { prefix: '/_icons' })
app.register(import('./controllers/render.ts'), { prefix: '/_render' }) app.register(import('./controllers/render.ts'), { prefix: '/_render' })

@ -1,13 +1,21 @@
import path from 'node:path' import path from 'node:path'
import mime from 'mime' import mime from 'mime'
import { and, eq, inArray, sql } from 'drizzle-orm' import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import { assets as assetsTable, tree as treeTable } from '../db/schema.ts' import { assets as assetsTable, tree as treeTable } from '../db/schema.ts'
import { CustomError, decodeTreePath } from '../helpers/common.ts' import { CustomError, decodeTreePath, encodeTreePath } from '../helpers/common.ts'
import { makeImageThumbnail } from '../helpers/images.ts' import { makeImageThumbnail } from '../helpers/images.ts'
/** How large the file manager renders a preview. Generated once, at upload time. */ /** How large the file manager renders a preview. Generated once, at upload time. */
const THUMBNAIL_SIZE = { width: 320, height: 200 } const THUMBNAIL_SIZE = { width: 320, height: 200 }
/**
* Extensions a browser may render inline. Everything else is sent as a download.
*
* Read by both routes that hand out an asset's bytes — the API's `/content` and the public
* `/_files/` path which have to agree on what a browser is allowed to open in place.
*/
export const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'])
/** What an asset is, for the sake of grouping and filtering. Mirrors the `assetKind` schema enum. */ /** What an asset is, for the sake of grouping and filtering. Mirrors the `assetKind` schema enum. */
export type AssetKind = 'document' | 'image' | 'other' export type AssetKind = 'document' | 'image' | 'other'
@ -46,6 +54,14 @@ export interface Asset {
updatedAt: Date updatedAt: Date
} }
/**
* An asset found by its path, which is the one lookup that has to say which locale it landed on: the
* URL in a page carries none, and the permission rules may be written against one.
*/
export interface AssetAtPath extends Asset {
locale: string
}
/** /**
* Reduce whatever a client called the file to something safe to store, address and serve. * Reduce whatever a client called the file to something safe to store, address and serve.
* *
@ -232,6 +248,65 @@ class Assets {
} as Asset } as Asset
} }
/**
* An asset's metadata, addressed the way a page's content addresses it: by its path within the
* site. Null if there is nothing there.
*
* The path lives on the tree row rather than on the asset the two share an ID so the lookup
* splits it into the folder and the file the way the tree stores them, the folder as an ltree.
* Both are lowercased, because that is what an upload stored them as.
*
* A path can exist once per locale and the URL carries none, so the site's primary locale wins
* where more than one has a file there. That is also the only one the file manager uploads into.
*/
async getAssetByPath(siteId: string, filePath: string): Promise<AssetAtPath | null> {
const segments = filePath.split('/').filter(Boolean)
const fileName = segments.pop()?.toLowerCase()
if (!fileName) {
return null
}
const primaryLocale = WIKI.sites[siteId]?.config?.locales?.primary ?? 'en'
const results = await WIKI.db
.select({
id: assetsTable.id,
fileName: assetsTable.fileName,
fileExt: assetsTable.fileExt,
kind: assetsTable.kind,
mimeType: assetsTable.mimeType,
fileSize: assetsTable.fileSize,
createdAt: assetsTable.createdAt,
updatedAt: assetsTable.updatedAt,
folderPath: treeTable.folderPath,
locale: treeTable.locale,
title: treeTable.title,
hasPreview: sql<boolean>`${assetsTable.preview} IS NOT NULL`
})
.from(assetsTable)
.innerJoin(treeTable, eq(treeTable.id, assetsTable.id))
.where(
and(
eq(assetsTable.siteId, siteId),
eq(treeTable.type, 'asset'),
eq(treeTable.folderPath, encodeTreePath(segments.join('/'))),
eq(treeTable.fileName, fileName)
)
)
.orderBy(desc(sql`${treeTable.locale} = ${primaryLocale}`))
.limit(1)
const row = results[0]
if (!row) {
return null
}
return {
...row,
fileSize: row.fileSize ?? 0,
folderPath: decodeTreePath(row.folderPath ?? '') ?? '',
hasPreview: Boolean(row.hasPreview)
} as AssetAtPath
}
/** /**
* An asset's bytes, along with what to serve them as. Null if there is no such asset. * An asset's bytes, along with what to serve them as. Null if there is no such asset.
* *

@ -67,8 +67,17 @@ export interface PostProcessResult {
* closed by whoever asked for it rather than opened per page. * closed by whoever asked for it rather than opened per page.
*/ */
interface PageRenderer { interface PageRenderer {
/** Markdown in, the editor's own HTML out — before `postProcess` gets to it. */ /**
render(content: string, config: Record<string, any>): Promise<string> * Markdown in, the editor's own HTML out before `postProcess` gets to it.
*
* `context` carries what the source cannot say about itself, currently the page's own path: a
* relative image in a page resolves against the folder it sits in, as it would in a repository.
*/
render(
content: string,
config: Record<string, any>,
context: Record<string, any>
): Promise<string>
close(): Promise<void> close(): Promise<void>
} }
@ -726,7 +735,8 @@ class Rendering {
} }
const html = await renderer.render( const html = await renderer.render(
page.content ?? '', page.content ?? '',
WIKI.sites[entry.siteId]?.config?.editors?.[page.editor]?.config ?? {} WIKI.sites[entry.siteId]?.config?.editors?.[page.editor]?.config ?? {},
{ pagePath: page.path }
) )
await WIKI.models.pages.storeRender(entry.siteId, page.id, html, { await WIKI.models.pages.storeRender(entry.siteId, page.id, html, {
scripts: entry.allowScripts, scripts: entry.allowScripts,
@ -801,7 +811,11 @@ class Rendering {
}) })
return { return {
async render(content: string, config: Record<string, any>): Promise<string> { async render(
content: string,
config: Record<string, any>,
context: Record<string, any>
): Promise<string> {
/* /*
`page.evaluate` has no timeout of its own, and what it calls is a synchronous pass over `page.evaluate` has no timeout of its own, and what it calls is a synchronous pass over
content somebody else wrote: an input that sends one of the markdown plugins into content somebody else wrote: an input that sends one of the markdown plugins into
@ -827,9 +841,11 @@ class Rendering {
// -> This callback is serialized and runs in the browser, where `globalThis` is the window // -> This callback is serialized and runs in the browser, where `globalThis` is the window
// the renderer bundle attached itself to // the renderer bundle attached itself to
const render = page.evaluate( const render = page.evaluate(
(src: string, cfg: Record<string, any>) => (globalThis as any).__wikiRender(src, cfg), (src: string, cfg: Record<string, any>, ctx: Record<string, any>) =>
(globalThis as any).__wikiRender(src, cfg, ctx),
content, content,
config config,
context
) )
return await Promise.race([render, expiry]) return await Promise.race([render, expiry])
} finally { } finally {

@ -312,6 +312,7 @@ import { useI18n } from 'vue-i18n'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab' import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks' import { blockMarkdown } from '@/helpers/blocks'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
@ -408,16 +409,27 @@ function insertAssets() {
siteStore.openFileManager({ insertMode: true }) siteStore.openFileManager({ insertMode: true })
} }
/**
* What the file manager handed back, as markdown at the cursor.
*
* Both kinds go in as paths from the site root: a file through `assetPath`, which is where the
* reasoning about that form lives, and a page the way the link picker writes one.
*
* An image goes in as one and anything else as a link -- a PDF picked from the file manager is a link
* to a PDF, not a broken picture -- which is the same distinction `insertFilesAsAssets` draws for a
* file that arrives by drop.
*/
function insertAssetClb(opts) { function insertAssetClb(opts) {
const assetPath = opts.folderPath ? `${opts.folderPath}/${opts.fileName}` : opts.fileName
let content = '' let content = ''
switch (opts.type) { switch (opts.type) {
case 'asset': { case 'asset': {
content = `![${opts.title}](${assetPath})` const isImage = opts.mimeType?.startsWith('image/')
content = `${isImage ? '!' : ''}[${opts.title}](${assetPath(opts.folderPath, opts.fileName)})`
break break
} }
case 'page': { case 'page': {
content = `[${opts.title}](${assetPath})` const pagePath = opts.folderPath ? `${opts.folderPath}/${opts.fileName}` : opts.fileName
content = `[${opts.title}](/${pagePath})`
break break
} }
} }
@ -841,7 +853,9 @@ function processContent(newContent) {
*/ */
let html let html
try { try {
html = md.render(newContent) // -> The page's own path, because a relative image in the source is relative to the folder it
// sits in -- and it is being edited, so it is whatever the path field says right now
html = md.render(newContent, { pagePath: pageStore.path })
} catch (err) { } catch (err) {
console.error(err) console.error(err)
notify({ notify({

@ -499,7 +499,11 @@ async function renderOf(version, content) {
if (!editorStore.configIsLoaded) { if (!editorStore.configIsLoaded) {
await editorStore.fetchConfigs() await editorStore.fetchConfigs()
} }
return new MarkdownRenderer(editorStore.editors.markdown ?? {}).render(content) // -> Rendered as the page it is a version of, so a relative image in it resolves the way it does
// in the page view rather than against the site root
return new MarkdownRenderer(editorStore.editors.markdown ?? {}).render(content, {
pagePath: pageStore.path
})
} }
async function viewSource(version) { async function viewSource(version) {

@ -27,6 +27,7 @@ import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import { assetPath } from '@/helpers/assets'
// EMITS // EMITS
@ -88,11 +89,9 @@ onMounted(async () => {
} }
// -> The stored name is not always the one asked for: a file already in the folder gets the // -> The stored name is not always the one asked for: a file already in the folder gets the
// next free `name-1.ext`, and the content has to point at what was actually stored // next free `name-1.ext`, and the content has to point at what was actually stored
const storedPath = resp?.asset?.folderPath const storedPath = assetPath(resp?.asset?.folderPath, resp?.asset?.fileName)
? `${resp.asset.folderPath}/${resp.asset.fileName}` pageStore.content = pageStore.content.replaceAll(item.blobUrl, storedPath)
: resp?.asset?.fileName replacements.push({ from: item.blobUrl, to: storedPath })
pageStore.content = pageStore.content.replaceAll(item.blobUrl, `/${storedPath}`)
replacements.push({ from: item.blobUrl, to: `/${storedPath}` })
URL.revokeObjectURL(item.blobUrl) URL.revokeObjectURL(item.blobUrl)
} }
editorStore.pendingAssets = [] editorStore.pendingAssets = []

@ -0,0 +1,22 @@
/**
* How a page's source points at an uploaded file.
*
* From the site root, so that the path says where the file is rather than where it is being written
* about: a page that later moves to another folder keeps pointing at the same picture, which a path
* relative to the page's own folder would not.
*
* The renderer resolves it to the `/_files/` URL this server answers, at render time -- see `fileSrc`
* in `renderers/markdown.js` -- so what is stored is a path anybody can read rather than the shape
* this instance happens to serve files under. That resolution also accepts a path relative to the
* page, which is what markdown written for a repository uses, so imported content keeps working; this
* is only about what the wiki's own editors write.
*/
/**
* @param {string} folderPath Folder the asset sits in, slash-separated, empty at the site root.
* @param {string} fileName The asset's stored file name.
* @returns {string} A path from the site root, e.g. `/media/photo.png`.
*/
export function assetPath(folderPath, fileName) {
return folderPath ? `/${folderPath}/${fileName}` : `/${fileName}`
}

@ -1,6 +1,7 @@
import { BUNDLED_ICONS } from '@/assets/icons.generated' import { BUNDLED_ICONS } from '@/assets/icons.generated'
import { copyToClipboard } from './clipboard' import { copyToClipboard } from './clipboard'
import { isServerPath } from './serverPaths'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
/** /**
@ -171,21 +172,6 @@ export function enhanceRenderedContent(root) {
addHeadingAnchors(root) addHeadingAnchors(root)
} }
/**
* Paths the server owns rather than the router: assets, the API, block bundles, per-site files,
* thumbnails and avatars. A link to one of these is a request for a file, not a page, and handing it
* to the router would render the catch-all page view over the top of nothing.
*/
const SERVER_PATHS = [
'/_assets/',
'/_api/',
'/_blocks/',
'/_icons/',
'/_site/',
'/_thumb/',
'/_user/'
]
/** /**
* Where a link inside rendered content should take the reader, if the router should handle it. * Where a link inside rendered content should take the reader, if the router should handle it.
* *
@ -219,7 +205,9 @@ export function routableHref({ href, target, download, rel } = {}, current) {
if (url.origin !== current.origin || !/^https?:$/.test(url.protocol)) { if (url.origin !== current.origin || !/^https?:$/.test(url.protocol)) {
return null return null
} }
if (SERVER_PATHS.some((prefix) => url.pathname.startsWith(prefix))) { // -> A link to one of these is a request for a file, not a page, and handing it to the router would
// render the catch-all page view over the top of nothing
if (isServerPath(url.pathname)) {
return null return null
} }
// -> Same page, different fragment: the browser scrolls and announces it, and the router would do // -> Same page, different fragment: the browser scrolls and announces it, and the router would do

@ -0,0 +1,23 @@
/**
* Paths the server owns rather than the page tree: build assets, the API, block bundles, uploaded
* files, icons, per-site files, thumbnails and avatars.
*
* One list, because two different things ask the same question of a URL and must not drift apart:
* which links the router should keep its hands off (`renderedContent.js`), and which image sources
* are already pointing at a file rather than at something to resolve (`renderers/markdown.js`).
*/
export const SERVER_PATHS = [
'/_assets/',
'/_api/',
'/_blocks/',
'/_files/',
'/_icons/',
'/_site/',
'/_thumb/',
'/_user/'
]
/** Whether a root-relative path is one of them. */
export function isServerPath(path) {
return SERVER_PATHS.some((prefix) => path.startsWith(prefix))
}

@ -377,7 +377,9 @@ function reviewedContent() {
*/ */
function renderReviewed(content) { function renderReviewed(content) {
const md = new MarkdownRenderer(editorStore.editors.markdown ?? {}) const md = new MarkdownRenderer(editorStore.editors.markdown ?? {})
return md.render(content) // -> The page the suggestion is against, so a relative image in it resolves against that page's
// folder -- this HTML is what the page will be published with
return md.render(content, { pagePath: state.selected?.page?.path ?? '' })
} }
function approveSubmission() { function approveSubmission() {

@ -17,11 +17,13 @@ import { MarkdownRenderer } from './markdown'
* @param {string} content Markdown source * @param {string} content Markdown source
* @param {object} config The site's markdown editor config, so the result matches what an author * @param {object} config The site's markdown editor config, so the result matches what an author
* would have produced in the editor * would have produced in the editor
* @param {object} context What the source cannot say about itself: `pagePath`, which a relative image
* in it resolves against, exactly as the editor passes it
* @returns {string} Rendered HTML, before the server's own post-processing * @returns {string} Rendered HTML, before the server's own post-processing
*/ */
window.__wikiRender = function (content, config = {}) { window.__wikiRender = function (content, config = {}, context = {}) {
const renderer = new MarkdownRenderer(config) const renderer = new MarkdownRenderer(config)
return renderer.render(content ?? '') return renderer.render(content ?? '', context)
} }
// -> Polled by the caller: a module script is deferred, so the page can be "loaded" before this ran // -> Polled by the caller: a module script is deferred, so the page can be "loaded" before this ran

@ -20,6 +20,10 @@ import hljs from 'highlight.js'
import { escape } from 'es-toolkit/string' import { escape } from 'es-toolkit/string'
// -> Relative, like this file's other in-repo imports: it is also the entry point of the headless
// renderer bundle, which is built on its own
import { isServerPath } from '../helpers/serverPaths'
const quoteStyles = { const quoteStyles = {
chinese: '””‘’', chinese: '””‘’',
english: '“”‘’', english: '“”‘’',
@ -61,6 +65,85 @@ function isExternalHref(href) {
} }
} }
/** Where uploaded files are served from — `backend/controllers/files.ts`. */
const FILES_PREFIX = '/_files/'
/**
* Where an image in a page should actually load from.
*
* A page's source addresses a picture the way a file sitting next to it would -- `photo.png`,
* `img/photo.png`, `/media/photo.png` -- which is what the same markdown means in a repository, and
* what an author who wrote it elsewhere expects it to mean here. None of those is a URL this server
* answers: uploaded files live under `/_files/`. So the resolution happens at render time and the
* source is left holding the path that was written, which is what keeps the file readable on GitHub.
*
* Relative is relative to the page's FOLDER, as it would be to a file's directory in a repository, so
* a picture beside the page is found from a page at any depth. A path that starts at the root means
* the site root.
*
* Only images. A relative LINK is a link to another page and means exactly what it says, so the same
* treatment would break it -- an image is the one thing that is always a file.
*
* Left alone: anything carrying a scheme of its own (`http:`, `data:`, and the `blob:` a pending
* upload sits behind until the save that uploads it), a protocol-relative URL, a bare fragment, and a
* path the server already owns -- `/_files/` included, so rendering a render changes nothing.
*
* @param {string} src The source as written.
* @param {string} pagePath Path of the page being rendered, without a leading slash. The site root
* when it is not known, which is where a render with no page behind it --
* a review, a history entry -- resolves from.
* @returns {string} The source to render with.
*/
function fileSrc(src, pagePath = '') {
const value = (src ?? '').trim()
if (
!value ||
value.startsWith('#') ||
value.startsWith('//') ||
/^[a-z][a-z\d+.-]*:/i.test(value)
) {
return src
}
if (isServerPath(value)) {
return src
}
/*
Resolved with `URL` so that `..`, `.`, a query and a fragment all behave the way they do
everywhere else, and so that a space in a file name comes out encoded. The origin is a
placeholder that never survives -- only the path it works out does.
*/
const folder = pagePath.split('/').slice(0, -1).join('/')
try {
const url = new URL(value, `http://page.invalid/${folder ? `${folder}/` : ''}`)
return `${FILES_PREFIX}${url.pathname.replace(/^\/+/, '')}${url.search}${url.hash}`
} catch {
return src
}
}
/**
* An `<img>` written as HTML rather than as markdown, matched on its `src` and nothing else.
*
* The whitespace before `src` is what keeps `data-src` -- and any other attribute ending in those
* three characters -- out of it, since a word boundary alone sits happily after the hyphen.
*/
const HTML_IMAGE_SRC = /(<img\b[^>]*?\ssrc\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi
/**
* The same resolution, for the images an author wrote as HTML.
*
* Raw HTML reaches the renderer as text -- markdown-it does not parse it -- so this is a pass over
* that text rather than over a token's attributes. It rewrites the `src` of an `img` tag and touches
* nothing else, and every value it produces has been through `URL`, so quoting it is safe.
*/
function rewriteHtmlImages(html, pagePath) {
return html.replace(HTML_IMAGE_SRC, (match, before, quoted, singleQuoted, bare) => {
const value = quoted ?? singleQuoted ?? bare
const resolved = fileSrc(value, pagePath)
return resolved === value ? match : `${before}"${resolved}"`
})
}
export class MarkdownRenderer { export class MarkdownRenderer {
constructor(config = {}) { constructor(config = {}) {
this.md = new MarkdownIt({ this.md = new MarkdownIt({
@ -225,6 +308,40 @@ export class MarkdownRenderer {
return slf.renderToken(tokens, idx, options, env, slf) return slf.renderToken(tokens, idx, options, env, slf)
} }
// --------------------------------
// RESOLVE IMAGE SOURCES
// --------------------------------
/*
Where a picture loads from -- see `fileSrc` for what is rewritten and why the source keeps what
the author wrote.
Wrapped around whichever rule is in place rather than replacing it: the default one is what turns
an image token's children into its `alt` text, and `markdown-it-imsize` has already put the size
it parsed on the same token.
*/
const renderImage =
this.md.renderer.rules.image ??
((tokens, idx, options, env, slf) => slf.renderToken(tokens, idx, options, env, slf))
this.md.renderer.rules.image = (tokens, idx, options, env, slf) => {
const src = tokens[idx].attrGet('src')
if (src) {
tokens[idx].attrSet('src', fileSrc(src, env?.pagePath))
}
return renderImage(tokens, idx, options, env, slf)
}
/*
And the same for an `<img>` the author wrote as HTML, which never becomes a token to hold an
attribute -- so it is the rendered text that is rewritten, after whatever rule produced it.
*/
const passthrough = (tokens, idx) => tokens[idx].content
for (const rule of ['html_block', 'html_inline']) {
const renderHtml = this.md.renderer.rules[rule] ?? passthrough
this.md.renderer.rules[rule] = (tokens, idx, options, env, slf) =>
rewriteHtmlImages(renderHtml(tokens, idx, options, env, slf), env?.pagePath)
}
// -------------------------------- // --------------------------------
// TWEMOJI // TWEMOJI
// -------------------------------- // --------------------------------
@ -268,9 +385,16 @@ export class MarkdownRenderer {
this.md.renderer.rules.blockquote_open = injectLineNumbers this.md.renderer.rules.blockquote_open = injectLineNumbers
} }
render(src) { /**
* @param {string} src Markdown source.
* @param {string} [pagePath] Path of the page this source belongs to, without a leading slash. What
* a relative image resolves against -- see `fileSrc`.
*/
render(src, { pagePath = '' } = {}) {
this.linesMap = [] this.linesMap = []
return this.md.render(src) // -> A fresh env every time, whatever the caller passed: markdown-it keeps per-render state in it
// (footnotes and references), and one shared between renders would carry the last one's
return this.md.render(src, { pagePath })
} }
getClosestPreviewLine(line) { getClosestPreviewLine(line) {

@ -210,7 +210,7 @@ export default defineConfig(({ mode }) => {
host: '0.0.0.0', host: '0.0.0.0',
allowedHosts: true, allowedHosts: true,
port: userConfig.dev?.port, port: userConfig.dev?.port,
proxy: ['_api', '_blocks', '_collab', '_icons', '_site', '_thumb', '_user'].reduce( proxy: ['_api', '_blocks', '_collab', '_files', '_icons', '_site', '_thumb', '_user'].reduce(
(result, key) => { (result, key) => {
result[`/${key}`] = { result[`/${key}`] = {
target: { target: {

Loading…
Cancel
Save