diff --git a/astro.config.mjs b/astro.config.mjs index 93fe652..b015d7c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -53,8 +53,31 @@ try { } } +// Draft posts build and answer at their real URL, but must stay out of the +// sitemap. Collections don't exist yet at config time, so read the frontmatter +// off disk — the same rule content.config.ts applies, one layer earlier. +/** @type {Set} */ +const draftPaths = new Set(); +try { + const postsDir = join(__dirname, 'src/content/posts'); + for (const slug of readdirSync(postsDir)) { + if (slug.startsWith('.')) continue; + try { + const content = readFileSync(join(postsDir, slug, 'index.mdx'), 'utf8'); + const fm = content.match(/^---\n([\s\S]*?)\n---/); + if (fm && /^draft:[ \t]*true[ \t]*$/m.test(fm[1])) draftPaths.add(`/blog/${slug}`); + } catch { + // not a post directory, or no index.mdx — nothing to exclude + } + } +} catch (err) { + if (err instanceof Error && 'code' in err && err.code !== 'ENOENT') { + console.warn('[sitemap] draft scan failed:', err.message); + } +} + /** @type {string[]} */ -const SKIP_PATTERNS = ['/write', '/search']; +const SKIP_PATTERNS = ['/write', '/search', '/drafts']; // Paginated listing pages (/blog/2, /topics/x/2, /tags/x/2, /authors/x/articles/2) // are secondary — keep them below their first page and below real articles. @@ -172,6 +195,7 @@ export default defineConfig({ try { const url = new URL(page); const p = url.pathname.replace(/\/$/, '') || '/'; + if (draftPaths.has(p)) return false; return !SKIP_PATTERNS.some((skip) => p === skip || p.startsWith(skip)); } catch { return true; diff --git a/src/components/DraftNotice.astro b/src/components/DraftNotice.astro new file mode 100644 index 0000000..9b7faaa --- /dev/null +++ b/src/components/DraftNotice.astro @@ -0,0 +1,65 @@ +--- +interface Props { + slug: string; +} + +const { slug } = Astro.props; +--- + + + + diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 9df0932..51918a7 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -8,6 +8,7 @@ import { mdxComponents } from '@/components/MDXComponents.tsx'; import ArticleActions from '@/components/ArticleActions.tsx'; import Comments from '@/components/Comments.tsx'; import Avatar from '@/components/Avatar.astro'; +import DraftNotice from '@/components/DraftNotice.astro'; import { formatDate, sortPostsByDate, tagSlug } from '@/lib/data'; import { topicName } from '@/lib/topics'; import { resolvePostAuthors } from '@/lib/posts'; @@ -15,7 +16,9 @@ import { SITE } from '@/lib/site'; import katexCssUrl from 'katex/dist/katex.min.css?url'; export async function getStaticPaths() { - const posts = await getCollection('posts', ({ data }) => !data.draft); + // Drafts build and answer at their real URL — they are held back from the + // listings, the sitemap and search, not from the site. + const posts = await getCollection('posts'); return posts.map((post) => ({ params: { slug: post.id }, props: { post }, @@ -128,17 +131,20 @@ const breadcrumbJsonLd = { section={topicLabel} author={authorNames} tags={post.data.tags} + noindex={post.data.draft} jsonLd={[articleJsonLd, breadcrumbJsonLd]} > {hasMath && }
← Back to archive + {post.data.draft && } + )} + {loadNotice && ( +
+ {loadNotice} + +
+ )}
)} -
+
{storageOff && ( Autosave is off — your browser blocked storage. )} + diff --git a/src/write/convert/mdxToSource.mjs b/src/write/convert/mdxToSource.mjs index c1d9fb0..61f8c63 100644 --- a/src/write/convert/mdxToSource.mjs +++ b/src/write/convert/mdxToSource.mjs @@ -4,19 +4,59 @@ const D = { backgroundColor: 'default', textColor: 'default', textAlignment: 'left' }; +// Every delimiter carries `(?([\s\S]*?)<\/u>/g, kind: 'style', key: 'underline' }, + { re: /(?([\s\S]*?)<\/(?:strong|b)>/g, kind: 'style', key: 'bold' }, + { re: /(?([\s\S]*?)<\/(?:em|i)>/g, kind: 'style', key: 'italic' }, + { re: /(?([\s\S]*?)<\/(?:s|del)>/g, kind: 'style', key: 'strike' }, + { re: /(?([\s\S]*?)<\/code>/g, kind: 'code' }, + // Background outermost, matching the serializer. The combined form is first so + // it wins the tie against the background-only pattern at the same position. + { + re: /(?((?:(?!<\/span>)[\s\S])*)<\/span><\/span>/g, + kind: 'colorbg', + }, + { + re: /(?((?:(?!<\/span>)[\s\S])*)<\/span>/g, + kind: 'bg', + }, + { + re: /(?((?:(?!<\/span>)[\s\S])*)<\/span>/g, + kind: 'color', + }, + { re: /(? + value.match(new RegExp(`var\\(--${prefix}-([a-z]+)`))?.[1] ?? null; + +// Reverse of the serializer's escapeProse. Runs on plain-text runs only, after +// markup has matched, so an escaped delimiter is never read as markup on the way +// in nor shown with its backslash on the way out. +function unescapeProse(s) { + return s + .replace(/^(\s{0,3})\\([>#+-])/, '$1$2') + .replace(/^(\s{0,3})(\d+)\\([.)])/, '$1$2$3') + .replace(/\\([\\<{*_`[~$])/g, '$1'); +} + function smartQuotes(s) { return s .replace(/(\w)'(\w)/g, '$1’$2') @@ -27,6 +67,8 @@ function smartQuotes(s) { } function inline(text, inherited = {}) { + // Shift+Enter is published as
; BlockNote stores it as a literal newline. + text = text.replace(//g, '\n'); const runs = []; let pos = 0; while (pos < text.length) { @@ -37,13 +79,17 @@ function inline(text, inherited = {}) { if (m && (!best || m.index < best.m.index)) best = { p, m }; } if (!best) { - runs.push({ type: 'text', text: smartQuotes(text.slice(pos)), styles: { ...inherited } }); + runs.push({ + type: 'text', + text: smartQuotes(unescapeProse(text.slice(pos))), + styles: { ...inherited }, + }); break; } if (best.m.index > pos) runs.push({ type: 'text', - text: smartQuotes(text.slice(pos, best.m.index)), + text: smartQuotes(unescapeProse(text.slice(pos, best.m.index))), styles: { ...inherited }, }); const { p, m } = best; @@ -57,6 +103,22 @@ function inline(text, inherited = {}) { href: m[2], content: inline(m[1], inherited).filter((r) => r.type === 'text'), }); + } else if (p.kind === 'colorbg') { + const bg = paletteName(m[1], 'mark'); + const fg = paletteName(m[2], 'tc'); + runs.push( + ...inline(m[3], { + ...inherited, + ...(bg ? { backgroundColor: bg } : {}), + ...(fg ? { textColor: fg } : {}), + }), + ); + } else if (p.kind === 'bg') { + const bg = paletteName(m[1], 'mark'); + runs.push(...inline(m[2], { ...inherited, ...(bg ? { backgroundColor: bg } : {}) })); + } else if (p.kind === 'color') { + const fg = paletteName(m[1], 'tc'); + runs.push(...inline(m[2], { ...inherited, ...(fg ? { textColor: fg } : {}) })); } else { runs.push(...inline(m[1], { ...inherited, [p.key]: true })); } @@ -88,7 +150,7 @@ function unwrapJsxStyle(svg) { ); } -function figureSegment(attrs, inner) { +function figureSegment(attrs, inner, imports = new Map()) { const caption = frameAttr(attrs, 'caption').replace(/\s+/g, ' ').trim(); const width = Number(attrs.match(/width=\{(\d+)\}/)?.[1] ?? '') || ''; // A leading {/* mermaid ... */} comment carries the editable diagram source. @@ -115,6 +177,18 @@ function figureSegment(attrs, inner) { width: width || 360, }; } + // A local image is written ``, where ident is an import + // binding — the import lines are the only route back to a filename. + const local = inner.match(/^]*)\/>$/); + if (local && imports.has(local[1])) { + return { + kind: 'image', + alt: local[2].match(/alt="([^"]*)"/)?.[1] ?? '', + src: `./${imports.get(local[1])}`, + caption, + width: width || 360, + }; + } return { kind: 'md', text: inner }; } @@ -156,8 +230,28 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { const m = fmText.match(new RegExp(`^${key}: (.*)$`, 'm')); return m ? m[1].replace(/^(["'])(.*)\1$/, '$2') : ''; }; + // The editor quotes its output, but a hand-written entry is as likely to use + // YAML's bare form, which JSON.parse rejects. + const parseFlowList = (raw) => + raw + .trim() + .replace(/^\[|\]$/g, '') + .split(',') + .map((s) => + s + .trim() + .replace(/^(['"])([\s\S]*)\1$/, '$2') + .trim(), + ) + .filter(Boolean); + const tagsMatch = fmText.match(/^tags: (\[.*\])$/m); - const tags = tagsMatch ? JSON.parse(tagsMatch[1].replace(/'/g, '"')) : []; + const tags = tagsMatch ? parseFlowList(tagsMatch[1]) : []; + + // Frontmatter is the only record of draft state, so reading it here is what + // lets the checkbox reflect a hand-written entry and stops a re-publish from + // silently publishing it. + const draft = /^draft:[ \t]*true[ \t]*$/m.test(fmText); let n = 0; const id = (p) => `${p}-${++n}`; @@ -176,7 +270,13 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { }); const pattern = - /(]*)>\s*([\s\S]*?)\s*<\/Figure>)|(\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)|(]*)>\s*([\s\S]*?)\s*<\/Table>)/g; + /(]*?)\/>)|(]*)>\s*([\s\S]*?)\s*<\/Figure>)|(\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)|(]*)>\s*([\s\S]*?)\s*<\/Table>)|(
\s*([\s\S]*?)<\/summary>\s*([\s\S]*?)\s*<\/details>)|(]*)>\s*([\s\S]*?)\s*<\/Gallery>)|(]*?)\/>)/g; + + // ident -> filename, from the entry's own import lines. `` + // names its file only through the binding. + const imports = new Map(); + for (const im of body.matchAll(/^import\s+(\w+)\s+from\s+['"]\.\/([^'"]+)['"];?\s*$/gm)) + imports.set(im[1], im[2]); let cursor = 0; const segments = []; @@ -184,30 +284,70 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { while ((m = pattern.exec(body))) { if (m.index > cursor) segments.push({ kind: 'md', text: body.slice(cursor, m.index) }); if (m[1]) { - segments.push(figureSegment(m[2] ?? '', m[3].trim())); - } else if (m[4]) segments.push({ kind: 'note', text: m[5].replace(/\s+/g, ' ').trim() }); - else if (m[6]) { - const code = m[8].replace(/\n$/, ''); - if ((m[7] || '') === 'mermaid') segments.push({ kind: 'mermaid', source: code }); - else segments.push({ kind: 'code', lang: m[7] || 'text', code }); - } else if (m[9]) segments.push({ kind: 'component', name: m[11], frame: frameProps(m[10]) }); - else if (m[12]) + // Figure.astro takes a src prop instead of children, and someone will. + const src = frameAttr(m[2], 'src'); + segments.push( + src + ? { + kind: 'image', + src, + alt: frameAttr(m[2], 'alt'), + caption: frameAttr(m[2], 'caption'), + width: Number(m[2].match(/width=\{(\d+)\}/)?.[1] ?? '') || 360, + } + : { kind: 'md', text: m[1] }, + ); + } else if (m[3]) { + segments.push(figureSegment(m[4] ?? '', m[5].trim(), imports)); + } else if (m[6]) segments.push({ kind: 'note', text: m[7].replace(/\s+/g, ' ').trim() }); + else if (m[8]) { + const code = m[10].replace(/\n$/, ''); + if ((m[9] || '') === 'mermaid') segments.push({ kind: 'mermaid', source: code }); + else segments.push({ kind: 'code', lang: m[9] || 'text', code }); + } else if (m[11]) segments.push({ kind: 'component', name: m[13], frame: frameProps(m[12]) }); + else if (m[14]) segments.push({ kind: 'component', - name: m[13], + name: m[15], frame: { frameTitle: '', frameCaption: '', frameSize: 'normal', frameExpand: false }, }); - else if (m[14]) { - const caption = frameAttr(m[15], 'caption'); + else if (m[16]) { + const caption = frameAttr(m[17], 'caption'); segments.push({ kind: 'table', - text: m[16], + text: m[18], style: { - border: m[15].match(/variant="(\w+)"/)?.[1] ?? 'rule', - zebra: /(^|\s)zebra(\s|$|=)/.test(m[15]), + border: m[17].match(/variant="(\w+)"/)?.[1] ?? 'rule', + zebra: /(^|\s)zebra(\s|$|=)/.test(m[17]), ...(caption ? { caption } : {}), }, }); + } else if (m[19]) { + segments.push({ kind: 'details', summary: m[20].trim(), body: m[21] }); + } else if (m[22]) { + // A gallery entry is either a stored asset's filename, written + // ``, or an absolute URL, written as a plain . + const files = [...m[24].matchAll(/<(Image|img)\s+([^>]*?)\/>/g)].map((g) => { + const ident = g[2].match(/src=\{(\w+)\}/)?.[1]; + return { + file: ident ? imports.get(ident) : g[2].match(/src="([^"]+)"/)?.[1], + alt: g[2].match(/alt="([^"]*)"/)?.[1] ?? '', + }; + }); + if (files.length > 0 && files.every((f) => f.file)) { + segments.push({ + kind: 'gallery', + fileNames: files.map((f) => f.file), + alts: files.map((f) => f.alt), + min: Number(m[23].match(/min=\{(\d+)\}/)?.[1] ?? '') || '', + }); + } else { + segments.push({ kind: 'md', text: m[22] }); + } + } else if (m[25]) { + const id = m[26].match(/id="([^"]*)"/)?.[1] ?? ''; + if (id) segments.push({ kind: 'video', videoId: id, caption: frameAttr(m[26], 'caption') }); + else segments.push({ kind: 'md', text: m[25] }); } cursor = m.index + m[0].length; } @@ -329,7 +469,11 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { para.push(lines[i]); i++; } - push('paragraph', { ...D }, inline(para.join(' '))); + // The serializer wraps a paragraph in

when it opens with a tag, so + // MDX keeps it a paragraph. Unwrap before reading the inline marks. + const joined = para.join(' ').trim(); + const unwrapped = /^

[\s\S]*<\/p>$/.test(joined) ? joined.slice(3, -4) : joined; + push('paragraph', { ...D }, inline(unwrapped)); } } @@ -366,6 +510,19 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { source: getComponentSource(seg.name), ...seg.frame, }); + else if (seg.kind === 'gallery') + push('gallery', { + fileNames: JSON.stringify(seg.fileNames), + alts: JSON.stringify(seg.alts), + min: seg.min, + }); + else if (seg.kind === 'video') push('video', { videoId: seg.videoId, caption: seg.caption }); + else if (seg.kind === 'details') { + const before = blocks.length; + emitMd(seg.body); + const children = blocks.splice(before); + push('toggleListItem', { ...D }, inline(seg.summary), children); + } } const doc = { @@ -376,7 +533,7 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { summary: fmVal('summary'), authors: (() => { const inlineList = fmText.match(/^authors: (\[.*\])$/m); - if (inlineList) return JSON.parse(inlineList[1].replace(/'/g, '"')); + if (inlineList) return parseFlowList(inlineList[1]); const list = [...fmText.matchAll(/^ {2}- (.+)$/gm)] .map((x) => x[1]) .filter((a) => !/^\d{4}-/.test(a)); @@ -387,8 +544,11 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { topicName: fmVal('topic'), tags, slug, - coverFileName: '', - ogCard: false, + // Frontmatter the editor can set but the converter used to ignore, so a + // hand-edit to either was dropped when the entry was reopened. + coverFileName: (fmVal('cover').match(/^\.\/(.+)$/) ?? [])[1] ?? '', + ogCard: /^ogCard:[ \t]*true[ \t]*$/m.test(fmText), + draft, proposedTopic: '', newAuthor: null, date: fmVal('date'), @@ -397,6 +557,27 @@ export function convertMdx(src, { slug = 'post-slug', componentSource } = {}) { tableVariants, }; + // A component with no editor block survives conversion as literal text, and + // the serializer then escapes its `<` — so re-publishing turns it into visible + // markup. The editor cannot represent it, but it can refuse to do so quietly. + const stray = new Set(); + const scan = (list) => { + for (const b of list) { + if (Array.isArray(b.content)) { + for (const run of b.content) { + for (const m of String(run?.text ?? '').matchAll(/<([A-Z]\w*)[\s/>]/g)) stray.add(m[1]); + } + } + if (b.children?.length) scan(b.children); + } + }; + scan(blocks); + for (const name of stray) { + warnings.push( + `<${name}> has no editor block — it will be turned into plain text if you publish from here. Edit this entry's index.mdx by hand instead.`, + ); + } + const ids = blocks.map((b) => b.id); if (new Set(ids).size !== ids.length) throw new Error('duplicate ids'); for (const b of blocks) if (!ALLOWED_TYPES.has(b.type)) throw new Error('bad type ' + b.type); diff --git a/src/write/editor/editor-theme.css b/src/write/editor/editor-theme.css index 829c274..51bf1b3 100644 --- a/src/write/editor/editor-theme.css +++ b/src/write/editor/editor-theme.css @@ -1500,3 +1500,52 @@ color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); } + +.write-draft-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + margin-right: auto; + font-family: var(--font-sans); + font-size: 13px; + color: var(--ink-2); + cursor: pointer; + user-select: none; +} + +.write-draft-toggle input { + width: 15px; + height: 15px; + accent-color: var(--accent); + cursor: pointer; +} + +.write-load-notice { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 20px; + padding: 12px 16px; + border: 1px solid var(--line-2); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent-soft); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.55; + color: var(--ink-body); +} + +.write-load-notice button { + flex-shrink: 0; + border: none; + background: none; + font-size: 14px; + line-height: 1; + color: var(--ink-3); + cursor: pointer; +} + +.write-load-notice button:hover { + color: var(--ink); +} diff --git a/src/write/serialize/fetchExisting.ts b/src/write/serialize/fetchExisting.ts index 42b0c95..487cd97 100644 --- a/src/write/serialize/fetchExisting.ts +++ b/src/write/serialize/fetchExisting.ts @@ -1,10 +1,23 @@ import { restoreAsset } from '../storage/assets'; import { parseSource, SOURCE_FILENAME, type ParsedSource } from './source'; import { convertMdx } from '../convert/mdxToSource.mjs'; -import type { SBlock } from './toMdx'; +import { serializePost, type PostMeta, type SBlock } from './toMdx'; + +// A loaded post, plus anything the writer should know about how it loaded. +export type LoadedSource = ParsedSource & { notice?: string }; export const NOT_FOUND_MESSAGE = 'No editable post found at that link. Please try manual update.'; +export const DIVERGED_MESSAGE = + 'This post\u2019s index.mdx was edited outside the editor, so it was loaded from the ' + + 'markdown rather than the saved editor data. Check custom components and table ' + + 'styling before publishing.'; + +export const UNCONVERTIBLE_MESSAGE = + 'This post\u2019s index.mdx was edited outside the editor but could not be read, so the ' + + 'older editor data was loaded instead. Publishing will overwrite those edits \u2014 copy ' + + 'anything you need out of index.mdx first.'; + export const BAD_SOURCE_MESSAGE = 'The editor data for that post is unreadable. Edit its index.mdx by hand instead.'; @@ -34,7 +47,11 @@ function imageNames(blocks: SBlock[], cover: string): string[] { if (b.type === 'figure' && b.props.fileName) out.push(String(b.props.fileName)); if (b.type === 'gallery') { try { - out.push(...(JSON.parse(String(b.props.fileNames || '[]')) as string[])); + out.push( + ...(JSON.parse(String(b.props.fileNames || '[]')) as string[]).filter( + (n) => !/^https?:/.test(n), + ), + ); } catch { // malformed gallery — skip it } @@ -47,18 +64,45 @@ function imageNames(blocks: SBlock[], cover: string): string[] { return [...new Set(out.filter(Boolean))]; } +// A post's body, normalised for comparison. Frontmatter is dropped +// deliberately: the serializer stamps `updated:` with today's date, so a +// regenerated file never matches a stored one there. What matters is the content. +function bodyOf(mdx: string): string { + return mdx + .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '') + .replace(/\r\n/g, '\n') + .trim(); +} + +// Frontmatter fields the MDX converter can read, and so is authoritative on. +function mergeMeta(sidecar: PostMeta, fromMdx: PostMeta): PostMeta { + return { + ...sidecar, + title: fromMdx.title || sidecar.title, + summary: fromMdx.summary || sidecar.summary, + tags: fromMdx.tags.length ? fromMdx.tags : sidecar.tags, + draft: fromMdx.draft, + date: fromMdx.date || sidecar.date, + coverFileName: fromMdx.coverFileName || sidecar.coverFileName, + }; +} + // Fallback for posts committed without editor data: fetch the index.mdx, -// convert it, pull its component files and repo-hosted images. -async function fetchFromMdx(base: string, slug: string): Promise { - let res: Response; - try { - res = await fetch(base + 'index.mdx', { cache: 'no-store' }); - } catch { - throw new Error(NETWORK_MESSAGE); +// convert it, pull its component files and repo-hosted images. `known` skips the +// fetch when the caller already holds the file. +async function fetchFromMdx(base: string, slug: string, known?: string): Promise { + let text = known; + if (text === undefined) { + let res: Response; + try { + res = await fetch(base + 'index.mdx', { cache: 'no-store' }); + } catch { + throw new Error(NETWORK_MESSAGE); + } + if (res.status === 404) throw new Error(NOT_FOUND_MESSAGE); + if (!res.ok) throw new Error(NETWORK_MESSAGE); + text = await res.text(); } - if (res.status === 404) throw new Error(NOT_FOUND_MESSAGE); - if (!res.ok) throw new Error(NETWORK_MESSAGE); - const text = await res.text(); try { const names = new Set(); @@ -80,11 +124,53 @@ async function fetchFromMdx(base: string, slug: string): Promise { } }), ); - const { doc } = convertMdx(text, { + const { doc, warnings } = convertMdx(text, { slug, componentSource: (name) => sources.get(name) ?? '', }); + // The cover lives in the post folder like any other asset, and the editor + // shows it from local storage — without this it opens blank. + if (doc.meta.coverFileName) { + try { + const r = await fetch(base + encodeURIComponent(doc.meta.coverFileName), { + cache: 'no-store', + }); + if (r.ok) { + const blob = await r.blob(); + const name = doc.meta.coverFileName; + restoreAsset(name, new File([blob], name, { type: blob.type })); + } + } catch { + // a missing cover shouldn't block opening the post + } + } + + const galleryNames = doc.blocks + .filter((b) => b.type === 'gallery') + .flatMap((b) => { + try { + // Remote entries are already fetchable by the browser. + return (JSON.parse(String(b.props.fileNames || '[]')) as string[]).filter( + (n) => !/^https?:/.test(n), + ); + } catch { + return []; + } + }); + await Promise.all( + galleryNames.map(async (name) => { + try { + const r = await fetch(base + encodeURIComponent(name), { cache: 'no-store' }); + if (!r.ok) return; + const blob = await r.blob(); + restoreAsset(name, new File([blob], name, { type: blob.type })); + } catch { + // a single missing image shouldn't block opening the post + } + }), + ); + const local = doc.blocks.filter( (b) => b.type === 'figure' && b.props.src && !/^(https?:|data:)/.test(String(b.props.src)), ); @@ -104,7 +190,9 @@ async function fetchFromMdx(base: string, slug: string): Promise { } }), ); - return doc; + // Anything the converter could not represent is the writer's problem to know + // about before they hit publish, not after. + return warnings.length > 0 ? { ...doc, notice: warnings.join(' ') } : doc; } catch { throw new Error(BAD_SOURCE_MESSAGE); } @@ -113,7 +201,7 @@ async function fetchFromMdx(base: string, slug: string): Promise { // Pulls a published post's editor data + images straight from GitHub (raw), // so a URL is all the writer needs to re-open it. Never throws for a missing // image — only for a missing/unreadable post. -export async function fetchExisting(repoUrl: string, input: string): Promise { +export async function fetchExisting(repoUrl: string, input: string): Promise { const slug = slugFromInput(input); const base = rawBase(repoUrl, slug); @@ -129,6 +217,51 @@ export async function fetchExisting(repoUrl: string, input: string): Promise { try { diff --git a/src/write/serialize/toMdx.test.ts b/src/write/serialize/toMdx.test.ts index f4b3f54..05bba06 100644 --- a/src/write/serialize/toMdx.test.ts +++ b/src/write/serialize/toMdx.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { escapeText, + INLINE_MATH_RE, serializeInline, serializePost, type InlineRun, @@ -69,9 +70,16 @@ describe('escapeText', () => { expect(escapeText('an *update* $\\Delta W$ to $W$')).toBe('an \\*update\\* $\\Delta W$ to $W$'); }); - it('does not treat spaced dollar amounts as math', () => { - expect(escapeText('$5 and $10')).toBe('$5 and $10'); - expect(escapeText('costs $5, saves _time_')).toBe('costs $5, saves \\_time\\_'); + it('escapes dollar amounts so they cannot pair into a math span', () => { + expect(escapeText('$5 and $10')).toBe('\\$5 and \\$10'); + expect(escapeText('costs $5, saves _time_')).toBe('costs \\$5, saves \\_time\\_'); + }); + + it('leaves escaped dollar amounts alone rather than reading them as math', () => { + expect(escapeText('a \\$4.00 click is worth \\$8.00')).toBe( + 'a \\\\\\$4.00 click is worth \\\\\\$8.00', + ); + expect(INLINE_MATH_RE.test('\\$4.00 and \\$8.00')).toBe(false); }); }); diff --git a/src/write/serialize/toMdx.ts b/src/write/serialize/toMdx.ts index dffbe25..12ecfea 100644 --- a/src/write/serialize/toMdx.ts +++ b/src/write/serialize/toMdx.ts @@ -40,6 +40,11 @@ export type PostMeta = { // Opt in to a generated share card (title over the cover) instead of the raw // cover. Only meaningful when a cover is set. ogCard?: boolean; + // Holds the post back from every listing, the sitemap and search. It still + // builds and still answers at its real URL — see `draft` in + // src/content.config.ts. Round-trips through frontmatter, so a post written by + // hand and one written here mean the same thing. + draft?: boolean; // Set only when editing an existing post; preserves its original publish date. date?: string; // A topic the writer proposes that isn't in the list yet — a maintainer (or, later, @@ -73,14 +78,20 @@ type Ctx = { tableVariants: Record; }; -// Matches remark-math's inline rule: no space just inside either delimiter. -export const INLINE_MATH_RE = /\$(\S(?:[^$\n]*\S)?)\$/g; +// Matches remark-math's inline rule: no space just inside either delimiter, and +// neither delimiter escaped — a `\$` is a currency sign, not the start of math. +export const INLINE_MATH_RE = /(? `\\${c}`) - .replace(/^(\s{0,3})(\d+)([.)])/, '$1$2\\$3') - .replace(/^(\s{0,3})([>#+-])/, '$1\\$2'); + return ( + s + // `$` is escaped for the same reason as `*` and `_`: left bare, a pair of + // dollar amounts in one paragraph reads as an inline math span. Real math + // never reaches here — escapeText passes those spans through untouched. + .replace(/[\\<{*_`[~$]/g, (c) => `\\${c}`) + .replace(/^(\s{0,3})(\d+)([.)])/, '$1$2\\$3') + .replace(/^(\s{0,3})([>#+-])/, '$1\\$2') + ); } // Inline math spans pass through verbatim — escaping \ or _ inside them would @@ -138,11 +149,15 @@ function safeColor( } function wrapStyles(text: string, styles: Record): string { + // Markdown only closes an emphasis run when a non-space sits just inside the + // marker, so whitespace at the edges of a styled run has to stay outside it. + const [, lead, core, trail] = /^(\s*)([\s\S]*?)(\s*)$/.exec(text) as RegExpExecArray; + if (!core) return escapeText(text).replace(/\n/g, '
'); let out: string; if (styles.code) { - out = text.includes('`') ? `\`\` ${text} \`\`` : `\`${text}\``; + out = core.includes('`') ? `\`\` ${core} \`\`` : `\`${core}\``; } else { - out = escapeText(text); + out = escapeText(core); if (styles.bold) out = `**${out}**`; if (styles.italic) out = `_${out}_`; if (styles.strike) out = `~~${out}~~`; @@ -156,7 +171,13 @@ function wrapStyles(text: string, styles: Record): str const bg = safeColor(styles.backgroundColor, BG_COLORS, 'mark'); if (bg) out = `${out}`; } - return out; + // Shift+Enter lands as a literal newline inside the run. A bare newline is a + // markdown soft break and, inside a quote, ends the blockquote at the next + // blank line — so publish it as an explicit
, which mdxToSource reads + // back as a newline. Code spans keep raw newlines: a
inside backticks + // would render as text. + const result = lead + out + trail; + return styles.code ? result : result.replace(/\n/g, '
'); } export function serializeInline(content: unknown): string { @@ -299,7 +320,15 @@ function serializeGallery(block: SBlock, ctx: Ctx): string { const alts = JSON.parse(String(block.props.alts || '[]')) as string[]; const min = block.props.min; const minAttr = min !== '' && min != null ? ` min={${Number(min)}}` : ''; - const images = fileNames.map((f, i) => ` ${imageLine(f, alts[i] ?? '', ctx)}`).join('\n'); + // A gallery entry is either a stored asset's filename or an absolute URL. + // Only the former has a file to import. + const images = fileNames + .map((f, i) => + /^https?:/.test(f) + ? ` ` + : ` ${imageLine(f, alts[i] ?? '', ctx)}`, + ) + .join('\n'); return `\n${images}\n`; } @@ -377,8 +406,13 @@ function detailsBlock(block: SBlock, ctx: Ctx): string { function serializeBlock(block: SBlock, ctx: Ctx, listNumber: number): string { switch (block.type) { - case 'paragraph': - return aligned(block, serializeInline(block.content)); + case 'paragraph': { + const text = serializeInline(block.content); + // MDX reads a line opening with a tag as a JSX block, which is not wrapped + // in

and runs into the block after it. Wrapping keeps it a paragraph; + // mdxToSource unwraps it on the way back in. + return aligned(block, text.startsWith('<') ? `

${text}

` : text); + } case 'heading': { if (block.props.isToggleable) return detailsBlock(block, ctx); const level = Math.min(Number(block.props.level ?? 1), 3); @@ -501,6 +535,9 @@ function buildFrontmatter(meta: PostMeta, blocks: SBlock[], opts: SerializeOptio if (meta.proposedTopic?.trim()) lines.push(`proposedTopic: ${yaml(meta.proposedTopic.trim())}`); if (meta.coverFileName) lines.push(`cover: ${yaml(`./${meta.coverFileName}`)}`); if (meta.coverFileName && meta.ogCard) lines.push('ogCard: true'); + // Only written when set: the schema defaults it to false, so `draft: false` on + // every published post would be noise. + if (meta.draft) lines.push('draft: true'); return `---\n${lines.join('\n')}\n---`; }