blodemd 0.0.13 → 0.0.15

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 (136) hide show
  1. package/README.md +1 -1
  2. package/dev-server/lib/local-runtime.tsx +12 -15
  3. package/dev-server/package.json +5 -1
  4. package/dist/cli.mjs +1211 -983
  5. package/dist/cli.mjs.map +1 -1
  6. package/docs/app/globals.css +1 -1
  7. package/docs/components/api/api-playground.tsx +2 -5
  8. package/docs/components/api/api-reference.tsx +29 -3
  9. package/docs/components/docs/contextual-menu.tsx +37 -33
  10. package/docs/components/docs/copy-page-menu.tsx +18 -0
  11. package/docs/components/docs/doc-header.tsx +4 -1
  12. package/docs/components/docs/doc-shell.tsx +48 -18
  13. package/docs/components/docs/doc-sidebar.tsx +131 -89
  14. package/docs/components/docs/doc-toc.tsx +1 -0
  15. package/docs/components/docs/mobile-nav.tsx +72 -68
  16. package/docs/components/docs/sidebar-active-highlight.tsx +34 -0
  17. package/docs/components/docs/sidebar-scroll-area.tsx +9 -4
  18. package/docs/components/git/repo-picker.tsx +5 -2
  19. package/docs/components/mdx/index.tsx +31 -21
  20. package/docs/components/posthog-provider.tsx +42 -0
  21. package/docs/components/tenant-analytics.tsx +49 -0
  22. package/docs/components/third-parties.tsx +4 -1
  23. package/docs/components/ui/copy-button.tsx +16 -2
  24. package/docs/components/ui/search-dialog.tsx +431 -0
  25. package/docs/components/ui/search.tsx +44 -427
  26. package/docs/components/web-mcp.tsx +115 -0
  27. package/docs/lib/agent-skills.ts +48 -0
  28. package/docs/lib/config.ts +4 -5
  29. package/docs/lib/docs-collection.ts +7 -22
  30. package/docs/lib/docs-runtime.tsx +160 -10
  31. package/docs/lib/llms-full.ts +82 -0
  32. package/docs/lib/marketing-markdown.ts +79 -0
  33. package/docs/lib/marketing-site.ts +31 -0
  34. package/docs/lib/mdx.ts +17 -6
  35. package/docs/lib/routes.ts +19 -3
  36. package/docs/lib/shiki.ts +4 -0
  37. package/docs/lib/tenancy.ts +15 -2
  38. package/docs/lib/tenant-headers.ts +29 -0
  39. package/docs/lib/tenant-static.ts +73 -99
  40. package/package.json +8 -5
  41. package/packages/@repo/common/package.json +1 -1
  42. package/packages/@repo/contracts/dist/analytics.d.ts +21 -0
  43. package/packages/@repo/contracts/dist/analytics.d.ts.map +1 -0
  44. package/packages/@repo/contracts/dist/analytics.js +18 -0
  45. package/packages/@repo/contracts/dist/deployment.js +1 -1
  46. package/packages/@repo/contracts/dist/git.js +2 -2
  47. package/packages/@repo/contracts/dist/ids.d.ts.map +1 -1
  48. package/packages/@repo/contracts/dist/ids.js +2 -1
  49. package/packages/@repo/contracts/dist/index.d.ts +1 -0
  50. package/packages/@repo/contracts/dist/index.d.ts.map +1 -1
  51. package/packages/@repo/contracts/dist/index.js +1 -0
  52. package/packages/@repo/contracts/dist/project.d.ts +18 -1
  53. package/packages/@repo/contracts/dist/project.d.ts.map +1 -1
  54. package/packages/@repo/contracts/dist/project.js +3 -1
  55. package/packages/@repo/contracts/dist/tenant.d.ts +36 -0
  56. package/packages/@repo/contracts/dist/tenant.d.ts.map +1 -1
  57. package/packages/@repo/contracts/dist/tenant.js +2 -0
  58. package/packages/@repo/contracts/package.json +1 -1
  59. package/packages/@repo/contracts/src/analytics.ts +33 -0
  60. package/packages/@repo/contracts/src/deployment.ts +1 -1
  61. package/packages/@repo/contracts/src/git.ts +2 -2
  62. package/packages/@repo/contracts/src/ids.ts +2 -1
  63. package/packages/@repo/contracts/src/index.ts +1 -0
  64. package/packages/@repo/contracts/src/project.ts +3 -1
  65. package/packages/@repo/contracts/src/tenant.ts +2 -0
  66. package/packages/@repo/models/dist/docs-config.d.ts +0 -198
  67. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
  68. package/packages/@repo/models/dist/docs-config.js +3 -2
  69. package/packages/@repo/models/dist/tenant.d.ts +12 -0
  70. package/packages/@repo/models/dist/tenant.d.ts.map +1 -1
  71. package/packages/@repo/models/package.json +1 -1
  72. package/packages/@repo/models/src/docs-config.ts +3 -3
  73. package/packages/@repo/models/src/tenant.ts +15 -0
  74. package/packages/@repo/prebuild/dist/openapi.d.ts.map +1 -1
  75. package/packages/@repo/prebuild/dist/openapi.js +32 -4
  76. package/packages/@repo/prebuild/package.json +1 -1
  77. package/packages/@repo/prebuild/src/openapi.ts +42 -4
  78. package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -1
  79. package/packages/@repo/previewing/dist/blob-source.js +18 -5
  80. package/packages/@repo/previewing/dist/config-loader.d.ts +134 -0
  81. package/packages/@repo/previewing/dist/config-loader.d.ts.map +1 -0
  82. package/packages/@repo/previewing/dist/config-loader.js +231 -0
  83. package/packages/@repo/previewing/dist/constants.d.ts +15 -0
  84. package/packages/@repo/previewing/dist/constants.d.ts.map +1 -0
  85. package/packages/@repo/previewing/dist/constants.js +14 -0
  86. package/packages/@repo/previewing/dist/content-index.d.ts +7 -0
  87. package/packages/@repo/previewing/dist/content-index.d.ts.map +1 -0
  88. package/packages/@repo/previewing/dist/content-index.js +291 -0
  89. package/packages/@repo/previewing/dist/index.d.ts +13 -121
  90. package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
  91. package/packages/@repo/previewing/dist/index.js +11 -1003
  92. package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts +3 -0
  93. package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts.map +1 -0
  94. package/packages/@repo/previewing/dist/markdown/agent-markdown.js +176 -0
  95. package/packages/@repo/previewing/dist/markdown/format.d.ts +8 -0
  96. package/packages/@repo/previewing/dist/markdown/format.d.ts.map +1 -0
  97. package/packages/@repo/previewing/dist/markdown/format.js +43 -0
  98. package/packages/@repo/previewing/dist/markdown/links.d.ts +3 -0
  99. package/packages/@repo/previewing/dist/markdown/links.d.ts.map +1 -0
  100. package/packages/@repo/previewing/dist/markdown/links.js +31 -0
  101. package/packages/@repo/previewing/dist/openapi-pages.d.ts +7 -0
  102. package/packages/@repo/previewing/dist/openapi-pages.d.ts.map +1 -0
  103. package/packages/@repo/previewing/dist/openapi-pages.js +200 -0
  104. package/packages/@repo/previewing/dist/search-index.d.ts +4 -0
  105. package/packages/@repo/previewing/dist/search-index.d.ts.map +1 -0
  106. package/packages/@repo/previewing/dist/search-index.js +23 -0
  107. package/packages/@repo/previewing/dist/serialization.d.ts +13 -0
  108. package/packages/@repo/previewing/dist/serialization.d.ts.map +1 -0
  109. package/packages/@repo/previewing/dist/serialization.js +108 -0
  110. package/packages/@repo/previewing/dist/toc-index.d.ts +5 -0
  111. package/packages/@repo/previewing/dist/toc-index.d.ts.map +1 -0
  112. package/packages/@repo/previewing/dist/toc-index.js +53 -0
  113. package/packages/@repo/previewing/dist/types.d.ts +92 -0
  114. package/packages/@repo/previewing/dist/types.d.ts.map +1 -0
  115. package/packages/@repo/previewing/dist/types.js +1 -0
  116. package/packages/@repo/previewing/dist/utility-index.d.ts +7 -0
  117. package/packages/@repo/previewing/dist/utility-index.d.ts.map +1 -0
  118. package/packages/@repo/previewing/dist/utility-index.js +201 -0
  119. package/packages/@repo/previewing/package.json +1 -1
  120. package/packages/@repo/previewing/src/blob-source.ts +20 -5
  121. package/packages/@repo/previewing/src/config-loader.ts +316 -0
  122. package/packages/@repo/previewing/src/constants.ts +16 -0
  123. package/packages/@repo/previewing/src/content-index.ts +384 -0
  124. package/packages/@repo/previewing/src/index.ts +69 -1515
  125. package/packages/@repo/previewing/src/markdown/agent-markdown.ts +254 -0
  126. package/packages/@repo/previewing/src/markdown/format.ts +63 -0
  127. package/packages/@repo/previewing/src/markdown/links.ts +46 -0
  128. package/packages/@repo/previewing/src/openapi-pages.ts +319 -0
  129. package/packages/@repo/previewing/src/search-index.ts +38 -0
  130. package/packages/@repo/previewing/src/serialization.ts +191 -0
  131. package/packages/@repo/previewing/src/toc-index.ts +72 -0
  132. package/packages/@repo/previewing/src/types.ts +105 -0
  133. package/packages/@repo/previewing/src/utility-index.ts +304 -0
  134. package/packages/@repo/validation/package.json +1 -1
  135. package/scripts/repo-packages.mjs +8 -0
  136. package/docs/components/ui/site-footer.tsx +0 -41
@@ -0,0 +1,316 @@
1
+ import path from "node:path";
2
+
3
+ import { normalizePath } from "@repo/common";
4
+ import type { DocsConfig, SiteConfig } from "@repo/models";
5
+ import { validateDocsConfig, validateSiteConfig } from "@repo/validation";
6
+
7
+ import { LEGACY_PROJECT_NAME_FALLBACK_WARNING } from "./constants.js";
8
+ import type { ContentSource } from "./content-source.js";
9
+ import type { SiteConfigResult } from "./types.js";
10
+
11
+ const DOCS_CONFIG_FILE = "docs.json";
12
+
13
+ const defaultLinkLabel = (input: {
14
+ href: string;
15
+ label?: string;
16
+ type?: "discord" | "github";
17
+ }) => {
18
+ if (input.label) {
19
+ return input.label;
20
+ }
21
+ if (input.type === "github") {
22
+ return "GitHub";
23
+ }
24
+ if (input.type === "discord") {
25
+ return "Discord";
26
+ }
27
+ try {
28
+ return new URL(input.href).hostname;
29
+ } catch {
30
+ return input.href;
31
+ }
32
+ };
33
+
34
+ // oxlint-disable-next-line eslint/complexity
35
+ const mapDocsConfig = (docs: DocsConfig): SiteConfig => {
36
+ const navigation = {
37
+ global:
38
+ docs.navbar?.links?.length || docs.navigation.global?.anchors?.length
39
+ ? {
40
+ anchors: docs.navigation.global?.anchors?.map((anchor) => ({
41
+ href: anchor.href,
42
+ label: anchor.anchor,
43
+ })),
44
+ links: docs.navbar?.links?.map((link) => ({
45
+ href: link.href,
46
+ label: defaultLinkLabel(link),
47
+ })),
48
+ }
49
+ : undefined,
50
+ groups: docs.navigation.groups?.map((group) => ({
51
+ expanded: group.expanded,
52
+ group: group.group,
53
+ hidden: group.hidden,
54
+ pages: group.root
55
+ ? [
56
+ group.root,
57
+ ...(group.pages ?? []).filter((page) => page !== group.root),
58
+ ]
59
+ : group.pages,
60
+ })),
61
+ languages: docs.navigation.languages?.map((language) => ({
62
+ label: language.language,
63
+ locale: language.language,
64
+ url: language.href,
65
+ })),
66
+ pages: docs.navigation.pages,
67
+ tabs: docs.navigation.tabs?.map((tab) => ({
68
+ groups: tab.groups?.map((group) => ({
69
+ expanded: group.expanded,
70
+ group: group.group,
71
+ hidden: group.hidden,
72
+ pages: group.root
73
+ ? [
74
+ group.root,
75
+ ...(group.pages ?? []).filter((page) => page !== group.root),
76
+ ]
77
+ : group.pages,
78
+ })),
79
+ href: tab.href,
80
+ icon: tab.icon,
81
+ label: tab.tab,
82
+ pages: tab.pages,
83
+ })),
84
+ versions: docs.navigation.versions?.map((version) => ({
85
+ label: version.version,
86
+ url: version.href,
87
+ })),
88
+ } satisfies SiteConfig["navigation"];
89
+
90
+ return {
91
+ collections: [
92
+ {
93
+ id: "docs",
94
+ navigation,
95
+ openapi: docs.api?.openapi,
96
+ root: "",
97
+ type: "docs",
98
+ },
99
+ ],
100
+ contextual: docs.contextual,
101
+ description: docs.description,
102
+ favicon:
103
+ typeof docs.favicon === "string" ? docs.favicon : docs.favicon?.light,
104
+ features: {
105
+ rightToc: true,
106
+ search: true,
107
+ themeToggle: docs.appearance?.strict !== true,
108
+ toc: true,
109
+ },
110
+ logo: docs.logo
111
+ ? {
112
+ dark: typeof docs.logo === "string" ? docs.logo : docs.logo.dark,
113
+ href: typeof docs.logo === "string" ? undefined : docs.logo.href,
114
+ light: typeof docs.logo === "string" ? docs.logo : docs.logo.light,
115
+ }
116
+ : undefined,
117
+ name: docs.name,
118
+ navigation,
119
+ openapiProxy: {
120
+ enabled:
121
+ docs.api?.playground?.proxy !== false &&
122
+ Boolean(docs.api?.openapi || docs.api?.asyncapi),
123
+ },
124
+ seo: docs.seo,
125
+ slug: docs.slug,
126
+ };
127
+ };
128
+
129
+ const getProjectWarnings = (config: { slug?: string }): string[] =>
130
+ config.slug ? [] : [LEGACY_PROJECT_NAME_FALLBACK_WARNING];
131
+
132
+ const readJsonConfig = async (source: ContentSource, relativePath: string) =>
133
+ JSON.parse(await source.readFile(relativePath)) as unknown;
134
+
135
+ const normalizeRefPath = (baseDirectory: string, reference: string) => {
136
+ if (
137
+ reference.startsWith("/") ||
138
+ reference.startsWith("\\") ||
139
+ reference.startsWith("http://") ||
140
+ reference.startsWith("https://")
141
+ ) {
142
+ throw new Error(
143
+ `Invalid $ref "${reference}". Only relative JSON files are supported.`
144
+ );
145
+ }
146
+
147
+ const normalized = normalizePath(path.posix.join(baseDirectory, reference));
148
+ if (
149
+ !normalized ||
150
+ normalized === "." ||
151
+ normalized.startsWith("../") ||
152
+ normalized.includes("/../")
153
+ ) {
154
+ throw new Error(`Invalid $ref "${reference}".`);
155
+ }
156
+ return normalized;
157
+ };
158
+
159
+ const resolveJsonRefs = async (
160
+ source: ContentSource,
161
+ value: unknown,
162
+ baseDirectory: string,
163
+ seen: Set<string>
164
+ ): Promise<unknown> => {
165
+ if (Array.isArray(value)) {
166
+ return await Promise.all(
167
+ value.map((item) => resolveJsonRefs(source, item, baseDirectory, seen))
168
+ );
169
+ }
170
+
171
+ if (!value || typeof value !== "object") {
172
+ return value;
173
+ }
174
+
175
+ const record = value as Record<string, unknown>;
176
+ const reference = record.$ref;
177
+ if (typeof reference === "string") {
178
+ const resolvedPath = normalizeRefPath(baseDirectory, reference);
179
+ if (seen.has(resolvedPath)) {
180
+ throw new Error(`Circular $ref detected for "${resolvedPath}".`);
181
+ }
182
+
183
+ const nextSeen = new Set(seen);
184
+ // oxlint-disable-next-line eslint-plugin-unicorn/no-immediate-mutation
185
+ nextSeen.add(resolvedPath);
186
+ const referenced = await readJsonConfig(source, resolvedPath);
187
+ const referencedValue = await resolveJsonRefs(
188
+ source,
189
+ referenced,
190
+ path.posix.dirname(resolvedPath) === "."
191
+ ? ""
192
+ : normalizePath(path.posix.dirname(resolvedPath)),
193
+ nextSeen
194
+ );
195
+
196
+ const siblingEntries = Object.entries(record).filter(
197
+ ([key]) => key !== "$ref"
198
+ );
199
+ if (
200
+ !siblingEntries.length ||
201
+ !referencedValue ||
202
+ typeof referencedValue !== "object" ||
203
+ Array.isArray(referencedValue)
204
+ ) {
205
+ return referencedValue;
206
+ }
207
+
208
+ const siblingValue = await resolveJsonRefs(
209
+ source,
210
+ Object.fromEntries(siblingEntries),
211
+ baseDirectory,
212
+ seen
213
+ );
214
+ return {
215
+ ...(referencedValue as Record<string, unknown>),
216
+ ...(siblingValue as Record<string, unknown>),
217
+ };
218
+ }
219
+
220
+ const resolvedEntries = await Promise.all(
221
+ Object.entries(record).map(async ([key, entryValue]) => [
222
+ key,
223
+ await resolveJsonRefs(source, entryValue, baseDirectory, seen),
224
+ ])
225
+ );
226
+ return Object.fromEntries(resolvedEntries);
227
+ };
228
+
229
+ const readResolvedJsonConfig = async (
230
+ source: ContentSource,
231
+ relativePath: string
232
+ ) =>
233
+ await resolveJsonRefs(
234
+ source,
235
+ await readJsonConfig(source, relativePath),
236
+ path.posix.dirname(relativePath) === "."
237
+ ? ""
238
+ : normalizePath(path.posix.dirname(relativePath)),
239
+ new Set([relativePath])
240
+ );
241
+
242
+ const loadDocsConfig = async (
243
+ source: ContentSource
244
+ ): Promise<SiteConfigResult | null> => {
245
+ if (!(await source.exists(DOCS_CONFIG_FILE))) {
246
+ return null;
247
+ }
248
+
249
+ try {
250
+ const parsed = await readResolvedJsonConfig(source, DOCS_CONFIG_FILE);
251
+
252
+ // Try SiteConfig format first (has collections, theme, colors, etc.)
253
+ const siteResult = validateSiteConfig(parsed);
254
+ if (siteResult.success) {
255
+ return {
256
+ config: siteResult.data,
257
+ ok: true,
258
+ warnings: getProjectWarnings(siteResult.data),
259
+ };
260
+ }
261
+
262
+ // Fall back to DocsConfig format (Mintlify-compatible) and map to SiteConfig
263
+ const docsResult = validateDocsConfig(parsed);
264
+ if (docsResult.success) {
265
+ return {
266
+ config: mapDocsConfig(docsResult.data),
267
+ ok: true,
268
+ warnings: getProjectWarnings(docsResult.data),
269
+ };
270
+ }
271
+
272
+ return { errors: docsResult.errors, ok: false };
273
+ } catch (error) {
274
+ return {
275
+ errors: [
276
+ error instanceof Error
277
+ ? error.message
278
+ : `Failed to load ${DOCS_CONFIG_FILE}`,
279
+ ],
280
+ ok: false,
281
+ };
282
+ }
283
+ };
284
+
285
+ export const loadSiteConfig = async (
286
+ source: ContentSource
287
+ ): Promise<SiteConfigResult> => {
288
+ const docsConfig = await loadDocsConfig(source);
289
+ if (docsConfig) {
290
+ return docsConfig;
291
+ }
292
+
293
+ return {
294
+ errors: [`${DOCS_CONFIG_FILE} not found.`],
295
+ ok: false,
296
+ };
297
+ };
298
+
299
+ export const getDocsCollection = (config: SiteConfig) =>
300
+ config.collections.find((collection) => collection.type === "docs");
301
+
302
+ export const getDocsNavigation = (config: SiteConfig) =>
303
+ getDocsCollection(config)?.navigation ?? config.navigation;
304
+
305
+ export const getDocsCollectionWithNavigation = (
306
+ config: SiteConfig
307
+ ): SiteConfig["collections"][number] | undefined => {
308
+ const docsCollection = getDocsCollection(config);
309
+ const docsNavigation = getDocsNavigation(config);
310
+
311
+ return docsCollection &&
312
+ docsNavigation &&
313
+ docsCollection.navigation !== docsNavigation
314
+ ? { ...docsCollection, navigation: docsNavigation }
315
+ : docsCollection;
316
+ };
@@ -0,0 +1,16 @@
1
+ export const PREBUILT_INDEX_PATH = "_content-index.json";
2
+ export const PREBUILT_OPENAPI_INDEX_PATH = "_openapi-index.json";
3
+ export const PREBUILT_SEARCH_INDEX_PATH = "_search-index.json";
4
+ export const PREBUILT_TOC_INDEX_PATH = "_toc-index.json";
5
+ export const PREBUILT_UTILITY_INDEX_PATH = "_utility-index.json";
6
+ export const PREBUILT_UTILITY_SITEMAP_PATH = "_utility/sitemap.xml";
7
+ export const PREBUILT_UTILITY_LLMS_PATH = "_utility/llms.txt";
8
+ export const PREBUILT_UTILITY_LLMS_FULL_PATH = "_utility/llms-full.txt";
9
+ export const PREBUILT_UTILITY_LLMS_SEGMENT_PREFIX = "_utility/llms/";
10
+ export const PREBUILT_UTILITY_SKILLS_INDEX_PATH = "_utility/skills-index.json";
11
+ export const PREBUILT_UTILITY_SKILLS_MD_PREFIX = "_utility/skills/";
12
+ export const UTILITY_DOCS_ROOT_TOKEN = "__BLODEMD_DOCS_ROOT__";
13
+ export const LEGACY_PROJECT_NAME_FALLBACK_WARNING =
14
+ "docs.json.slug is recommended. Falling back to docs.json.name as the deployment slug is deprecated.";
15
+
16
+ export const NEWLINE_REGEX = /\r?\n/;
@@ -0,0 +1,384 @@
1
+ import path from "node:path";
2
+
3
+ import { normalizePath } from "@repo/common";
4
+ import type {
5
+ CollectionConfig,
6
+ ContentType,
7
+ FrontmatterByType,
8
+ PageMode,
9
+ SiteConfig,
10
+ } from "@repo/models";
11
+ import { PageModeSchema } from "@repo/models";
12
+ import { validateFrontmatter } from "@repo/validation";
13
+
14
+ import type { ContentSource } from "./content-source.js";
15
+ import { parseFrontmatter } from "./markdown/format.js";
16
+ import type { ContentEntry, ContentIndex, PageMetadata } from "./types.js";
17
+
18
+ const validModes = new Set<string>(PageModeSchema.options);
19
+
20
+ export const buildPageMetadataMap = (
21
+ index: ContentIndex
22
+ ): Map<string, PageMetadata> => {
23
+ const map = new Map<string, PageMetadata>();
24
+ for (const entry of index.entries) {
25
+ if (entry.kind !== "entry") {
26
+ continue;
27
+ }
28
+ const fm = entry.frontmatter as Record<string, unknown>;
29
+ const meta: PageMetadata = {
30
+ title: entry.title,
31
+ };
32
+ if (typeof fm.sidebarTitle === "string") {
33
+ meta.sidebarTitle = fm.sidebarTitle;
34
+ }
35
+ if (typeof fm.icon === "string") {
36
+ meta.icon = fm.icon;
37
+ }
38
+ if (typeof fm.iconType === "string") {
39
+ meta.iconType = fm.iconType;
40
+ }
41
+ if (typeof fm.tag === "string") {
42
+ meta.tag = fm.tag;
43
+ }
44
+ if (typeof fm.hidden === "boolean") {
45
+ meta.hidden = fm.hidden;
46
+ }
47
+ if (typeof fm.deprecated === "boolean") {
48
+ meta.deprecated = fm.deprecated;
49
+ }
50
+ if (typeof fm.url === "string") {
51
+ meta.url = fm.url;
52
+ }
53
+ if (typeof fm.mode === "string" && validModes.has(fm.mode)) {
54
+ meta.mode = fm.mode as PageMode;
55
+ }
56
+ if (typeof fm.noindex === "boolean") {
57
+ meta.noindex = fm.noindex;
58
+ }
59
+ if (typeof fm.hideFooterPagination === "boolean") {
60
+ meta.hideFooterPagination = fm.hideFooterPagination;
61
+ }
62
+ if (typeof fm.hideApiMarker === "boolean") {
63
+ meta.hideApiMarker = fm.hideApiMarker;
64
+ }
65
+ if (Array.isArray(fm.keywords)) {
66
+ meta.keywords = fm.keywords as string[];
67
+ }
68
+ map.set(entry.slug, meta);
69
+ }
70
+ return map;
71
+ };
72
+
73
+ const DOC_FILE_EXTENSION_REGEX = /\.(mdx|md)$/;
74
+ const INDEX_SUFFIX = "/index";
75
+
76
+ const titleFromSlug = (slug: string) => {
77
+ const clean = slug.replaceAll("-", " ").split("/").pop() ?? slug;
78
+ if (clean === "index") {
79
+ return "Overview";
80
+ }
81
+ return clean.replaceAll(/\b\w/g, (char) => char.toUpperCase());
82
+ };
83
+
84
+ const slugFromFile = (relativePath: string) => {
85
+ const clean = normalizePath(relativePath);
86
+ const withoutExt = clean.replace(DOC_FILE_EXTENSION_REGEX, "");
87
+ if (withoutExt.endsWith(INDEX_SUFFIX)) {
88
+ const trimmed = withoutExt.slice(0, -INDEX_SUFFIX.length);
89
+ return trimmed.length ? trimmed : "index";
90
+ }
91
+ return withoutExt.length ? withoutExt : "index";
92
+ };
93
+
94
+ const listContentFiles = async (source: ContentSource, directory: string) => {
95
+ const files = await source.listFiles(directory);
96
+ return files.filter((file: string) => DOC_FILE_EXTENSION_REGEX.test(file));
97
+ };
98
+
99
+ const sortDefaults: Record<
100
+ ContentType,
101
+ { field: "date" | "order" | "title" | "price"; direction: "asc" | "desc" }
102
+ > = {
103
+ blog: { direction: "desc", field: "date" },
104
+ courses: { direction: "asc", field: "order" },
105
+ docs: { direction: "asc", field: "title" },
106
+ forms: { direction: "asc", field: "title" },
107
+ notes: { direction: "desc", field: "date" },
108
+ products: { direction: "asc", field: "title" },
109
+ sheets: { direction: "asc", field: "title" },
110
+ site: { direction: "asc", field: "title" },
111
+ slides: { direction: "asc", field: "title" },
112
+ todos: { direction: "desc", field: "date" },
113
+ };
114
+
115
+ const normalizeSortValue = (value: unknown) => {
116
+ if (typeof value === "number") {
117
+ return value;
118
+ }
119
+ if (typeof value === "string") {
120
+ const timestamp = Date.parse(value);
121
+ if (!Number.isNaN(timestamp)) {
122
+ return timestamp;
123
+ }
124
+ return value.toLowerCase();
125
+ }
126
+ return null;
127
+ };
128
+
129
+ const compareValues = (a: unknown, b: unknown, direction: "asc" | "desc") => {
130
+ const left = normalizeSortValue(a);
131
+ const right = normalizeSortValue(b);
132
+ if (left === null && right === null) {
133
+ return 0;
134
+ }
135
+ if (left === null) {
136
+ return 1;
137
+ }
138
+ if (right === null) {
139
+ return -1;
140
+ }
141
+ const multiplier = direction === "desc" ? -1 : 1;
142
+ if (typeof left === "number" && typeof right === "number") {
143
+ return (left - right) * multiplier;
144
+ }
145
+ if (left < right) {
146
+ return -1 * multiplier;
147
+ }
148
+ if (left > right) {
149
+ return 1 * multiplier;
150
+ }
151
+ return 0;
152
+ };
153
+
154
+ const autoIndexTypes = new Set<ContentType>([
155
+ "blog",
156
+ "courses",
157
+ "products",
158
+ "notes",
159
+ "forms",
160
+ "sheets",
161
+ "slides",
162
+ "todos",
163
+ ]);
164
+
165
+ const getCollectionIndex = (
166
+ collection: CollectionConfig,
167
+ slugPrefix: string
168
+ ) => {
169
+ if (collection.index) {
170
+ return collection.index;
171
+ }
172
+ if (autoIndexTypes.has(collection.type)) {
173
+ const slug = slugPrefix || collection.id;
174
+ return {
175
+ slug,
176
+ title: titleFromSlug(slug),
177
+ };
178
+ }
179
+ return null;
180
+ };
181
+
182
+ const addEntry = (
183
+ entry: ContentEntry,
184
+ index: ContentIndex,
185
+ errors: string[]
186
+ ) => {
187
+ if (index.bySlug.has(entry.slug)) {
188
+ errors.push(`slug "${entry.slug}" is defined more than once`);
189
+ return;
190
+ }
191
+ index.entries.push(entry);
192
+ index.bySlug.set(entry.slug, entry);
193
+ };
194
+
195
+ const resolveEntrySlug = (relativeSlug: string, slugPrefix: string) => {
196
+ if (!slugPrefix) {
197
+ return relativeSlug;
198
+ }
199
+
200
+ if (relativeSlug === "index") {
201
+ return slugPrefix;
202
+ }
203
+
204
+ return normalizePath(`${slugPrefix}/${relativeSlug}`);
205
+ };
206
+
207
+ const buildEntryFromFile = async ({
208
+ collection,
209
+ errors,
210
+ file,
211
+ root,
212
+ slugPrefix,
213
+ source,
214
+ }: {
215
+ collection: CollectionConfig;
216
+ errors: string[];
217
+ file: string;
218
+ root: string;
219
+ slugPrefix: string;
220
+ source: ContentSource;
221
+ }): Promise<Extract<ContentEntry, { kind: "entry" }> | null> => {
222
+ const sourcePath = root
223
+ ? normalizePath(path.join(root, file))
224
+ : normalizePath(file);
225
+
226
+ let entrySource = "";
227
+ try {
228
+ entrySource = await source.readFile(sourcePath);
229
+ } catch (error) {
230
+ errors.push(
231
+ error instanceof Error ? error.message : `Failed to read ${sourcePath}`
232
+ );
233
+ return null;
234
+ }
235
+
236
+ const { frontmatter } = parseFrontmatter(entrySource);
237
+ const frontmatterResult = validateFrontmatter(collection.type, frontmatter);
238
+ if (!frontmatterResult.success) {
239
+ for (const issue of frontmatterResult.errors) {
240
+ errors.push(`${sourcePath}: ${issue}`);
241
+ }
242
+ }
243
+
244
+ const resolvedFrontmatter = frontmatterResult.success
245
+ ? frontmatterResult.data
246
+ : (frontmatter as FrontmatterByType[ContentType]);
247
+ const relativeSlug = slugFromFile(file);
248
+ const slug = resolveEntrySlug(relativeSlug, slugPrefix);
249
+ const title =
250
+ typeof resolvedFrontmatter?.title === "string"
251
+ ? resolvedFrontmatter.title
252
+ : titleFromSlug(slug);
253
+ const description =
254
+ typeof resolvedFrontmatter?.description === "string"
255
+ ? resolvedFrontmatter.description
256
+ : undefined;
257
+
258
+ const hidden =
259
+ typeof resolvedFrontmatter?.hidden === "boolean"
260
+ ? resolvedFrontmatter.hidden
261
+ : undefined;
262
+
263
+ return {
264
+ collectionId: collection.id,
265
+ description,
266
+ frontmatter: resolvedFrontmatter,
267
+ hidden: hidden || undefined,
268
+ kind: "entry",
269
+ relativePath: sourcePath,
270
+ slug,
271
+ sourcePath,
272
+ title,
273
+ type: collection.type,
274
+ };
275
+ };
276
+
277
+ export const buildContentIndex = async (
278
+ source: ContentSource,
279
+ config: SiteConfig
280
+ ): Promise<ContentIndex> => {
281
+ const errors: string[] = [];
282
+ const index: ContentIndex = {
283
+ byCollection: new Map<string, ContentEntry[]>(),
284
+ bySlug: new Map<string, ContentEntry>(),
285
+ entries: [],
286
+ errors,
287
+ };
288
+
289
+ for (const collection of config.collections) {
290
+ const root = normalizePath(collection.root ?? "");
291
+ const slugPrefix = normalizePath(collection.slugPrefix ?? "");
292
+ let files: string[] = [];
293
+ try {
294
+ files = await listContentFiles(source, root);
295
+ } catch (error) {
296
+ errors.push(
297
+ error instanceof Error ? error.message : `Failed to read ${root || "."}`
298
+ );
299
+ continue;
300
+ }
301
+
302
+ const collectionEntries: ContentEntry[] = [];
303
+
304
+ const resolvedEntries = await Promise.all(
305
+ files.map(
306
+ async (file) =>
307
+ await buildEntryFromFile({
308
+ collection,
309
+ errors,
310
+ file,
311
+ root,
312
+ slugPrefix,
313
+ source,
314
+ })
315
+ )
316
+ );
317
+
318
+ for (const entry of resolvedEntries) {
319
+ if (!entry) {
320
+ continue;
321
+ }
322
+
323
+ collectionEntries.push(entry);
324
+ addEntry(entry, index, errors);
325
+ }
326
+
327
+ const sortConfig = {
328
+ ...sortDefaults[collection.type],
329
+ ...collection.sort,
330
+ };
331
+ const sortField = sortConfig.field ?? "title";
332
+ const sortDirection = sortConfig.direction ?? "asc";
333
+ collectionEntries.sort((left, right) => {
334
+ const leftValue =
335
+ left.kind === "entry"
336
+ ? (left.frontmatter as Record<string, unknown>)[sortField]
337
+ : undefined;
338
+ const rightValue =
339
+ right.kind === "entry"
340
+ ? (right.frontmatter as Record<string, unknown>)[sortField]
341
+ : undefined;
342
+ return compareValues(leftValue, rightValue, sortDirection);
343
+ });
344
+
345
+ index.byCollection.set(collection.id, collectionEntries);
346
+
347
+ const collectionIndex = getCollectionIndex(collection, slugPrefix);
348
+ if (collectionIndex) {
349
+ const indexEntry: ContentEntry = {
350
+ collectionId: collection.id,
351
+ description: collectionIndex.description,
352
+ kind: "index",
353
+ slug: collectionIndex.slug,
354
+ title: collectionIndex.title ?? titleFromSlug(collectionIndex.slug),
355
+ type: collection.type,
356
+ };
357
+ addEntry(indexEntry, index, errors);
358
+ }
359
+ }
360
+
361
+ return index;
362
+ };
363
+
364
+ export const shouldIncludeSearchEntry = (
365
+ entry: ContentEntry,
366
+ pageMetadataMap: Map<string, PageMetadata>,
367
+ config: SiteConfig
368
+ ) => {
369
+ const pageMeta = pageMetadataMap.get(entry.slug);
370
+
371
+ if (pageMeta?.hidden || pageMeta?.noindex) {
372
+ return false;
373
+ }
374
+
375
+ if (
376
+ config.seo?.indexing !== "all" &&
377
+ entry.kind === "entry" &&
378
+ entry.hidden === true
379
+ ) {
380
+ return false;
381
+ }
382
+
383
+ return true;
384
+ };