Improve callouts and symbol publishing
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Neeldhara Misra 2026-06-25 15:48:33 +05:30
parent 6439559017
commit 7aa1fd2372
31 changed files with 1939 additions and 12 deletions

View file

@ -92,7 +92,9 @@ Use colocated relative assets:
Quarto image attributes such as `{width=70%}` were removed during import. Going Quarto image attributes such as `{width=70%}` were removed during import. Going
forward, use normal Markdown and let Astro CSS handle presentation. forward, use normal Markdown and let Astro CSS handle presentation.
Quarto callouts were converted to portable blockquotes: ### Callouts
Write callouts as portable blockquotes. This is the canonical form:
```markdown ```markdown
> **Note** > **Note**
@ -100,7 +102,7 @@ Quarto callouts were converted to portable blockquotes:
> Text of the callout. > Text of the callout.
``` ```
Use the same convention for new notes: Supported labels are `Note`, `Tip`, `Warning`, `Caution`, and `Aside`:
```markdown ```markdown
> **Warning** > **Warning**
@ -108,12 +110,78 @@ Use the same convention for new notes:
> This point needs attention. > This point needs attention.
``` ```
Obsidian-style labels also work:
```markdown
> [!tip]
> This is a tip.
```
For quick writing, a few leading emoji markers are recognized and stripped:
```markdown
> 📝 This becomes a note.
> 💡 This becomes a tip.
> ⚡ This also becomes a tip.
> ⚠️ This becomes a warning.
```
In Astro these render as `<aside class="callout callout-note">`, etc. In the
PDF pipeline they render as pastel `tcolorbox` blocks:
```text
note -> blognotebox
tip -> blogtipbox
warning -> blogwarningbox
caution -> blogcautionbox
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.
Quarto margin notes such as `[text]{.aside}` were converted to: Quarto margin notes such as `[text]{.aside}` were converted to:
```markdown ```markdown
> **Aside:** text > **Aside:** text
``` ```
### Emoji And Symbol Text
Write emoji, chess pieces, and card suits directly in Markdown:
```markdown
I like this move: ♘f3. Hearts and diamonds are red: ♥ ♦. Spades and clubs are black: ♠ ♣.
This reminder is visible in both outputs: 🎯
```
Astro wraps these glyphs in small semantic spans so they get stable font
fallbacks and accessible labels:
```html
<span class="symbol symbol-chess" data-symbol="white-knight" aria-label="white knight"></span>
<span class="symbol symbol-card" data-symbol="heart" aria-label="heart"></span>
<span class="symbol symbol-emoji">🎯</span>
```
The PDF pipeline maps the same characters to LaTeX macros:
```text
emoji and emoji sequences -> \BlogEmojiText{...} using the emoji package
♔ ♕ ♖ ♗ ♘ ♙ -> \BlogChessWhite{\symking}, etc. using chessfss
♚ ♛ ♜ ♝ ♞ ♟ -> \BlogChessBlack{\symking}, etc. using chessfss
♥ ♡ ♦ ♢ -> red \heartsuit / \diamondsuit via amssymb + xcolor
♠ ♤ ♣ ♧ -> black \spadesuit / \clubsuit via amssymb + xcolor
playing-card emoji -> \BlogEmojiText{...}
```
Use Unicode characters in the source. Do not write LaTeX-only symbols in posts
unless the post is PDF-only, because raw LaTeX will not generally survive the
HTML path.
## Semantic Blocks ## Semantic Blocks
Use Pandoc-style fenced attributes for blocks that need to render differently Use Pandoc-style fenced attributes for blocks that need to render differently
@ -303,9 +371,18 @@ npm run pdf:post -- sites/research/src/content/research/example/index.md --out /
The command runs: The command runs:
- `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and - `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and
environment fences. environment fences, internal links, Markdown callouts, emoji, chess pieces,
and card suits.
- `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`, - `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`,
`cleveref`, and the shared theorem definitions. `cleveref`, `tcolorbox`, `emoji`, `chessfss`, and the shared theorem,
callout, and symbol definitions.
Current smoke-test PDFs live outside the repo:
```text
/private/tmp/blog-pdf-samples/callout-symbol-test.pdf
/private/tmp/blog-pdf-samples/vibes-building-interactives.pdf
```
The PDF route is intentionally separate from Astro. Astro owns HTML rendering; The PDF route is intentionally separate from Astro. Astro owns HTML rendering;
Pandoc owns PDF rendering. The Markdown conventions above are the shared Pandoc owns PDF rendering. The Markdown conventions above are the shared

View file

@ -9,6 +9,58 @@ local env_types = {
proof = true, proof = true,
} }
traverse = "topdown"
local callout_labels = {
aside = "Aside",
caution = "Caution",
note = "Note",
tip = "Tip",
warning = "Warning",
}
local callout_envs = {
aside = "blogasidebox",
caution = "blogcautionbox",
note = "blognotebox",
tip = "blogtipbox",
warning = "blogwarningbox",
}
local callout_markers = {
{ marker = "📝", key = "note" },
{ marker = "💡", key = "tip" },
{ marker = "", key = "tip" },
{ marker = "⚠️", key = "warning" },
{ marker = "", key = "warning" },
}
local chess_macros = {
[""] = "\\BlogChessWhite{\\symking}",
[""] = "\\BlogChessWhite{\\symqueen}",
[""] = "\\BlogChessWhite{\\symrook}",
[""] = "\\BlogChessWhite{\\symbishop}",
[""] = "\\BlogChessWhite{\\symknight}",
[""] = "\\BlogChessWhite{\\sympawn}",
[""] = "\\BlogChessBlack{\\symking}",
[""] = "\\BlogChessBlack{\\symqueen}",
[""] = "\\BlogChessBlack{\\symrook}",
[""] = "\\BlogChessBlack{\\symbishop}",
[""] = "\\BlogChessBlack{\\symknight}",
[""] = "\\BlogChessBlack{\\sympawn}",
}
local card_macros = {
[""] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
}
local function has_class(el, class_name) local function has_class(el, class_name)
for _, value in ipairs(el.classes) do for _, value in ipairs(el.classes) do
if value == class_name then if value == class_name then
@ -27,6 +79,28 @@ local function first_env_class(el)
return nil return nil
end end
local function normalize_label(value)
value = tostring(value or ""):lower()
value = value:gsub("^%s+", ""):gsub("%s+$", "")
value = value:gsub("^%[!", ""):gsub("%]$", "")
value = value:gsub(":$", "")
return value
end
local function inline_text(inline)
if not inline then
return ""
end
return pandoc.utils.stringify(inline)
end
local function first_inline_text(block)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return ""
end
return inline_text(block.content[1])
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{}")
@ -36,6 +110,154 @@ local function latex_escape(value)
return value return value
end end
local function codepoint_at(value, index)
return utf8.codepoint(value, index, index)
end
local function codepoint_len(value, index)
local byte = string.byte(value, index)
if not byte then
return 0
elseif byte < 0x80 then
return 1
elseif byte < 0xE0 then
return 2
elseif byte < 0xF0 then
return 3
end
return 4
end
local function char_at(value, index)
local len = codepoint_len(value, index)
return value:sub(index, index + len - 1), index + len
end
local function is_variation_selector(cp)
return cp == 0xFE0E or cp == 0xFE0F
end
local function is_skin_tone(cp)
return cp >= 0x1F3FB and cp <= 0x1F3FF
end
local function is_emoji_base(cp)
return (cp >= 0x1F000 and cp <= 0x1FAFF)
or (cp >= 0x2600 and cp <= 0x27BF)
or cp == 0x00A9
or cp == 0x00AE
or cp == 0x203C
or cp == 0x2049
or cp == 0x2122
or cp == 0x2139
or cp == 0x3030
or cp == 0x303D
or cp == 0x3297
or cp == 0x3299
end
local function emoji_cluster(value, index)
local start = index
local current, next_index = char_at(value, index)
if current == "" then
return "", index
end
index = next_index
while index <= #value do
local cp = codepoint_at(value, index)
if is_variation_selector(cp) or is_skin_tone(cp) then
local _, ni = char_at(value, index)
index = ni
elseif cp == 0x200D then
local _, ni = char_at(value, index)
index = ni
if index <= #value then
local _, after_joined = char_at(value, index)
index = after_joined
end
else
break
end
end
return value:sub(start, index - 1), index
end
local function symbol_macro_for_cluster(cluster)
local cp = utf8.codepoint(cluster, 1, 1)
if not cp then
return nil
end
if chess_macros[cluster] then
return chess_macros[cluster]
end
if card_macros[cluster] then
return card_macros[cluster]
end
if cp >= 0x1F0A0 and cp <= 0x1F0FF then
return "\\BlogEmojiText{" .. cluster .. "}"
end
if is_emoji_base(cp) then
return "\\BlogEmojiText{" .. cluster .. "}"
end
return nil
end
local function symbol_str_to_latex(el)
if not FORMAT:match("latex") then
return nil
end
local text = el.text
local pieces = pandoc.List()
local plain = {}
local changed = false
local index = 1
local function flush_plain()
if #plain > 0 then
pieces:insert(pandoc.Str(table.concat(plain)))
plain = {}
end
end
while index <= #text do
local cp = codepoint_at(text, index)
if cp and (chess_macros[utf8.char(cp)] or card_macros[utf8.char(cp)] or is_emoji_base(cp)) then
local cluster, next_index = emoji_cluster(text, index)
local macro = symbol_macro_for_cluster(cluster)
if macro then
flush_plain()
pieces:insert(pandoc.RawInline("latex", macro))
changed = true
index = next_index
else
local char, ni = char_at(text, index)
plain[#plain + 1] = char
index = ni
end
else
local char, ni = char_at(text, index)
plain[#plain + 1] = char
index = ni
end
end
flush_plain()
if changed then
return pieces
end
return nil
end
local function internal_link_to_cref(el) local function internal_link_to_cref(el)
if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then
local label = el.target:sub(2) local label = el.target:sub(2)
@ -46,12 +268,24 @@ local function internal_link_to_cref(el)
return nil return nil
end end
local function latexize_doc(doc)
return doc:walk({
Link = internal_link_to_cref,
Str = symbol_str_to_latex,
})
end
local function markdown_to_latex(markdown) local function markdown_to_latex(markdown)
local doc = pandoc.read( local doc = pandoc.read(
markdown or "", markdown or "",
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html" "markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html"
) )
doc = doc:walk({ Link = internal_link_to_cref }) doc = latexize_doc(doc)
return pandoc.write(doc, "latex")
end
local function blocks_to_latex(blocks)
local doc = latexize_doc(pandoc.Pandoc(blocks))
return pandoc.write(doc, "latex") return pandoc.write(doc, "latex")
end end
@ -88,6 +322,102 @@ local function interactive_to_latex(el)
) )
end end
local function remove_first_inline(block)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return
end
table.remove(block.content, 1)
while #block.content > 0 and block.content[1].t == "Space" do
table.remove(block.content, 1)
end
if #block.content > 0 and block.content[1].t == "Str" then
block.content[1].text = block.content[1].text:gsub("^:%s*", "")
if block.content[1].text == "" then
table.remove(block.content, 1)
end
end
end
local function strip_text_prefix(block, pattern)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return
end
local first = block.content[1]
if first.t ~= "Str" then
return
end
first.text = first.text:gsub(pattern, "")
if first.text == "" then
remove_first_inline(block)
end
end
local function detect_callout(block)
local text = first_inline_text(block)
if text == "" then
return nil
end
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" }
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
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 }
end
end
return nil
end
local function callout_to_latex(el)
if not FORMAT:match("latex") or not el.content or #el.content == 0 then
return nil
end
local blocks = pandoc.List(el.content)
local callout = detect_callout(blocks[1])
if not callout then
return nil
end
if callout.remover == "strong" then
remove_first_inline(blocks[1])
elseif callout.remover == "bracket" then
strip_text_prefix(blocks[1], "^%[![%a-]+%]%s*")
elseif callout.remover == "marker" then
strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*")
end
if blocks[1] and blocks[1].t == "Para" and #blocks[1].content == 0 then
table.remove(blocks, 1)
end
local env = callout_envs[callout.key] or "blognotebox"
return pandoc.RawBlock(
"latex",
"\\begin{" .. env .. "}{}\n"
.. blocks_to_latex(blocks)
.. "\n\\end{" .. env .. "}"
)
end
local function env_to_latex(el) local function env_to_latex(el)
local env_type = el.attributes.type or first_env_class(el) local env_type = el.attributes.type or first_env_class(el)
if not env_type or not env_types[env_type] then if not env_type or not env_types[env_type] then
@ -126,6 +456,14 @@ function CodeBlock(el)
return nil return nil
end end
function BlockQuote(el)
return callout_to_latex(el)
end
function Link(el) function Link(el)
return internal_link_to_cref(el) return internal_link_to_cref(el)
end end
function Str(el)
return symbol_str_to_latex(el)
end

View file

@ -1,6 +1,104 @@
\usepackage{amsmath} \usepackage{amsmath}
\usepackage{amssymb} \usepackage{amssymb}
\usepackage{amsthm} \usepackage{amsthm}
\usepackage[most]{tcolorbox}
\usepackage{emoji}
\usepackage{chessfss}
\usepackage{xcolor}
\definecolor{BlogCalloutNoteBack}{HTML}{FFF8D7}
\definecolor{BlogCalloutNoteFrame}{HTML}{D4A72C}
\definecolor{BlogCalloutTipBack}{HTML}{E7F6EC}
\definecolor{BlogCalloutTipFrame}{HTML}{3A8F64}
\definecolor{BlogCalloutWarningBack}{HTML}{FFF0E5}
\definecolor{BlogCalloutWarningFrame}{HTML}{C4662D}
\definecolor{BlogCalloutCautionBack}{HTML}{FDECEC}
\definecolor{BlogCalloutCautionFrame}{HTML}{B94A48}
\definecolor{BlogCalloutAsideBack}{HTML}{EEF3FA}
\definecolor{BlogCalloutAsideFrame}{HTML}{5D7FA3}
\tcbset{
blog callout/.style={
enhanced,
breakable,
boxrule=0.45pt,
leftrule=2.2pt,
arc=1.6mm,
outer arc=1.6mm,
left=7pt,
right=7pt,
top=6pt,
bottom=6pt,
before skip=10pt,
after skip=10pt,
fonttitle=\bfseries\sffamily\small,
coltitle=black,
attach boxed title to top left={xshift=7pt,yshift=-2.4mm},
boxed title style={
enhanced,
boxrule=0pt,
arc=1.2mm,
left=5pt,
right=5pt,
top=2pt,
bottom=2pt
}
}
}
\newtcolorbox{blognotebox}[2][]{
blog callout,
title={NOTE\if\relax\detokenize{#2}\relax\else: #2\fi},
colback=BlogCalloutNoteBack,
colframe=BlogCalloutNoteFrame,
boxed title style={colback=BlogCalloutNoteBack},
#1
}
\newtcolorbox{blogtipbox}[2][]{
blog callout,
title={TIP\if\relax\detokenize{#2}\relax\else: #2\fi},
colback=BlogCalloutTipBack,
colframe=BlogCalloutTipFrame,
boxed title style={colback=BlogCalloutTipBack},
#1
}
\newtcolorbox{blogwarningbox}[2][]{
blog callout,
title={WARNING\if\relax\detokenize{#2}\relax\else: #2\fi},
colback=BlogCalloutWarningBack,
colframe=BlogCalloutWarningFrame,
boxed title style={colback=BlogCalloutWarningBack},
#1
}
\newtcolorbox{blogcautionbox}[2][]{
blog callout,
title={CAUTION\if\relax\detokenize{#2}\relax\else: #2\fi},
colback=BlogCalloutCautionBack,
colframe=BlogCalloutCautionFrame,
boxed title style={colback=BlogCalloutCautionBack},
#1
}
\newtcolorbox{blogasidebox}[2][]{
blog callout,
title={ASIDE\if\relax\detokenize{#2}\relax\else: #2\fi},
colback=BlogCalloutAsideBack,
colframe=BlogCalloutAsideFrame,
boxed title style={colback=BlogCalloutAsideBack},
#1
}
\ExplSyntaxOn
\NewDocumentCommand{\BlogEmojiText}{m}{{\emoji_font: #1}}
\ExplSyntaxOff
\newcommand{\BlogChessWhite}[1]{\textcolor{black!55}{#1}}
\newcommand{\BlogChessBlack}[1]{\textcolor{black}{#1}}
\newcommand{\BlogCardRed}[1]{\textcolor{red!65!black}{#1}}
\newcommand{\BlogCardBlack}[1]{\textcolor{black}{#1}}
\theoremstyle{plain} \theoremstyle{plain}
\newtheorem{theorem}{Theorem} \newtheorem{theorem}{Theorem}

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;

View file

@ -13,6 +13,7 @@ import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkSymbols from "./src/remark/symbols.mjs";
import remarkTypography from "./src/remark/typography.mjs"; import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -24,6 +25,7 @@ const remarkPlugins = [
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, remarkCallouts,
remarkSymbols,
]; ];
const rehypePlugins = [rehypeKatex]; const rehypePlugins = [rehypeKatex];

View file

@ -6,6 +6,14 @@ const CALLOUT_LABELS = new Map([
["warning", "Warning"], ["warning", "Warning"],
]); ]);
const CALLOUT_MARKERS = new Map([
["📝", "Note"],
["💡", "Tip"],
["⚡", "Tip"],
["⚠️", "Warning"],
["⚠", "Warning"],
]);
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;
@ -30,6 +38,13 @@ function paragraphCalloutLabel(paragraph) {
if (match) { if (match) {
return CALLOUT_LABELS.get(normalizeLabel(match[1])); return CALLOUT_LABELS.get(normalizeLabel(match[1]));
} }
const trimmed = first.value.trimStart();
for (const [marker, label] of CALLOUT_MARKERS) {
if (trimmed.startsWith(marker)) {
return label;
}
}
} }
if (first?.type !== "strong") return null; if (first?.type !== "strong") return null;
@ -46,6 +61,10 @@ function removeLeadingLabel(paragraph, label) {
const first = paragraph.children?.[0]; const first = paragraph.children?.[0];
if (first?.type === "text") { if (first?.type === "text") {
first.value = first.value.replace(/^\s*\[!(aside|caution|note|tip|warning)\]\s*/i, ""); 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 (!first.value) paragraph.children.shift();
return; return;
} }

View file

@ -0,0 +1,160 @@
const CHESS_LABELS = {
"♔": "white king",
"♕": "white queen",
"♖": "white rook",
"♗": "white bishop",
"♘": "white knight",
"♙": "white pawn",
"♚": "black king",
"♛": "black queen",
"♜": "black rook",
"♝": "black bishop",
"♞": "black knight",
"♟": "black pawn",
};
const CARD_LABELS = {
"♠": "spade",
"♤": "spade",
"♣": "club",
"♧": "club",
"♥": "heart",
"♡": "heart",
"♦": "diamond",
"♢": "diamond",
};
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function isVariationSelector(codePoint) {
return codePoint === 0xfe0e || codePoint === 0xfe0f;
}
function isSkinTone(codePoint) {
return codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
}
function isEmojiBase(codePoint) {
return (
(codePoint >= 0x1f000 && codePoint <= 0x1faff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
codePoint === 0x00a9 ||
codePoint === 0x00ae ||
codePoint === 0x203c ||
codePoint === 0x2049 ||
codePoint === 0x2122 ||
codePoint === 0x2139 ||
codePoint === 0x3030 ||
codePoint === 0x303d ||
codePoint === 0x3297 ||
codePoint === 0x3299
);
}
function readCluster(chars, index) {
let value = chars[index];
let next = index + 1;
while (next < chars.length) {
const codePoint = chars[next].codePointAt(0);
if (isVariationSelector(codePoint) || isSkinTone(codePoint)) {
value += chars[next];
next += 1;
continue;
}
if (codePoint === 0x200d && next + 1 < chars.length) {
value += chars[next] + chars[next + 1];
next += 2;
continue;
}
break;
}
return { value, next };
}
function symbolHtml(cluster) {
if (CHESS_LABELS[cluster]) {
return `<span class="symbol symbol-chess" data-symbol="${escapeHtml(CHESS_LABELS[cluster].replaceAll(" ", "-"))}" aria-label="${escapeHtml(CHESS_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
if (CARD_LABELS[cluster]) {
return `<span class="symbol symbol-card" data-symbol="${escapeHtml(CARD_LABELS[cluster])}" aria-label="${escapeHtml(CARD_LABELS[cluster])}">${escapeHtml(cluster)}</span>`;
}
const codePoint = cluster.codePointAt(0);
if ((codePoint >= 0x1f0a0 && codePoint <= 0x1f0ff) || isEmojiBase(codePoint)) {
return `<span class="symbol symbol-emoji">${escapeHtml(cluster)}</span>`;
}
return null;
}
function splitText(value) {
const chars = Array.from(value);
const nodes = [];
let text = "";
function flushText() {
if (text) {
nodes.push({ type: "text", value: text });
text = "";
}
}
for (let index = 0; index < chars.length; ) {
const codePoint = chars[index].codePointAt(0);
const shouldCluster =
CHESS_LABELS[chars[index]] ||
CARD_LABELS[chars[index]] ||
isEmojiBase(codePoint);
if (!shouldCluster) {
text += chars[index];
index += 1;
continue;
}
const { value: cluster, next } = readCluster(chars, index);
const html = symbolHtml(cluster);
if (html) {
flushText();
nodes.push({ type: "html", value: html });
} else {
text += cluster;
}
index = next;
}
flushText();
return nodes;
}
function transformChildren(parent) {
if (!Array.isArray(parent?.children)) return;
const nextChildren = [];
for (const child of parent.children) {
if (child.type === "text") {
nextChildren.push(...splitText(child.value));
continue;
}
transformChildren(child);
nextChildren.push(child);
}
parent.children = nextChildren;
}
export default function remarkSymbols() {
return (tree) => transformChildren(tree);
}

View file

@ -66,7 +66,9 @@
.article-content { .article-content {
color: hsl(var(--foreground)); color: hsl(var(--foreground));
font-family: var(--font-heliotrope); font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem; font-size: 1.0625rem;
line-height: 1.78; line-height: 1.78;
} }
@ -129,6 +131,25 @@
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
.article-content :where(.symbol) {
display: inline-block;
line-height: 1;
vertical-align: -0.08em;
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
font-family: "Noto Sans Symbols 2", "DejaVu Sans", "Symbola", serif;
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
.article-content :where(code) { .article-content :where(code) {
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
border-radius: 0.25rem; border-radius: 0.25rem;