@haruhimemoe/ui 0.8.0 → 0.10.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 (52) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +171 -3
  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/components/osu/PlayerCard.d.ts +59 -0
  30. package/dist/components/osu/PlayerCard.js +40 -0
  31. package/dist/components/osu/playerLinks.d.ts +47 -0
  32. package/dist/components/osu/playerLinks.js +65 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/mdx.d.ts +17 -0
  36. package/dist/mdx.js +17 -0
  37. package/dist/remark/callouts.d.ts +20 -0
  38. package/dist/remark/callouts.js +58 -0
  39. package/dist/remark/codeMeta.d.ts +17 -0
  40. package/dist/remark/codeMeta.js +22 -0
  41. package/dist/remark/headingIds.d.ts +16 -0
  42. package/dist/remark/headingIds.js +28 -0
  43. package/dist/remark/index.d.ts +29 -0
  44. package/dist/remark/index.js +33 -0
  45. package/dist/remark/mdast.d.ts +42 -0
  46. package/dist/remark/mdast.js +37 -0
  47. package/dist/remark/slugify.d.ts +23 -0
  48. package/dist/remark/slugify.js +40 -0
  49. package/dist/shiki.d.ts +13 -0
  50. package/dist/shiki.js +38 -0
  51. package/dist/theme.css +18 -0
  52. package/package.json +28 -3
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file src/components/mdx/MdxPre.tsx
3
+ * @desc react-markdown/MDX's `pre` override: a fenced code block (a `<pre>` wrapping a single
4
+ * child with a `language-*` class, a `data-meta` prop, or the literal `code` element type)
5
+ * is handed to CodeBlock with its parsed meta; detecting the fence by the child's props
6
+ * rather than only `child.type === "code"` means an app that overrides the `code` component
7
+ * (through `mdxComponents`' own map or MDX's `useMDXComponents`) still gets highlighting.
8
+ * Anything else renders as a plain, focusable `<pre>`. Drops the `node` prop react-markdown
9
+ * passes to every component.
10
+ * @author David @dvhsh (https://dvh.sh)
11
+ * @created Sat Oct 3, 2026
12
+ * @modified Sat Oct 3, 2026
13
+ */
14
+ import { type ComponentProps } from "react";
15
+ /** Every native `<pre>` prop, plus the `node` react-markdown passes (dropped). */
16
+ export type MdxPreProps = ComponentProps<"pre"> & {
17
+ node?: unknown;
18
+ };
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 declare function MdxPre({ node: _node, children, ...props }: MdxPreProps): import("react").JSX.Element;
@@ -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
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file src/components/osu/PlayerCard.tsx
3
+ * @desc osu!-web's user card (the 120px card from the friends list and user tooltips) from plain
4
+ * props: cover under a b5 overlay, 60px avatar, country flag, team flag and supporter heart,
5
+ * the username (the whole card links to the profile), and an optional status row. It never
6
+ * fetches: the app passes a snapshot, so nothing here claims live data unless told to. The
7
+ * online ring's lime is osu!'s own green-light, hue-independent like StarRating's spectrum.
8
+ * Keyboard focus outlines the whole card in h1 as well as the username's own ring.
9
+ * Plain `<img>`s, not next/image, so apps need no `images.remotePatterns` for osu!'s hosts.
10
+ * Server-safe.
11
+ * @author David @dvhsh (https://dvh.sh)
12
+ * @created Sun Oct 4, 2026
13
+ * @modified Sun Oct 4, 2026
14
+ */
15
+ import type { ComponentProps, ReactNode } from "react";
16
+ /** An osu! team: its name (the flag's alt text and tooltip) and its flag image. */
17
+ export type PlayerTeam = {
18
+ /** The team's name. */
19
+ name: string;
20
+ /** The team flag's URL (osu! serves them from assets.ppy.sh). */
21
+ flagUrl: string;
22
+ };
23
+ /** Every native `<div>` prop except children, plus the player's snapshot. */
24
+ export type PlayerCardProps = Omit<ComponentProps<"div">, "children"> & {
25
+ /** The name shown on the card, as-is. */
26
+ username: string;
27
+ /** The osu! user id: the avatar comes from a.ppy.sh and the card links to the profile. */
28
+ userId?: number | undefined;
29
+ /** Replaces the profile link; `null` draws no link even with a `userId`. */
30
+ href?: string | null | undefined;
31
+ /** Replaces the a.ppy.sh avatar. Without it and a `userId`, a letter stands in. */
32
+ avatarUrl?: string | undefined;
33
+ /** The profile cover, drawn behind everything. None leaves the card plain b4. */
34
+ coverUrl?: string | undefined;
35
+ /** ISO 3166-1 alpha-2 code; draws osu!'s flag. Anything else draws none. */
36
+ countryCode?: string | undefined;
37
+ /** The flag's alt text (default: the English name from Intl, else the code). */
38
+ countryName?: string | undefined;
39
+ /** The player's osu! team, drawn as its flag beside the country's. */
40
+ team?: PlayerTeam | undefined;
41
+ /** Draws the supporter heart. */
42
+ supporter?: boolean | undefined;
43
+ /** What screen readers hear for the heart (default "osu! supporter"). */
44
+ supporterLabel?: string | undefined;
45
+ /** Draws the status ring. Leave it out for static data: the card never guesses a status. */
46
+ status?: "online" | "offline" | undefined;
47
+ /** The bottom row's main line (default "Online" or "Offline" when `status` is set). */
48
+ statusText?: ReactNode;
49
+ /** A small line above it ("Last seen 29 days ago", "formerly RMarc"). */
50
+ statusNote?: ReactNode;
51
+ };
52
+ /**
53
+ * @function PlayerCard
54
+ * @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
55
+ * status) and native div props
56
+ * @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
57
+ * has a link
58
+ */
59
+ export declare function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter, supporterLabel, status, statusText, statusNote, className, ...props }: PlayerCardProps): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ import { AutoLink } from "../basics/AutoLink.js";
4
+ import { avatarUrl as defaultAvatarUrl, countryName as defaultCountryName, flagUrl, isAnimatedImage, normalizeCountryCode, profileUrl, } from "./playerLinks.js";
5
+ /** The first letter or digit of a name, for the stand-in avatar. */
6
+ function initialOf(name) {
7
+ return name.match(/[\p{L}\p{N}]/u)?.[0]?.toUpperCase() ?? "?";
8
+ }
9
+ /** Whether a status line has something to show: not null, undefined, a boolean or "". */
10
+ function shown(node) {
11
+ return node != null && typeof node !== "boolean" && node !== "";
12
+ }
13
+ /** The supporter heart: osu!'s pink-circle badge with a white heart. */
14
+ function SupporterHeart({ label }) {
15
+ return (_jsx("span", { role: "img", "aria-label": label, className: "inline-flex size-[26px] shrink-0 items-center justify-center rounded-full bg-h2 text-c1", children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", className: "size-3.5", fill: "currentColor", children: _jsx("path", { d: "M12 21s-7.5-4.6-10-9.3C.3 8.4 2.2 4.5 6 4.5c2.2 0 3.6 1.2 4.5 2.5l1.5 2 1.5-2c.9-1.3 2.3-2.5 4.5-2.5 3.8 0 5.7 3.9 4 7.2C19.5 16.4 12 21 12 21z" }) }) }));
16
+ }
17
+ /**
18
+ * @function PlayerCard
19
+ * @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
20
+ * status) and native div props
21
+ * @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
22
+ * has a link
23
+ */
24
+ export function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter = false, supporterLabel = "osu! supporter", status, statusText, statusNote, className, ...props }) {
25
+ const link = href === null ? null : (href ?? (userId === undefined ? null : profileUrl(userId)));
26
+ const avatar = avatarUrl ?? (userId === undefined ? null : defaultAvatarUrl(userId));
27
+ const code = normalizeCountryCode(countryCode);
28
+ const flag = flagUrl(countryCode);
29
+ const mainLine = shown(statusText)
30
+ ? statusText
31
+ : status === "online"
32
+ ? "Online"
33
+ : status
34
+ ? "Offline"
35
+ : null;
36
+ const note = shown(statusNote) ? statusNote : null;
37
+ const hasStatusRow = status !== undefined || mainLine !== null || note !== null;
38
+ return (_jsxs("div", { className: cx("relative isolate flex h-[120px] flex-col justify-between overflow-hidden rounded-[10px] bg-b4 text-c1", link &&
39
+ "hover:outline-2 hover:outline-c3 has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-h1", className), ...props, children: [coverUrl ? (_jsx("img", { src: coverUrl, alt: "", loading: "lazy", decoding: "async", className: cx("absolute inset-0 -z-10 size-full object-cover", isAnimatedImage(coverUrl) && "motion-reduce:hidden") })) : null, coverUrl ? _jsx("div", { "aria-hidden": "true", className: "absolute inset-0 -z-10 bg-b5/80" }) : null, _jsxs("div", { className: "flex gap-2.5 p-2.5", children: [avatar ? (_jsx("img", { src: avatar, alt: "", width: 60, height: 60, loading: "lazy", decoding: "async", className: "size-[60px] shrink-0 rounded-md bg-b4 object-cover" })) : (_jsx("span", { "aria-hidden": "true", className: "flex size-[60px] shrink-0 items-center justify-center rounded-md bg-b3 font-bold text-2xl text-c3", children: initialOf(username) })), _jsxs("div", { className: "grid min-w-0 flex-1 grid-rows-[26px_1fr]", children: [_jsxs("div", { className: "flex h-[26px] items-center gap-1.5", children: [flag && code ? (_jsx("img", { src: flag, alt: countryName ?? defaultCountryName(code), width: 36, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-auto shrink-0" })) : null, team ? (_jsx("img", { src: team.flagUrl, alt: team.name, width: 52, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-[52px] shrink-0 rounded-sm object-cover" })) : null, supporter ? _jsx(SupporterHeart, { label: supporterLabel }) : null] }), _jsx("div", { className: "flex min-w-0 items-center", children: link ? (_jsx(AutoLink, { href: link, className: "truncate font-semibold text-base text-c1 after:absolute after:inset-0", children: username })) : (_jsx("span", { className: "truncate font-semibold text-base", children: username })) })] })] }), hasStatusRow ? (_jsxs("div", { className: "flex items-center gap-2.5 px-2.5 pb-2.5", children: [_jsx("div", { className: "flex w-[60px] shrink-0 justify-center", children: status ? (_jsx("span", { "aria-hidden": "true", className: cx("size-[25px] rounded-full border-4", status === "online" ? "border-lime-400" : "border-b6") })) : null }), _jsxs("div", { className: "flex min-w-0 flex-col", children: [note !== null ? _jsx("span", { className: "truncate text-c2 text-xs", children: note }) : null, mainLine !== null ? _jsx("span", { className: "truncate text-sm", children: mainLine }) : null] })] })) : null] }));
40
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file src/components/osu/playerLinks.ts
3
+ * @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
4
+ * user id, osu-web's own country flag SVG from a two-letter code, the country's English
5
+ * name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sun Oct 4, 2026
8
+ * @modified Sun Oct 4, 2026
9
+ */
10
+ /**
11
+ * @function profileUrl
12
+ * @param id {number} the osu! user id
13
+ * @returns {string} the user's osu! profile page
14
+ */
15
+ export declare const profileUrl: (id: number) => string;
16
+ /**
17
+ * @function avatarUrl
18
+ * @param id {number} the osu! user id
19
+ * @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
20
+ */
21
+ export declare const avatarUrl: (id: number) => string;
22
+ /**
23
+ * @function normalizeCountryCode
24
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
25
+ * @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
26
+ */
27
+ export declare function normalizeCountryCode(code: string | undefined): string | null;
28
+ /**
29
+ * @function flagUrl
30
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code
31
+ * @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
32
+ * points, lowercase hex joined by "-"), or null for anything but two ASCII letters
33
+ */
34
+ export declare function flagUrl(code: string | undefined): string | null;
35
+ /**
36
+ * @function countryName
37
+ * @param code {string} an ISO 3166-1 alpha-2 code
38
+ * @returns {string} the region's English name from Intl, or the code in capitals when Intl
39
+ * doesn't know it
40
+ */
41
+ export declare function countryName(code: string): string;
42
+ /**
43
+ * @function isAnimatedImage
44
+ * @param url {string} an image URL
45
+ * @returns {boolean} true when the path ends in .gif (before any query or hash)
46
+ */
47
+ export declare const isAnimatedImage: (url: string) => boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file src/components/osu/playerLinks.ts
3
+ * @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
4
+ * user id, osu-web's own country flag SVG from a two-letter code, the country's English
5
+ * name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sun Oct 4, 2026
8
+ * @modified Sun Oct 4, 2026
9
+ */
10
+ const REGIONAL_INDICATOR_A = 0x1f1e6;
11
+ /**
12
+ * @function profileUrl
13
+ * @param id {number} the osu! user id
14
+ * @returns {string} the user's osu! profile page
15
+ */
16
+ export const profileUrl = (id) => `https://osu.ppy.sh/users/${id}`;
17
+ /**
18
+ * @function avatarUrl
19
+ * @param id {number} the osu! user id
20
+ * @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
21
+ */
22
+ export const avatarUrl = (id) => `https://a.ppy.sh/${id}`;
23
+ /**
24
+ * @function normalizeCountryCode
25
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
26
+ * @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
27
+ */
28
+ export function normalizeCountryCode(code) {
29
+ const upper = code?.trim().toUpperCase() ?? "";
30
+ return /^[A-Z]{2}$/.test(upper) ? upper : null;
31
+ }
32
+ /**
33
+ * @function flagUrl
34
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code
35
+ * @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
36
+ * points, lowercase hex joined by "-"), or null for anything but two ASCII letters
37
+ */
38
+ export function flagUrl(code) {
39
+ const upper = normalizeCountryCode(code);
40
+ if (!upper)
41
+ return null;
42
+ const points = [...upper].map((letter) => (REGIONAL_INDICATOR_A + letter.charCodeAt(0) - 65).toString(16));
43
+ return `https://osu.ppy.sh/assets/images/flags/${points.join("-")}.svg`;
44
+ }
45
+ /**
46
+ * @function countryName
47
+ * @param code {string} an ISO 3166-1 alpha-2 code
48
+ * @returns {string} the region's English name from Intl, or the code in capitals when Intl
49
+ * doesn't know it
50
+ */
51
+ export function countryName(code) {
52
+ const upper = code.trim().toUpperCase();
53
+ try {
54
+ return new Intl.DisplayNames(["en"], { type: "region" }).of(upper) ?? upper;
55
+ }
56
+ catch {
57
+ return upper;
58
+ }
59
+ }
60
+ /**
61
+ * @function isAnimatedImage
62
+ * @param url {string} an image URL
63
+ * @returns {boolean} true when the path ends in .gif (before any query or hash)
64
+ */
65
+ export const isAnimatedImage = (url) => /\.gif(?:[?#]|$)/i.test(url);
package/dist/index.d.ts CHANGED
@@ -49,6 +49,7 @@ export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./compone
49
49
  export { JsonLd, type JsonLdProps } from "./components/meta/JsonLd.js";
50
50
  export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./components/osu/BeatmapStats.js";
51
51
  export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
52
+ export { PlayerCard, type PlayerCardProps, type PlayerTeam, } from "./components/osu/PlayerCard.js";
52
53
  export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
53
54
  export { CommandPalette } from "./components/palette/CommandPalette.js";
54
55
  export { CommandPaletteButton, type CommandPaletteButtonProps, } from "./components/palette/CommandPaletteButton.js";
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ export { JsonLd } from "./components/meta/JsonLd.js";
54
54
  // osu!
55
55
  export { BeatmapStats, } from "./components/osu/BeatmapStats.js";
56
56
  export { ModBadge } from "./components/osu/ModBadge.js";
57
+ export { PlayerCard, } from "./components/osu/PlayerCard.js";
57
58
  export { StarRating } from "./components/osu/StarRating.js";
58
59
  // Palette
59
60
  export { CommandPalette } from "./components/palette/CommandPalette.js";
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";