@raystack/chronicle 0.6.1 → 0.7.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 (37) hide show
  1. package/dist/cli/index.js +277 -27
  2. package/package.json +5 -1
  3. package/src/components/ui/search.tsx +3 -3
  4. package/src/lib/config.ts +5 -0
  5. package/src/lib/remark-resolve-images.ts +59 -0
  6. package/src/lib/remark-resolve-links.ts +32 -0
  7. package/src/lib/source.ts +20 -4
  8. package/src/pages/ApiLayout.tsx +0 -2
  9. package/src/pages/LandingPage.module.css +137 -24
  10. package/src/pages/LandingPage.tsx +23 -7
  11. package/src/server/api/apis-proxy.ts +2 -2
  12. package/src/server/api/health.ts +1 -1
  13. package/src/server/api/page.ts +2 -2
  14. package/src/server/api/search.ts +4 -4
  15. package/src/server/api/specs.ts +2 -2
  16. package/src/server/entry-server.tsx +4 -1
  17. package/src/server/routes/[...slug].md.ts +1 -2
  18. package/src/server/routes/[version]/llms.txt.ts +1 -2
  19. package/src/server/routes/_content/[...path].ts +40 -0
  20. package/src/server/routes/llms.txt.ts +2 -3
  21. package/src/server/routes/og.tsx +1 -3
  22. package/src/server/routes/robots.txt.ts +2 -3
  23. package/src/server/routes/sitemap.xml.ts +3 -5
  24. package/src/server/vite-config.ts +8 -2
  25. package/src/themes/paper/ChapterNav.module.css +23 -12
  26. package/src/themes/paper/ChapterNav.tsx +1 -17
  27. package/src/themes/paper/Layout.module.css +61 -16
  28. package/src/themes/paper/Layout.tsx +73 -17
  29. package/src/themes/paper/Page.module.css +89 -37
  30. package/src/themes/paper/Page.tsx +89 -53
  31. package/src/themes/paper/ReaderModeContext.tsx +28 -0
  32. package/src/themes/paper/ReadingProgress.tsx +1 -0
  33. package/src/themes/paper/fonts/DepartureMono-Regular.woff2 +0 -0
  34. package/src/themes/registry.ts +1 -1
  35. package/src/types/config.ts +1 -0
  36. package/src/types/content.ts +1 -0
  37. package/src/lib/remark-strip-md-extensions.ts +0 -14
package/dist/cli/index.js CHANGED
@@ -1,5 +1,35 @@
1
1
  import { createRequire } from "node:module";
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
2
4
  var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ function __accessProp(key) {
8
+ return this[key];
9
+ }
10
+ var __toESMCache_node;
11
+ var __toESMCache_esm;
12
+ var __toESM = (mod, isNodeMode, target) => {
13
+ var canCache = mod != null && typeof mod === "object";
14
+ if (canCache) {
15
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
+ var cached = cache.get(mod);
17
+ if (cached)
18
+ return cached;
19
+ }
20
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
21
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
+ for (let key of __getOwnPropNames(mod))
23
+ if (!__hasOwnProp.call(to, key))
24
+ __defProp(to, key, {
25
+ get: __accessProp.bind(mod, key),
26
+ enumerable: true
27
+ });
28
+ if (canCache)
29
+ cache.set(mod, to);
30
+ return to;
31
+ };
32
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
3
33
  var __returnValue = (v) => v;
4
34
  function __exportSetter(name, newValue) {
5
35
  this[name] = __returnValue.bind(null, newValue);
@@ -16,28 +46,239 @@ var __export = (target, all) => {
16
46
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
18
48
 
19
- // src/lib/remark-strip-md-extensions.ts
49
+ // src/lib/remark-resolve-images.ts
50
+ import path4 from "node:path";
20
51
  import { visit } from "unist-util-visit";
21
- var remarkStripMdExtensions = () => {
22
- return (tree) => {
23
- visit(tree, "link", (node) => {
52
+ function resolveUrl(src, dir) {
53
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(src))
54
+ return src;
55
+ if (src.startsWith("//"))
56
+ return src;
57
+ if (src.startsWith("#"))
58
+ return src;
59
+ if (src.startsWith("/_content/"))
60
+ return src;
61
+ if (src.startsWith("/"))
62
+ return `/_content${src}`;
63
+ return `/_content/${path4.posix.normalize(path4.posix.join(dir, src))}`;
64
+ }
65
+ var remarkResolveImages = () => {
66
+ return (tree, file) => {
67
+ const filePath = file.path;
68
+ if (!filePath)
69
+ return;
70
+ const contentIdx = filePath.lastIndexOf("/content/");
71
+ if (contentIdx === -1)
72
+ return;
73
+ const relative = filePath.slice(contentIdx + "/content/".length);
74
+ const dir = path4.posix.dirname(relative);
75
+ visit(tree, "image", (node) => {
24
76
  if (!node.url)
25
77
  return;
26
- if (node.url.startsWith("http://") || node.url.startsWith("https://"))
78
+ node.url = resolveUrl(node.url, dir);
79
+ });
80
+ visit(tree, "html", (node) => {
81
+ node.value = node.value.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`);
82
+ });
83
+ visit(tree, (node) => {
84
+ if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement")
85
+ return;
86
+ const jsx = node;
87
+ if (jsx.name !== "img")
88
+ return;
89
+ const srcAttr = jsx.attributes.find((a) => a.type === "mdxJsxAttribute" && a.name === "src");
90
+ if (!srcAttr?.value || typeof srcAttr.value !== "string")
91
+ return;
92
+ srcAttr.value = resolveUrl(srcAttr.value, dir);
93
+ });
94
+ visit(tree, "element", (node) => {
95
+ if (node.tagName !== "img")
96
+ return;
97
+ const src = node.properties?.src;
98
+ if (typeof src !== "string")
27
99
  return;
28
- node.url = node.url.replace(/\.mdx?(#|$)/, "$1");
100
+ node.properties.src = resolveUrl(src, dir);
29
101
  });
30
102
  };
31
- }, remark_strip_md_extensions_default;
32
- var init_remark_strip_md_extensions = __esm(() => {
33
- remark_strip_md_extensions_default = remarkStripMdExtensions;
103
+ }, remark_resolve_images_default;
104
+ var init_remark_resolve_images = __esm(() => {
105
+ remark_resolve_images_default = remarkResolveImages;
34
106
  });
35
107
 
36
- // src/lib/remark-unused-directives.ts
108
+ // src/lib/remark-resolve-links.ts
109
+ import path5 from "node:path";
37
110
  import { visit as visit2 } from "unist-util-visit";
111
+ var remarkResolveLinks = () => {
112
+ return (tree, file) => {
113
+ const filePath = file.path;
114
+ if (!filePath)
115
+ return;
116
+ const contentIdx = filePath.lastIndexOf("/content/");
117
+ if (contentIdx === -1)
118
+ return;
119
+ const relative = filePath.slice(contentIdx + "/content/".length);
120
+ const dir = path5.posix.dirname(relative);
121
+ visit2(tree, "link", (node) => {
122
+ if (!node.url)
123
+ return;
124
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(node.url))
125
+ return;
126
+ if (node.url.startsWith("#"))
127
+ return;
128
+ if (node.url.startsWith("/"))
129
+ return;
130
+ const [rawPath, hash] = node.url.split("#");
131
+ const stripped = rawPath.replace(/\.mdx?$/, "");
132
+ let resolved = path5.posix.normalize(path5.posix.join(dir, stripped));
133
+ resolved = resolved.replace(/\/(index|readme)$/i, "") || ".";
134
+ node.url = `/${resolved}${hash ? `#${hash}` : ""}`;
135
+ });
136
+ };
137
+ }, remark_resolve_links_default;
138
+ var init_remark_resolve_links = __esm(() => {
139
+ remark_resolve_links_default = remarkResolveLinks;
140
+ });
141
+
142
+ // ../../node_modules/.bun/reading-time@1.5.0/node_modules/reading-time/lib/reading-time.js
143
+ var require_reading_time = __commonJS((exports, module) => {
144
+ /*!
145
+ * reading-time
146
+ * Copyright (c) Nicolas Gryman <ngryman@gmail.com>
147
+ * MIT Licensed
148
+ */
149
+ function codeIsInRanges(number, arrayOfRanges) {
150
+ return arrayOfRanges.some(([lowerBound, upperBound]) => lowerBound <= number && number <= upperBound);
151
+ }
152
+ function isCJK(c) {
153
+ if (typeof c !== "string") {
154
+ return false;
155
+ }
156
+ const charCode = c.charCodeAt(0);
157
+ return codeIsInRanges(charCode, [
158
+ [12352, 12447],
159
+ [19968, 40959],
160
+ [44032, 55203],
161
+ [131072, 191456]
162
+ ]);
163
+ }
164
+ function isAnsiWordBound(c) {
165
+ return `
166
+ \r `.includes(c);
167
+ }
168
+ function isPunctuation(c) {
169
+ if (typeof c !== "string") {
170
+ return false;
171
+ }
172
+ const charCode = c.charCodeAt(0);
173
+ return codeIsInRanges(charCode, [
174
+ [33, 47],
175
+ [58, 64],
176
+ [91, 96],
177
+ [123, 126],
178
+ [12288, 12351],
179
+ [65280, 65519]
180
+ ]);
181
+ }
182
+ function readingTime(text, options = {}) {
183
+ let words = 0, start = 0, end = text.length - 1;
184
+ const wordsPerMinute = options.wordsPerMinute || 200;
185
+ const isWordBound = options.wordBound || isAnsiWordBound;
186
+ while (isWordBound(text[start]))
187
+ start++;
188
+ while (isWordBound(text[end]))
189
+ end--;
190
+ const normalizedText = `${text}
191
+ `;
192
+ for (let i = start;i <= end; i++) {
193
+ if (isCJK(normalizedText[i]) || !isWordBound(normalizedText[i]) && (isWordBound(normalizedText[i + 1]) || isCJK(normalizedText[i + 1]))) {
194
+ words++;
195
+ }
196
+ if (isCJK(normalizedText[i])) {
197
+ while (i <= end && (isPunctuation(normalizedText[i + 1]) || isWordBound(normalizedText[i + 1]))) {
198
+ i++;
199
+ }
200
+ }
201
+ }
202
+ const minutes = words / wordsPerMinute;
203
+ const time = Math.round(minutes * 60 * 1000);
204
+ const displayed = Math.ceil(minutes.toFixed(2));
205
+ return {
206
+ text: displayed + " min read",
207
+ minutes,
208
+ time,
209
+ words
210
+ };
211
+ }
212
+ module.exports = readingTime;
213
+ });
214
+
215
+ // ../../node_modules/.bun/reading-time@1.5.0/node_modules/reading-time/lib/stream.js
216
+ var require_stream = __commonJS((exports, module) => {
217
+ /*!
218
+ * reading-time
219
+ * Copyright (c) Nicolas Gryman <ngryman@gmail.com>
220
+ * MIT Licensed
221
+ */
222
+ var readingTime = require_reading_time();
223
+ var Transform = __require("stream").Transform;
224
+ var util = __require("util");
225
+ function ReadingTimeStream(options) {
226
+ if (!(this instanceof ReadingTimeStream)) {
227
+ return new ReadingTimeStream(options);
228
+ }
229
+ Transform.call(this, { objectMode: true });
230
+ this.options = options || {};
231
+ this.stats = {
232
+ minutes: 0,
233
+ time: 0,
234
+ words: 0
235
+ };
236
+ }
237
+ util.inherits(ReadingTimeStream, Transform);
238
+ ReadingTimeStream.prototype._transform = function(chunk, encoding, callback) {
239
+ const stats = readingTime(chunk.toString(encoding), this.options);
240
+ this.stats.minutes += stats.minutes;
241
+ this.stats.time += stats.time;
242
+ this.stats.words += stats.words;
243
+ callback();
244
+ };
245
+ ReadingTimeStream.prototype._flush = function(callback) {
246
+ this.stats.text = Math.ceil(this.stats.minutes.toFixed(2)) + " min read";
247
+ this.push(this.stats);
248
+ callback();
249
+ };
250
+ module.exports = ReadingTimeStream;
251
+ });
252
+
253
+ // ../../node_modules/.bun/reading-time@1.5.0/node_modules/reading-time/index.js
254
+ var require_reading_time2 = __commonJS((exports, module) => {
255
+ exports.default = module.exports = require_reading_time();
256
+ module.exports.readingTimeStream = require_stream();
257
+ });
258
+
259
+ // ../../node_modules/.bun/remark-reading-time@2.1.0/node_modules/remark-reading-time/index.js
260
+ import { visit as visit3 } from "unist-util-visit";
261
+ function readingTime({
262
+ attribute = "readingTime"
263
+ } = {}) {
264
+ return function(info, file) {
265
+ let text = "";
266
+ visit3(info, ["text", "code"], (node) => {
267
+ text += node.value;
268
+ });
269
+ file.data[attribute] = import_reading_time.default(text);
270
+ };
271
+ }
272
+ var import_reading_time;
273
+ var init_remark_reading_time = __esm(() => {
274
+ import_reading_time = __toESM(require_reading_time2(), 1);
275
+ });
276
+
277
+ // src/lib/remark-unused-directives.ts
278
+ import { visit as visit4 } from "unist-util-visit";
38
279
  var remarkUnusedDirectives = () => {
39
280
  return (tree) => {
40
- visit2(tree, ["textDirective"], (node) => {
281
+ visit4(tree, ["textDirective"], (node) => {
41
282
  const directive = node;
42
283
  if (!directive.data) {
43
284
  const hasAttributes = directive.attributes && Object.keys(directive.attributes).length > 0;
@@ -69,12 +310,12 @@ import { defineConfig as defineFumadocsConfig } from "fumadocs-mdx/config";
69
310
  import mdx from "fumadocs-mdx/vite";
70
311
  import { nitro } from "nitro/vite";
71
312
  import fs3 from "node:fs/promises";
72
- import path4 from "node:path";
313
+ import path6 from "node:path";
73
314
  import remarkDirective from "remark-directive";
74
315
  function resolveOutputDir(projectRoot, preset) {
75
316
  if (preset === "vercel" || preset === "vercel-static")
76
- return path4.resolve(projectRoot, ".vercel/output");
77
- return path4.resolve(projectRoot, ".output");
317
+ return path6.resolve(projectRoot, ".vercel/output");
318
+ return path6.resolve(projectRoot, ".output");
78
319
  }
79
320
  async function readChronicleConfig(projectRoot, configPath) {
80
321
  if (configPath) {
@@ -85,7 +326,7 @@ async function readChronicleConfig(projectRoot, configPath) {
85
326
  }
86
327
  }
87
328
  try {
88
- return await fs3.readFile(path4.join(projectRoot, "chronicle.yaml"), "utf-8");
329
+ return await fs3.readFile(path6.join(projectRoot, "chronicle.yaml"), "utf-8");
89
330
  } catch {
90
331
  return null;
91
332
  }
@@ -93,18 +334,20 @@ async function readChronicleConfig(projectRoot, configPath) {
93
334
  async function createViteConfig(options) {
94
335
  const { packageRoot, projectRoot, configPath, preset } = options;
95
336
  const rawConfig = await readChronicleConfig(projectRoot, configPath);
96
- const contentMirror = path4.resolve(packageRoot, ".content");
337
+ const contentMirror = path6.resolve(packageRoot, ".content");
97
338
  return {
98
339
  root: packageRoot,
99
340
  configFile: false,
100
341
  plugins: [
101
342
  nitro({
102
- serverDir: path4.resolve(packageRoot, "src/server"),
343
+ serverDir: path6.resolve(packageRoot, "src/server"),
103
344
  ...preset && { preset }
104
345
  }),
105
346
  mdx({
106
347
  default: defineFumadocsConfig({
107
348
  mdxOptions: {
349
+ remarkImageOptions: false,
350
+ valueToExport: ["readingTime"],
108
351
  remarkPlugins: [
109
352
  remarkDirective,
110
353
  [remarkDirectiveAdmonition, {
@@ -125,8 +368,10 @@ async function createViteConfig(options) {
125
368
  }
126
369
  }],
127
370
  remark_unused_directives_default,
128
- remark_strip_md_extensions_default,
129
- remarkMdxMermaid
371
+ remark_resolve_links_default,
372
+ remark_resolve_images_default,
373
+ remarkMdxMermaid,
374
+ readingTime
130
375
  ]
131
376
  }
132
377
  })
@@ -135,7 +380,7 @@ async function createViteConfig(options) {
135
380
  ],
136
381
  resolve: {
137
382
  alias: {
138
- "@": path4.resolve(packageRoot, "src"),
383
+ "@": path6.resolve(packageRoot, "src"),
139
384
  tslib: "tslib/tslib.es6.js"
140
385
  },
141
386
  dedupe: [
@@ -168,7 +413,7 @@ async function createViteConfig(options) {
168
413
  client: {
169
414
  build: {
170
415
  rollupOptions: {
171
- input: path4.resolve(packageRoot, "src/server/entry-client.tsx")
416
+ input: path6.resolve(packageRoot, "src/server/entry-client.tsx")
172
417
  }
173
418
  }
174
419
  }
@@ -182,7 +427,9 @@ async function createViteConfig(options) {
182
427
  };
183
428
  }
184
429
  var init_vite_config = __esm(() => {
185
- init_remark_strip_md_extensions();
430
+ init_remark_resolve_images();
431
+ init_remark_resolve_links();
432
+ init_remark_reading_time();
186
433
  init_remark_unused_directives();
187
434
  });
188
435
 
@@ -266,6 +513,7 @@ var dirNameSchema = z.string().min(1).refine((s) => DIR_NAME_PATTERN.test(s) &&
266
513
  var contentEntrySchema = z.object({
267
514
  dir: dirNameSchema,
268
515
  label: z.string().min(1),
516
+ description: z.string().optional(),
269
517
  icon: z.string().optional()
270
518
  });
271
519
  var badgeVariantSchema = z.enum([
@@ -428,6 +676,7 @@ function getLatestContentRoots(config2) {
428
676
  versionLabel: config2.latest?.label ?? null,
429
677
  contentDir: c.dir,
430
678
  contentLabel: c.label,
679
+ contentDescription: c.description,
431
680
  contentIcon: c.icon,
432
681
  fsPath: `content/${c.dir}`,
433
682
  urlPrefix: `/${c.dir}`
@@ -442,6 +691,7 @@ function getVersionContentRoots(config2, versionDir) {
442
691
  versionLabel: version.label,
443
692
  contentDir: c.dir,
444
693
  contentLabel: c.label,
694
+ contentDescription: c.description,
445
695
  contentIcon: c.icon,
446
696
  fsPath: `versions/${version.dir}/${c.dir}`,
447
697
  urlPrefix: `/${version.dir}/${c.dir}`
@@ -549,7 +799,7 @@ var devCommand = new Command2("dev").description("Start development server").opt
549
799
 
550
800
  // src/cli/commands/init.ts
551
801
  import fs4 from "node:fs";
552
- import path5 from "node:path";
802
+ import path7 from "node:path";
553
803
  import chalk4 from "chalk";
554
804
  import { Command as Command3 } from "commander";
555
805
  import { stringify } from "yaml";
@@ -576,12 +826,12 @@ var GITIGNORE_ENTRIES = ["node_modules", "dist", ".output"];
576
826
  function runInit(projectDir) {
577
827
  const events = [];
578
828
  const defaultDir = defaultInitConfig.content[0].dir;
579
- const contentDir = path5.join(projectDir, "content", defaultDir);
829
+ const contentDir = path7.join(projectDir, "content", defaultDir);
580
830
  if (!fs4.existsSync(contentDir)) {
581
831
  fs4.mkdirSync(contentDir, { recursive: true });
582
832
  events.push({ type: "created", path: contentDir });
583
833
  }
584
- const configPath = path5.join(projectDir, "chronicle.yaml");
834
+ const configPath = path7.join(projectDir, "chronicle.yaml");
585
835
  if (!fs4.existsSync(configPath)) {
586
836
  fs4.writeFileSync(configPath, stringify(defaultInitConfig));
587
837
  events.push({ type: "created", path: configPath });
@@ -590,11 +840,11 @@ function runInit(projectDir) {
590
840
  }
591
841
  const contentFiles = fs4.readdirSync(contentDir);
592
842
  if (contentFiles.length === 0) {
593
- const indexPath = path5.join(contentDir, "index.mdx");
843
+ const indexPath = path7.join(contentDir, "index.mdx");
594
844
  fs4.writeFileSync(indexPath, sampleMdx);
595
845
  events.push({ type: "created", path: indexPath });
596
846
  }
597
- const gitignorePath = path5.join(projectDir, ".gitignore");
847
+ const gitignorePath = path7.join(projectDir, ".gitignore");
598
848
  if (fs4.existsSync(gitignorePath)) {
599
849
  const existing = fs4.readFileSync(gitignorePath, "utf-8");
600
850
  const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raystack/chronicle",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Config-driven documentation framework",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -22,12 +22,16 @@
22
22
  "devDependencies": {
23
23
  "@biomejs/biome": "^2.3.13",
24
24
  "@raystack/tools-config": "0.56.0",
25
+ "@types/hast": "^3.0.4",
25
26
  "@types/lodash": "^4.17.23",
27
+ "@types/mdast": "^4.0.4",
26
28
  "@types/mdx": "^2.0.13",
27
29
  "@types/node": "^25.1.0",
28
30
  "@types/react": "^19.2.10",
29
31
  "@types/react-dom": "^19.2.3",
30
32
  "@types/semver": "^7.7.1",
33
+ "@types/unist": "^3.0.3",
34
+ "mdast-util-mdx-jsx": "^3.2.0",
31
35
  "semver": "^7.7.4",
32
36
  "typescript": "5.9.3"
33
37
  },
@@ -13,10 +13,10 @@ import { usePageContext } from '@/lib/page-context';
13
13
  import styles from './search.module.css';
14
14
 
15
15
  interface SearchProps {
16
- className?: string;
16
+ classNames?: { trigger?: string };
17
17
  }
18
18
 
19
- export function Search({ className }: SearchProps) {
19
+ export function Search({ classNames }: SearchProps) {
20
20
  const [open, setOpen] = useState(false);
21
21
  const navigate = useNavigate();
22
22
  const { version } = usePageContext();
@@ -60,7 +60,7 @@ export function Search({ className }: SearchProps) {
60
60
  aria-label='Search'
61
61
  title='Search (Ctrl/⌘K)'
62
62
  onClick={() => setOpen(true)}
63
- className={className}
63
+ className={classNames?.trigger}
64
64
  >
65
65
  <MagnifyingGlassIcon width={16} height={16} />
66
66
  </IconButton>
package/src/lib/config.ts CHANGED
@@ -35,6 +35,7 @@ export interface ContentRoot {
35
35
  versionLabel: string | null
36
36
  contentDir: string
37
37
  contentLabel: string
38
+ contentDescription?: string
38
39
  contentIcon?: string
39
40
  fsPath: string
40
41
  urlPrefix: string
@@ -46,6 +47,7 @@ export function getLatestContentRoots(config: ChronicleConfig): ContentRoot[] {
46
47
  versionLabel: config.latest?.label ?? null,
47
48
  contentDir: c.dir,
48
49
  contentLabel: c.label,
50
+ contentDescription: c.description,
49
51
  contentIcon: c.icon,
50
52
  fsPath: `content/${c.dir}`,
51
53
  urlPrefix: `/${c.dir}`,
@@ -64,6 +66,7 @@ export function getVersionContentRoots(
64
66
  versionLabel: version.label,
65
67
  contentDir: c.dir,
66
68
  contentLabel: c.label,
69
+ contentDescription: c.description,
67
70
  contentIcon: c.icon,
68
71
  fsPath: `versions/${version.dir}/${c.dir}`,
69
72
  urlPrefix: `/${version.dir}/${c.dir}`,
@@ -79,6 +82,7 @@ export interface VersionDescriptor {
79
82
 
80
83
  export interface LandingEntry {
81
84
  label: string
85
+ description?: string
82
86
  href: string
83
87
  contentDir: string
84
88
  icon?: string
@@ -95,6 +99,7 @@ export function getLandingEntries(
95
99
 
96
100
  return roots.map((r) => ({
97
101
  label: r.contentLabel,
102
+ description: r.contentDescription,
98
103
  href: r.urlPrefix,
99
104
  contentDir: r.contentDir,
100
105
  icon: r.contentIcon,
@@ -0,0 +1,59 @@
1
+ import path from 'node:path'
2
+ import { visit } from 'unist-util-visit'
3
+ import type { Plugin } from 'unified'
4
+ import type { Image, Html } from 'mdast'
5
+ import type { Element } from 'hast'
6
+ import type { MdxJsxFlowElement, MdxJsxTextElement, MdxJsxAttribute } from 'mdast-util-mdx-jsx'
7
+
8
+ function resolveUrl(src: string, dir: string): string {
9
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src
10
+ if (src.startsWith('//')) return src
11
+ if (src.startsWith('#')) return src
12
+ if (src.startsWith('/_content/')) return src
13
+
14
+ if (src.startsWith('/')) return `/_content${src}`
15
+ return `/_content/${path.posix.normalize(path.posix.join(dir, src))}`
16
+ }
17
+
18
+ const remarkResolveImages: Plugin = () => {
19
+ return (tree, file) => {
20
+ const filePath = file.path
21
+ if (!filePath) return
22
+
23
+ const contentIdx = filePath.lastIndexOf('/content/')
24
+ if (contentIdx === -1) return
25
+
26
+ const relative = filePath.slice(contentIdx + '/content/'.length)
27
+ const dir = path.posix.dirname(relative)
28
+
29
+ visit(tree, 'image', (node: Image) => {
30
+ if (!node.url) return
31
+ node.url = resolveUrl(node.url, dir)
32
+ })
33
+
34
+ visit(tree, 'html', (node: Html) => {
35
+ node.value = node.value.replace(
36
+ /(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi,
37
+ (_, before, src, after) => `${before}${resolveUrl(src, dir)}${after}`
38
+ )
39
+ })
40
+
41
+ visit(tree, (node) => {
42
+ if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return
43
+ const jsx = node as MdxJsxFlowElement | MdxJsxTextElement
44
+ if (jsx.name !== 'img') return
45
+ const srcAttr = jsx.attributes.find((a): a is MdxJsxAttribute => a.type === 'mdxJsxAttribute' && a.name === 'src')
46
+ if (!srcAttr?.value || typeof srcAttr.value !== 'string') return
47
+ srcAttr.value = resolveUrl(srcAttr.value, dir)
48
+ })
49
+
50
+ visit(tree, 'element', (node: Element) => {
51
+ if (node.tagName !== 'img') return
52
+ const src = node.properties?.src
53
+ if (typeof src !== 'string') return
54
+ node.properties.src = resolveUrl(src, dir)
55
+ })
56
+ }
57
+ }
58
+
59
+ export default remarkResolveImages
@@ -0,0 +1,32 @@
1
+ import path from 'node:path'
2
+ import { visit } from 'unist-util-visit'
3
+ import type { Plugin } from 'unified'
4
+ import type { Link } from 'mdast'
5
+
6
+ const remarkResolveLinks: Plugin = () => {
7
+ return (tree, file) => {
8
+ const filePath = file.path
9
+ if (!filePath) return
10
+
11
+ const contentIdx = filePath.lastIndexOf('/content/')
12
+ if (contentIdx === -1) return
13
+
14
+ const relative = filePath.slice(contentIdx + '/content/'.length)
15
+ const dir = path.posix.dirname(relative)
16
+
17
+ visit(tree, 'link', (node: Link) => {
18
+ if (!node.url) return
19
+ if (/^[a-z][a-z0-9+\-.]*:/i.test(node.url)) return
20
+ if (node.url.startsWith('#')) return
21
+ if (node.url.startsWith('/')) return
22
+
23
+ const [rawPath, hash] = node.url.split('#')
24
+ const stripped = rawPath.replace(/\.mdx?$/, '')
25
+ let resolved = path.posix.normalize(path.posix.join(dir, stripped))
26
+ resolved = resolved.replace(/\/(index|readme)$/i, '') || '.'
27
+ node.url = `/${resolved}${hash ? `#${hash}` : ''}`
28
+ })
29
+ }
30
+ }
31
+
32
+ export default remarkResolveLinks
package/src/lib/source.ts CHANGED
@@ -23,6 +23,11 @@ const frontmatterGlob: Record<string, Record<string, unknown>> = import.meta.glo
23
23
  { eager: true, import: 'frontmatter' }
24
24
  );
25
25
 
26
+ const readingTimeGlob: Record<string, { text: string; minutes: number; words: number; time: number } | undefined> = import.meta.glob(
27
+ '../../.content/**/*.{mdx,md}',
28
+ { eager: true, import: 'readingTime' }
29
+ );
30
+
26
31
  const metaGlob: Record<string, Record<string, unknown>> = import.meta.glob(
27
32
  '../../.content/**/meta.json',
28
33
  { eager: true }
@@ -38,10 +43,12 @@ function buildFiles() {
38
43
  for (const [key, data] of Object.entries(frontmatterGlob)) {
39
44
  const originalPath = key.slice(CONTENT_PREFIX.length);
40
45
  const relativePath = originalPath.replace(/readme\.(mdx?)$/i, 'index.$1');
46
+ const rt = readingTimeGlob[key];
47
+ const _readingTime = rt?.minutes != null ? Math.max(1, Math.round(rt.minutes)) : undefined;
41
48
  files.push({
42
49
  type: 'page',
43
50
  path: relativePath,
44
- data: { ...data, _relativePath: relativePath, _originalPath: originalPath }
51
+ data: { ...data, _readingTime, _relativePath: relativePath, _originalPath: originalPath }
45
52
  });
46
53
  }
47
54
 
@@ -206,6 +213,7 @@ export function extractFrontmatter(page: { data: unknown }, fallbackTitle?: stri
206
213
  order: d.order as number | undefined,
207
214
  icon: d.icon as string | undefined,
208
215
  lastModified: d.lastModified as string | undefined,
216
+ _readingTime: d._readingTime as number | undefined,
209
217
  };
210
218
  }
211
219
 
@@ -217,13 +225,20 @@ export function getOriginalPath(page: { data: unknown }): string {
217
225
  return ((page.data as Record<string, unknown>)._originalPath as string) ?? '';
218
226
  }
219
227
 
220
- const ssrModules = import.meta.glob<{ default?: MDXContent; toc?: TableOfContents }>(
228
+ interface ReadingTime {
229
+ text: string;
230
+ minutes: number;
231
+ words: number;
232
+ time: number;
233
+ }
234
+
235
+ const ssrModules = import.meta.glob<{ default?: MDXContent; toc?: TableOfContents; readingTime?: ReadingTime }>(
221
236
  '../../.content/**/*.{mdx,md}'
222
237
  );
223
238
 
224
239
  export async function loadPageModule(
225
240
  relativePath: string
226
- ): Promise<{ default: MDXContent | null; toc: TableOfContents }> {
241
+ ): Promise<{ default: MDXContent | null; toc: TableOfContents; _readingTime?: number }> {
227
242
  if (!relativePath || relativePath.includes('..')) return { default: null, toc: [] };
228
243
  const withoutExt = relativePath.replace(/\.(mdx|md)$/, '');
229
244
  const key = relativePath.endsWith('.md')
@@ -232,5 +247,6 @@ export async function loadPageModule(
232
247
  const loader = ssrModules[key];
233
248
  if (!loader) return { default: null, toc: [] };
234
249
  const mod = await loader();
235
- return { default: mod.default ?? null, toc: mod.toc ?? [] };
250
+ const minutes = mod.readingTime?.minutes;
251
+ return { default: mod.default ?? null, toc: mod.toc ?? [], _readingTime: minutes != null ? Math.max(1, Math.round(minutes)) : undefined };
236
252
  }