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
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:

View file

@ -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

View file

@ -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 .. "}"
)

View file

@ -46,49 +46,119 @@
}
}
\newtcolorbox{blognotebox}[2][]{
\NewDocumentEnvironment{blognotebox}{O{} m}{%
\if\relax\detokenize{#2}\relax
\begin{tcolorbox}[
blog callout,
title={NOTE\if\relax\detokenize{#2}\relax\else: #2\fi},
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][]{
\NewDocumentEnvironment{blogtipbox}{O{} m}{%
\if\relax\detokenize{#2}\relax
\begin{tcolorbox}[
blog callout,
title={TIP\if\relax\detokenize{#2}\relax\else: #2\fi},
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][]{
\NewDocumentEnvironment{blogwarningbox}{O{} m}{%
\if\relax\detokenize{#2}\relax
\begin{tcolorbox}[
blog callout,
title={WARNING\if\relax\detokenize{#2}\relax\else: #2\fi},
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][]{
\NewDocumentEnvironment{blogcautionbox}{O{} m}{%
\if\relax\detokenize{#2}\relax
\begin{tcolorbox}[
blog callout,
title={CAUTION\if\relax\detokenize{#2}\relax\else: #2\fi},
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][]{
\NewDocumentEnvironment{blogasidebox}{O{} m}{%
\if\relax\detokenize{#2}\relax
\begin{tcolorbox}[
blog callout,
title={ASIDE\if\relax\detokenize{#2}\relax\else: #2\fi},
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

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}

View file

@ -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}`],
},
};
}

View file

@ -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;
}