Make callout headings explicit
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Neeldhara Misra 2026-06-26 01:44:41 +05:30
parent 0025e453e8
commit b03d27cc3a
20 changed files with 1052 additions and 330 deletions

View file

@ -94,7 +94,9 @@ forward, use normal Markdown and let Astro CSS handle presentation.
### Callouts ### 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 ```markdown
> **Note** > **Note**
@ -110,13 +112,31 @@ Supported labels are `Note`, `Tip`, `Warning`, `Caution`, and `Aside`:
> This point needs attention. > This point needs attention.
``` ```
Obsidian-style labels also work: Obsidian-style bracket labels also work. A plain bracket label is still
titleless:
```markdown ```markdown
> [!tip] > [!tip]
> This is a 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: For quick writing, a few leading emoji markers are recognized and stripped:
```markdown ```markdown
@ -139,9 +159,9 @@ aside -> blogasidebox
The LaTeX boxes use `tcolorbox` with the `breakable` and `enhanced` libraries, 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 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 attached title only when the Markdown explicitly provides one. This follows the
callouts: compact colored boxes that can split across pages without becoming same design vocabulary as the DM book callouts: compact colored boxes that can
visually heavy. split across pages without becoming visually heavy.
Quarto margin notes such as `[text]{.aside}` were converted to: Quarto margin notes such as `[text]{.aside}` were converted to:

View file

@ -279,7 +279,8 @@ authorName: "Neeldhara"
- Standard Markdown headings, lists, links, tables, code fences, footnotes, and raw HTML are preserved. - Standard Markdown headings, lists, links, tables, code fences, footnotes, and raw HTML are preserved.
- Use colocated relative assets: \`![Caption](image.png)\`. - 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 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 \`\`\`markdown
> **Note** > **Note**
@ -287,14 +288,22 @@ authorName: "Neeldhara"
> Text of the callout. > 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 \`\`\`markdown
> **Warning** > [!warning]
> >
> This point needs attention. > 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: - Quarto margin notes such as \`[text]{.aside}\` are converted to:
\`\`\`markdown \`\`\`markdown

View file

@ -101,6 +101,13 @@ local function first_inline_text(block)
return inline_text(block.content[1]) return inline_text(block.content[1])
end 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) local function latex_escape(value)
value = tostring(value or "") value = tostring(value or "")
value = value:gsub("\\", "\\textbackslash{}") value = value:gsub("\\", "\\textbackslash{}")
@ -356,6 +363,70 @@ local function strip_text_prefix(block, pattern)
end end
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 function detect_callout(block)
local text = first_inline_text(block) local text = first_inline_text(block)
if text == "" then if text == "" then
@ -365,21 +436,18 @@ local function detect_callout(block)
if block.content[1].t == "Strong" then if block.content[1].t == "Strong" then
local key = normalize_label(text) local key = normalize_label(text)
if callout_labels[key] then if callout_labels[key] then
return { key = key, label = callout_labels[key], remover = "strong" } return { key = key, title = "", remover = "strong" }
end end
end end
local bracket = text:match("^%[!([%a-]+)%]") local key, title = parse_bracket_callout(paragraph_text(block))
if bracket then if key and callout_labels[key] then
local key = normalize_label(bracket) return { key = key, title = title or "", remover = "bracket" }
if callout_labels[key] then
return { key = key, label = callout_labels[key], remover = "bracket" }
end
end end
for _, item in ipairs(callout_markers) do for _, item in ipairs(callout_markers) do
if text:sub(1, #item.marker) == item.marker then 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
end end
@ -400,7 +468,7 @@ local function callout_to_latex(el)
if callout.remover == "strong" then if callout.remover == "strong" then
remove_first_inline(blocks[1]) remove_first_inline(blocks[1])
elseif callout.remover == "bracket" then elseif callout.remover == "bracket" then
strip_text_prefix(blocks[1], "^%[![%a-]+%]%s*") strip_bracket_callout(blocks[1])
elseif callout.remover == "marker" then elseif callout.remover == "marker" then
strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*") strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*")
end end
@ -412,7 +480,7 @@ local function callout_to_latex(el)
local env = callout_envs[callout.key] or "blognotebox" local env = callout_envs[callout.key] or "blognotebox"
return pandoc.RawBlock( return pandoc.RawBlock(
"latex", "latex",
"\\begin{" .. env .. "}{}\n" "\\begin{" .. env .. "}{" .. latex_escape(callout.title or "") .. "}\n"
.. blocks_to_latex(blocks) .. blocks_to_latex(blocks)
.. "\n\\end{" .. env .. "}" .. "\n\\end{" .. env .. "}"
) )

View file

@ -46,49 +46,119 @@
} }
} }
\newtcolorbox{blognotebox}[2][]{ \NewDocumentEnvironment{blognotebox}{O{} m}{%
blog callout, \if\relax\detokenize{#2}\relax
title={NOTE\if\relax\detokenize{#2}\relax\else: #2\fi}, \begin{tcolorbox}[
colback=BlogCalloutNoteBack, blog callout,
colframe=BlogCalloutNoteFrame, colback=BlogCalloutNoteBack,
boxed title style={colback=BlogCalloutNoteBack}, colframe=BlogCalloutNoteFrame,
#1 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][]{ \NewDocumentEnvironment{blogtipbox}{O{} m}{%
blog callout, \if\relax\detokenize{#2}\relax
title={TIP\if\relax\detokenize{#2}\relax\else: #2\fi}, \begin{tcolorbox}[
colback=BlogCalloutTipBack, blog callout,
colframe=BlogCalloutTipFrame, colback=BlogCalloutTipBack,
boxed title style={colback=BlogCalloutTipBack}, colframe=BlogCalloutTipFrame,
#1 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][]{ \NewDocumentEnvironment{blogwarningbox}{O{} m}{%
blog callout, \if\relax\detokenize{#2}\relax
title={WARNING\if\relax\detokenize{#2}\relax\else: #2\fi}, \begin{tcolorbox}[
colback=BlogCalloutWarningBack, blog callout,
colframe=BlogCalloutWarningFrame, colback=BlogCalloutWarningBack,
boxed title style={colback=BlogCalloutWarningBack}, colframe=BlogCalloutWarningFrame,
#1 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][]{ \NewDocumentEnvironment{blogcautionbox}{O{} m}{%
blog callout, \if\relax\detokenize{#2}\relax
title={CAUTION\if\relax\detokenize{#2}\relax\else: #2\fi}, \begin{tcolorbox}[
colback=BlogCalloutCautionBack, blog callout,
colframe=BlogCalloutCautionFrame, colback=BlogCalloutCautionBack,
boxed title style={colback=BlogCalloutCautionBack}, colframe=BlogCalloutCautionFrame,
#1 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][]{ \NewDocumentEnvironment{blogasidebox}{O{} m}{%
blog callout, \if\relax\detokenize{#2}\relax
title={ASIDE\if\relax\detokenize{#2}\relax\else: #2\fi}, \begin{tcolorbox}[
colback=BlogCalloutAsideBack, blog callout,
colframe=BlogCalloutAsideFrame, colback=BlogCalloutAsideBack,
boxed title style={colback=BlogCalloutAsideBack}, colframe=BlogCalloutAsideFrame,
#1 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 \ExplSyntaxOn

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,19 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "warning",
]); ]);
const CALLOUT_MARKERS = new Map([ const CALLOUT_MARKERS = new Map([
["📝", "Note"], ["📝", "note"],
["💡", "Tip"], ["💡", "tip"],
["⚡", "Tip"], ["⚡", "tip"],
["⚠️", "Warning"], ["⚠️", "warning"],
["⚠", "Warning"], ["⚠", "warning"],
]); ]);
const BRACKET_CALLOUT_PATTERN = /^\s*\[!(aside|caution|note|tip|warning)(?::\s*([^\]\r\n]+)|\|\s*([^\]\r\n]+))?\]\s*/i;
function textFromInline(node) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -21,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -29,48 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return bracket;
} }
const trimmed = first.value.trimStart(); const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) { for (const [marker, type] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) { if (trimmed.startsWith(marker)) {
return label; return { kind: "marker", type, title: "", marker };
} }
} }
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
for (const marker of CALLOUT_MARKERS.keys()) { cleanupParagraphStart(paragraph);
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
first.value = first.value.replace(new RegExp(`^\\s*${escapedMarker}\\s*`, "u"), "");
}
if (!first.value) paragraph.children.shift();
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -81,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -221,6 +221,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }

View file

@ -1,11 +1,21 @@
const CALLOUT_LABELS = new Map([ const CALLOUT_TYPES = new Set([
["aside", "Aside"], "aside",
["caution", "Caution"], "caution",
["note", "Note"], "note",
["tip", "Tip"], "tip",
["warning", "Warning"], "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) { function textFromInline(node) {
if (!node) return ""; if (!node) return "";
if (typeof node.value === "string") return node.value; if (typeof node.value === "string") return node.value;
@ -13,7 +23,7 @@ function textFromInline(node) {
return node.children.map(textFromInline).join(""); return node.children.map(textFromInline).join("");
} }
function normalizeLabel(value) { function normalizeType(value) {
return value return value
.trim() .trim()
.replace(/^["“”]+|["“”]+$/g, "") .replace(/^["“”]+|["“”]+$/g, "")
@ -21,37 +31,99 @@ function normalizeLabel(value) {
.toLowerCase(); .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; if (paragraph?.type !== "paragraph") return null;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
const match = first.value.match(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i); const bracket = parseBracketCallout(first.value);
if (match) { if (bracket?.type) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); 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; if (first?.type !== "strong") return null;
const label = CALLOUT_LABELS.get(normalizeLabel(textFromInline(first))); const type = calloutType(textFromInline(first));
if (!label) return null; 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; if (paragraph?.type !== "paragraph") return;
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (callout.kind === "bracket" && first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); first.value = first.value.replace(BRACKET_CALLOUT_PATTERN, "");
if (!first.value) paragraph.children.shift(); cleanupParagraphStart(paragraph);
return; return;
} }
const firstText = normalizeLabel(textFromInline(first)); if (callout.kind === "marker" && first?.type === "text") {
if (!first || first.type !== "strong" || firstText !== label.toLowerCase()) { 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; return;
} }
@ -62,25 +134,30 @@ function removeLeadingLabel(paragraph, label) {
next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, ""); next.value = next.value.replace(/^:\s*/, "").replace(/^\s+/, "");
if (!next.value) paragraph.children.shift(); if (!next.value) paragraph.children.shift();
} }
cleanupParagraphStart(paragraph);
} }
function transformBlockquote(node) { function transformBlockquote(node) {
if (node.type !== "blockquote") return; if (node.type !== "blockquote") return;
const first = node.children?.[0]; const first = node.children?.[0];
const label = paragraphCalloutLabel(first); const callout = paragraphCallout(first);
if (!label) return; if (!callout) return;
removeLeadingLabel(first, label); removeLeadingCallout(first, callout);
if (first.children?.length === 0) { if (first.children?.length === 0) {
node.children.shift(); node.children.shift();
} }
if (callout.title) {
node.children.unshift(calloutTitleParagraph(callout.title));
}
node.data = { node.data = {
hName: "aside", hName: "aside",
hProperties: { hProperties: {
className: ["callout", `callout-${label.toLowerCase()}`], className: ["callout", `callout-${callout.type}`],
dataCalloutLabel: label,
}, },
}; };
} }

View file

@ -200,6 +200,15 @@
margin-top: 0.8rem; 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) { .article-content :where(.callout h1) {
font-size: 1.35rem; font-size: 1.35rem;
} }