handoff-app 1.1.2 → 1.1.3-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 (31) hide show
  1. package/Changelog.md +81 -0
  2. package/dist/app-builder/build.js +2 -0
  3. package/dist/app-builder/watchers.d.ts +9 -1
  4. package/dist/app-builder/watchers.js +59 -5
  5. package/dist/cli.js +1 -1
  6. package/package.json +2 -1
  7. package/src/app/components/ComponentGuidelines.tsx +3 -1
  8. package/src/app/components/Markdown/MarkdownComponents.tsx +183 -50
  9. package/src/app/components/Navigation/AnchorNav.tsx +28 -28
  10. package/src/app/components/util/index.ts +99 -79
  11. package/src/app/css/index.css +4 -0
  12. package/src/app/pages/{[level1] → [...slug]}/index.tsx +24 -31
  13. package/src/app/pages/assets/fonts.tsx +3 -2
  14. package/src/app/pages/assets/index.tsx +3 -2
  15. package/src/app/pages/assets/logos.tsx +3 -2
  16. package/src/app/pages/foundations/colors.tsx +3 -2
  17. package/src/app/pages/foundations/effects.tsx +3 -2
  18. package/src/app/pages/foundations/icons/index.tsx +3 -2
  19. package/src/app/pages/foundations/index.tsx +3 -2
  20. package/src/app/pages/foundations/logo.tsx +3 -2
  21. package/src/app/pages/foundations/typography.tsx +3 -2
  22. package/src/app/pages/index.tsx +3 -2
  23. package/src/app/pages/system/component/[component]/index.tsx +3 -2
  24. package/src/app/pages/system/index.tsx +3 -2
  25. package/src/app/pages/system/tokens/foundations/colors.tsx +3 -2
  26. package/src/app/pages/system/tokens/foundations/effects.tsx +3 -2
  27. package/src/app/pages/system/tokens/foundations/typography.tsx +3 -2
  28. package/src/app-builder/build.ts +3 -1
  29. package/src/app-builder/watchers.ts +66 -4
  30. package/src/cli.ts +1 -1
  31. package/src/app/pages/[level1]/[level2]/index.tsx +0 -104
package/Changelog.md CHANGED
@@ -6,6 +6,87 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to
7
7
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## \[1.1.3] - 2026-03-27
10
+
11
+ This release reworks the markdown page rendering system with four improvements:
12
+ n-level deep routing, automatic table-of-contents generation, standardized code
13
+ block styling, and GitHub Flavored Markdown table support.
14
+
15
+ ### Features
16
+
17
+ - **N-level deep markdown routing** — Replaced the fixed two-level routing
18
+ (`[level1]` / `[level1]/[level2]`) with a single `[...slug]` catch-all route
19
+ that supports markdown pages at any directory depth. A new recursive
20
+ `buildCatchAllStaticPaths` function walks `config/docs` and `pages`
21
+ directories to collect `.md` files at any nesting level. The sidebar menu now
22
+ also recursively scans directories to surface deeply nested pages.
23
+
24
+ - **Automatic table of contents** — The existing but unused `PageTOC` component
25
+ is now wired into the catch-all markdown page. It scans rendered headings
26
+ (h1–h6) via a `MutationObserver` and populates the right-hand "On This Page"
27
+ anchor nav automatically. Fixed a timing bug where headings were scanned
28
+ before the markdown content was in the DOM, and fixed anchor IDs that were
29
+ incorrectly prefixed with `#` (producing `##id` in URLs).
30
+
31
+ - **Standardized code block styling** — Markdown fenced code blocks now render
32
+ with a toolbar header matching the site's `CodeHighlight` component: a
33
+ language icon, filename or language label, and a copy-to-clipboard button. A
34
+ new `remarkCodeMeta` remark plugin preserves the fence info string so metadata
35
+ like `` ```bash filename="deploy.sh" `` is parsed and displayed. The Prism
36
+ theme respects dark mode via `next-themes`, and registered languages now
37
+ include bash, css, scss, jsx, tsx, typescript, yaml, handlebars, and more.
38
+
39
+ - **Markdown table support** — Added `remark-gfm` to all `ReactMarkdown`
40
+ instances across the codebase (17 files). Pipe tables, strikethrough,
41
+ autolinks, and task lists now render correctly. Tables are styled via Tailwind
42
+ Typography's `.prose table` defaults.
43
+
44
+ ### Bug Fixes
45
+
46
+ - **New components not detected in dev mode** — `npm run start` now watches
47
+ the parent component directories (from `config.entries.components`) for
48
+ newly created components. Previously, only components known at startup had
49
+ their files watched; adding a new component directory required restarting
50
+ the dev server. A new `watchComponentDirectories` watcher detects when a
51
+ config file (`.json` / `.js` / `.cjs`) appears in a new subdirectory,
52
+ reloads the runtime config, restarts the component and configuration
53
+ watchers, and processes the new component automatically.
54
+
55
+ - **Heading anchor IDs truncated** — The markdown heading anchor generator
56
+ was only reading the first child node (`children[0]`) instead of the full
57
+ heading text, producing single-character or partial IDs (e.g. `id="u"`
58
+ instead of `id="using-icons-in-templates"`). A new `extractText` helper
59
+ recursively walks all children to build the complete anchor slug.
60
+
61
+ - **Fenced code blocks without a language unstyled** — Bare ```` ``` ````
62
+ blocks (no language identifier) now receive the same styled treatment as
63
+ language-tagged blocks: toolbar, copy button, and themed background.
64
+ Inline code is unaffected.
65
+
66
+ - **Code blocks pushing page width** — Added `min-w-0` and `max-w-full` on
67
+ the code block wrapper and the grid content column to prevent wide code
68
+ from stretching the layout and pushing the TOC off-screen.
69
+
70
+ - **Frontmatter menu duplicated by auto-scan** — When a markdown page
71
+ defines its sidebar menu via frontmatter `menu`, the automatic directory
72
+ scanner no longer appends duplicate entries. Auto-scanning now only runs
73
+ as a fallback when no frontmatter menu is present.
74
+
75
+ - **`.prose` max-width too narrow** — Overrode Tailwind Typography's default
76
+ `max-width: 65ch` on `.prose` to `100%` so markdown content fills the
77
+ available container width.
78
+
79
+ ### Changed
80
+
81
+ - Deleted `src/app/pages/[level1]/index.tsx` and
82
+ `src/app/pages/[level1]/[level2]/index.tsx` (replaced by `[...slug]`).
83
+ - Removed `buildL1StaticPaths`, `buildL2StaticPaths`, and the `SubPageType`
84
+ interface from `src/app/components/util/index.ts`.
85
+ - `ComponentGuidelines` now uses `MarkdownComponents` and the remark plugins
86
+ for consistent rendering.
87
+ - Added `system/tokens/**` paths to `knownPaths` to prevent catch-all route
88
+ conflicts with dedicated token pages.
89
+
9
90
  ## \[1.1.2] - 2026-03-26
10
91
 
11
92
  This release improves integration with downstream platforms (for example HubSpot) by letting
@@ -175,10 +175,12 @@ const watchApp = (handoff) => __awaiter(void 0, void 0, void 0, function* () {
175
175
  debounce: false,
176
176
  runtimeComponentsWatcher: null,
177
177
  runtimeConfigurationWatcher: null,
178
+ componentDirectoriesWatcher: null,
178
179
  };
179
180
  (0, watchers_1.watchPublicDirectory)(handoff, wss, state, chokidarConfig);
180
181
  (0, watchers_1.watchRuntimeComponents)(handoff, state, (0, watchers_1.getRuntimeComponentsPathsToWatch)(handoff));
181
182
  (0, watchers_1.watchRuntimeConfiguration)(handoff, state);
183
+ (0, watchers_1.watchComponentDirectories)(handoff, state, chokidarConfig);
182
184
  (0, watchers_1.watchGlobalEntries)(handoff, state, chokidarConfig);
183
185
  (0, watchers_1.watchPages)(handoff, chokidarConfig);
184
186
  });
@@ -6,6 +6,7 @@ export interface WatcherState {
6
6
  debounce: boolean;
7
7
  runtimeComponentsWatcher: chokidar.FSWatcher | null;
8
8
  runtimeConfigurationWatcher: chokidar.FSWatcher | null;
9
+ componentDirectoriesWatcher: chokidar.FSWatcher | null;
9
10
  }
10
11
  /**
11
12
  * Watches the working public directory for changes and updates the app.
@@ -34,7 +35,7 @@ export declare const mapEntryTypeToSegment: (type: keyof ComponentListObject["en
34
35
  *
35
36
  * @returns A Map of paths to watch and their entry types
36
37
  */
37
- export declare const getRuntimeComponentsPathsToWatch: (handoff: Handoff) => Map<string, "template" | "js" | "scss">;
38
+ export declare const getRuntimeComponentsPathsToWatch: (handoff: Handoff) => Map<string, "js" | "scss" | "template">;
38
39
  /**
39
40
  * Watches runtime components for changes.
40
41
  */
@@ -43,3 +44,10 @@ export declare const watchRuntimeComponents: (handoff: Handoff, state: WatcherSt
43
44
  * Watches the runtime configuration for changes.
44
45
  */
45
46
  export declare const watchRuntimeConfiguration: (handoff: Handoff, state: WatcherState) => void;
47
+ /**
48
+ * Watches the parent component directories from config.entries.components
49
+ * for new components being added. When a new config file (e.g. button.json)
50
+ * appears in a new subdirectory, reloads the runtime config and restarts
51
+ * the component/configuration watchers so the new component is picked up.
52
+ */
53
+ export declare const watchComponentDirectories: (handoff: Handoff, state: WatcherState, chokidarConfig: chokidar.WatchOptions) => void;
@@ -35,7 +35,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  return (mod && mod.__esModule) ? mod : { "default": mod };
36
36
  };
37
37
  Object.defineProperty(exports, "__esModule", { value: true });
38
- exports.watchRuntimeConfiguration = exports.watchRuntimeComponents = exports.getRuntimeComponentsPathsToWatch = exports.mapEntryTypeToSegment = exports.watchGlobalEntries = exports.watchPages = exports.watchAppSource = exports.watchPublicDirectory = void 0;
38
+ exports.watchComponentDirectories = exports.watchRuntimeConfiguration = exports.watchRuntimeComponents = exports.getRuntimeComponentsPathsToWatch = exports.mapEntryTypeToSegment = exports.watchGlobalEntries = exports.watchPages = exports.watchAppSource = exports.watchPublicDirectory = void 0;
39
39
  const chokidar_1 = __importDefault(require("chokidar"));
40
40
  const fs_extra_1 = __importDefault(require("fs-extra"));
41
41
  const path_1 = __importDefault(require("path"));
@@ -277,13 +277,9 @@ const watchRuntimeConfiguration = (handoff, state) => {
277
277
  state.debounce = true;
278
278
  try {
279
279
  file = path_1.default.dirname(file);
280
- // Reload the Handoff instance to pick up configuration changes
281
280
  handoff.reload();
282
- // After reloading, persist the updated client configuration
283
281
  yield (0, client_config_1.persistClientConfig)(handoff);
284
- // Restart the runtime components watcher to track potentially updated/added/removed components
285
282
  (0, exports.watchRuntimeComponents)(handoff, state, (0, exports.getRuntimeComponentsPathsToWatch)(handoff));
286
- // Process components based on the updated configuration and file path
287
283
  yield (0, builder_1.default)(handoff, path_1.default.basename(file));
288
284
  }
289
285
  catch (e) {
@@ -299,3 +295,61 @@ const watchRuntimeConfiguration = (handoff, state) => {
299
295
  }
300
296
  };
301
297
  exports.watchRuntimeConfiguration = watchRuntimeConfiguration;
298
+ /**
299
+ * Watches the parent component directories from config.entries.components
300
+ * for new components being added. When a new config file (e.g. button.json)
301
+ * appears in a new subdirectory, reloads the runtime config and restarts
302
+ * the component/configuration watchers so the new component is picked up.
303
+ */
304
+ const watchComponentDirectories = (handoff, state, chokidarConfig) => {
305
+ var _a, _b, _c, _d, _e;
306
+ if (state.componentDirectoriesWatcher) {
307
+ state.componentDirectoriesWatcher.close();
308
+ }
309
+ const componentPaths = (_b = (_a = handoff.config.entries) === null || _a === void 0 ? void 0 : _a.components) !== null && _b !== void 0 ? _b : [];
310
+ if (componentPaths.length === 0)
311
+ return;
312
+ const dirsToWatch = [];
313
+ for (const componentPath of componentPaths) {
314
+ const resolved = path_1.default.resolve(handoff.workingPath, componentPath);
315
+ if (fs_extra_1.default.existsSync(resolved) && fs_extra_1.default.statSync(resolved).isDirectory()) {
316
+ dirsToWatch.push(resolved);
317
+ }
318
+ }
319
+ if (dirsToWatch.length === 0)
320
+ return;
321
+ const knownComponents = new Set(Object.keys((_e = (_d = (_c = handoff.runtimeConfig) === null || _c === void 0 ? void 0 : _c.entries) === null || _d === void 0 ? void 0 : _d.components) !== null && _e !== void 0 ? _e : {}));
322
+ state.componentDirectoriesWatcher = chokidar_1.default.watch(dirsToWatch, Object.assign(Object.assign({}, chokidarConfig), { depth: 1 }));
323
+ state.componentDirectoriesWatcher.on('add', (file) => __awaiter(void 0, void 0, void 0, function* () {
324
+ var _a, _b, _c;
325
+ const basename = path_1.default.basename(file);
326
+ const dirName = path_1.default.basename(path_1.default.dirname(file));
327
+ const isConfigFile = basename.endsWith('.json') || basename.endsWith('.js') || basename.endsWith('.cjs');
328
+ const isNewComponent = isConfigFile && basename.startsWith(dirName) && !knownComponents.has(dirName);
329
+ if (!isNewComponent)
330
+ return;
331
+ if (!state.debounce) {
332
+ state.debounce = true;
333
+ try {
334
+ logger_1.Logger.warn(`New component detected: ${dirName}. Reloading configuration...`);
335
+ handoff.reload();
336
+ knownComponents.add(dirName);
337
+ // Update known set with all current components
338
+ for (const id of Object.keys((_c = (_b = (_a = handoff.runtimeConfig) === null || _a === void 0 ? void 0 : _a.entries) === null || _b === void 0 ? void 0 : _b.components) !== null && _c !== void 0 ? _c : {})) {
339
+ knownComponents.add(id);
340
+ }
341
+ yield (0, client_config_1.persistClientConfig)(handoff);
342
+ (0, exports.watchRuntimeComponents)(handoff, state, (0, exports.getRuntimeComponentsPathsToWatch)(handoff));
343
+ (0, exports.watchRuntimeConfiguration)(handoff, state);
344
+ yield (0, builder_1.default)(handoff, dirName);
345
+ }
346
+ catch (e) {
347
+ logger_1.Logger.error('Error processing new component:', e);
348
+ }
349
+ finally {
350
+ state.debounce = false;
351
+ }
352
+ }
353
+ }));
354
+ };
355
+ exports.watchComponentDirectories = watchComponentDirectories;
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ class HandoffCliError extends Error {
19
19
  * Show the help message
20
20
  */
21
21
  const showVersion = () => {
22
- return 'Handoff App - 1.1.2';
22
+ return 'Handoff App - 1.1.3';
23
23
  };
24
24
  /**
25
25
  * Define a CLI error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "1.1.2",
3
+ "version": "1.1.3-1",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -109,6 +109,7 @@
109
109
  "react-scroll": "^1.8.9",
110
110
  "react-syntax-highlighter": "^16.1.0",
111
111
  "rehype-raw": "^7.0.0",
112
+ "remark-gfm": "^4.0.1",
112
113
  "sass": "^1.85.0",
113
114
  "tailwind-merge": "^2.5.5",
114
115
  "tailwindcss": "^4.1.14",
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import ReactMarkdown from 'react-markdown';
3
3
  import rehypeRaw from 'rehype-raw';
4
+ import remarkGfm from 'remark-gfm';
5
+ import { MarkdownComponents, remarkCodeMeta } from './Markdown/MarkdownComponents';
4
6
 
5
7
  export interface ComponentGuidelinesProps {
6
8
  content: string;
@@ -10,7 +12,7 @@ export const ComponentGuidelines: React.FC<ComponentGuidelinesProps> = ({ conten
10
12
  return (
11
13
  <>
12
14
  <div className="prose">
13
- <ReactMarkdown rehypePlugins={[rehypeRaw]}>{content}</ReactMarkdown>
15
+ <ReactMarkdown components={MarkdownComponents} remarkPlugins={[remarkGfm, remarkCodeMeta]} rehypePlugins={[rehypeRaw]}>{content}</ReactMarkdown>
14
16
  </div>
15
17
  </>
16
18
  );
@@ -1,23 +1,56 @@
1
+ import { useTheme } from 'next-themes';
1
2
  import React, { ReactElement } from 'react';
3
+ import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
4
+ import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
5
+ import handlebars from 'react-syntax-highlighter/dist/esm/languages/prism/handlebars';
2
6
  import js from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
3
7
  import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
8
+ import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx';
4
9
  import markdown from 'react-syntax-highlighter/dist/esm/languages/prism/markdown';
5
10
  import sass from 'react-syntax-highlighter/dist/esm/languages/prism/scss';
6
- import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
11
+ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
12
+ import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
7
13
  import html from 'react-syntax-highlighter/dist/esm/languages/prism/xml-doc';
14
+ import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
8
15
  import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light';
9
- import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
16
+ import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
17
+ import CopyCode from '../CopyCode';
18
+
19
+ import {
20
+ Braces,
21
+ Code,
22
+ FileCode2,
23
+ FileText,
24
+ FileType,
25
+ Terminal,
26
+ } from 'lucide-react';
27
+
28
+ SyntaxHighlighter.registerLanguage('bash', bash);
29
+ SyntaxHighlighter.registerLanguage('sh', bash);
30
+ SyntaxHighlighter.registerLanguage('shell', bash);
31
+ SyntaxHighlighter.registerLanguage('zsh', bash);
32
+ SyntaxHighlighter.registerLanguage('css', css);
33
+ SyntaxHighlighter.registerLanguage('handlebars', handlebars);
34
+ SyntaxHighlighter.registerLanguage('hbs', handlebars);
35
+ SyntaxHighlighter.registerLanguage('javascript', js);
10
36
  SyntaxHighlighter.registerLanguage('js', js);
11
37
  SyntaxHighlighter.registerLanguage('json', json);
38
+ SyntaxHighlighter.registerLanguage('jsx', jsx);
12
39
  SyntaxHighlighter.registerLanguage('markdown', markdown);
40
+ SyntaxHighlighter.registerLanguage('md', markdown);
13
41
  SyntaxHighlighter.registerLanguage('sass', sass);
42
+ SyntaxHighlighter.registerLanguage('scss', sass);
14
43
  SyntaxHighlighter.registerLanguage('tsx', tsx);
44
+ SyntaxHighlighter.registerLanguage('typescript', typescript);
45
+ SyntaxHighlighter.registerLanguage('ts', typescript);
15
46
  SyntaxHighlighter.registerLanguage('html', html);
16
- /**
17
- * Custom renderer type that we support
18
- */
47
+ SyntaxHighlighter.registerLanguage('xml', html);
48
+ SyntaxHighlighter.registerLanguage('yaml', yaml);
49
+ SyntaxHighlighter.registerLanguage('yml', yaml);
50
+
19
51
  export type CustomRenderers = {
20
52
  code: (props: any) => ReactElement;
53
+ pre: (props: any) => ReactElement;
21
54
  h1: (props: any) => ReactElement;
22
55
  h2: (props: any) => ReactElement;
23
56
  h3: (props: any) => ReactElement;
@@ -27,32 +60,27 @@ export type CustomRenderers = {
27
60
  paragraph: (props: { children: React.ReactNode }) => ReactElement;
28
61
  };
29
62
 
30
- /**
31
- * Build the headers in markdown
32
- * @param param0
33
- * @returns
34
- */
35
- const Headings = (element) => {
63
+ function extractText(node: any): string {
64
+ if (typeof node === 'string') return node;
65
+ if (typeof node === 'number') return String(node);
66
+ if (Array.isArray(node)) return node.map(extractText).join('');
67
+ if (node?.props?.children) return extractText(node.props.children);
68
+ return '';
69
+ }
70
+
71
+ const Headings = (element: any) => {
36
72
  const { children, node } = element;
37
- // Access actual (string) value of heading
38
73
  if (children[0]) {
39
- const heading = children[0];
40
74
  const type = node.tagName || 'h6';
41
75
 
42
- // If we have a heading, make it lower case
43
- let anchor = heading.toString().toLowerCase();
44
-
45
- // Clean anchor (replace special characters whitespaces).
46
- // Alternatively, use encodeURIComponent() if you don't care about
47
- // pretty anchor links
76
+ let anchor = extractText(children).toLowerCase();
48
77
  anchor = anchor.replace(/[^a-zA-Z0-9 ]/g, '');
49
78
  anchor = anchor.replace(/ /g, '-');
50
79
 
51
- // Utility
52
80
  const container = (children: React.ReactNode): React.ReactNode => (
53
81
  <>
54
82
  {children}
55
- <a id={anchor} href={`#${anchor}`} className="doc-link" onClick={(e) => console.log(e)}></a>
83
+ <a id={anchor} href={`#${anchor}`} className="doc-link"></a>
56
84
  </>
57
85
  );
58
86
 
@@ -69,22 +97,147 @@ const Headings = (element) => {
69
97
  return <h5 className="text-sm font-bold">{container(children)}</h5>;
70
98
  case 'h6':
71
99
  return <h6 className="text-xs font-bold">{container(children)}</h6>;
72
-
73
100
  default:
74
- return (
75
- <h6 className="text-xs font-bold">
76
- {JSON.stringify(type)}: {container(children)}
77
- </h6>
78
- );
101
+ return <h6 className="text-xs font-bold">{container(children)}</h6>;
79
102
  }
80
103
  } else {
81
104
  return <h1>children</h1>;
82
105
  }
83
106
  };
84
107
 
108
+ const LANGUAGE_ICONS: Record<string, React.FC<{ className?: string }>> = {
109
+ bash: Terminal,
110
+ sh: Terminal,
111
+ shell: Terminal,
112
+ zsh: Terminal,
113
+ js: FileCode2,
114
+ javascript: FileCode2,
115
+ ts: FileCode2,
116
+ typescript: FileCode2,
117
+ jsx: FileCode2,
118
+ tsx: FileCode2,
119
+ css: FileType,
120
+ scss: FileType,
121
+ sass: FileType,
122
+ html: FileCode2,
123
+ xml: FileCode2,
124
+ json: Braces,
125
+ yaml: FileText,
126
+ yml: FileText,
127
+ markdown: FileText,
128
+ md: FileText,
129
+ handlebars: Code,
130
+ hbs: Code,
131
+ };
132
+
133
+ const LANGUAGE_LABELS: Record<string, string> = {
134
+ bash: 'Bash',
135
+ sh: 'Shell',
136
+ shell: 'Shell',
137
+ zsh: 'Zsh',
138
+ js: 'JavaScript',
139
+ javascript: 'JavaScript',
140
+ ts: 'TypeScript',
141
+ typescript: 'TypeScript',
142
+ jsx: 'JSX',
143
+ tsx: 'TSX',
144
+ css: 'CSS',
145
+ scss: 'SCSS',
146
+ sass: 'SASS',
147
+ html: 'HTML',
148
+ xml: 'XML',
149
+ json: 'JSON',
150
+ yaml: 'YAML',
151
+ yml: 'YAML',
152
+ markdown: 'Markdown',
153
+ md: 'Markdown',
154
+ handlebars: 'Handlebars',
155
+ hbs: 'Handlebars',
156
+ text: 'Code',
157
+ };
158
+
159
+ function parseMeta(meta: string | undefined): Record<string, string> {
160
+ if (!meta) return {};
161
+ const result: Record<string, string> = {};
162
+ const regex = /(\w+)="([^"]+)"/g;
163
+ let match;
164
+ while ((match = regex.exec(meta)) !== null) {
165
+ result[match[1]] = match[2];
166
+ }
167
+ return result;
168
+ }
169
+
170
+ function MarkdownCodeBlock(props: any) {
171
+ const { className, children, node, 'data-meta': dataMeta, ...rest } = props;
172
+ const { resolvedTheme } = useTheme();
173
+ const isDark = resolvedTheme === 'dark';
174
+
175
+ const langMatch = /language-(\w+)/.exec(className || '');
176
+ const isBlock = String(children).includes('\n');
177
+
178
+ if (!langMatch && !isBlock) {
179
+ return <code className={className} {...rest}>{children}</code>;
180
+ }
181
+
182
+ const lang = langMatch ? langMatch[1] : 'text';
183
+ const meta = parseMeta(dataMeta);
184
+ const filename = meta.filename;
185
+ const codeString = String(children).replace(/\n$/, '');
186
+ const IconComponent = LANGUAGE_ICONS[lang] || Code;
187
+ const langLabel = LANGUAGE_LABELS[lang] || lang;
188
+
189
+ const theme = isDark ? { ...oneDark } : { ...oneLight };
190
+ theme['pre[class*="language-"]'] = {
191
+ ...theme['pre[class*="language-"]'],
192
+ overflow: 'auto',
193
+ margin: '0',
194
+ borderRadius: '0 0 0.5rem 0.5rem',
195
+ background: isDark ? '#1e1e1e' : '#fafafa',
196
+ };
197
+
198
+ return (
199
+ <div className="not-prose my-4 min-w-0 max-w-full overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
200
+ <div className="flex items-center justify-between bg-gray-50 px-4 py-2 dark:bg-gray-800">
201
+ <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
202
+ <IconComponent className="h-4 w-4" />
203
+ <span>{filename || langLabel}</span>
204
+ </div>
205
+ <CopyCode code={codeString} />
206
+ </div>
207
+ <SyntaxHighlighter
208
+ style={theme}
209
+ language={lang}
210
+ PreTag="div"
211
+ showLineNumbers={lang !== 'text'}
212
+ wrapLines={true}
213
+ useInlineStyles={true}
214
+ >
215
+ {codeString}
216
+ </SyntaxHighlighter>
217
+ </div>
218
+ );
219
+ }
220
+
85
221
  /**
86
- * Build custom renderers for markdown
222
+ * Remark plugin that preserves the code fence meta string
223
+ * (the text after the language identifier) as a hast data attribute.
87
224
  */
225
+ export function remarkCodeMeta() {
226
+ return (tree: any) => {
227
+ const walk = (node: any) => {
228
+ if (node.type === 'code' && node.meta) {
229
+ node.data = node.data || {};
230
+ node.data.hProperties = node.data.hProperties || {};
231
+ node.data.hProperties['data-meta'] = node.meta;
232
+ }
233
+ if (node.children) {
234
+ node.children.forEach(walk);
235
+ }
236
+ };
237
+ walk(tree);
238
+ };
239
+ }
240
+
88
241
  export const MarkdownComponents: CustomRenderers = {
89
242
  h1: Headings,
90
243
  h2: Headings,
@@ -93,28 +246,8 @@ export const MarkdownComponents: CustomRenderers = {
93
246
  h5: Headings,
94
247
  h6: Headings,
95
248
  paragraph: ({ children }) => <p>{children}</p>,
96
- code(props) {
97
- const { className } = props;
98
- const match = /language-(\w+)/.exec(className || '');
99
- // This was designed to flatten out children arrays, but I think its not needed
100
- // if (props.children[0]) {
101
- // props.children[0] = props.children[0].toString().trim();
102
- // }
103
- return match ? (
104
- <SyntaxHighlighter
105
- // @ts-ignore
106
- style={oneLight}
107
- language={match[1]}
108
- PreTag="div"
109
- showLineNumbers={true}
110
- wrapLines={true}
111
- useInlineStyles={true}
112
- {...props}
113
- ></SyntaxHighlighter>
114
- ) : (
115
- <code {...props} />
116
- );
117
- },
249
+ pre: ({ children }) => <>{children}</>,
250
+ code: MarkdownCodeBlock,
118
251
  };
119
252
 
120
253
  export default Headings;
@@ -18,45 +18,45 @@ interface TOCProps {
18
18
  export function PageTOC({ body, title }: TOCProps) {
19
19
  const [headers, setHeaders] = React.useState<{ id: string; title: string | null; level: number }[]>([]);
20
20
  const router = useRouter();
21
- const getHeaders = () => {
22
- if (body.current) {
23
- const headers = Array.from(body.current.querySelectorAll('h1, h2, h3, h4, h5, h6')).map((node) => {
24
- const id = anchorSlugify(node?.textContent?.toString() ?? '');
25
- return {
26
- id,
27
- title: node.textContent,
28
- level: parseInt(node.tagName[1]),
29
- };
30
- });
31
- setHeaders(headers);
32
- }
33
- };
34
- const headersCallback = React.useCallback(getHeaders, [body]);
21
+
22
+ const scanHeaders = React.useCallback(() => {
23
+ if (!body.current) return;
24
+ const found = Array.from(body.current.querySelectorAll('h1, h2, h3, h4, h5, h6')).map((node) => ({
25
+ id: anchorSlugify(node?.textContent?.toString() ?? ''),
26
+ title: node.textContent,
27
+ level: parseInt(node.tagName[1]),
28
+ }));
29
+ setHeaders(found);
30
+ }, [body]);
35
31
 
36
32
  useEffect(() => {
37
- router.events.on('routeChangeComplete', () => {
38
- headersCallback();
39
- });
40
- return () => {
41
- router.events.off('routeChangeComplete', () => {
42
- headersCallback();
43
- });
44
- };
45
- }, [router, headersCallback]);
33
+ // Scan after paint to catch SSR-hydrated content
34
+ requestAnimationFrame(scanHeaders);
35
+
36
+ // Watch for dynamically inserted headings (client-rendered markdown)
37
+ const el = body.current;
38
+ if (!el) return;
39
+ const observer = new MutationObserver(scanHeaders);
40
+ observer.observe(el, { childList: true, subtree: true });
41
+ return () => observer.disconnect();
42
+ }, [body, scanHeaders]);
43
+
46
44
  useEffect(() => {
47
- headersCallback();
48
- }, [body, headersCallback]);
45
+ const handler = () => requestAnimationFrame(scanHeaders);
46
+ router.events.on('routeChangeComplete', handler);
47
+ return () => router.events.off('routeChangeComplete', handler);
48
+ }, [router, scanHeaders]);
49
49
  return (
50
50
  <AnchorNav
51
51
  title={title}
52
52
  groups={headers.reduce((acc, header) => {
53
53
  if (header.level === 1) {
54
- acc.push({ [`#${header.id}`]: header.title ?? '' });
54
+ acc.push({ [header.id]: header.title ?? '' });
55
55
  } else {
56
56
  if (acc.length === 0) {
57
- acc.push({ [`#${header.id}`]: header.title ?? '' });
57
+ acc.push({ [header.id]: header.title ?? '' });
58
58
  } else {
59
- acc[acc.length - 1][`#${header.id}`] = header.title ?? '';
59
+ acc[acc.length - 1][header.id] = header.title ?? '';
60
60
  }
61
61
  }
62
62
  return acc;