@haruhimemoe/ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +133 -1
  3. package/dist/components/basics/Prose.d.ts +9 -6
  4. package/dist/components/basics/Prose.js +4 -4
  5. package/dist/components/mdx/Callout.d.ts +24 -0
  6. package/dist/components/mdx/Callout.js +22 -0
  7. package/dist/components/mdx/CodeBlock.d.ts +26 -0
  8. package/dist/components/mdx/CodeBlock.js +48 -0
  9. package/dist/components/mdx/CodeCopyButton.d.ts +21 -0
  10. package/dist/components/mdx/CodeCopyButton.js +34 -0
  11. package/dist/components/mdx/MdxBlockquote.d.ts +23 -0
  12. package/dist/components/mdx/MdxBlockquote.js +17 -0
  13. package/dist/components/mdx/MdxHeading.d.ts +26 -0
  14. package/dist/components/mdx/MdxHeading.js +23 -0
  15. package/dist/components/mdx/MdxLink.d.ts +29 -0
  16. package/dist/components/mdx/MdxLink.js +26 -0
  17. package/dist/components/mdx/MdxPre.d.ts +25 -0
  18. package/dist/components/mdx/MdxPre.js +39 -0
  19. package/dist/components/mdx/MdxTable.d.ts +20 -0
  20. package/dist/components/mdx/MdxTable.js +23 -0
  21. package/dist/components/mdx/highlighter.d.ts +37 -0
  22. package/dist/components/mdx/highlighter.js +66 -0
  23. package/dist/components/mdx/mdxComponents.d.ts +21 -0
  24. package/dist/components/mdx/mdxComponents.js +22 -0
  25. package/dist/components/mdx/parseCodeMeta.d.ts +21 -0
  26. package/dist/components/mdx/parseCodeMeta.js +40 -0
  27. package/dist/components/mdx/textOf.d.ts +17 -0
  28. package/dist/components/mdx/textOf.js +28 -0
  29. package/dist/mdx.d.ts +17 -0
  30. package/dist/mdx.js +17 -0
  31. package/dist/remark/callouts.d.ts +20 -0
  32. package/dist/remark/callouts.js +58 -0
  33. package/dist/remark/codeMeta.d.ts +17 -0
  34. package/dist/remark/codeMeta.js +22 -0
  35. package/dist/remark/headingIds.d.ts +16 -0
  36. package/dist/remark/headingIds.js +28 -0
  37. package/dist/remark/index.d.ts +29 -0
  38. package/dist/remark/index.js +33 -0
  39. package/dist/remark/mdast.d.ts +42 -0
  40. package/dist/remark/mdast.js +37 -0
  41. package/dist/remark/slugify.d.ts +23 -0
  42. package/dist/remark/slugify.js +40 -0
  43. package/dist/shiki.d.ts +13 -0
  44. package/dist/shiki.js +38 -0
  45. package/dist/theme.css +18 -0
  46. package/package.json +27 -2
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/mdx/MdxPre.tsx
4
+ * @desc react-markdown/MDX's `pre` override: a fenced code block (a `<pre>` wrapping a single
5
+ * child with a `language-*` class, a `data-meta` prop, or the literal `code` element type)
6
+ * is handed to CodeBlock with its parsed meta; detecting the fence by the child's props
7
+ * rather than only `child.type === "code"` means an app that overrides the `code` component
8
+ * (through `mdxComponents`' own map or MDX's `useMDXComponents`) still gets highlighting.
9
+ * Anything else renders as a plain, focusable `<pre>`. Drops the `node` prop react-markdown
10
+ * passes to every component.
11
+ * @author David @dvhsh (https://dvh.sh)
12
+ * @created Sat Oct 3, 2026
13
+ * @modified Sat Oct 3, 2026
14
+ */
15
+ import { Children, isValidElement } from "react";
16
+ import { CodeBlock } from "./CodeBlock.js";
17
+ import { parseCodeMeta } from "./parseCodeMeta.js";
18
+ import { textOf } from "./textOf.js";
19
+ /**
20
+ * @function MdxPre
21
+ * @param props {MdxPreProps} native pre props; a single `code` child carries the language class
22
+ * and the fence's meta string in `data-meta`
23
+ * @returns {JSX.Element} a CodeBlock for a fenced `code` child, otherwise a plain, focusable pre
24
+ */
25
+ export function MdxPre({ node: _node, children, ...props }) {
26
+ const [child] = Children.toArray(children);
27
+ const isFence = isValidElement(child) &&
28
+ (child.type === "code" ||
29
+ /(?:^|\s)language-/.test(child.props.className ?? "") ||
30
+ child.props["data-meta"] !== undefined);
31
+ if (!isFence || !isValidElement(child)) {
32
+ return (_jsx("pre", {
33
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
34
+ tabIndex: 0, ...props, children: children }));
35
+ }
36
+ const lang = /(?:^|\s)language-(\S+)/.exec(child.props.className ?? "")?.[1];
37
+ const { title, highlight } = parseCodeMeta(child.props["data-meta"]);
38
+ return (_jsx(CodeBlock, { code: textOf(child.props.children), lang: lang, title: title, highlight: highlight }));
39
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/components/mdx/MdxTable.tsx
3
+ * @desc react-markdown/MDX's `table` override: wraps the table in a named, focusable scroll
4
+ * region (a table can overflow its column on narrow screens), named by its caption when
5
+ * one is given. Drops the `node` prop react-markdown passes to every component.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import { type ComponentProps } from "react";
11
+ /** Every native `<table>` prop, plus the `node` react-markdown passes (dropped). */
12
+ export type MdxTableProps = ComponentProps<"table"> & {
13
+ node?: unknown;
14
+ };
15
+ /**
16
+ * @function MdxTable
17
+ * @param props {MdxTableProps} native table props; a `caption` child names the scroll region
18
+ * @returns {JSX.Element} a horizontally scrollable, focusable, labelled region wrapping the table
19
+ */
20
+ export declare function MdxTable({ node: _node, children, ...props }: MdxTableProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/mdx/MdxTable.tsx
4
+ * @desc react-markdown/MDX's `table` override: wraps the table in a named, focusable scroll
5
+ * region (a table can overflow its column on narrow screens), named by its caption when
6
+ * one is given. Drops the `node` prop react-markdown passes to every component.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { Children, isValidElement } from "react";
12
+ import { textOf } from "./textOf.js";
13
+ /**
14
+ * @function MdxTable
15
+ * @param props {MdxTableProps} native table props; a `caption` child names the scroll region
16
+ * @returns {JSX.Element} a horizontally scrollable, focusable, labelled region wrapping the table
17
+ */
18
+ export function MdxTable({ node: _node, children, ...props }) {
19
+ const caption = Children.toArray(children).find((child) => isValidElement(child) && child.type === "caption");
20
+ return (_jsx("div", { role: "group", "aria-label": textOf(caption) || "Table",
21
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
22
+ tabIndex: 0, className: "overflow-x-auto", children: _jsx("table", { ...props, children: children }) }));
23
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file src/components/mdx/highlighter.ts
3
+ * @desc The highlighter CodeBlock asks for. Nothing here names Shiki outside `import type`, so
4
+ * an app without Shiki builds: `@haruhimemoe/ui/shiki` (src/shiki.ts) registers the real
5
+ * loader when an app imports it. The loader lives in a `globalThis` slot keyed by
6
+ * `Symbol.for`, so it survives duplicate copies of this module. The first highlighter is
7
+ * cached; with no loader registered, or a loader that rejects, code renders plain (warned
8
+ * once, outside production) rather than throwing.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ import type { HighlighterCore } from "shiki/core";
14
+ /** The Shiki CSS-variables theme name CodeBlock renders with. */
15
+ export declare const THEME = "haruhime";
16
+ /** Loads a Shiki highlighter: what `@haruhimemoe/ui/shiki` registers. */
17
+ export type HighlighterLoader = () => Promise<HighlighterCore>;
18
+ /**
19
+ * @function setHighlighterLoader
20
+ * @param loader {HighlighterLoader} loads the Shiki highlighter; replaces any earlier one
21
+ * @returns {void} registers the loader in the global slot and drops the cached highlighter
22
+ */
23
+ export declare const setHighlighterLoader: (loader: HighlighterLoader) => void;
24
+ /**
25
+ * @function getHighlighter
26
+ * @param loader {HighlighterLoader | undefined} loads the highlighter; defaults to the registered
27
+ * one, overridable in tests
28
+ * @returns {Promise<HighlighterCore | null>} the cached highlighter, or null when no loader is
29
+ * registered or it failed to load
30
+ */
31
+ export declare const getHighlighter: (loader?: HighlighterLoader | undefined) => Promise<HighlighterCore | null>;
32
+ /**
33
+ * @function resetHighlighter
34
+ * @returns {void} clears the registered loader, the cached highlighter and the warned flag
35
+ * (tests only)
36
+ */
37
+ export declare const resetHighlighter: () => void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file src/components/mdx/highlighter.ts
3
+ * @desc The highlighter CodeBlock asks for. Nothing here names Shiki outside `import type`, so
4
+ * an app without Shiki builds: `@haruhimemoe/ui/shiki` (src/shiki.ts) registers the real
5
+ * loader when an app imports it. The loader lives in a `globalThis` slot keyed by
6
+ * `Symbol.for`, so it survives duplicate copies of this module. The first highlighter is
7
+ * cached; with no loader registered, or a loader that rejects, code renders plain (warned
8
+ * once, outside production) rather than throwing.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ /** The Shiki CSS-variables theme name CodeBlock renders with. */
14
+ export const THEME = "haruhime";
15
+ const SLOT = Symbol.for("@haruhimemoe/ui/highlighter");
16
+ // No node types in the build (tsconfig.build.json "types": []), so read process through globalThis.
17
+ const isProduction = () => globalThis.process?.env
18
+ ?.NODE_ENV === "production";
19
+ let cached;
20
+ let warned = false;
21
+ const warnOnce = (message, error) => {
22
+ if (warned || isProduction())
23
+ return;
24
+ warned = true;
25
+ if (error === undefined)
26
+ console.warn(message);
27
+ else
28
+ console.warn(message, error);
29
+ };
30
+ /**
31
+ * @function setHighlighterLoader
32
+ * @param loader {HighlighterLoader} loads the Shiki highlighter; replaces any earlier one
33
+ * @returns {void} registers the loader in the global slot and drops the cached highlighter
34
+ */
35
+ export const setHighlighterLoader = (loader) => {
36
+ globalThis[SLOT] = loader;
37
+ cached = undefined;
38
+ };
39
+ /**
40
+ * @function getHighlighter
41
+ * @param loader {HighlighterLoader | undefined} loads the highlighter; defaults to the registered
42
+ * one, overridable in tests
43
+ * @returns {Promise<HighlighterCore | null>} the cached highlighter, or null when no loader is
44
+ * registered or it failed to load
45
+ */
46
+ export const getHighlighter = (loader = globalThis[SLOT]) => {
47
+ if (!loader) {
48
+ warnOnce('@haruhimemoe/ui: code blocks aren\'t highlighted. Install shiki and add `import "@haruhimemoe/ui/shiki";` once, e.g. in mdx-components.tsx.');
49
+ return Promise.resolve(null);
50
+ }
51
+ cached ??= loader().catch((error) => {
52
+ warnOnce("@haruhimemoe/ui: Shiki didn't load, so code blocks aren't highlighted.", error);
53
+ return null;
54
+ });
55
+ return cached;
56
+ };
57
+ /**
58
+ * @function resetHighlighter
59
+ * @returns {void} clears the registered loader, the cached highlighter and the warned flag
60
+ * (tests only)
61
+ */
62
+ export const resetHighlighter = () => {
63
+ globalThis[SLOT] = undefined;
64
+ cached = undefined;
65
+ warned = false;
66
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/mdx/mdxComponents.ts
3
+ * @desc The element override map for @next/mdx's `useMDXComponents` and react-markdown's
4
+ * `components` prop: links, headings (h2/h3), fenced code, tables and callout blockquotes.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ import { MdxBlockquote } from "./MdxBlockquote.js";
10
+ import { MdxLink } from "./MdxLink.js";
11
+ import { MdxPre } from "./MdxPre.js";
12
+ import { MdxTable } from "./MdxTable.js";
13
+ /** The element overrides for @next/mdx's useMDXComponents and react-markdown's `components`. */
14
+ export declare const mdxComponents: {
15
+ a: typeof MdxLink;
16
+ blockquote: typeof MdxBlockquote;
17
+ h2: ({ node: _node, id, children, ...props }: import("./MdxHeading.js").MdxHeadingProps) => import("react").JSX.Element;
18
+ h3: ({ node: _node, id, children, ...props }: import("./MdxHeading.js").MdxHeadingProps) => import("react").JSX.Element;
19
+ pre: typeof MdxPre;
20
+ table: typeof MdxTable;
21
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/components/mdx/mdxComponents.ts
3
+ * @desc The element override map for @next/mdx's `useMDXComponents` and react-markdown's
4
+ * `components` prop: links, headings (h2/h3), fenced code, tables and callout blockquotes.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sat Oct 3, 2026
7
+ * @modified Sat Oct 3, 2026
8
+ */
9
+ import { MdxBlockquote } from "./MdxBlockquote.js";
10
+ import { MdxH2, MdxH3 } from "./MdxHeading.js";
11
+ import { MdxLink } from "./MdxLink.js";
12
+ import { MdxPre } from "./MdxPre.js";
13
+ import { MdxTable } from "./MdxTable.js";
14
+ /** The element overrides for @next/mdx's useMDXComponents and react-markdown's `components`. */
15
+ export const mdxComponents = {
16
+ a: MdxLink,
17
+ blockquote: MdxBlockquote,
18
+ h2: MdxH2,
19
+ h3: MdxH3,
20
+ pre: MdxPre,
21
+ table: MdxTable,
22
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/mdx/parseCodeMeta.ts
3
+ * @desc Parses a fenced code block's meta string (the text after the language on ```ts fences)
4
+ * into a title (`title="..."` or `title='...'`) and a sorted, deduped list of highlighted
5
+ * line numbers (`{1,3-5}`), capped at 10000 lines so a huge or malicious range can't hang
6
+ * the render.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ /** A parsed code fence meta string. */
12
+ export type CodeMeta = {
13
+ title?: string;
14
+ highlight: number[];
15
+ };
16
+ /**
17
+ * @function parseCodeMeta
18
+ * @param meta {string | null | undefined} the code fence's meta string, if any
19
+ * @returns {CodeMeta} the title (when present) and the sorted, deduped, capped highlight lines
20
+ */
21
+ export declare const parseCodeMeta: (meta?: string | null) => CodeMeta;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file src/components/mdx/parseCodeMeta.ts
3
+ * @desc Parses a fenced code block's meta string (the text after the language on ```ts fences)
4
+ * into a title (`title="..."` or `title='...'`) and a sorted, deduped list of highlighted
5
+ * line numbers (`{1,3-5}`), capped at 10000 lines so a huge or malicious range can't hang
6
+ * the render.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ const TITLE = /\btitle=(?:"([^"]*)"|'([^']*)')/;
12
+ const RANGES = /\{([^}]*)\}/;
13
+ const MAX_LINES = 10000;
14
+ /**
15
+ * @function parseCodeMeta
16
+ * @param meta {string | null | undefined} the code fence's meta string, if any
17
+ * @returns {CodeMeta} the title (when present) and the sorted, deduped, capped highlight lines
18
+ */
19
+ export const parseCodeMeta = (meta) => {
20
+ const text = meta ?? "";
21
+ const lines = new Set();
22
+ for (const part of RANGES.exec(text)?.[1]?.split(",") ?? []) {
23
+ const match = /^\s*(\d+)\s*(?:-\s*(\d+)\s*)?$/.exec(part);
24
+ if (!match)
25
+ continue;
26
+ let from = Number(match[1]);
27
+ let to = match[2] === undefined ? from : Number(match[2]);
28
+ if (from > to)
29
+ [from, to] = [to, from];
30
+ from = Math.max(from, 1);
31
+ for (let line = from; line <= to && lines.size < MAX_LINES; line++)
32
+ lines.add(line);
33
+ }
34
+ const title = TITLE.exec(text);
35
+ const result = { highlight: [...lines].sort((a, b) => a - b) };
36
+ const name = title?.[1] ?? title?.[2];
37
+ if (name)
38
+ result.title = name;
39
+ return result;
40
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file src/components/mdx/textOf.ts
3
+ * @desc Flattens a ReactNode tree to plain text: strings and numbers pass through, arrays and
4
+ * element children are recursively joined, and null/undefined/boolean contribute nothing.
5
+ * Used to get a code block's plain text for Shiki and the copy button, since react-markdown
6
+ * hands components React children, not a string.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { type ReactNode } from "react";
12
+ /**
13
+ * @function textOf
14
+ * @param node {ReactNode} the node (or tree of nodes) to flatten
15
+ * @returns {string} the concatenated text content
16
+ */
17
+ export declare const textOf: (node: ReactNode) => string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file src/components/mdx/textOf.ts
3
+ * @desc Flattens a ReactNode tree to plain text: strings and numbers pass through, arrays and
4
+ * element children are recursively joined, and null/undefined/boolean contribute nothing.
5
+ * Used to get a code block's plain text for Shiki and the copy button, since react-markdown
6
+ * hands components React children, not a string.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { isValidElement } from "react";
12
+ /**
13
+ * @function textOf
14
+ * @param node {ReactNode} the node (or tree of nodes) to flatten
15
+ * @returns {string} the concatenated text content
16
+ */
17
+ export const textOf = (node) => {
18
+ if (node === null || node === undefined || typeof node === "boolean")
19
+ return "";
20
+ if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
21
+ return String(node);
22
+ }
23
+ if (Array.isArray(node))
24
+ return node.map(textOf).join("");
25
+ if (isValidElement(node))
26
+ return textOf(node.props.children);
27
+ return "";
28
+ };
package/dist/mdx.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file src/mdx.ts
3
+ * @desc The `@haruhimemoe/ui/mdx` subpath: MDX element overrides and the pieces apps wire them
4
+ * up with. Every export here is a server component or a plain server-safe function; the
5
+ * only client code (CodeCopyButton) is rendered internally by CodeBlock, never exported.
6
+ * `CodeBlock` colorizes code only when the app installs `shiki` (an optional peer
7
+ * dependency) and imports `@haruhimemoe/ui/shiki` once; otherwise code renders as plain,
8
+ * unstyled text.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ export { Callout, type CalloutProps, type CalloutType } from "./components/mdx/Callout.js";
14
+ export { CodeBlock, type CodeBlockProps } from "./components/mdx/CodeBlock.js";
15
+ export { mdxComponents } from "./components/mdx/mdxComponents.js";
16
+ export { type CodeMeta, parseCodeMeta } from "./components/mdx/parseCodeMeta.js";
17
+ export { slugify } from "./remark/slugify.js";
package/dist/mdx.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file src/mdx.ts
3
+ * @desc The `@haruhimemoe/ui/mdx` subpath: MDX element overrides and the pieces apps wire them
4
+ * up with. Every export here is a server component or a plain server-safe function; the
5
+ * only client code (CodeCopyButton) is rendered internally by CodeBlock, never exported.
6
+ * `CodeBlock` colorizes code only when the app installs `shiki` (an optional peer
7
+ * dependency) and imports `@haruhimemoe/ui/shiki` once; otherwise code renders as plain,
8
+ * unstyled text.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ export { Callout } from "./components/mdx/Callout.js";
14
+ export { CodeBlock } from "./components/mdx/CodeBlock.js";
15
+ export { mdxComponents } from "./components/mdx/mdxComponents.js";
16
+ export { parseCodeMeta } from "./components/mdx/parseCodeMeta.js";
17
+ export { slugify } from "./remark/slugify.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @file src/remark/callouts.ts
3
+ * @desc A remark plugin for GitHub-style callouts: a blockquote whose first paragraph starts
4
+ * with `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` (case-insensitive)
5
+ * gets `data.hProperties.dataCallout` set to one of three rendered types, and the marker
6
+ * text is stripped from the paragraph (the paragraph itself is dropped if the marker was
7
+ * its only content).
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { type MdNode } from "./mdast.js";
13
+ /** The three callout types this plugin renders. */
14
+ export type CalloutType = "note" | "tip" | "warning";
15
+ /**
16
+ * @function remarkCallouts
17
+ * @returns {(tree: MdNode) => void} a remark transformer that types matching blockquotes and
18
+ * strips their marker text
19
+ */
20
+ export declare const remarkCallouts: () => (tree: MdNode) => void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file src/remark/callouts.ts
3
+ * @desc A remark plugin for GitHub-style callouts: a blockquote whose first paragraph starts
4
+ * with `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` (case-insensitive)
5
+ * gets `data.hProperties.dataCallout` set to one of three rendered types, and the marker
6
+ * text is stripped from the paragraph (the paragraph itself is dropped if the marker was
7
+ * its only content).
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import { setProperty, walk } from "./mdast.js";
13
+ const TYPES = {
14
+ note: "note",
15
+ tip: "tip",
16
+ important: "tip",
17
+ warning: "warning",
18
+ caution: "warning",
19
+ };
20
+ const MARKER = /^\[!(note|tip|important|warning|caution)\][ \t]*(?:\r?\n)?/i;
21
+ /**
22
+ * @function remarkCallouts
23
+ * @returns {(tree: MdNode) => void} a remark transformer that types matching blockquotes and
24
+ * strips their marker text
25
+ */
26
+ export const remarkCallouts = () => (tree) => {
27
+ walk(tree, (node) => {
28
+ if (node.type !== "blockquote")
29
+ return;
30
+ const paragraph = node.children?.[0];
31
+ if (paragraph?.type !== "paragraph" || !paragraph.children)
32
+ return;
33
+ // A parser may split "[!NOTE]" over several text nodes: join the leading run first.
34
+ let end = 0;
35
+ while (paragraph.children[end]?.type === "text")
36
+ end++;
37
+ if (end === 0)
38
+ return;
39
+ const text = paragraph.children
40
+ .slice(0, end)
41
+ .map((child) => child.value ?? "")
42
+ .join("");
43
+ const match = MARKER.exec(text);
44
+ if (!match)
45
+ return;
46
+ const key = match[1].toLowerCase();
47
+ const calloutType = TYPES[key];
48
+ if (!calloutType)
49
+ return;
50
+ setProperty(node, "dataCallout", calloutType);
51
+ const rest = text.slice(match[0].length);
52
+ paragraph.children.splice(0, end, ...(rest ? [{ type: "text", value: rest }] : []));
53
+ if (paragraph.children[0]?.type === "break")
54
+ paragraph.children.shift();
55
+ if (paragraph.children.length === 0)
56
+ node.children?.shift();
57
+ });
58
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @file src/remark/codeMeta.ts
3
+ * @desc A remark plugin that copies a fenced code block's meta string (the text after the
4
+ * language on the opening fence, e.g. `title="a.ts" {2}`) onto the node as
5
+ * `data.hProperties.dataMeta`, so mdast-util-to-hast renders it as `data-meta` and
6
+ * CodeBlock can read it client-side.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { type MdNode } from "./mdast.js";
12
+ /**
13
+ * @function remarkCodeMeta
14
+ * @returns {(tree: MdNode) => void} a remark transformer that sets `dataMeta` on every code node
15
+ * that has a non-empty `meta` string
16
+ */
17
+ export declare const remarkCodeMeta: () => (tree: MdNode) => void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/remark/codeMeta.ts
3
+ * @desc A remark plugin that copies a fenced code block's meta string (the text after the
4
+ * language on the opening fence, e.g. `title="a.ts" {2}`) onto the node as
5
+ * `data.hProperties.dataMeta`, so mdast-util-to-hast renders it as `data-meta` and
6
+ * CodeBlock can read it client-side.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { setProperty, walk } from "./mdast.js";
12
+ /**
13
+ * @function remarkCodeMeta
14
+ * @returns {(tree: MdNode) => void} a remark transformer that sets `dataMeta` on every code node
15
+ * that has a non-empty `meta` string
16
+ */
17
+ export const remarkCodeMeta = () => (tree) => {
18
+ walk(tree, (node) => {
19
+ if (node.type === "code" && node.meta)
20
+ setProperty(node, "dataMeta", node.meta);
21
+ });
22
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @file src/remark/headingIds.ts
3
+ * @desc A remark plugin that gives every depth-2 and depth-3 heading a GitHub-style id slugged
4
+ * from its text, deduplicated across the document. A heading that already has an id
5
+ * (`data.hProperties.id`) keeps it, and a heading whose text slugs to an empty string gets
6
+ * no id at all.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { type MdNode } from "./mdast.js";
12
+ /**
13
+ * @function remarkHeadingIds
14
+ * @returns {(tree: MdNode) => void} a remark transformer that ids depth-2/3 headings
15
+ */
16
+ export declare const remarkHeadingIds: () => (tree: MdNode) => void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file src/remark/headingIds.ts
3
+ * @desc A remark plugin that gives every depth-2 and depth-3 heading a GitHub-style id slugged
4
+ * from its text, deduplicated across the document. A heading that already has an id
5
+ * (`data.hProperties.id`) keeps it, and a heading whose text slugs to an empty string gets
6
+ * no id at all.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { setProperty, textContent, walk } from "./mdast.js";
12
+ import { createSlugger } from "./slugify.js";
13
+ /**
14
+ * @function remarkHeadingIds
15
+ * @returns {(tree: MdNode) => void} a remark transformer that ids depth-2/3 headings
16
+ */
17
+ export const remarkHeadingIds = () => (tree) => {
18
+ const slug = createSlugger();
19
+ walk(tree, (node) => {
20
+ if (node.type !== "heading" || (node.depth !== 2 && node.depth !== 3))
21
+ return;
22
+ if (node.data?.hProperties?.id)
23
+ return;
24
+ const id = slug(textContent(node));
25
+ if (id)
26
+ setProperty(node, "id", id);
27
+ });
28
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file src/remark/index.ts
3
+ * @desc The public entry point for `@haruhimemoe/ui/remark`: one remark plugin for MDX and
4
+ * react-markdown that combines code meta, callouts and heading ids, plus the individual
5
+ * plugins and the slug helpers for callers who want only one piece.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import type { MdNode } from "./mdast.js";
11
+ export type { CalloutType } from "./callouts.js";
12
+ export { remarkCallouts } from "./callouts.js";
13
+ export { remarkCodeMeta } from "./codeMeta.js";
14
+ export { remarkHeadingIds } from "./headingIds.js";
15
+ export { createSlugger, slugify } from "./slugify.js";
16
+ /** Which of the three plugins `remarkHaruhime` runs; each defaults to on. */
17
+ export type RemarkHaruhimeOptions = {
18
+ codeMeta?: boolean;
19
+ callouts?: boolean;
20
+ headingIds?: boolean;
21
+ };
22
+ /**
23
+ * @function remarkHaruhime
24
+ * @param options {RemarkHaruhimeOptions} per-plugin opt-outs; omit to run all three
25
+ * @returns {(tree: MdNode) => void} a remark transformer running code meta, callouts and heading
26
+ * ids over one tree. A default export, because Turbopack only takes MDX plugins by
27
+ * module name: `remarkPlugins: ["@haruhimemoe/ui/remark"]`.
28
+ */
29
+ export default function remarkHaruhime(options?: RemarkHaruhimeOptions): (tree: MdNode) => void;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file src/remark/index.ts
3
+ * @desc The public entry point for `@haruhimemoe/ui/remark`: one remark plugin for MDX and
4
+ * react-markdown that combines code meta, callouts and heading ids, plus the individual
5
+ * plugins and the slug helpers for callers who want only one piece.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import { remarkCallouts } from "./callouts.js";
11
+ import { remarkCodeMeta } from "./codeMeta.js";
12
+ import { remarkHeadingIds } from "./headingIds.js";
13
+ export { remarkCallouts } from "./callouts.js";
14
+ export { remarkCodeMeta } from "./codeMeta.js";
15
+ export { remarkHeadingIds } from "./headingIds.js";
16
+ export { createSlugger, slugify } from "./slugify.js";
17
+ /**
18
+ * @function remarkHaruhime
19
+ * @param options {RemarkHaruhimeOptions} per-plugin opt-outs; omit to run all three
20
+ * @returns {(tree: MdNode) => void} a remark transformer running code meta, callouts and heading
21
+ * ids over one tree. A default export, because Turbopack only takes MDX plugins by
22
+ * module name: `remarkPlugins: ["@haruhimemoe/ui/remark"]`.
23
+ */
24
+ export default function remarkHaruhime(options = {}) {
25
+ return (tree) => {
26
+ if (options.codeMeta !== false)
27
+ remarkCodeMeta()(tree);
28
+ if (options.callouts !== false)
29
+ remarkCallouts()(tree);
30
+ if (options.headingIds !== false)
31
+ remarkHeadingIds()(tree);
32
+ };
33
+ }