@farming-labs/theme 0.1.45 → 0.1.48

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.
@@ -3,6 +3,7 @@ import { withLangInUrl } from "./i18n.mjs";
3
3
  import { DocsPageClient } from "./docs-page-client.mjs";
4
4
  import { DocsAIFeatures } from "./docs-ai-features.mjs";
5
5
  import { DocsCommandSearch } from "./docs-command-search.mjs";
6
+ import { resolvePageReadingTime, resolveReadingTimeOptions } from "./reading-time.mjs";
6
7
  import { SidebarSearchWithAI } from "./sidebar-search-ai.mjs";
7
8
  import { LocaleThemeControl } from "./locale-theme-control.mjs";
8
9
  import fs from "node:fs";
@@ -10,7 +11,7 @@ import path from "node:path";
10
11
  import matter from "gray-matter";
11
12
  import { DocsLayout } from "fumadocs-ui/layouts/docs";
12
13
  import { Suspense } from "react";
13
- import { buildPageOpenGraph, buildPageTwitter, resolveChangelogConfig } from "@farming-labs/docs";
14
+ import { buildPageOpenGraph, buildPageTwitter, resolveChangelogConfig, resolveDocsAgentMdxContent } from "@farming-labs/docs";
14
15
  import { jsx, jsxs } from "react/jsx-runtime";
15
16
 
16
17
  //#region src/docs-layout.tsx
@@ -340,6 +341,30 @@ function buildDescriptionMap(config, ctx) {
340
341
  scan(docsDir, []);
341
342
  return map;
342
343
  }
344
+ function buildReadingTimeMap(config, ctx, options) {
345
+ const docsDir = ctx.docsDir;
346
+ const map = {};
347
+ const excludedDirs = getExcludedDocsDirs(config, ctx);
348
+ function scan(dir, slugParts) {
349
+ if (!fs.existsSync(dir)) return;
350
+ if (isExcludedDir(dir, excludedDirs)) return;
351
+ const pagePath = path.join(dir, "page.mdx");
352
+ if (fs.existsSync(pagePath)) {
353
+ const { data, content } = matter(fs.readFileSync(pagePath, "utf-8"));
354
+ const minutes = resolvePageReadingTime(data, resolveDocsAgentMdxContent(content, "human"), options);
355
+ if (typeof minutes === "number") {
356
+ const url = slugParts.length === 0 ? `/${ctx.entryPath}` : `/${ctx.entryPath}/${slugParts.join("/")}`;
357
+ map[url] = minutes;
358
+ }
359
+ }
360
+ for (const name of fs.readdirSync(dir)) {
361
+ const full = path.join(dir, name);
362
+ if (fs.statSync(full).isDirectory()) scan(full, [...slugParts, name]);
363
+ }
364
+ }
365
+ scan(docsDir, []);
366
+ return map;
367
+ }
343
368
  /**
344
369
  * Build a Next.js Metadata object from the docs config.
345
370
  *
@@ -556,6 +581,9 @@ function createDocsLayout(config, options) {
556
581
  const lastUpdatedRaw = config.lastUpdated;
557
582
  const lastUpdatedEnabled = lastUpdatedRaw !== false && (typeof lastUpdatedRaw !== "object" || lastUpdatedRaw.enabled !== false);
558
583
  const lastUpdatedPosition = typeof lastUpdatedRaw === "object" ? lastUpdatedRaw.position ?? "footer" : "footer";
584
+ const readingTimeOptions = resolveReadingTimeOptions(config.readingTime);
585
+ const readingTimeEnabledByDefault = readingTimeOptions.enabled;
586
+ const readingTimeWordsPerMinute = readingTimeOptions.wordsPerMinute ?? 220;
559
587
  const llmsTxtEnabled = resolveBool(config.llmsTxt);
560
588
  const feedbackConfig = resolveFeedbackConfig(config.feedback);
561
589
  const openDocsProviders = (typeof pageActions?.openDocs === "object" && pageActions.openDocs.providers ? pageActions.openDocs.providers : void 0)?.map((p) => ({
@@ -588,6 +616,11 @@ function createDocsLayout(config, options) {
588
616
  }
589
617
  const lastModifiedMap = buildLastModifiedMap(config, localeContext);
590
618
  const descriptionMap = buildDescriptionMap(config, localeContext);
619
+ const readingTimeMap = buildReadingTimeMap(config, localeContext, {
620
+ enabledByDefault: readingTimeEnabledByDefault,
621
+ wordsPerMinute: readingTimeWordsPerMinute
622
+ });
623
+ const readingTimeEnabled = readingTimeEnabledByDefault || Object.keys(readingTimeMap).length > 0;
591
624
  return function DocsLayoutWrapper({ children }) {
592
625
  const tree = buildTree(config, localeContext, !!sidebarFlat);
593
626
  const localizedTree = i18n ? localizeTreeUrls(tree, activeLocale) : tree;
@@ -680,6 +713,8 @@ function createDocsLayout(config, options) {
680
713
  lastModifiedMap,
681
714
  lastUpdatedEnabled,
682
715
  lastUpdatedPosition,
716
+ readingTimeEnabled,
717
+ readingTimeMap,
683
718
  llmsTxtEnabled,
684
719
  descriptionMap,
685
720
  feedbackEnabled: feedbackConfig.enabled,
@@ -39,6 +39,15 @@ interface DocsPageClientProps {
39
39
  lastModifiedMap?: Record<string, string>;
40
40
  /** Direct last-modified value override for the current page. */
41
41
  lastModified?: string;
42
+ /** Map of pathname → reading time in minutes */
43
+ readingTimeMap?: Record<string, number>;
44
+ /** Direct reading-time override for the current page. */
45
+ readingTime?: number | null;
46
+ /**
47
+ * Whether path-based reading time values should render by default.
48
+ * Explicit `readingTime` overrides can still render when this is false.
49
+ */
50
+ readingTimeEnabled?: boolean;
42
51
  /** Whether to show "Last updated" at all */
43
52
  lastUpdatedEnabled?: boolean;
44
53
  /** Where to show the "Last updated" date: "footer" (next to Edit on GitHub) or "below-title" */
@@ -78,6 +87,9 @@ declare function DocsPageClient({
78
87
  editOnGithubUrl,
79
88
  lastModifiedMap,
80
89
  lastModified: lastModifiedProp,
90
+ readingTimeMap,
91
+ readingTime: readingTimeProp,
92
+ readingTimeEnabled,
81
93
  lastUpdatedEnabled,
82
94
  lastUpdatedPosition,
83
95
  llmsTxtEnabled,
@@ -97,6 +97,9 @@ function decodeHashTarget(hash) {
97
97
  return value;
98
98
  }
99
99
  }
100
+ function formatReadingTimeLabel(minutes) {
101
+ return `${Math.max(1, Math.ceil(minutes))} min read`;
102
+ }
100
103
  function escapeIdSelector(value) {
101
104
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value);
102
105
  return value.replace(/["\\.#:[\]>+~(){}^$|*?=!'`\s]/g, "\\$&");
@@ -116,7 +119,7 @@ function injectTitleDecorations(node, { description, belowTitle }) {
116
119
  inserted: false
117
120
  };
118
121
  let inserted = false;
119
- const extras = [description, belowTitle].filter(Boolean);
122
+ const extras = Children.toArray([description, belowTitle].filter(Boolean));
120
123
  if (extras.length === 0) return {
121
124
  node,
122
125
  inserted: false
@@ -131,7 +134,7 @@ function injectTitleDecorations(node, { description, belowTitle }) {
131
134
  if (!isValidElement(current)) return current;
132
135
  if (typeof current.type === "string" && current.type === "h1") {
133
136
  inserted = true;
134
- return [current, ...extras];
137
+ return Children.toArray([current, ...extras]);
135
138
  }
136
139
  const childProps = current.props ?? null;
137
140
  if (childProps?.children === void 0) return current;
@@ -156,9 +159,9 @@ function injectTitleDecorations(node, { description, belowTitle }) {
156
159
  }
157
160
  function TitleDecorations({ description, belowTitle }) {
158
161
  if (!description && !belowTitle) return null;
159
- return /* @__PURE__ */ jsxs(Fragment, { children: [description, belowTitle] });
162
+ return /* @__PURE__ */ jsx(Fragment, { children: Children.toArray([description, belowTitle].filter(Boolean)) });
160
163
  }
161
- function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled = true, changelogBasePath, entry = "docs", locale, copyMarkdown = false, openDocs = false, openDocsProviders, pageActionsPosition = "below-title", pageActionsAlignment = "left", githubUrl, contentDir, githubBranch = "main", githubDirectory, editOnGithubUrl, lastModifiedMap, lastModified: lastModifiedProp, lastUpdatedEnabled = true, lastUpdatedPosition = "footer", llmsTxtEnabled = false, descriptionMap, description, feedbackEnabled = false, feedbackQuestion, feedbackPlaceholder, feedbackPositiveLabel, feedbackNegativeLabel, feedbackSubmitLabel, feedbackOnFeedback, children }) {
164
+ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled = true, changelogBasePath, entry = "docs", locale, copyMarkdown = false, openDocs = false, openDocsProviders, pageActionsPosition = "below-title", pageActionsAlignment = "left", githubUrl, contentDir, githubBranch = "main", githubDirectory, editOnGithubUrl, lastModifiedMap, lastModified: lastModifiedProp, readingTimeMap, readingTime: readingTimeProp, readingTimeEnabled = false, lastUpdatedEnabled = true, lastUpdatedPosition = "footer", llmsTxtEnabled = false, descriptionMap, description, feedbackEnabled = false, feedbackQuestion, feedbackPlaceholder, feedbackPositiveLabel, feedbackNegativeLabel, feedbackSubmitLabel, feedbackOnFeedback, children }) {
162
165
  const fdTocStyle = tocStyle === "directional" ? "clerk" : void 0;
163
166
  const [toc, setToc] = useState([]);
164
167
  const [titlePortalHost, setTitlePortalHost] = useState(null);
@@ -169,6 +172,8 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
169
172
  const pageDescription = description ?? descriptionMap?.[pathname.replace(/\/$/, "") || "/"];
170
173
  const normalizedPath = (browserPath ?? pathname).replace(/\/$/, "") || "/";
171
174
  const isChangelogRoute = !!(changelogBasePath && (normalizedPath === changelogBasePath || normalizedPath.startsWith(`${changelogBasePath}/`)));
175
+ const matchedReadingTime = readingTimeMap?.[normalizedPath];
176
+ const resolvedReadingTime = !isChangelogRoute ? readingTimeProp !== void 0 ? readingTimeProp : readingTimeEnabled ? matchedReadingTime : void 0 : void 0;
172
177
  const effectiveTocEnabled = isChangelogRoute ? false : tocEnabled;
173
178
  const effectiveBreadcrumbEnabled = isChangelogRoute ? false : breadcrumbEnabled;
174
179
  useEffect(() => {
@@ -246,11 +251,24 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
246
251
  const showLastUpdatedBelowTitle = !!lastModified && lastUpdatedPosition === "below-title";
247
252
  const showLastUpdatedInFooter = !!lastModified && lastUpdatedPosition === "footer";
248
253
  const showFooter = !isChangelogRoute && (!!githubFileUrl || showLastUpdatedInFooter || llmsTxtEnabled);
254
+ const readingTimeBlock = typeof resolvedReadingTime === "number" ? /* @__PURE__ */ jsxs("div", {
255
+ className: "fd-page-meta not-prose",
256
+ children: [/* @__PURE__ */ jsx("span", {
257
+ className: "fd-page-meta-dot",
258
+ "aria-hidden": "true",
259
+ children: "·"
260
+ }), /* @__PURE__ */ jsx("span", {
261
+ className: "fd-page-meta-item",
262
+ children: formatReadingTimeLabel(resolvedReadingTime)
263
+ })]
264
+ }) : void 0;
249
265
  const titleDescription = pageDescription ? /* @__PURE__ */ jsx("p", {
250
266
  className: "fd-page-description",
251
267
  children: pageDescription
252
268
  }) : void 0;
253
- const belowTitleBlock = showLastUpdatedBelowTitle || showActionsBelowTitle ? /* @__PURE__ */ jsxs("div", {
269
+ const showReadingTimeAboveTitle = !!readingTimeBlock && showActionsAboveTitle;
270
+ const showReadingTimeBelowTitle = !!readingTimeBlock && !showReadingTimeAboveTitle && (showActionsBelowTitle || showLastUpdatedBelowTitle || !showActions && pageActionsPosition === "below-title");
271
+ const belowTitleBlock = showLastUpdatedBelowTitle || showActionsBelowTitle || showReadingTimeBelowTitle ? /* @__PURE__ */ jsxs("div", {
254
272
  className: "fd-below-title-block not-prose",
255
273
  children: [
256
274
  showLastUpdatedBelowTitle && /* @__PURE__ */ jsxs("p", {
@@ -268,7 +286,8 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
268
286
  alignment: pageActionsAlignment,
269
287
  githubFileUrl
270
288
  })
271
- })
289
+ }),
290
+ showReadingTimeBelowTitle && readingTimeBlock
272
291
  ]
273
292
  }) : void 0;
274
293
  const { node: decoratedChildren, inserted: titleDecorationsInserted } = injectTitleDecorations(children, {
@@ -318,9 +337,9 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
318
337
  entry,
319
338
  locale: activeLocale
320
339
  }),
321
- showActionsAboveTitle && /* @__PURE__ */ jsx("div", {
340
+ showActionsAboveTitle && /* @__PURE__ */ jsxs("div", {
322
341
  className: "fd-below-title-block not-prose",
323
- children: /* @__PURE__ */ jsx("div", {
342
+ children: [/* @__PURE__ */ jsx("div", {
324
343
  className: "fd-actions-portal",
325
344
  "data-actions-alignment": pageActionsAlignment,
326
345
  children: /* @__PURE__ */ jsx(PageActions, {
@@ -330,8 +349,9 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
330
349
  alignment: pageActionsAlignment,
331
350
  githubFileUrl
332
351
  })
333
- })
352
+ }), readingTimeBlock]
334
353
  }),
354
+ !showReadingTimeAboveTitle && !showReadingTimeBelowTitle ? readingTimeBlock : null,
335
355
  /* @__PURE__ */ jsxs(DocsBody, {
336
356
  style: {
337
357
  display: "flex",
@@ -24,8 +24,8 @@ const PixelBorderUIDefaults = {
24
24
  },
25
25
  typography: { font: {
26
26
  style: {
27
- sans: "var(--font-geist-sans, system-ui, -apple-system, sans-serif)",
28
- mono: "var(--font-geist-mono, ui-monospace, monospace)"
27
+ sans: "var(--font-sans, system-ui, -apple-system, sans-serif)",
28
+ mono: "var(--font-mono, ui-monospace, monospace)"
29
29
  },
30
30
  h1: {
31
31
  size: "2.25rem",
@@ -0,0 +1,39 @@
1
+ import matter from "gray-matter";
2
+
3
+ //#region src/reading-time.ts
4
+ function hasExplicitReadingTime(frontmatter) {
5
+ return Object.prototype.hasOwnProperty.call(frontmatter ?? {}, "readingTime");
6
+ }
7
+ function normalizeWordsPerMinute(wordsPerMinute) {
8
+ if (typeof wordsPerMinute !== "number" || !Number.isFinite(wordsPerMinute)) return 220;
9
+ return Math.max(1, Math.floor(wordsPerMinute));
10
+ }
11
+ function stripNonReadingContent(content) {
12
+ return content.replace(/(`{3,})[^\n]*\n[\s\S]*?\1/g, " ").replace(/(~{3,})[^\n]*\n[\s\S]*?\1/g, " ").replace(/`[^`\n]+`/g, " ").replace(/!\[[^\]]*\]\([^)]+\)/g, " ").replace(/\[([^\]]+)\]\([^)]+\)/g, " $1 ").replace(/<[^>]+>/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/https?:\/\/\S+/g, " ").replace(/[#>*_~|]/g, " ");
13
+ }
14
+ function estimateReadingTimeMinutes(content, wordsPerMinute) {
15
+ const wordCount = stripNonReadingContent(content).match(/\b[\p{L}\p{N}][\p{L}\p{N}'’-]*\b/gu)?.length ?? 0;
16
+ return Math.max(1, Math.ceil(wordCount / normalizeWordsPerMinute(wordsPerMinute)));
17
+ }
18
+ function resolveReadingTimeOptions(readingTime) {
19
+ if (readingTime === true) return { enabled: true };
20
+ if (readingTime === false || readingTime === void 0 || readingTime === null) return { enabled: false };
21
+ if (typeof readingTime !== "object") return { enabled: false };
22
+ return {
23
+ enabled: readingTime.enabled !== false,
24
+ wordsPerMinute: typeof readingTime.wordsPerMinute === "number" && Number.isFinite(readingTime.wordsPerMinute) ? readingTime.wordsPerMinute : void 0
25
+ };
26
+ }
27
+ function resolveReadingTimeFromContent(frontmatter, content, wordsPerMinute) {
28
+ const pageData = frontmatter ?? {};
29
+ if (pageData.readingTime === false) return null;
30
+ if (typeof pageData.readingTime === "number" && Number.isFinite(pageData.readingTime)) return Math.max(1, Math.ceil(pageData.readingTime));
31
+ return estimateReadingTimeMinutes(content, wordsPerMinute);
32
+ }
33
+ function resolvePageReadingTime(frontmatter, content, options) {
34
+ if (!(options?.enabledByDefault ?? false) && !hasExplicitReadingTime(frontmatter)) return;
35
+ return resolveReadingTimeFromContent(frontmatter, content, options?.wordsPerMinute);
36
+ }
37
+
38
+ //#endregion
39
+ export { resolvePageReadingTime, resolveReadingTimeOptions };
@@ -28,6 +28,7 @@ interface TanstackDocsLayoutProps {
28
28
  tree: TreeRoot;
29
29
  locale?: string;
30
30
  description?: string;
31
+ readingTime?: number | null;
31
32
  lastModified?: string;
32
33
  editOnGithubUrl?: string;
33
34
  children: ReactNode;
@@ -37,6 +38,7 @@ declare function TanstackDocsLayout({
37
38
  tree,
38
39
  locale,
39
40
  description,
41
+ readingTime,
40
42
  lastModified,
41
43
  editOnGithubUrl,
42
44
  children
@@ -2,6 +2,7 @@ import { withLangInUrl } from "./i18n.mjs";
2
2
  import { DocsPageClient } from "./docs-page-client.mjs";
3
3
  import { DocsAIFeatures } from "./docs-ai-features.mjs";
4
4
  import { DocsCommandSearch } from "./docs-command-search.mjs";
5
+ import { resolveReadingTimeOptions } from "./reading-time.mjs";
5
6
  import { SidebarSearchWithAI } from "./sidebar-search-ai.mjs";
6
7
  import { LocaleThemeControl } from "./locale-theme-control.mjs";
7
8
  import { DocsLayout } from "fumadocs-ui/layouts/docs";
@@ -205,7 +206,7 @@ function resolveFeedbackConfig(feedback) {
205
206
  function ForcedThemeScript({ theme }) {
206
207
  return /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `document.documentElement.classList.remove('light','dark');document.documentElement.classList.add('${theme === "light" || theme === "dark" ? theme : "light"}');` } });
207
208
  }
208
- function TanstackDocsLayout({ config, tree, locale, description, lastModified, editOnGithubUrl, children }) {
209
+ function TanstackDocsLayout({ config, tree, locale, description, readingTime, lastModified, editOnGithubUrl, children }) {
209
210
  const tocConfig = config.theme?.ui?.layout?.toc;
210
211
  const tocEnabled = tocConfig?.enabled !== false;
211
212
  const tocStyle = tocConfig?.style;
@@ -232,6 +233,7 @@ function TanstackDocsLayout({ config, tree, locale, description, lastModified, e
232
233
  const lastUpdatedRaw = config.lastUpdated;
233
234
  const lastUpdatedEnabled = lastUpdatedRaw !== false && (typeof lastUpdatedRaw !== "object" || lastUpdatedRaw.enabled !== false);
234
235
  const lastUpdatedPosition = typeof lastUpdatedRaw === "object" ? lastUpdatedRaw.position ?? "footer" : "footer";
236
+ const readingTimeEnabled = resolveReadingTimeOptions(config.readingTime).enabled;
235
237
  const llmsTxtEnabled = resolveBool(config.llmsTxt);
236
238
  const feedbackConfig = resolveFeedbackConfig(config.feedback);
237
239
  const staticExport = !!config.staticExport;
@@ -339,6 +341,8 @@ function TanstackDocsLayout({ config, tree, locale, description, lastModified, e
339
341
  lastUpdatedEnabled,
340
342
  lastUpdatedPosition,
341
343
  lastModified,
344
+ readingTimeEnabled,
345
+ readingTime: typeof readingTime === "number" ? readingTime : void 0,
342
346
  llmsTxtEnabled,
343
347
  description,
344
348
  feedbackEnabled: feedbackConfig.enabled,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/theme",
3
- "version": "0.1.45",
3
+ "version": "0.1.48",
4
4
  "description": "Theme package for @farming-labs/docs — layout, provider, MDX components, and styles",
5
5
  "keywords": [
6
6
  "docs",
@@ -133,7 +133,7 @@
133
133
  "tsdown": "^0.20.3",
134
134
  "typescript": "^5.9.3",
135
135
  "vitest": "^3.2.4",
136
- "@farming-labs/docs": "0.1.45"
136
+ "@farming-labs/docs": "0.1.48"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@farming-labs/docs": ">=0.0.1",
package/styles/base.css CHANGED
@@ -171,6 +171,49 @@ figure.shiki:has(figcaption) figcaption {
171
171
 
172
172
  /* ─── Page description (frontmatter) ─────────────────────────────────── */
173
173
 
174
+ .fd-page-meta {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ align-items: center;
178
+ gap: 0.375rem;
179
+ margin-bottom: 0.75rem;
180
+ }
181
+
182
+ .fd-page-meta-dot {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ font-family: var(
187
+ --fd-font-mono,
188
+ ui-monospace,
189
+ SFMono-Regular,
190
+ "SF Mono",
191
+ Menlo,
192
+ Consolas,
193
+ monospace
194
+ );
195
+ font-size: 0.8rem;
196
+ line-height: 1;
197
+ color: color-mix(in srgb, var(--color-fd-muted-foreground) 78%, transparent);
198
+ }
199
+
200
+ .fd-page-meta-item {
201
+ font-family: var(
202
+ --fd-font-mono,
203
+ ui-monospace,
204
+ SFMono-Regular,
205
+ "SF Mono",
206
+ Menlo,
207
+ Consolas,
208
+ monospace
209
+ );
210
+ font-size: 0.6875rem;
211
+ font-weight: 500;
212
+ letter-spacing: 0;
213
+ text-transform: uppercase;
214
+ color: var(--color-fd-muted-foreground);
215
+ }
216
+
174
217
  .fd-page-description {
175
218
  margin-bottom: 1rem;
176
219
  font-size: 1.125rem;