From b03d27cc3a628b819e9067a1fca387f3f6a338f5 Mon Sep 17 00:00:00 2001 From: Neeldhara Misra Date: Fri, 26 Jun 2026 01:44:41 +0530 Subject: [PATCH] Make callout headings explicit --- CONTENT_BRIDGE.md | 30 +++- scripts/import-quarto-blog.mjs | 15 +- scripts/pandoc-filters/semantic-blocks.lua | 88 +++++++++-- .../pandoc-templates/semantic-preamble.tex | 140 +++++++++++++----- sites/art/src/remark/callouts.mjs | 130 +++++++++++----- sites/art/src/styles/article.css | 9 ++ sites/poetry/src/remark/callouts.mjs | 130 +++++++++++----- sites/poetry/src/styles/article.css | 9 ++ sites/puzzles/src/remark/callouts.mjs | 130 +++++++++++----- sites/puzzles/src/styles/article.css | 9 ++ sites/reflections/src/remark/callouts.mjs | 130 +++++++++++----- sites/reflections/src/styles/article.css | 9 ++ sites/research/src/remark/callouts.mjs | 130 +++++++++++----- sites/research/src/styles/article.css | 9 ++ sites/reviews/src/remark/callouts.mjs | 130 +++++++++++----- sites/reviews/src/styles/article.css | 9 ++ sites/vibes/src/remark/callouts.mjs | 130 +++++++++++----- sites/vibes/src/styles/article.css | 9 ++ src/remark-callouts.mjs | 127 ++++++++++++---- src/styles/article.css | 9 ++ 20 files changed, 1052 insertions(+), 330 deletions(-) diff --git a/CONTENT_BRIDGE.md b/CONTENT_BRIDGE.md index 420b36e..9e3013d 100644 --- a/CONTENT_BRIDGE.md +++ b/CONTENT_BRIDGE.md @@ -94,7 +94,9 @@ forward, use normal Markdown and let Astro CSS handle presentation. ### Callouts -Write callouts as portable blockquotes. This is the canonical form: +Write callouts as portable blockquotes. The type controls the visual styling, +but it does not create a visible heading by itself. This is the canonical form +for a titleless note: ```markdown > **Note** @@ -110,13 +112,31 @@ Supported labels are `Note`, `Tip`, `Warning`, `Caution`, and `Aside`: > This point needs attention. ``` -Obsidian-style labels also work: +Obsidian-style bracket labels also work. A plain bracket label is still +titleless: ```markdown > [!tip] > This is a tip. ``` +To show a heading, put it explicitly inside the bracket after `:` or `|`: + +```markdown +> [!tip: Useful observation] +> +> Text of the callout. + +> [!warning|Check this assumption] +> +> This point needs attention. +``` + +The text after `:` or `|` is treated as the only callout heading. Do not write +`> [!tip] Useful observation` for a heading; that form is interpreted as a +titleless tip whose body begins with β€œUseful observation”. This keeps the +Markdown unambiguous across Astro, Pandoc, and LaTeX. + For quick writing, a few leading emoji markers are recognized and stripped: ```markdown @@ -139,9 +159,9 @@ aside -> blogasidebox The LaTeX boxes use `tcolorbox` with the `breakable` and `enhanced` libraries, a light tinted background, a thin frame, a stronger left rule, and a small -attached title label. This follows the same design vocabulary as the DM book -callouts: compact colored boxes that can split across pages without becoming -visually heavy. +attached title only when the Markdown explicitly provides one. This follows the +same design vocabulary as the DM book callouts: compact colored boxes that can +split across pages without becoming visually heavy. Quarto margin notes such as `[text]{.aside}` were converted to: diff --git a/scripts/import-quarto-blog.mjs b/scripts/import-quarto-blog.mjs index cba405b..aa947ca 100644 --- a/scripts/import-quarto-blog.mjs +++ b/scripts/import-quarto-blog.mjs @@ -279,7 +279,8 @@ authorName: "Neeldhara" - Standard Markdown headings, lists, links, tables, code fences, footnotes, and raw HTML are preserved. - Use colocated relative assets: \`![Caption](image.png)\`. - Quarto image attributes such as \`{width=70%}\` are removed during import. Use normal Markdown now; layout should be handled by Astro CSS. -- Quarto callouts are converted to simple blockquotes: +- Quarto callouts are converted to simple blockquotes. The callout type controls + styling only; it does not create a visible heading by default: \`\`\`markdown > **Note** @@ -287,14 +288,22 @@ authorName: "Neeldhara" > Text of the callout. \`\`\` -- For new notes, use the same blockquote convention for portable callouts: +- For new notes, the bracket form is also supported: \`\`\`markdown -> **Warning** +> [!warning] > > This point needs attention. \`\`\` +- To show a heading, put it explicitly inside the bracket after \`:\` or \`|\`: + +\`\`\`markdown +> [!tip: Useful observation] +> +> Text of the callout. +\`\`\` + - Quarto margin notes such as \`[text]{.aside}\` are converted to: \`\`\`markdown diff --git a/scripts/pandoc-filters/semantic-blocks.lua b/scripts/pandoc-filters/semantic-blocks.lua index b34e8f0..eabf7b8 100644 --- a/scripts/pandoc-filters/semantic-blocks.lua +++ b/scripts/pandoc-filters/semantic-blocks.lua @@ -101,6 +101,13 @@ local function first_inline_text(block) return inline_text(block.content[1]) end +local function paragraph_text(block) + if not block or block.t ~= "Para" then + return "" + end + return pandoc.utils.stringify(block) +end + local function latex_escape(value) value = tostring(value or "") value = value:gsub("\\", "\\textbackslash{}") @@ -356,6 +363,70 @@ local function strip_text_prefix(block, pattern) end end +local function cleanup_inline_start(block) + if not block or block.t ~= "Para" or not block.content then + return + end + + while #block.content > 0 do + local first = block.content[1] + if first.t == "Space" or first.t == "SoftBreak" or first.t == "LineBreak" then + table.remove(block.content, 1) + elseif first.t == "Str" then + first.text = first.text:gsub("^%s+", "") + if first.text == "" then + table.remove(block.content, 1) + else + return + end + else + return + end + end +end + +local function strip_bracket_callout(block) + if not block or block.t ~= "Para" or not block.content then + return + end + + while #block.content > 0 do + local first = block.content[1] + local text = inline_text(first) + local close_index = text:find("%]") + + if close_index then + local remaining = text:sub(close_index + 1):gsub("^%s+", "") + if first.t == "Str" and remaining ~= "" then + first.text = remaining + elseif remaining ~= "" then + block.content[1] = pandoc.Str(remaining) + else + table.remove(block.content, 1) + end + break + end + + table.remove(block.content, 1) + end + + cleanup_inline_start(block) +end + +local function parse_bracket_callout(text) + local key, title = text:match("^%s*%[!([%a-]+)%s*[:|]%s*([^%]]+)%]") + if key then + return normalize_label(key), title:gsub("^%s+", ""):gsub("%s+$", "") + end + + key = text:match("^%s*%[!([%a-]+)%]") + if key then + return normalize_label(key), "" + end + + return nil, nil +end + local function detect_callout(block) local text = first_inline_text(block) if text == "" then @@ -365,21 +436,18 @@ local function detect_callout(block) if block.content[1].t == "Strong" then local key = normalize_label(text) if callout_labels[key] then - return { key = key, label = callout_labels[key], remover = "strong" } + return { key = key, title = "", remover = "strong" } end end - local bracket = text:match("^%[!([%a-]+)%]") - if bracket then - local key = normalize_label(bracket) - if callout_labels[key] then - return { key = key, label = callout_labels[key], remover = "bracket" } - end + local key, title = parse_bracket_callout(paragraph_text(block)) + if key and callout_labels[key] then + return { key = key, title = title or "", remover = "bracket" } end for _, item in ipairs(callout_markers) do if text:sub(1, #item.marker) == item.marker then - return { key = item.key, label = callout_labels[item.key], remover = "marker", marker = item.marker } + return { key = item.key, title = "", remover = "marker", marker = item.marker } end end @@ -400,7 +468,7 @@ local function callout_to_latex(el) if callout.remover == "strong" then remove_first_inline(blocks[1]) elseif callout.remover == "bracket" then - strip_text_prefix(blocks[1], "^%[![%a-]+%]%s*") + strip_bracket_callout(blocks[1]) elseif callout.remover == "marker" then strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*") end @@ -412,7 +480,7 @@ local function callout_to_latex(el) local env = callout_envs[callout.key] or "blognotebox" return pandoc.RawBlock( "latex", - "\\begin{" .. env .. "}{}\n" + "\\begin{" .. env .. "}{" .. latex_escape(callout.title or "") .. "}\n" .. blocks_to_latex(blocks) .. "\n\\end{" .. env .. "}" ) diff --git a/scripts/pandoc-templates/semantic-preamble.tex b/scripts/pandoc-templates/semantic-preamble.tex index 2d2d1bd..19ca9f1 100644 --- a/scripts/pandoc-templates/semantic-preamble.tex +++ b/scripts/pandoc-templates/semantic-preamble.tex @@ -46,49 +46,119 @@ } } -\newtcolorbox{blognotebox}[2][]{ - blog callout, - title={NOTE\if\relax\detokenize{#2}\relax\else: #2\fi}, - colback=BlogCalloutNoteBack, - colframe=BlogCalloutNoteFrame, - boxed title style={colback=BlogCalloutNoteBack}, - #1 +\NewDocumentEnvironment{blognotebox}{O{} m}{% + \if\relax\detokenize{#2}\relax + \begin{tcolorbox}[ + blog callout, + colback=BlogCalloutNoteBack, + colframe=BlogCalloutNoteFrame, + boxed title style={colback=BlogCalloutNoteBack}, + #1 + ]% + \else + \begin{tcolorbox}[ + blog callout, + title={#2}, + colback=BlogCalloutNoteBack, + colframe=BlogCalloutNoteFrame, + boxed title style={colback=BlogCalloutNoteBack}, + #1 + ]% + \fi +}{% + \end{tcolorbox}% } -\newtcolorbox{blogtipbox}[2][]{ - blog callout, - title={TIP\if\relax\detokenize{#2}\relax\else: #2\fi}, - colback=BlogCalloutTipBack, - colframe=BlogCalloutTipFrame, - boxed title style={colback=BlogCalloutTipBack}, - #1 +\NewDocumentEnvironment{blogtipbox}{O{} m}{% + \if\relax\detokenize{#2}\relax + \begin{tcolorbox}[ + blog callout, + colback=BlogCalloutTipBack, + colframe=BlogCalloutTipFrame, + boxed title style={colback=BlogCalloutTipBack}, + #1 + ]% + \else + \begin{tcolorbox}[ + blog callout, + title={#2}, + colback=BlogCalloutTipBack, + colframe=BlogCalloutTipFrame, + boxed title style={colback=BlogCalloutTipBack}, + #1 + ]% + \fi +}{% + \end{tcolorbox}% } -\newtcolorbox{blogwarningbox}[2][]{ - blog callout, - title={WARNING\if\relax\detokenize{#2}\relax\else: #2\fi}, - colback=BlogCalloutWarningBack, - colframe=BlogCalloutWarningFrame, - boxed title style={colback=BlogCalloutWarningBack}, - #1 +\NewDocumentEnvironment{blogwarningbox}{O{} m}{% + \if\relax\detokenize{#2}\relax + \begin{tcolorbox}[ + blog callout, + colback=BlogCalloutWarningBack, + colframe=BlogCalloutWarningFrame, + boxed title style={colback=BlogCalloutWarningBack}, + #1 + ]% + \else + \begin{tcolorbox}[ + blog callout, + title={#2}, + colback=BlogCalloutWarningBack, + colframe=BlogCalloutWarningFrame, + boxed title style={colback=BlogCalloutWarningBack}, + #1 + ]% + \fi +}{% + \end{tcolorbox}% } -\newtcolorbox{blogcautionbox}[2][]{ - blog callout, - title={CAUTION\if\relax\detokenize{#2}\relax\else: #2\fi}, - colback=BlogCalloutCautionBack, - colframe=BlogCalloutCautionFrame, - boxed title style={colback=BlogCalloutCautionBack}, - #1 +\NewDocumentEnvironment{blogcautionbox}{O{} m}{% + \if\relax\detokenize{#2}\relax + \begin{tcolorbox}[ + blog callout, + colback=BlogCalloutCautionBack, + colframe=BlogCalloutCautionFrame, + boxed title style={colback=BlogCalloutCautionBack}, + #1 + ]% + \else + \begin{tcolorbox}[ + blog callout, + title={#2}, + colback=BlogCalloutCautionBack, + colframe=BlogCalloutCautionFrame, + boxed title style={colback=BlogCalloutCautionBack}, + #1 + ]% + \fi +}{% + \end{tcolorbox}% } -\newtcolorbox{blogasidebox}[2][]{ - blog callout, - title={ASIDE\if\relax\detokenize{#2}\relax\else: #2\fi}, - colback=BlogCalloutAsideBack, - colframe=BlogCalloutAsideFrame, - boxed title style={colback=BlogCalloutAsideBack}, - #1 +\NewDocumentEnvironment{blogasidebox}{O{} m}{% + \if\relax\detokenize{#2}\relax + \begin{tcolorbox}[ + blog callout, + colback=BlogCalloutAsideBack, + colframe=BlogCalloutAsideFrame, + boxed title style={colback=BlogCalloutAsideBack}, + #1 + ]% + \else + \begin{tcolorbox}[ + blog callout, + title={#2}, + colback=BlogCalloutAsideBack, + colframe=BlogCalloutAsideFrame, + boxed title style={colback=BlogCalloutAsideBack}, + #1 + ]% + \fi +}{% + \end{tcolorbox}% } \ExplSyntaxOn diff --git a/sites/art/src/remark/callouts.mjs b/sites/art/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/art/src/remark/callouts.mjs +++ b/sites/art/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/art/src/styles/article.css b/sites/art/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/art/src/styles/article.css +++ b/sites/art/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/poetry/src/remark/callouts.mjs b/sites/poetry/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/poetry/src/remark/callouts.mjs +++ b/sites/poetry/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/poetry/src/styles/article.css b/sites/poetry/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/poetry/src/styles/article.css +++ b/sites/poetry/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/puzzles/src/remark/callouts.mjs b/sites/puzzles/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/puzzles/src/remark/callouts.mjs +++ b/sites/puzzles/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/puzzles/src/styles/article.css b/sites/puzzles/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/puzzles/src/styles/article.css +++ b/sites/puzzles/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/reflections/src/remark/callouts.mjs b/sites/reflections/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/reflections/src/remark/callouts.mjs +++ b/sites/reflections/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/reflections/src/styles/article.css b/sites/reflections/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/reflections/src/styles/article.css +++ b/sites/reflections/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/research/src/remark/callouts.mjs b/sites/research/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/research/src/remark/callouts.mjs +++ b/sites/research/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/research/src/styles/article.css b/sites/research/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/research/src/styles/article.css +++ b/sites/research/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/reviews/src/remark/callouts.mjs b/sites/reviews/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/reviews/src/remark/callouts.mjs +++ b/sites/reviews/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/reviews/src/styles/article.css b/sites/reviews/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/reviews/src/styles/article.css +++ b/sites/reviews/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/sites/vibes/src/remark/callouts.mjs b/sites/vibes/src/remark/callouts.mjs index e9bd010..c145bb6 100644 --- a/sites/vibes/src/remark/callouts.mjs +++ b/sites/vibes/src/remark/callouts.mjs @@ -1,19 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); const CALLOUT_MARKERS = new Map([ - ["πŸ“", "Note"], - ["πŸ’‘", "Tip"], - ["⚑", "Tip"], - ["⚠️", "Warning"], - ["⚠", "Warning"], + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], ]); +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -21,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -29,48 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; } const trimmed = first.value.trimStart(); - for (const [marker, label] of CALLOUT_MARKERS) { + for (const [marker, type] of CALLOUT_MARKERS) { if (trimmed.startsWith(marker)) { - return label; + return { kind: "marker", type, title: "", marker }; } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - for (const marker of CALLOUT_MARKERS.keys()) { - const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); - } - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/sites/vibes/src/styles/article.css b/sites/vibes/src/styles/article.css index 64b7e91..f263513 100644 --- a/sites/vibes/src/styles/article.css +++ b/sites/vibes/src/styles/article.css @@ -221,6 +221,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; } diff --git a/src/remark-callouts.mjs b/src/remark-callouts.mjs index 56f6a1f..c145bb6 100644 --- a/src/remark-callouts.mjs +++ b/src/remark-callouts.mjs @@ -1,11 +1,21 @@ -const CALLOUT_LABELS = new Map([ - ["aside", "Aside"], - ["caution", "Caution"], - ["note", "Note"], - ["tip", "Tip"], - ["warning", "Warning"], +const CALLOUT_TYPES = new Set([ + "aside", + "caution", + "note", + "tip", + "warning", ]); +const CALLOUT_MARKERS = new Map([ + ["πŸ“", "note"], + ["πŸ’‘", "tip"], + ["⚑", "tip"], + ["⚠️", "warning"], + ["⚠", "warning"], +]); + +const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i; + function textFromInline(node) { if (!node) return ""; if (typeof node.value === "string") return node.value; @@ -13,7 +23,7 @@ function textFromInline(node) { return node.children.map(textFromInline).join(""); } -function normalizeLabel(value) { +function normalizeType(value) { return value .trim() .replace(/^["β€œβ€]+|["β€œβ€]+$/g, "") @@ -21,37 +31,99 @@ function normalizeLabel(value) { .toLowerCase(); } -function paragraphCalloutLabel(paragraph) { +function calloutType(value) { + const type = normalizeType(value); + return CALLOUT_TYPES.has(type) ? type : null; +} + +function parseBracketCallout(value) { + const match = value.match(BRACKET_CALLOUT_PATTERN); + if (!match) return null; + + return { + kind: "bracket", + type: calloutType(match[1]), + title: (match[2] ?? match[3] ?? "").trim(), + }; +} + +function paragraphCallout(paragraph) { if (paragraph?.type !== "paragraph") return null; const first = paragraph.children?.[0]; if (first?.type === "text") { - const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); - if (match) { - return CALLOUT_LABELS.get(normalizeLabel(match[1])); + const bracket = parseBracketCallout(first.value); + if (bracket?.type) { + return bracket; + } + + const trimmed = first.value.trimStart(); + for (const [marker, type] of CALLOUT_MARKERS) { + if (trimmed.startsWith(marker)) { + return { kind: "marker", type, title: "", marker }; + } } } if (first?.type !== "strong") return null; - const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); - if (!label) return null; + const type = calloutType(textFromInline(first)); + if (!type) return null; - return label; + return { kind: "strong", type, title: "" }; } -function removeLeadingLabel(paragraph, label) { +function cleanupParagraphStart(paragraph) { + while (paragraph.children?.length > 0) { + const first = paragraph.children[0]; + if (first.type === "text") { + first.value = first.value.replace(/^\s+/, ""); + if (first.value) return; + paragraph.children.shift(); + continue; + } + + if (first.type === "break") { + paragraph.children.shift(); + continue; + } + + return; + } +} + +function calloutTitleParagraph(title) { + return { + type: "paragraph", + data: { + hName: "p", + hProperties: { + className: ["callout-title"], + }, + }, + children: [{ type: "text", value: title }], + }; +} + +function removeLeadingCallout(paragraph, callout) { if (paragraph?.type !== "paragraph") return; const first = paragraph.children?.[0]; - if (first?.type === "text") { - first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); - if (!first.value) paragraph.children.shift(); + if (callout.kind === "bracket" && first?.type === "text") { + first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, ""); + cleanupParagraphStart(paragraph); return; } - const firstText = normalizeLabel(textFromInline(first)); - if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { + if (callout.kind === "marker" && first?.type === "text") { + const escapedMarker = callout.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), ""); + cleanupParagraphStart(paragraph); + return; + } + + const firstText = calloutType(textFromInline(first)); + if (callout.kind !== "strong" || !first || first.type !== "strong" || firstText !== callout.type) { return; } @@ -62,25 +134,30 @@ function removeLeadingLabel(paragraph, label) { next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); if (!next.value) paragraph.children.shift(); } + + cleanupParagraphStart(paragraph); } function transformBlockquote(node) { if (node.type !== "blockquote") return; const first = node.children?.[0]; - const label = paragraphCalloutLabel(first); - if (!label) return; + const callout = paragraphCallout(first); + if (!callout) return; - removeLeadingLabel(first, label); + removeLeadingCallout(first, callout); if (first.children?.length === 0) { node.children.shift(); } + if (callout.title) { + node.children.unshift(calloutTitleParagraph(callout.title)); + } + node.data = { hName: "aside", hProperties: { - className: ["callout", `callout-${label.toLowerCase()}`], - dataCalloutLabel: label, + className: ["callout", `callout-${callout.type}`], }, }; } diff --git a/src/styles/article.css b/src/styles/article.css index 798a3b9..71dd791 100644 --- a/src/styles/article.css +++ b/src/styles/article.css @@ -200,6 +200,15 @@ margin-top: 0.8rem; } +.article-content :where(.callout-title) { + margin: 0 0 0.45rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-fraunces); + font-size: 1rem; + font-weight: 650; + line-height: 1.35; +} + .article-content :where(.callout h1) { font-size: 1.35rem; }