@farming-labs/theme 0.1.45 → 0.1.47

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 { resolveReadingTimeFromContent, 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, wordsPerMinute) {
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 = resolveReadingTimeFromContent(data, resolveDocsAgentMdxContent(content, "human"), wordsPerMinute);
355
+ if (minutes !== null) {
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 readingTimeEnabled = 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,7 @@ function createDocsLayout(config, options) {
588
616
  }
589
617
  const lastModifiedMap = buildLastModifiedMap(config, localeContext);
590
618
  const descriptionMap = buildDescriptionMap(config, localeContext);
619
+ const readingTimeMap = readingTimeEnabled ? buildReadingTimeMap(config, localeContext, readingTimeWordsPerMinute) : {};
591
620
  return function DocsLayoutWrapper({ children }) {
592
621
  const tree = buildTree(config, localeContext, !!sidebarFlat);
593
622
  const localizedTree = i18n ? localizeTreeUrls(tree, activeLocale) : tree;
@@ -680,6 +709,8 @@ function createDocsLayout(config, options) {
680
709
  lastModifiedMap,
681
710
  lastUpdatedEnabled,
682
711
  lastUpdatedPosition,
712
+ readingTimeEnabled,
713
+ readingTimeMap,
683
714
  llmsTxtEnabled,
684
715
  descriptionMap,
685
716
  feedbackEnabled: feedbackConfig.enabled,
@@ -39,6 +39,12 @@ 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;
46
+ /** Whether to show estimated reading time at the top of the page. */
47
+ readingTimeEnabled?: boolean;
42
48
  /** Whether to show "Last updated" at all */
43
49
  lastUpdatedEnabled?: boolean;
44
50
  /** Where to show the "Last updated" date: "footer" (next to Edit on GitHub) or "below-title" */
@@ -78,6 +84,9 @@ declare function DocsPageClient({
78
84
  editOnGithubUrl,
79
85
  lastModifiedMap,
80
86
  lastModified: lastModifiedProp,
87
+ readingTimeMap,
88
+ readingTime: readingTimeProp,
89
+ readingTimeEnabled,
81
90
  lastUpdatedEnabled,
82
91
  lastUpdatedPosition,
83
92
  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,7 @@ 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 resolvedReadingTime = !isChangelogRoute && readingTimeEnabled ? readingTimeProp ?? readingTimeMap?.[normalizedPath] : void 0;
172
176
  const effectiveTocEnabled = isChangelogRoute ? false : tocEnabled;
173
177
  const effectiveBreadcrumbEnabled = isChangelogRoute ? false : breadcrumbEnabled;
174
178
  useEffect(() => {
@@ -246,11 +250,24 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
246
250
  const showLastUpdatedBelowTitle = !!lastModified && lastUpdatedPosition === "below-title";
247
251
  const showLastUpdatedInFooter = !!lastModified && lastUpdatedPosition === "footer";
248
252
  const showFooter = !isChangelogRoute && (!!githubFileUrl || showLastUpdatedInFooter || llmsTxtEnabled);
253
+ const readingTimeBlock = typeof resolvedReadingTime === "number" ? /* @__PURE__ */ jsxs("div", {
254
+ className: "fd-page-meta not-prose",
255
+ children: [/* @__PURE__ */ jsx("span", {
256
+ className: "fd-page-meta-dot",
257
+ "aria-hidden": "true",
258
+ children: "·"
259
+ }), /* @__PURE__ */ jsx("span", {
260
+ className: "fd-page-meta-item",
261
+ children: formatReadingTimeLabel(resolvedReadingTime)
262
+ })]
263
+ }) : void 0;
249
264
  const titleDescription = pageDescription ? /* @__PURE__ */ jsx("p", {
250
265
  className: "fd-page-description",
251
266
  children: pageDescription
252
267
  }) : void 0;
253
- const belowTitleBlock = showLastUpdatedBelowTitle || showActionsBelowTitle ? /* @__PURE__ */ jsxs("div", {
268
+ const showReadingTimeAboveTitle = !!readingTimeBlock && showActionsAboveTitle;
269
+ const showReadingTimeBelowTitle = !!readingTimeBlock && !showReadingTimeAboveTitle && (showActionsBelowTitle || showLastUpdatedBelowTitle || !showActions && pageActionsPosition === "below-title");
270
+ const belowTitleBlock = showLastUpdatedBelowTitle || showActionsBelowTitle || showReadingTimeBelowTitle ? /* @__PURE__ */ jsxs("div", {
254
271
  className: "fd-below-title-block not-prose",
255
272
  children: [
256
273
  showLastUpdatedBelowTitle && /* @__PURE__ */ jsxs("p", {
@@ -268,7 +285,8 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
268
285
  alignment: pageActionsAlignment,
269
286
  githubFileUrl
270
287
  })
271
- })
288
+ }),
289
+ showReadingTimeBelowTitle && readingTimeBlock
272
290
  ]
273
291
  }) : void 0;
274
292
  const { node: decoratedChildren, inserted: titleDecorationsInserted } = injectTitleDecorations(children, {
@@ -318,9 +336,9 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
318
336
  entry,
319
337
  locale: activeLocale
320
338
  }),
321
- showActionsAboveTitle && /* @__PURE__ */ jsx("div", {
339
+ showActionsAboveTitle && /* @__PURE__ */ jsxs("div", {
322
340
  className: "fd-below-title-block not-prose",
323
- children: /* @__PURE__ */ jsx("div", {
341
+ children: [/* @__PURE__ */ jsx("div", {
324
342
  className: "fd-actions-portal",
325
343
  "data-actions-alignment": pageActionsAlignment,
326
344
  children: /* @__PURE__ */ jsx(PageActions, {
@@ -330,8 +348,9 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
330
348
  alignment: pageActionsAlignment,
331
349
  githubFileUrl
332
350
  })
333
- })
351
+ }), readingTimeBlock]
334
352
  }),
353
+ !showReadingTimeAboveTitle && !showReadingTimeBelowTitle ? readingTimeBlock : null,
335
354
  /* @__PURE__ */ jsxs(DocsBody, {
336
355
  style: {
337
356
  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,32 @@
1
+ import matter from "gray-matter";
2
+
3
+ //#region src/reading-time.ts
4
+ function normalizeWordsPerMinute(wordsPerMinute) {
5
+ if (typeof wordsPerMinute !== "number" || !Number.isFinite(wordsPerMinute)) return 220;
6
+ return Math.max(1, Math.floor(wordsPerMinute));
7
+ }
8
+ function stripNonReadingContent(content) {
9
+ 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, " ");
10
+ }
11
+ function estimateReadingTimeMinutes(content, wordsPerMinute) {
12
+ const wordCount = stripNonReadingContent(content).match(/\b[\p{L}\p{N}][\p{L}\p{N}'’-]*\b/gu)?.length ?? 0;
13
+ return Math.max(1, Math.ceil(wordCount / normalizeWordsPerMinute(wordsPerMinute)));
14
+ }
15
+ function resolveReadingTimeOptions(readingTime) {
16
+ if (readingTime === true) return { enabled: true };
17
+ if (readingTime === false || readingTime === void 0 || readingTime === null) return { enabled: false };
18
+ if (typeof readingTime !== "object") return { enabled: false };
19
+ return {
20
+ enabled: readingTime.enabled !== false,
21
+ wordsPerMinute: typeof readingTime.wordsPerMinute === "number" && Number.isFinite(readingTime.wordsPerMinute) ? readingTime.wordsPerMinute : void 0
22
+ };
23
+ }
24
+ function resolveReadingTimeFromContent(frontmatter, content, wordsPerMinute) {
25
+ const pageData = frontmatter ?? {};
26
+ if (pageData.readingTime === false) return null;
27
+ if (typeof pageData.readingTime === "number" && Number.isFinite(pageData.readingTime)) return Math.max(1, Math.ceil(pageData.readingTime));
28
+ return estimateReadingTimeMinutes(content, wordsPerMinute);
29
+ }
30
+
31
+ //#endregion
32
+ export { resolveReadingTimeFromContent, 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.47",
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.47"
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;