@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.
- package/CHANGELOG.md +16 -1
- package/README.md +133 -1
- package/dist/components/basics/Prose.d.ts +9 -6
- package/dist/components/basics/Prose.js +4 -4
- package/dist/components/mdx/Callout.d.ts +24 -0
- package/dist/components/mdx/Callout.js +22 -0
- package/dist/components/mdx/CodeBlock.d.ts +26 -0
- package/dist/components/mdx/CodeBlock.js +48 -0
- package/dist/components/mdx/CodeCopyButton.d.ts +21 -0
- package/dist/components/mdx/CodeCopyButton.js +34 -0
- package/dist/components/mdx/MdxBlockquote.d.ts +23 -0
- package/dist/components/mdx/MdxBlockquote.js +17 -0
- package/dist/components/mdx/MdxHeading.d.ts +26 -0
- package/dist/components/mdx/MdxHeading.js +23 -0
- package/dist/components/mdx/MdxLink.d.ts +29 -0
- package/dist/components/mdx/MdxLink.js +26 -0
- package/dist/components/mdx/MdxPre.d.ts +25 -0
- package/dist/components/mdx/MdxPre.js +39 -0
- package/dist/components/mdx/MdxTable.d.ts +20 -0
- package/dist/components/mdx/MdxTable.js +23 -0
- package/dist/components/mdx/highlighter.d.ts +37 -0
- package/dist/components/mdx/highlighter.js +66 -0
- package/dist/components/mdx/mdxComponents.d.ts +21 -0
- package/dist/components/mdx/mdxComponents.js +22 -0
- package/dist/components/mdx/parseCodeMeta.d.ts +21 -0
- package/dist/components/mdx/parseCodeMeta.js +40 -0
- package/dist/components/mdx/textOf.d.ts +17 -0
- package/dist/components/mdx/textOf.js +28 -0
- package/dist/mdx.d.ts +17 -0
- package/dist/mdx.js +17 -0
- package/dist/remark/callouts.d.ts +20 -0
- package/dist/remark/callouts.js +58 -0
- package/dist/remark/codeMeta.d.ts +17 -0
- package/dist/remark/codeMeta.js +22 -0
- package/dist/remark/headingIds.d.ts +16 -0
- package/dist/remark/headingIds.js +28 -0
- package/dist/remark/index.d.ts +29 -0
- package/dist/remark/index.js +33 -0
- package/dist/remark/mdast.d.ts +42 -0
- package/dist/remark/mdast.js +37 -0
- package/dist/remark/slugify.d.ts +23 -0
- package/dist/remark/slugify.js +40 -0
- package/dist/shiki.d.ts +13 -0
- package/dist/shiki.js +38 -0
- package/dist/theme.css +18 -0
- 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
|
+
}
|