create-zudo-doc 0.2.0 → 0.2.1

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 (72) hide show
  1. package/dist/api.js +4 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/preset.js +11 -0
  4. package/dist/prompts.js +2 -6
  5. package/dist/scaffold.js +15 -9
  6. package/dist/settings-gen.js +7 -7
  7. package/dist/utils.d.ts +8 -0
  8. package/dist/utils.js +25 -0
  9. package/dist/zfb-config-gen.js +11 -50
  10. package/package.json +1 -1
  11. package/templates/base/pages/_data.ts +10 -23
  12. package/templates/base/pages/docs/[[...slug]].tsx +27 -168
  13. package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
  14. package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
  15. package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
  16. package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
  17. package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
  18. package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
  19. package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
  20. package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
  21. package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
  22. package/templates/base/pages/lib/_header-with-defaults.tsx +51 -89
  23. package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
  24. package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
  25. package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
  26. package/templates/base/pages/lib/_search-widget-script.ts +32 -9
  27. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
  28. package/templates/base/pages/lib/locale-merge.ts +1 -1
  29. package/templates/base/pages/lib/route-enumerators.ts +11 -7
  30. package/templates/base/plugins/connect-adapter.mjs +30 -1
  31. package/templates/base/plugins/copy-public-plugin.mjs +10 -2
  32. package/templates/base/plugins/search-index-plugin.mjs +20 -8
  33. package/templates/base/src/components/sidebar-toggle.tsx +1 -1
  34. package/templates/base/src/components/sidebar-tree.tsx +10 -4
  35. package/templates/base/src/config/color-schemes.ts +4 -0
  36. package/templates/base/src/config/docs-schema.ts +94 -0
  37. package/templates/base/src/config/i18n.ts +10 -3
  38. package/templates/base/src/styles/global.css +14 -0
  39. package/templates/base/src/types/docs-entry.ts +8 -26
  40. package/templates/base/src/utils/base.ts +5 -3
  41. package/templates/base/src/utils/docs.ts +144 -169
  42. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
  43. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
  44. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
  45. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
  46. package/templates/features/docHistory/files/src/components/doc-history.tsx +28 -8
  47. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
  48. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
  49. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
  50. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
  51. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
  52. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
  53. package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
  54. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
  55. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
  56. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
  57. package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
  58. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
  59. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
  60. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
  61. package/templates/base/src/components/content/heading-h3.tsx +0 -20
  62. package/templates/base/src/components/theme-toggle.tsx +0 -107
  63. package/templates/base/src/hooks/use-active-heading.ts +0 -133
  64. package/templates/base/src/plugins/docs-source-map.ts +0 -103
  65. package/templates/base/src/plugins/hast-utils.ts +0 -10
  66. package/templates/base/src/plugins/rehype-code-title.ts +0 -50
  67. package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
  68. package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
  69. package/templates/base/src/plugins/url-utils.ts +0 -4
  70. package/templates/base/src/utils/dedent.ts +0 -24
  71. package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
  72. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
@@ -19,30 +19,20 @@
19
19
  //
20
20
  // Version banner: if version.banner is set ("unmaintained" | "unreleased"),
21
21
  // the DocLayoutWithDefaults version-banner prop drives the banner display.
22
+ //
23
+ // Enumeration + per-entry derived data are built by the shared, memoized
24
+ // buildDocRouteEntries (#2010); rendering by the shared renderDocPage. This
25
+ // file owns only the route's nav source, its versioned URL closure, and the
26
+ // param/prop shapes.
22
27
 
23
28
  import { settings } from "@/config/settings";
24
29
  import type { VersionConfig } from "@/config/settings";
25
- import { t } from "@/config/i18n";
26
- import { docsUrl, versionedDocsUrl, absoluteUrl } from "@/utils/base";
27
- import {
28
- buildNavTree,
29
- buildBreadcrumbs,
30
- collectAutoIndexNodes,
31
- type NavNode,
32
- } from "@/utils/docs";
33
- import { getNavSectionForSlug, getNavSubtree } from "@/utils/nav-scope";
34
- import { toRouteSlug, toSlugParams } from "@/utils/slug";
35
- // Locale-aware MDX components factory — see `pages/_mdx-components.ts`.
36
- import { createMdxComponents } from "../../../_mdx-components";
30
+ import { versionedDocsUrl } from "@/utils/base";
37
31
  import type { JSX } from "preact";
38
32
  import { resolveNavSource } from "../../../lib/_nav-source-docs";
39
- import { extractHeadings } from "../../../lib/_extract-headings";
40
- import type { DocPageEntry, AutoIndexNode, DocPageEntryProps, DocPageAutoIndexProps } from "../../../lib/doc-page-props";
41
- import { DocMetainfoArea } from "../../../lib/_doc-metainfo-area";
42
- import { buildInlineVersionSwitcher } from "../../../lib/_inline-version-switcher";
43
- import { DocContentHeader } from "../../../lib/_doc-content-header";
44
- import { DocPageShell } from "../../../lib/_doc-page-shell";
45
- import { resolveDocPrevNext, flattenSubtree, rewriteNavHref, remapNavChildHrefs } from "../../../lib/_doc-route-paths";
33
+ import type { DocPageEntryProps, DocPageAutoIndexProps } from "../../../lib/doc-page-props";
34
+ import { buildDocRouteEntries } from "../../../lib/_doc-route-entries";
35
+ import { renderDocPage } from "../../../lib/_doc-page-renderer";
46
36
 
47
37
  export const frontmatter = { title: "Docs" };
48
38
 
@@ -50,8 +40,6 @@ export const frontmatter = { title: "Docs" };
50
40
  // Types
51
41
  // ---------------------------------------------------------------------------
52
42
 
53
- // DocPageEntry, AutoIndexNode imported from pages/lib/doc-page-props.ts
54
-
55
43
  /** Route-specific extra fields — present on both branches of the union. */
56
44
  interface VersionedDocPageExtra {
57
45
  /** The version config for the active version. */
@@ -89,73 +77,31 @@ export function paths(): Array<{
89
77
  for (const version of settings.versions) {
90
78
  // Identity-stable nav source for this version (EN base, draft-filtered,
91
79
  // unlisted retained). Reused across the route's per-page paths()
92
- // invocations so buildNavTree's identity fast-path applies — see
93
- // pages/lib/_nav-source-docs.ts (#1902).
94
- const { docs: allDocs, navDocs, categoryMeta } = resolveNavSource("en", version.slug);
95
- // Versioned docs always use EN locale for nav tree
96
- const tree = buildNavTree(navDocs, "en", categoryMeta);
80
+ // invocations so buildNavTree's identity fast-path and the
81
+ // buildDocRouteEntries memo apply — see pages/lib/_nav-source-docs.ts
82
+ // (#1902). Versioned docs always use EN locale for the nav tree.
83
+ const source = resolveNavSource("en", version.slug);
97
84
 
98
85
  // URL closure for THIS version — every versioned href (prev/next,
99
86
  // breadcrumb crumbs, auto-index cards) is produced by this single
100
87
  // function bound to the version slug. Because it is built per-version
101
- // inside this loop, a latest-page pagination override (resolved against
102
- // `tree` below) is rewritten through the VERSIONED closure for this route
103
- // only — it can never bleed into the latest route, which has no such
104
- // closure (#1916).
88
+ // inside this loop, a latest-page pagination override is rewritten
89
+ // through the VERSIONED closure for this route only — it can never bleed
90
+ // into the latest route, which has no such closure (#1916).
105
91
  const urlFor = (s: string): string => versionedDocsUrl(s, version.slug);
106
92
 
107
- // Regular doc pages
108
- for (const entry of allDocs) {
109
- // A `category_no_page` index.mdx is metadata-only — kept in the nav tree
110
- // for breadcrumbs but emits no route (zfb retains every .mdx as a
111
- // collection entry, so the skip must be explicit).
112
- if (entry.data.category_no_page === true) continue;
113
- const slug = entry.data.slug ?? toRouteSlug(entry.slug);
114
- const navSection = getNavSectionForSlug(slug);
115
- const subtree = getNavSubtree(tree, navSection);
116
-
117
- // Prev/next + pagination overrides against THIS version's own `tree`,
118
- // then hrefs rewritten to the versioned URL form via urlFor.
119
- const { prev: prevNode, next: nextNode } = resolveDocPrevNext(
120
- tree,
121
- flattenSubtree(subtree),
122
- slug,
123
- entry.data,
124
- );
125
-
126
- result.push({
127
- params: { version: version.slug, slug: toSlugParams(slug) },
128
- props: {
129
- kind: "entry",
130
- entry,
131
- version,
132
- // #1916 #1: breadcrumb crumbs remapped to the versioned URL space.
133
- breadcrumbs: buildBreadcrumbs(tree, slug, "en", urlFor),
134
- prev: rewriteNavHref(prevNode, urlFor),
135
- next: rewriteNavHref(nextNode, urlFor),
136
- headings: extractHeadings(entry.body ?? ""),
137
- },
138
- });
139
- }
140
-
141
- // Auto-generated index pages for categories without index.mdx
142
- for (const node of collectAutoIndexNodes(tree)) {
93
+ for (const item of buildDocRouteEntries({
94
+ source,
95
+ locale: "en",
96
+ routeSig: `v-docs;${version.slug}`,
97
+ urlFor,
98
+ })) {
143
99
  result.push({
144
- params: { version: version.slug, slug: toSlugParams(node.slug) },
145
- props: {
146
- kind: "autoIndex",
147
- autoIndex: {
148
- ...node,
149
- // #1916 #2: child-card hrefs ALWAYS resolve to the versioned URL.
150
- children: remapNavChildHrefs(node.children, urlFor) as NavNode[],
151
- } as AutoIndexNode,
152
- version,
153
- // #1916 #1: breadcrumb crumbs remapped to the versioned URL space.
154
- breadcrumbs: buildBreadcrumbs(tree, node.slug, "en", urlFor),
155
- prev: null,
156
- next: null,
157
- headings: [],
158
- },
100
+ params: { version: version.slug, slug: item.slugParams },
101
+ props:
102
+ item.props.kind === "entry"
103
+ ? { ...item.props, version }
104
+ : { ...item.props, version },
159
105
  });
160
106
  }
161
107
  }
@@ -170,96 +116,8 @@ export function paths(): Array<{
170
116
  type PageArgs = DocPageProps & { params: { version: string; slug: string[] } };
171
117
 
172
118
  export default function VersionedDocsPage(props: PageArgs): JSX.Element {
173
- const { breadcrumbs, prev, next, headings, version } = props;
174
- const locale = "en";
175
-
176
- const slug = props.kind === "autoIndex"
177
- ? props.autoIndex.slug
178
- : (props.entry.data.slug ?? toRouteSlug(props.entry.slug));
179
-
180
- const title = props.kind === "autoIndex" ? props.autoIndex.label : props.entry.data.title;
181
- const description = props.kind === "autoIndex" ? props.autoIndex.description : props.entry.data.description;
182
-
183
- // Locale-aware components bag — creates nav wrappers bound to the active
184
- // locale so CategoryNav/CategoryTreeNav/SiteTreeNav query the right collection.
185
- const components = createMdxComponents(locale);
186
-
187
- // #1916 #2: child cards already carry versioned hrefs from paths(); just
188
- // filter to renderable nodes here.
189
- const autoIndexChildren = props.kind === "autoIndex"
190
- ? props.autoIndex.children.filter((c: NavNode) => c.hasPage || c.children.length > 0)
191
- : [];
192
-
193
- // Version banner: drives the `<VersionBanner>` element inside
194
- // DocLayoutWithDefaults when `version.banner` is "unmaintained" or
195
- // "unreleased". The banner links out to the latest version of the
196
- // current page (slug-preserving — strips the /v/{version}/ prefix).
197
- const versionBannerType = version.banner ? version.banner : undefined;
198
- const versionBannerLatestUrl = versionBannerType
199
- ? docsUrl(slug, locale)
200
- : undefined;
201
- const versionBannerLabels = versionBannerType
202
- ? {
203
- message:
204
- versionBannerType === "unmaintained"
205
- ? t("version.banner.unmaintained", locale)
206
- : t("version.banner.unreleased", locale),
207
- latestLink: t("version.banner.latestLink", locale),
208
- }
209
- : undefined;
210
-
211
- // Canonical URL — versioned pages use the versioned URL as canonical.
212
- const currentPath = versionedDocsUrl(slug, version.slug, locale);
213
- const canonical = absoluteUrl(currentPath);
214
-
215
- // Persist key: locale + nav-section so the sidebar DOM node is reused
216
- // across same-locale + same-section navigations only. No sanitizer needed —
217
- // both lang (BCP-47 locale string) and navSection (filesystem-derived
218
- // kebab-case slug) come from controlled, trusted sources.
219
- const navSection = getNavSectionForSlug(slug);
220
- const hideSidebar = props.kind === "entry" ? props.entry.data.hide_sidebar : undefined;
221
- const sidebarPersistKey = hideSidebar
222
- ? undefined
223
- : `sidebar-${locale}-${navSection ?? "default"}`;
224
-
225
- return (
226
- <DocPageShell
227
- kind={props.kind}
228
- locale={locale}
229
- slug={slug}
230
- title={title}
231
- description={description}
232
- canonical={canonical}
233
- breadcrumbs={breadcrumbs}
234
- prev={prev}
235
- next={next}
236
- headings={headings}
237
- navSection={navSection}
238
- sidebarPersistKey={sidebarPersistKey}
239
- hideSidebar={hideSidebar}
240
- hideToc={props.kind === "entry" ? props.entry.data.hide_toc : undefined}
241
- currentPath={currentPath}
242
- currentVersion={version.slug}
243
- versionSwitcher={buildInlineVersionSwitcher(slug, locale, version.slug)}
244
- versionBanner={versionBannerType}
245
- versionBannerLatestUrl={versionBannerLatestUrl}
246
- versionBannerLabels={versionBannerLabels}
247
- autoIndexLabel={props.kind === "autoIndex" ? props.autoIndex.label : undefined}
248
- autoIndexChildren={autoIndexChildren}
249
- metainfoSlot={
250
- props.kind === "autoIndex" ? <DocMetainfoArea slug={slug} locale={locale} /> : null
251
- }
252
- contentHeaderSlot={
253
- props.kind === "entry" ? (
254
- <DocContentHeader entry={props.entry} slug={slug} locale={locale} />
255
- ) : undefined
256
- }
257
- contentSlot={
258
- props.kind === "entry" ? <props.entry.Content components={components} /> : undefined
259
- }
260
- // #1916 #5: doc-history hidden on versioned pages until versioned
261
- // history is supported.
262
- docHistorySlot={null}
263
- />
264
- );
119
+ return renderDocPage(props, {
120
+ locale: "en",
121
+ version: props.version,
122
+ });
265
123
  }
@@ -1,20 +0,0 @@
1
- import type { CSSProperties } from 'react';
2
-
3
- type Props = React.ComponentPropsWithoutRef<'h3'>;
4
-
5
- export function HeadingH3({ id, children, className, ...rest }: Props) {
6
- return (
7
- <h3
8
- id={id}
9
- className={`text-body font-bold leading-snug pt-vsp-xs border-t-[2px] border-transparent${className ? ` ${className}` : ''}`}
10
- style={
11
- {
12
- borderImage: 'linear-gradient(to right, var(--color-muted), transparent) 1',
13
- } as CSSProperties
14
- }
15
- {...rest}
16
- >
17
- {children}
18
- </h3>
19
- );
20
- }
@@ -1,107 +0,0 @@
1
- "use client";
2
-
3
- // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
4
- // lets us consume React-typed components in this Preact app (configured
5
- // project-wide). Same pattern as packages/zudo-doc/src/theme/theme-toggle.tsx.
6
- import { useState, useEffect } from "preact/hooks";
7
-
8
- const STORAGE_KEY = "zudo-doc-theme";
9
-
10
- function SunIcon() {
11
- return (
12
- <svg
13
- aria-hidden="true"
14
- xmlns="http://www.w3.org/2000/svg"
15
- width="20"
16
- height="20"
17
- viewBox="0 0 24 24"
18
- fill="none"
19
- stroke="currentColor"
20
- strokeWidth="2"
21
- strokeLinecap="round"
22
- strokeLinejoin="round"
23
- >
24
- <circle cx="12" cy="12" r="5" />
25
- <line x1="12" y1="1" x2="12" y2="3" />
26
- <line x1="12" y1="21" x2="12" y2="23" />
27
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
28
- <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
29
- <line x1="1" y1="12" x2="3" y2="12" />
30
- <line x1="21" y1="12" x2="23" y2="12" />
31
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
32
- <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
33
- </svg>
34
- );
35
- }
36
-
37
- function MoonIcon() {
38
- return (
39
- <svg
40
- aria-hidden="true"
41
- xmlns="http://www.w3.org/2000/svg"
42
- width="20"
43
- height="20"
44
- viewBox="0 0 24 24"
45
- fill="none"
46
- stroke="currentColor"
47
- strokeWidth="2"
48
- strokeLinecap="round"
49
- strokeLinejoin="round"
50
- >
51
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
52
- </svg>
53
- );
54
- }
55
-
56
- interface ThemeToggleProps {
57
- defaultMode?: "light" | "dark";
58
- }
59
-
60
- export default function ThemeToggle({ defaultMode = "dark" }: ThemeToggleProps) {
61
- // Initial state must match server render to avoid hydration mismatch.
62
- // Actual theme is synced from DOM in useEffect below.
63
- const [mode, setMode] = useState<"light" | "dark">(defaultMode);
64
-
65
- useEffect(() => {
66
- const actual =
67
- (document.documentElement.getAttribute("data-theme") as
68
- | "light"
69
- | "dark") || defaultMode;
70
- if (actual !== mode) {
71
- setMode(actual);
72
- }
73
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
74
-
75
- function toggle() {
76
- const next = mode === "dark" ? "light" : "dark";
77
- setMode(next);
78
- document.documentElement.setAttribute("data-theme", next);
79
- document.documentElement.style.colorScheme = next;
80
- localStorage.setItem(STORAGE_KEY, next);
81
- // Clear both v1 and v2 tweak state so the new scheme's palette takes effect.
82
- localStorage.removeItem("zudo-doc-tweak-state");
83
- localStorage.removeItem("zudo-doc-tweak-state-v2");
84
- window.dispatchEvent(new CustomEvent("color-scheme-changed"));
85
- }
86
-
87
- const nextMode = mode === "dark" ? "light" : "dark";
88
-
89
- return (
90
- <button
91
- onClick={toggle}
92
- aria-label={`Switch to ${nextMode} mode`}
93
- className="text-muted hover:text-fg transition-colors p-hsp-sm focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
94
- >
95
- {mode === "dark" ? <SunIcon /> : <MoonIcon />}
96
- </button>
97
- );
98
- }
99
- // Pin the island marker name to "ThemeToggle" regardless of esbuild's
100
- // identifier deduplication. Both this host component and the v2 package's
101
- // ThemeToggleInner share the plain name "ThemeToggle"; when both land in the
102
- // same SSR bundle esbuild renames one to "ThemeToggle2", making
103
- // captureComponentName() emit "ThemeToggle2" — a name that has no entry in
104
- // the island manifest. Setting displayName explicitly ensures Island() reads
105
- // the attribute-level name (displayName is preferred over .name) and emits
106
- // the correct data-zfb-island="ThemeToggle" marker. zudolab/zudo-doc#1446.
107
- ThemeToggle.displayName = "ThemeToggle";
@@ -1,133 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "preact/compat";
2
- import type { Heading } from "@/types/heading";
3
-
4
- const SCROLL_MARGIN_TOP = 80;
5
- const DEBOUNCE_MS = 200;
6
-
7
- function getActiveHeadingId(
8
- headingIds: string[],
9
- elementMap: Map<string, HTMLElement>,
10
- ): string | null {
11
- if (headingIds.length === 0) return null;
12
-
13
- const viewportHeight = window.innerHeight;
14
-
15
- let firstVisibleIndex = -1;
16
- for (let i = 0; i < headingIds.length; i++) {
17
- const el = elementMap.get(headingIds[i]);
18
- if (!el) continue;
19
- const { top } = el.getBoundingClientRect();
20
- if (top >= SCROLL_MARGIN_TOP) {
21
- firstVisibleIndex = i;
22
- break;
23
- }
24
- }
25
-
26
- if (firstVisibleIndex === -1) {
27
- return headingIds[headingIds.length - 1];
28
- }
29
-
30
- if (firstVisibleIndex === 0) {
31
- const el = elementMap.get(headingIds[0]);
32
- if (el) {
33
- const { top } = el.getBoundingClientRect();
34
- if (top < viewportHeight / 2) {
35
- return headingIds[0];
36
- }
37
- }
38
- return null;
39
- }
40
-
41
- const el = elementMap.get(headingIds[firstVisibleIndex]);
42
- if (el) {
43
- const { top } = el.getBoundingClientRect();
44
- if (top < viewportHeight / 2) {
45
- return headingIds[firstVisibleIndex];
46
- }
47
- }
48
-
49
- return headingIds[firstVisibleIndex - 1];
50
- }
51
-
52
- interface UseActiveHeadingResult {
53
- activeId: string | null;
54
- activate: (id: string) => void;
55
- }
56
-
57
- export function useActiveHeading(
58
- headings: Heading[],
59
- ): UseActiveHeadingResult {
60
- const [activeId, setActiveId] = useState<string | null>(null);
61
- const headingIdsRef = useRef<string[]>([]);
62
- const elementMapRef = useRef<Map<string, HTMLElement>>(new Map());
63
- const suppressedRef = useRef(false);
64
-
65
- const activate = useCallback((id: string) => {
66
- setActiveId(id);
67
- suppressedRef.current = true;
68
- // Safety timeout: unsuppress if no scroll event fires (target already in view)
69
- setTimeout(() => {
70
- suppressedRef.current = false;
71
- }, 2000);
72
- }, []);
73
-
74
- useEffect(() => {
75
- const ids = headings.map((h) => h.slug);
76
- headingIdsRef.current = ids;
77
-
78
- const map = new Map<string, HTMLElement>();
79
- for (const id of ids) {
80
- const el = document.getElementById(id);
81
- if (el) map.set(id, el);
82
- }
83
- elementMapRef.current = map;
84
-
85
- let timerId: ReturnType<typeof setTimeout> | null = null;
86
-
87
- function update() {
88
- timerId = null;
89
- setActiveId(
90
- getActiveHeadingId(headingIdsRef.current, elementMapRef.current),
91
- );
92
- }
93
-
94
- let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
95
-
96
- function onScroll() {
97
- if (suppressedRef.current) {
98
- // Fallback for browsers without scrollend (Safari < 18)
99
- if (fallbackTimerId !== null) clearTimeout(fallbackTimerId);
100
- fallbackTimerId = setTimeout(onScrollEnd, 1500);
101
- return;
102
- }
103
- if (timerId !== null) clearTimeout(timerId);
104
- timerId = setTimeout(update, DEBOUNCE_MS);
105
- }
106
-
107
- function onScrollEnd() {
108
- suppressedRef.current = false;
109
- if (fallbackTimerId !== null) {
110
- clearTimeout(fallbackTimerId);
111
- fallbackTimerId = null;
112
- }
113
- if (timerId !== null) clearTimeout(timerId);
114
- update();
115
- }
116
-
117
- update();
118
-
119
- window.addEventListener("scroll", onScroll, { passive: true });
120
- window.addEventListener("resize", onScroll, { passive: true });
121
- window.addEventListener("scrollend", onScrollEnd, { passive: true });
122
-
123
- return () => {
124
- window.removeEventListener("scroll", onScroll);
125
- window.removeEventListener("resize", onScroll);
126
- window.removeEventListener("scrollend", onScrollEnd);
127
- if (timerId !== null) clearTimeout(timerId);
128
- if (fallbackTimerId !== null) clearTimeout(fallbackTimerId);
129
- };
130
- }, [headings]);
131
-
132
- return { activeId, activate };
133
- }
@@ -1,103 +0,0 @@
1
- import { readdirSync } from "node:fs";
2
- import { resolve } from "node:path";
3
-
4
- export interface DocsSourceMapOptions {
5
- /** Absolute root directory of the project */
6
- rootDir: string;
7
- /** Main docs directory relative to rootDir (e.g., "src/content/docs") */
8
- docsDir: string;
9
- /** Locale configurations: { ja: { dir: "src/content/docs-ja" } } */
10
- locales: Record<string, { dir: string }>;
11
- /** Version configurations */
12
- versions: Array<{ slug: string; docsDir: string }> | false;
13
- /** Base URL path (e.g., "/" or "/pj/my-docs/") */
14
- base: string;
15
- /** Whether to append trailing slash to URLs */
16
- trailingSlash: boolean;
17
- }
18
-
19
- /**
20
- * Build a map of absolute file paths to URL paths.
21
- * Scans all configured docs directories for .md/.mdx files.
22
- */
23
- export function buildDocsSourceMap(
24
- options: DocsSourceMapOptions,
25
- ): Map<string, string> {
26
- const map = new Map<string, string>();
27
- const { rootDir, docsDir, locales, versions, base, trailingSlash } = options;
28
-
29
- const normalizedBase = base.replace(/\/+$/, "");
30
-
31
- function applyTS(url: string): string {
32
- if (trailingSlash) {
33
- if (url.endsWith("/")) return url;
34
- return url + "/";
35
- }
36
- // Strip trailing slashes when trailingSlash is false (except root "/")
37
- if (url !== "/" && url.endsWith("/")) {
38
- return url.replace(/\/+$/, "");
39
- }
40
- return url;
41
- }
42
-
43
- function withBase(path: string): string {
44
- const raw =
45
- normalizedBase === ""
46
- ? path
47
- : `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
48
- return applyTS(raw);
49
- }
50
-
51
- function scanDir(dir: string, urlPrefix: string): void {
52
- const absDir = resolve(rootDir, dir);
53
- let files: string[];
54
- try {
55
- files = readdirSync(absDir, { recursive: true })
56
- .map((f) => String(f))
57
- .filter((f) => /\.(md|mdx)$/.test(f));
58
- } catch {
59
- // Directory doesn't exist — skip silently
60
- return;
61
- }
62
- for (const file of files) {
63
- const absFile = resolve(absDir, file);
64
- // Convert file path to slug: strip extension, strip /index suffix
65
- const slug = file
66
- .replace(/\.(md|mdx)$/, "")
67
- .replace(/(^|\/)index$/, "$1") // Strip index (root or nested)
68
- .replace(/(^|\\)index$/, "$1") // Windows
69
- .replace(/\\/g, "/") // Windows path sep
70
- .replace(/\/$/, ""); // Trailing slash from index strip
71
- const url = withBase(`${urlPrefix}/${slug}`);
72
- map.set(absFile, url);
73
-
74
- // Also register with alternative extension for cross-referencing
75
- // e.g., if file is foo.mdx, also register foo.md → same URL
76
- // Only set if not already registered (avoid shadowing a real file)
77
- const altExt = file.endsWith(".mdx")
78
- ? file.replace(/\.mdx$/, ".md")
79
- : file.replace(/\.md$/, ".mdx");
80
- const altFile = resolve(absDir, altExt);
81
- if (!map.has(altFile)) {
82
- map.set(altFile, url);
83
- }
84
- }
85
- }
86
-
87
- // Main docs (default locale)
88
- scanDir(docsDir, "/docs");
89
-
90
- // Locale docs
91
- for (const [code, config] of Object.entries(locales)) {
92
- scanDir(config.dir, `/${code}/docs`);
93
- }
94
-
95
- // Versioned docs
96
- if (versions) {
97
- for (const version of versions) {
98
- scanDir(version.docsDir, `/v/${version.slug}/docs`);
99
- }
100
- }
101
-
102
- return map;
103
- }
@@ -1,10 +0,0 @@
1
- import type { Element, ElementContent, Text } from "hast";
2
-
3
- /** Recursively extract plain text from a HAST node tree. */
4
- export function extractText(node: Element | ElementContent | Text): string {
5
- if (node.type === "text") return node.value;
6
- if (node.type === "element") {
7
- return node.children.map((c) => extractText(c)).join("");
8
- }
9
- return "";
10
- }
@@ -1,50 +0,0 @@
1
- import type { Root, Element } from "hast";
2
- import { visit, SKIP } from "unist-util-visit";
3
-
4
- /**
5
- * Rehype plugin that extracts title="..." from code block meta strings
6
- * and wraps the <pre> in a container with a title header.
7
- *
8
- * Usage in MDX:
9
- * ```js title="config.js"
10
- * const x = 1;
11
- * ```
12
- */
13
- export function rehypeCodeTitle() {
14
- return (tree: Root) => {
15
- visit(tree, "element", (node: Element, index, parent) => {
16
- if (node.tagName !== "pre" || !parent || index === undefined) return;
17
-
18
- const codeEl = node.children.find(
19
- (child): child is Element =>
20
- child.type === "element" && child.tagName === "code",
21
- );
22
- if (!codeEl) return;
23
-
24
- const meta =
25
- String(codeEl.properties?.meta ?? "") ||
26
- String((codeEl.data as Record<string, unknown> | undefined)?.meta ?? "");
27
- const titleMatch = meta.match(/title="([^"]+)"/);
28
- if (!titleMatch) return;
29
-
30
- const title = titleMatch[1];
31
-
32
- const titleEl: Element = {
33
- type: "element",
34
- tagName: "div",
35
- properties: { className: ["code-block-title"] },
36
- children: [{ type: "text", value: title }],
37
- };
38
-
39
- const wrapper: Element = {
40
- type: "element",
41
- tagName: "div",
42
- properties: { className: ["code-block-container"] },
43
- children: [titleEl, { ...node }],
44
- };
45
-
46
- (parent as Element).children[index] = wrapper;
47
- return SKIP;
48
- });
49
- };
50
- }