@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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-10-04
10
+
11
+ ### Added
12
+
13
+ - `PlayerCard`: osu!-web's user card from plain props (cover, avatar, country and team flags, supporter heart, username linking to the profile, an optional status row). It never fetches; apps pass a snapshot.
14
+
15
+ ## [0.9.0] - 2026-10-03
16
+
17
+ ### Added
18
+
19
+ - `@haruhimemoe/ui/mdx`: `mdxComponents` (the `a`/`h2`/`h3`/`pre`/`table`/`blockquote` element overrides for `@next/mdx` and `react-markdown`), `CodeBlock` (a fenced code block, Shiki-highlighted when registered), `Callout` (a note/tip/warning aside, also reached through a GitHub-style `> [!NOTE]` blockquote), `parseCodeMeta` and `slugify`.
20
+ - `@haruhimemoe/ui/remark`: `remarkHaruhime` (default export, for Turbopack's module-name-only `remarkPlugins`) plus the named `remarkCodeMeta`, `remarkCallouts` and `remarkHeadingIds`, and `createSlugger`.
21
+ - `@haruhimemoe/ui/shiki`: an opt-in side-effect import (`shiki` is now an optional peer dependency) that registers Shiki's core highlighter for `CodeBlock`. An app that skips it, or doesn't install `shiki`, gets plain code blocks instead of a build failure.
22
+ - `theme.css`: `--shiki-foreground`, `--shiki-background` and the `--shiki-token-*` variables Shiki's CSS-variables theme reads, derived from `--hue` like the rest of the palette.
23
+
24
+ ### Changed
25
+
26
+ - `Prose` styles a `pre` at any depth, excluding `CodeBlock`'s own (`role="group"`), so a plain `pre` nested inside `li` or `blockquote` keeps its fence look while `CodeBlock` (as `mdxComponents`' `pre` override renders it) keeps its own. `Prose` also styles `blockquote`.
27
+ - `CodeBlock` accepts every native `<div>` prop (`id`, `data-*`, `aria-*`, …) except `code`, `lang`, `title` and `highlight`, spread onto its wrapper.
28
+
9
29
  ## [0.8.0] - 2026-10-03
10
30
 
11
31
  ### Added
@@ -124,7 +144,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
124
144
  - `className` on every component, and the extras passed to `buttonClasses` and `fieldClasses`, merge with tailwind-merge: a caller's class replaces a built-in one that sets the same property (`fieldClasses("w-auto")` drops `w-full`).
125
145
  - Shell: `SiteHeader` (brand slot, nav links as data with `aria-current`, actions slot), `NavLinks`, `SiteFooter` (link columns as data, fine print, the haruhime.moe wordmark and a GitHub link) and `PageShell` (skip link, header, main, footer).
126
146
 
127
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.8.0...HEAD
147
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.10.0...HEAD
148
+ [0.10.0]: https://github.com/haruhimemoe/ui/compare/v0.9.0...v0.10.0
149
+ [0.9.0]: https://github.com/haruhimemoe/ui/compare/v0.8.0...v0.9.0
128
150
  [0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
129
151
  [0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
130
152
  [0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  # @haruhimemoe/ui
4
4
 
5
- React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
5
+ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces and player cards, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
6
6
 
7
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
8
8
 
9
- This README describes version 0.8.0. Anything marked "since 0.8.0" is not in 0.7.0, anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 0.5.0, anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 0.3.0, anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
9
+ This README describes version 0.9.0. Anything marked "since 0.9.0" is not in 0.8.0, anything marked "since 0.8.0" is not in 0.7.0, anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 0.5.0, anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 0.3.0, anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -814,7 +814,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
814
814
 
815
815
  ### osu!
816
816
 
817
- Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (no osu! API types) and are Server Components.
817
+ Since 0.4.0. Display pieces for beatmaps, mod pools and (since 0.10.0) players. They take plain values (no osu! API types) and are Server Components.
818
818
 
819
819
  #### `StarRating`
820
820
 
@@ -845,6 +845,174 @@ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: N
845
845
  | --- | --- | --- | --- |
846
846
  | `mod` | `string` | required | The mod or slot label. |
847
847
 
848
+ #### `PlayerCard`
849
+
850
+ Since 0.10.0. osu!-web's user card (the 120px card from the friends list and user tooltips): the profile cover under a dark overlay, the 60px avatar, the country flag, the team flag and the supporter heart, the username, and an optional status row. The whole card links to the osu! profile. Every native `<div>` prop except `children`.
851
+
852
+ It never fetches. Pass a snapshot you keep yourself (from one osu! API lookup, or typed by hand), so a page of cards costs no API calls and hits no rate limits. Leave `status` out for static data: the card draws no online or offline ring unless told to. Images are plain `<img>` tags (lazy; the avatar and flags are sized), so the app needs no `images.remotePatterns` for osu!'s hosts. A cover ending in `.gif` is hidden when the visitor asks for reduced motion.
853
+
854
+ ```tsx
855
+ <PlayerCard
856
+ username="peppy"
857
+ userId={2}
858
+ countryCode="AU"
859
+ coverUrl="https://assets.ppy.sh/user-profile-covers/2/….jpeg"
860
+ team={{ name: "mom?", flagUrl: "https://assets.ppy.sh/teams/flag/1/….png" }}
861
+ supporter
862
+ statusText="osu!"
863
+ />
864
+ ```
865
+
866
+ | Prop | Type | Default | What it does |
867
+ | --- | --- | --- | --- |
868
+ | `username` | `string` | required | The name on the card. |
869
+ | `userId` | `number` | none | The osu! id: the avatar comes from `https://a.ppy.sh/<id>` and the card links to `https://osu.ppy.sh/users/<id>`. |
870
+ | `href` | `string \| null` | the profile | Replaces the link; `null` draws none. Without a link the name is plain text. A linked card outlines on hover and on keyboard focus. |
871
+ | `avatarUrl` | `string` | from `userId` | Replaces the avatar. With neither, the name's first letter stands in. |
872
+ | `coverUrl` | `string` | none | The cover behind the card. None leaves it plain `b4`. |
873
+ | `countryCode` | `string` | none | ISO 3166-1 alpha-2. Draws osu!'s own flag (`osu.ppy.sh/assets/images/flags/<code points>.svg`); anything but two letters draws none. |
874
+ | `countryName` | `string` | English name from `Intl` | The flag's alt text. |
875
+ | `team` | `{ name, flagUrl }` | none | The team flag beside the country's, its alt text the team's name. |
876
+ | `supporter` | `boolean` | `false` | Draws the supporter heart. |
877
+ | `supporterLabel` | `string` | `"osu! supporter"` | What screen readers hear for the heart. |
878
+ | `status` | `"online" \| "offline"` | none | Draws the status ring (lime online, dark offline). |
879
+ | `statusText` | `ReactNode` | `"Online"`/`"Offline"` with a status | The bottom row's main line: a status, or anything short (a role). |
880
+ | `statusNote` | `ReactNode` | none | A small line above it ("Last seen 29 days ago", "formerly RMarc"). |
881
+
882
+ The bottom row is left out when there is no status, text or note; the card keeps its 120px height so cards line up in a grid. A card with a cover gets a `b5` overlay at 80%, enough for `c2` text at 12px to keep 4.5:1 over a white cover (axe can't check text over an image).
883
+
884
+ ### MDX (server)
885
+
886
+ Since 0.9.0. Three subpaths, so an app that never renders Markdown loads none of this: `@haruhimemoe/ui/mdx` (the React pieces: `mdxComponents`, `CodeBlock`, `Callout`), `@haruhimemoe/ui/remark` (plain functions, no React, for `@next/mdx` and `react-markdown`'s `remarkPlugins`), and `@haruhimemoe/ui/shiki` (opt-in code highlighting). The root `@haruhimemoe/ui` export is unchanged.
887
+
888
+ **With `@next/mdx`:**
889
+
890
+ ```ts
891
+ // next.config.ts
892
+ import createMDX from "@next/mdx";
893
+
894
+ const withMDX = createMDX({
895
+ extension: /\.mdx?$/,
896
+ // Turbopack only takes MDX plugins as module names, not imported functions.
897
+ options: { remarkPlugins: ["remark-gfm", "@haruhimemoe/ui/remark"] },
898
+ });
899
+
900
+ export default withMDX({ pageExtensions: ["ts", "tsx", "md", "mdx"] });
901
+ ```
902
+
903
+ ```tsx
904
+ // mdx-components.tsx
905
+ import { mdxComponents } from "@haruhimemoe/ui/mdx";
906
+ import "@haruhimemoe/ui/shiki"; // optional: see "Code highlighting" below
907
+
908
+ export function useMDXComponents(components) {
909
+ return { ...mdxComponents, ...components };
910
+ }
911
+ ```
912
+
913
+ `remark-gfm` isn't ui's dependency (pin it yourself); without it `@next/mdx` has no pipe tables, and a table with no GFM stays a paragraph.
914
+
915
+ **With `react-markdown`:**
916
+
917
+ ```tsx
918
+ import { mdxComponents } from "@haruhimemoe/ui/mdx";
919
+ import remarkHaruhime from "@haruhimemoe/ui/remark";
920
+ import remarkGfm from "remark-gfm";
921
+ import Markdown from "react-markdown";
922
+
923
+ <Markdown components={mdxComponents} remarkPlugins={[remarkGfm, remarkHaruhime]}>
924
+ {body}
925
+ </Markdown>;
926
+ ```
927
+
928
+ `mdxComponents` must render in a Server Component: `CodeBlock` (its `pre` override hands off to) is async, and a client component can't render an async one. A client-side renderer, like a live editor preview, can't use `pre` from this map; pass your own synchronous override for that case.
929
+
930
+ If you sanitize the output (`rehype-sanitize` or your own schema), allow `className` on `code` matching `/^language-/`, `dataMeta` on `code`, `dataCallout` on `blockquote`, and `id` on `h2`/`h3`: the remark plugin writes these, and `mdxComponents` reads them back. rehype-sanitize's default `clobberPrefix` renames those heading ids to `user-content-…`; pass `clobberPrefix: ""` (what haruhime.moe uses) to keep the plain id, or live with the prefix and update any hand-written anchor links to match.
931
+
932
+ #### `mdxComponents`
933
+
934
+ The element overrides apps pass to `@next/mdx`'s `useMDXComponents` or `react-markdown`'s `components`: `{ a, blockquote, h2, h3, pre, table }`. Spread it and add your own (`{ ...mdxComponents, Example: LiveExample }`).
935
+
936
+ | Element | Renders |
937
+ | --- | --- |
938
+ | `a` | `https://` opens in a new tab (`rel="noopener noreferrer"`); a same-page `#hash` is a plain anchor; everything else goes through `AutoLink` (`next/link`, or a plain `<a>` off-site). |
939
+ | `h2`, `h3` | The heading with its id (from the remark plugin, or a `slugify` of its own text) and, beside it, a `#` anchor link (`aria-label="Link to section: …"`, always visible in `c4`, turning `h1` on hover, never only on hover). |
940
+ | `pre` | Reads its single `code` child (text, `language-x` class, the fence's meta) and renders `CodeBlock`. Anything else (a `pre` with no single `code` child) renders as a plain, focusable `<pre>`. |
941
+ | `table` | The table wrapped in a focusable, named scroll region: `<div role="group" tabIndex={0} aria-label="…">`, labelled by the table's `<caption>` text, or `"Table"` without one. |
942
+ | `blockquote` | A blockquote the remark plugin marked `data-callout` renders as `Callout` of that type; any other blockquote renders plainly. |
943
+
944
+ #### `CodeBlock`
945
+
946
+ An async Server Component: a header bar (the fence's `title`, or the language name, or `"Code"`) with a copy button, over a `<pre role="group" tabIndex={0}>` of lines. Every native `<div>` prop goes on the wrapper except `code`, `lang`, `title` and `highlight`.
947
+
948
+ | Prop | Type | Default | What it does |
949
+ | --- | --- | --- | --- |
950
+ | `code` | `string` | required | The text. Any line endings (`\r\n`, a trailing newline) are normalized; a lone trailing newline doesn't add a phantom last line. |
951
+ | `lang` | `string` | none | A Shiki language id or alias (`ts`, `tsx`, `js`, `json`, `bash`/`sh`/`shell`, `css`, `html`, `md`/`markdown`, `diff`, `yaml`/`yml`). Unknown languages render plain. |
952
+ | `title` | `string` | none | Shown in the header instead of the language; also names the `<pre>` and the copy button ("Copy x.ts"). |
953
+ | `highlight` | `readonly number[]` | `[]` | 1-based line numbers to mark: an `h1` left border and a `b4` tint (`forced-colors:border-[Highlight]` keeps the mark visible under Windows high contrast). |
954
+
955
+ In MDX, pass these through the fence's meta string instead of writing `CodeBlock` by hand: ` ```ts title="pool.ts" {2,4-5}`. `parseCodeMeta` reads `title="…"` or `title='…'` and `{1,3-5}` ranges (anything else is ignored, and it never throws); the language comes from the fence's own tag.
956
+
957
+ #### Code highlighting
958
+
959
+ `shiki` is an optional peer dependency: apps that never render code don't install it, and ui's only runtime dependency stays `tailwind-merge`. An app that renders code:
960
+
961
+ ```sh
962
+ bun add shiki
963
+ ```
964
+
965
+ ```ts
966
+ // in the module that renders code: mdx-components.tsx, or next to your react-markdown call
967
+ import "@haruhimemoe/ui/shiki";
968
+ ```
969
+
970
+ That import is a side effect: it registers a Shiki core highlighter (`shiki/core` with the no-WASM JS regex engine, loaded through dynamic `import()`) under `@haruhimemoe/ui/mdx`'s `CodeBlock`. The registration lives on `globalThis` for the whole process, not per module graph, but it only exists once the import has actually run: put it in the module that renders code (`mdx-components.tsx`, which reaches every MDX page, or directly beside your `react-markdown` renderer) so it runs before `CodeBlock` does. `package.json`'s `sideEffects` lists `./dist/shiki.js`, so bundlers don't drop the bare import; Turbopack resolves even a dynamic `import("shiki/core")` at build time, which is why highlighting is a separate subpath instead of `CodeBlock` importing it unconditionally.
971
+
972
+ Without the import, `CodeBlock` renders plain, unstyled lines, and in development it logs one `console.warn` per process ("code blocks aren't highlighted…"). A highlighter that fails to load (a missing package, a broken build) falls back the same way, warned once.
973
+
974
+ Tokens render as `<span style={{ color: "var(--shiki-token-…)" }}>`, no injected Shiki HTML, through `--shiki-*` custom properties in `theme.css` (see "Setup" above for loading it). They follow `--hue` like the rest of the palette: `--shiki-foreground` and `--shiki-background` are `c2`/`b6`; `-punctuation` is `c3` and `-comment` is `c4`, the palette tokens as-is; `-keyword` and `-link` are `hsl(var(--hue) 100% 78%)`, the palette's own hue with no offset; `-string`, `-string-expression`, `-constant`, `-function` and `-parameter` are each `calc(var(--hue) + N)`, a hue-offset HSL value. Every one stays at or above 4.5:1 against both `b6` (the block's background) and `b4` (a highlighted line's tint) at every integer hue. `keyword` and `link` get their own fixed lightness instead of reusing `h1`: `h1`'s default (76%) drops to 4.33:1 against `b4` at hue 240, and a `--h1-l` override written for other text shouldn't silently recolor code too.
975
+
976
+ #### `Callout`
977
+
978
+ A labelled aside: a left-border panel (`role="note"`) with an icon and a bold label, usable directly in MDX or rendered by `mdxComponents`' `blockquote` override.
979
+
980
+ | Prop | Type | Default | What it does |
981
+ | --- | --- | --- | --- |
982
+ | `type` | `"note" \| "tip" \| "warning"` | `"note"` | The border color, icon and default label (`h1` / `c2` / `h2` border; "Note" / "Tip" / "Warning"). The type is in the text, never color alone. |
983
+ | `title` | `ReactNode` | the type's label | Replaces the default label. |
984
+ | `children` | `ReactNode` | required | The body. |
985
+
986
+ GitHub-style callout syntax works through the remark plugin: a blockquote whose first paragraph starts with `[!NOTE]`, `[!TIP]`, `[!WARNING]` (case-insensitive; GitHub's `[!IMPORTANT]` maps to `tip`, `[!CAUTION]` to `warning`) becomes a `Callout` of that type, the marker stripped:
987
+
988
+ ```md
989
+ > [!NOTE]
990
+ > Packs are deleted after 90 days of no edits.
991
+ ```
992
+
993
+ Or write `<Callout type="tip">` directly in an `.mdx` file.
994
+
995
+ #### `@haruhimemoe/ui/remark`
996
+
997
+ Plain functions (no React), for `remarkPlugins`. Default export `remarkHaruhime(options?)` runs all three transforms below in order and is what Turbopack needs by module name: `remarkPlugins: ["@haruhimemoe/ui/remark"]`. Named exports `remarkCodeMeta`, `remarkCallouts` and `remarkHeadingIds` run one each, for `react-markdown`'s array form or a custom pipeline.
998
+
999
+ | Option (all default `true`) | Turns off |
1000
+ | --- | --- |
1001
+ | `codeMeta` | Copying a fenced code block's meta string onto `data-meta`, which `CodeBlock`'s fence syntax (`title=`, `{…}`) reads. |
1002
+ | `callouts` | The `[!NOTE]` / `[!TIP]` / `[!WARNING]` blockquote markers. |
1003
+ | `headingIds` | Slugged, deduplicated ids on `h2`/`h3` (a heading that already has one keeps it). |
1004
+
1005
+ `slugify(text)` and `createSlugger()` are also exported: lowercase, Unicode-aware (letters, marks, digits and underscores from any script survive; everything else but whitespace and hyphens is dropped), spaces to hyphens, repeats suffixed `-1`, `-2` like GitHub's own heading anchors. `@haruhimemoe/ui/mdx` re-exports `slugify` for apps that build their own heading links.
1006
+
1007
+ #### Accessibility
1008
+
1009
+ - The `<pre>` `CodeBlock` renders and the `<div>` `mdxComponents`' `table` wraps a table in are both `role="group"` with `tabIndex={0}` and an `aria-label` (`pre` may not be named on its own): keyboard users can reach a sideways scroll that a mouse would otherwise require. Do the same for any `pre` you render yourself inside `Prose`.
1010
+ - Heading anchor links are always visible (`c4`, turning `h1` on hover), never hover-only, and at least 24px; their `aria-label` ("Link to section: …") keeps the heading's own accessible name as its own text.
1011
+ - `CodeCopyButton` reports "Copied" or "Copy failed" through a live region mounted before use, like `CopyButton`.
1012
+ - `Callout` is `role="note"` with the type in text, not color alone; highlighted code lines keep a `forced-colors` border so Windows high contrast mode still shows them.
1013
+
1014
+ `Prose` (in "Basics" above) styles a direct-child `pre` with its own fence look, so `CodeBlock` (which isn't a direct child; `mdxComponents`' `pre` override renders it) is untouched when both are in play. `Prose` also styles `blockquote`.
1015
+
848
1016
  ### Palette (client)
849
1017
 
850
1018
  Since 0.8.0. A command palette: press Ctrl K (⌘K on a Mac) anywhere on the page and a dialog opens with a search box over everything the app can do. It is one component for every haruhime tool: `CommandPalette` is the engine, `siteCommands` the defaults every site shares, and each app plugs in its own commands, pages and search providers through the same `Command` and `Provider` types. No new dependency.
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * @file src/components/basics/Prose.tsx
3
3
  * @desc Long-form typography for MDX, docs and legal text, styled with the osu!-web tokens.
4
- * Styles the plain elements inside it (headings, links, lists, code, tables). The element
5
- * that opens the block gets no top margin, so a leading heading sits flush.
4
+ * Styles the plain elements inside it (headings, links, lists, code, tables, blockquotes).
5
+ * The element that opens the block gets no top margin, so a leading heading sits flush. Any
6
+ * `pre` at any depth (including one nested inside `li` or `blockquote`) gets Prose's fence
7
+ * styles, except CodeBlock's own `pre` (`role="group"`, the `./mdx` subpath's fenced code
8
+ * renderer), which keeps its own look untouched.
6
9
  * @author David @dvhsh (https://dvh.sh)
7
10
  * @created Wed Sep 23, 2026
8
- * @modified Thu Sep 24, 2026
11
+ * @modified Sat Oct 3, 2026
9
12
  */
10
13
  import type { ComponentProps } from "react";
11
14
  /** Every native `<div>` prop (including `ref`). */
@@ -13,8 +16,8 @@ export type ProseProps = ComponentProps<"div">;
13
16
  /**
14
17
  * @function Prose
15
18
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
16
- * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
17
- * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
18
- * which outranks the h2 and h3 margins by specificity.
19
+ * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code,
20
+ * tables and blockquotes inside it. Its first child's top margin is zeroed
21
+ * (`[&>:first-child]:mt-0`), which outranks the h2 and h3 margins by specificity.
19
22
  */
20
23
  export declare function Prose({ className, ...props }: ProseProps): import("react").JSX.Element;
@@ -3,10 +3,10 @@ import { cx } from "../../utils/cx.js";
3
3
  /**
4
4
  * @function Prose
5
5
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
6
- * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
7
- * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
8
- * which outranks the h2 and h3 margins by specificity.
6
+ * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code,
7
+ * tables and blockquotes inside it. Its first child's top margin is zeroed
8
+ * (`[&>:first-child]:mt-0`), which outranks the h2 and h3 margins by specificity.
9
9
  */
10
10
  export function Prose({ className, ...props }) {
11
- return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
11
+ return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_blockquote]:mt-3 [&_blockquote]:border-b2 [&_blockquote]:border-l-2 [&_blockquote]:pl-4 [&_blockquote]:text-c3 [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre:not([role=group])]:mt-3 [&_pre:not([role=group])]:overflow-x-auto [&_pre:not([role=group])]:rounded-md [&_pre:not([role=group])]:bg-b6 [&_pre:not([role=group])]:p-3 [&_pre:not([role=group])]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
12
12
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @file src/components/mdx/Callout.tsx
3
+ * @desc A labelled aside for MDX prose (note, tip, warning): a left-border panel with an icon and
4
+ * a bold label, rendered by MdxBlockquote for a GitHub-style `> [!NOTE]` blockquote, or
5
+ * usable directly in hand-written MDX.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import type { ComponentProps, ReactNode } from "react";
11
+ import type { CalloutType } from "../../remark/callouts.js";
12
+ export type { CalloutType };
13
+ /** Every native `<div>` prop except `title` (replaced by the callout's own label slot). */
14
+ export type CalloutProps = Omit<ComponentProps<"div">, "title"> & {
15
+ type?: CalloutType | undefined;
16
+ title?: ReactNode;
17
+ };
18
+ /**
19
+ * @function Callout
20
+ * @param props {CalloutProps} the callout type (default "note"), an optional title replacing
21
+ * the type's default label, native div props and children
22
+ * @returns {JSX.Element} a bordered, labelled panel announced as a note
23
+ */
24
+ export declare function Callout({ type, title, className, children, ...props }: CalloutProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ const LABEL = { note: "Note", tip: "Tip", warning: "Warning" };
4
+ const BORDER = {
5
+ note: "border-h1",
6
+ tip: "border-c2",
7
+ warning: "border-h2",
8
+ };
9
+ const ICON = {
10
+ note: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2Zm0-8h-2V7h2Z",
11
+ tip: "M9 21h6v-2H9Zm3-19a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2Z",
12
+ warning: "M1 21h22L12 2Zm12-3h-2v-2h2Zm0-4h-2v-4h2Z",
13
+ };
14
+ /**
15
+ * @function Callout
16
+ * @param props {CalloutProps} the callout type (default "note"), an optional title replacing
17
+ * the type's default label, native div props and children
18
+ * @returns {JSX.Element} a bordered, labelled panel announced as a note
19
+ */
20
+ export function Callout({ type = "note", title, className, children, ...props }) {
21
+ return (_jsxs("div", { role: "note", className: cx("mt-4 rounded-md border-l-4 bg-b5 px-4 py-3 text-c2", BORDER[type], className), ...props, children: [_jsxs("p", { className: "mt-0! flex items-center gap-2 font-bold text-c1", children: [_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", className: "size-4 shrink-0 fill-current", children: _jsx("path", { d: ICON[type] }) }), title ?? LABEL[type]] }), _jsx("div", { className: "[&>:first-child]:mt-1", children: children })] }));
22
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/components/mdx/CodeBlock.tsx
3
+ * @desc A fenced code block rendered with Shiki highlighting when the app registered it (by
4
+ * importing `@haruhimemoe/ui/shiki`) and the language is one of the bundled set, plain text
5
+ * otherwise. Async so it can await the lazily-loaded highlighter from highlighter.ts.
6
+ * Server-safe: the only client piece is the copy button (CodeCopyButton).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { ComponentProps } from "react";
12
+ /** The code (any line endings, trailing newline optional), language, title, highlighted lines
13
+ * and every other native `<div>` prop (spread onto the wrapper). */
14
+ export type CodeBlockProps = Omit<ComponentProps<"div">, "title" | "children"> & {
15
+ code: string;
16
+ lang?: string | undefined;
17
+ title?: string | undefined;
18
+ highlight?: readonly number[] | undefined;
19
+ };
20
+ /**
21
+ * @function CodeBlock
22
+ * @param props {CodeBlockProps} the code, optional language, title, highlighted line numbers and
23
+ * wrapper class
24
+ * @returns {Promise<JSX.Element>} a header (name and copy button) over a highlighted `<pre>`
25
+ */
26
+ export declare function CodeBlock({ code, lang, title, highlight, className, ...rest }: CodeBlockProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ import { CodeCopyButton } from "./CodeCopyButton.js";
4
+ import { getHighlighter, THEME } from "./highlighter.js";
5
+ const BASE = "block min-h-6 border-l-2 px-3";
6
+ const LINE = `${BASE} border-transparent`;
7
+ // Never both border colors on one element: Tailwind emits border-h1 before border-transparent.
8
+ const MARKED = `${BASE} border-h1 bg-b4 forced-colors:border-[Highlight]`;
9
+ /** Shiki's tokens per line, or null to render plain text. */
10
+ const tokenize = async (code, lang) => {
11
+ if (!lang)
12
+ return null;
13
+ const highlighter = await getHighlighter();
14
+ if (!highlighter?.getLoadedLanguages().includes(lang.toLowerCase()))
15
+ return null;
16
+ try {
17
+ return highlighter.codeToTokens(code, { lang: lang.toLowerCase(), theme: THEME }).tokens;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ };
23
+ const tokenStyle = (token) => ({
24
+ color: token.color,
25
+ fontStyle: (token.fontStyle ?? 0) & 1 ? "italic" : undefined,
26
+ fontWeight: (token.fontStyle ?? 0) & 2 ? "bold" : undefined,
27
+ });
28
+ /**
29
+ * @function CodeBlock
30
+ * @param props {CodeBlockProps} the code, optional language, title, highlighted line numbers and
31
+ * wrapper class
32
+ * @returns {Promise<JSX.Element>} a header (name and copy button) over a highlighted `<pre>`
33
+ */
34
+ export async function CodeBlock({ code, lang, title, highlight = [], className, ...rest }) {
35
+ const text = code.replace(/\r\n?/g, "\n").replace(/\n$/, "");
36
+ const tokens = await tokenize(text, lang);
37
+ const lines = tokens ?? text.split("\n").map((content) => [{ content, offset: 0 }]);
38
+ const marked = new Set(highlight);
39
+ const name = title ?? lang;
40
+ return (_jsxs("div", { className: cx("mt-3 overflow-hidden rounded-md border border-b3 bg-b6", className), ...rest, children: [_jsxs("div", { className: "flex min-h-9 items-center justify-between gap-3 border-b3 border-b px-3 py-1 text-c3 text-sm", children: [_jsx("span", { className: "truncate", children: name ?? "Code" }), _jsx(CodeCopyButton, { code: text, label: title ? `Copy ${title}` : "Copy code" })] }), _jsx("pre", { role: "group",
41
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
42
+ tabIndex: 0, "aria-label": name ? `Code: ${name}` : "Code", className: "overflow-x-auto py-3 text-c2 text-sm leading-6", children: _jsx("code", { children: lines.flatMap((line, index) => {
43
+ const span = (_jsx("span", { className: marked.has(index + 1) ? MARKED : LINE, "data-highlighted": marked.has(index + 1) ? "" : undefined, children: line.map((token, i) => tokens ? (_jsx("span", { style: tokenStyle(token), children: token.content }, i)) : (token.content)) }, index));
44
+ // A plain "\n" text node between line spans (no key needed on a bare string): kept
45
+ // out of the last line so there's no trailing phantom newline.
46
+ return index < lines.length - 1 ? [span, "\n"] : [span];
47
+ }) }) })] }));
48
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/mdx/CodeCopyButton.tsx
3
+ * @desc The copy button CodeBlock renders beside a code fence's header: copies the plain code
4
+ * text and reports "Copied" or "Copy failed" in a polite live region, like CopyButton. A
5
+ * server component (CodeBlock) renders this client file, so it takes a finished class
6
+ * string and never imports cx, keeping tailwind-merge out of the client bundle.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ /** The code to copy, and the button's accessible name (e.g. "Copy x.ts" or "Copy code"). */
12
+ export type CodeCopyButtonProps = {
13
+ code: string;
14
+ label: string;
15
+ };
16
+ /**
17
+ * @function CodeCopyButton
18
+ * @param props {CodeCopyButtonProps} the code to copy and the button's accessible name
19
+ * @returns {JSX.Element} the copy button and an `<output>` announcing "Copied" or "Copy failed"
20
+ */
21
+ export declare function CodeCopyButton({ code, label }: CodeCopyButtonProps): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file src/components/mdx/CodeCopyButton.tsx
3
+ * @desc The copy button CodeBlock renders beside a code fence's header: copies the plain code
4
+ * text and reports "Copied" or "Copy failed" in a polite live region, like CopyButton. A
5
+ * server component (CodeBlock) renders this client file, so it takes a finished class
6
+ * string and never imports cx, keeping tailwind-merge out of the client bundle.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ "use client";
12
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
+ import { StatusOutput } from "../actions/StatusOutput.js";
14
+ import { useLatestStatus } from "../actions/useLatestStatus.js";
15
+ const BUTTON = "inline-flex min-h-6 min-w-6 items-center justify-center rounded px-2 py-0.5 text-c2 text-sm hover:bg-b4 hover:text-c1";
16
+ /**
17
+ * @function CodeCopyButton
18
+ * @param props {CodeCopyButtonProps} the code to copy and the button's accessible name
19
+ * @returns {JSX.Element} the copy button and an `<output>` announcing "Copied" or "Copy failed"
20
+ */
21
+ export function CodeCopyButton({ code, label }) {
22
+ const { status, start, settle } = useLatestStatus();
23
+ const copy = async () => {
24
+ const run = start();
25
+ try {
26
+ await navigator.clipboard.writeText(code);
27
+ settle(run, "copied");
28
+ }
29
+ catch {
30
+ settle(run, "failed");
31
+ }
32
+ };
33
+ return (_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(StatusOutput, { run: status?.run ?? null, children: status?.result === "copied" ? "Copied" : "Copy failed" }), _jsx("button", { type: "button", "aria-label": label, className: BUTTON, onClick: copy, children: "Copy" })] }));
34
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file src/components/mdx/MdxBlockquote.tsx
3
+ * @desc react-markdown/MDX's `blockquote` override: a blockquote carrying `data-callout` (set by
4
+ * the `remarkCallouts` plugin on a GitHub-style `> [!NOTE]` blockquote) renders as a
5
+ * Callout of that type, with every other prop (className, id, …) forwarded to it; any other
6
+ * blockquote renders plainly. Drops the `node` prop react-markdown passes to every
7
+ * component.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sat Oct 3, 2026
10
+ * @modified Sat Oct 3, 2026
11
+ */
12
+ import type { ComponentProps } from "react";
13
+ /** Every native `<blockquote>` prop, plus `node` (dropped) and the callout plugin's marker. */
14
+ export type MdxBlockquoteProps = ComponentProps<"blockquote"> & {
15
+ node?: unknown;
16
+ "data-callout"?: string;
17
+ };
18
+ /**
19
+ * @function MdxBlockquote
20
+ * @param props {MdxBlockquoteProps} native blockquote props and an optional `data-callout` type
21
+ * @returns {JSX.Element} a Callout for a known `data-callout` type, otherwise a plain blockquote
22
+ */
23
+ export declare function MdxBlockquote({ node: _node, "data-callout": callout, children, ...props }: MdxBlockquoteProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Callout } from "./Callout.js";
3
+ const TYPES = ["note", "tip", "warning"];
4
+ /**
5
+ * @function MdxBlockquote
6
+ * @param props {MdxBlockquoteProps} native blockquote props and an optional `data-callout` type
7
+ * @returns {JSX.Element} a Callout for a known `data-callout` type, otherwise a plain blockquote
8
+ */
9
+ export function MdxBlockquote({ node: _node, "data-callout": callout, children, ...props }) {
10
+ if (callout && TYPES.includes(callout)) {
11
+ // Callout's props are a div's (ComponentProps<"blockquote"> isn't literally
12
+ // ComponentProps<"div">, e.g. `ref`), but every field react-markdown passes here (className,
13
+ // id, aria-*, data-*) is one Callout already forwards straight onto its own div.
14
+ return (_jsx(Callout, { type: callout, ...props, children: children }));
15
+ }
16
+ return _jsx("blockquote", { ...props, children: children });
17
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/components/mdx/MdxHeading.tsx
3
+ * @desc react-markdown/MDX's `h2`/`h3` override: gives the heading a stable id (the given `id`,
4
+ * from remark's heading-id plugin, or a slug of its own text) and a hover-revealed anchor
5
+ * link beside it. 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 heading prop, plus the `node` react-markdown passes (dropped). */
12
+ export type MdxHeadingProps = ComponentProps<"h2"> & {
13
+ node?: unknown;
14
+ };
15
+ /**
16
+ * @function MdxH2
17
+ * @param props {MdxHeadingProps} native h2 props and an optional id
18
+ * @returns {JSX.Element} an `<h2>` with a slug id and an anchor link
19
+ */
20
+ export declare const MdxH2: ({ node: _node, id, children, ...props }: MdxHeadingProps) => import("react").JSX.Element;
21
+ /**
22
+ * @function MdxH3
23
+ * @param props {MdxHeadingProps} native h3 props and an optional id
24
+ * @returns {JSX.Element} an `<h3>` with a slug id and an anchor link
25
+ */
26
+ export declare const MdxH3: ({ node: _node, id, children, ...props }: MdxHeadingProps) => import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { slugify } from "../../remark/slugify.js";
3
+ import { textOf } from "./textOf.js";
4
+ const WRAP = "group flex items-baseline gap-2 [&:first-child>:first-child]:mt-0";
5
+ // The ! wins over Prose's [&_a]:text-h1 and [&_a]:underline, which outrank plain utilities.
6
+ const ANCHOR = "inline-flex min-h-6 min-w-6 items-center justify-center rounded text-c4! no-underline! hover:text-h1!";
7
+ const heading = (Tag) => function MdxHeading({ node: _node, id, children, ...props }) {
8
+ const text = textOf(children);
9
+ const slug = id ?? slugify(text);
10
+ return (_jsxs("div", { className: WRAP, children: [_jsx(Tag, { id: slug || undefined, ...props, children: children }), slug ? (_jsx("a", { href: `#${slug}`, "aria-label": `Link to section: ${text}`, className: ANCHOR, children: "#" })) : null] }));
11
+ };
12
+ /**
13
+ * @function MdxH2
14
+ * @param props {MdxHeadingProps} native h2 props and an optional id
15
+ * @returns {JSX.Element} an `<h2>` with a slug id and an anchor link
16
+ */
17
+ export const MdxH2 = heading("h2");
18
+ /**
19
+ * @function MdxH3
20
+ * @param props {MdxHeadingProps} native h3 props and an optional id
21
+ * @returns {JSX.Element} an `<h3>` with a slug id and an anchor link
22
+ */
23
+ export const MdxH3 = heading("h3");
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @file src/components/mdx/MdxLink.tsx
3
+ * @desc react-markdown/MDX's `a` override: an external `http(s)` href opens in a new tab with a
4
+ * safe rel (full props spread, like the `<a>` AutoLink itself renders for an off-app href);
5
+ * a same-page hash link is a plain anchor, since AutoLink's own `isExternalHref` check
6
+ * doesn't treat `#usage` as external and would otherwise send it through next/link, which
7
+ * has no reason to handle a same-page jump; everything else (internal paths, `mailto:` and
8
+ * other schemes) goes through AutoLink, which already picks `next/link` or a plain `<a>` by
9
+ * href and spreads every other prop onto whichever element it renders. No `href` at all (a
10
+ * README `<a name>`/`<a id>` anchor via rehype-raw) renders a plain `<a>` with no `href`
11
+ * attribute, not a focusable empty link. Drops the `node` prop react-markdown passes to
12
+ * every component, so it never reaches the DOM.
13
+ * @author David @dvhsh (https://dvh.sh)
14
+ * @created Sat Oct 3, 2026
15
+ * @modified Sat Oct 3, 2026
16
+ */
17
+ import type { ComponentProps } from "react";
18
+ /** Every native `<a>` prop, plus the `node` react-markdown passes (dropped, never rendered). */
19
+ export type MdxLinkProps = ComponentProps<"a"> & {
20
+ node?: unknown;
21
+ };
22
+ /**
23
+ * @function MdxLink
24
+ * @param props {MdxLinkProps} the link's href and native anchor props
25
+ * @returns {JSX.Element} an external link (new tab, `rel="noopener noreferrer"`), a plain anchor
26
+ * for a same-page hash, or an AutoLink (next/link or a plain anchor, by href) otherwise,
27
+ * every other prop forwarded in full
28
+ */
29
+ export declare function MdxLink({ node: _node, href, children, ...props }: MdxLinkProps): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { AutoLink } from "../basics/AutoLink.js";
3
+ /**
4
+ * @function MdxLink
5
+ * @param props {MdxLinkProps} the link's href and native anchor props
6
+ * @returns {JSX.Element} an external link (new tab, `rel="noopener noreferrer"`), a plain anchor
7
+ * for a same-page hash, or an AutoLink (next/link or a plain anchor, by href) otherwise,
8
+ * every other prop forwarded in full
9
+ */
10
+ export function MdxLink({ node: _node, href, children, ...props }) {
11
+ if (href === undefined) {
12
+ return _jsx("a", { ...props, children: children });
13
+ }
14
+ if (/^https?:\/\//i.test(href)) {
15
+ return (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", ...props, children: children }));
16
+ }
17
+ if (href.startsWith("#")) {
18
+ return (_jsx("a", { href: href, ...props, children: children }));
19
+ }
20
+ // AutoLink's props are next/link's (anchor attributes plus next/link-only props like
21
+ // `prefetch`); every prop MdxLink accepts from react-markdown is a native anchor attribute,
22
+ // which AutoLink spreads straight onto whichever element it renders. Narrow cast, like
23
+ // AutoLink's own `href` handling: ComponentProps<"a"> isn't literally ComponentProps<Link>,
24
+ // but every field here is one AutoLink already knows how to forward.
25
+ return (_jsx(AutoLink, { href: href, ...props, children: children }));
26
+ }