blume 2.0.2 → 2.0.3

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 (293) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli/{chunk-6k4ftwze.js → chunk-1d7ve1dm.js} +1 -1
  3. package/dist/cli/{chunk-zp79m0ts.js → chunk-2eytanqx.js} +2 -2
  4. package/dist/cli/{chunk-8g8ytmgx.js → chunk-2hsdwb9n.js} +19 -19
  5. package/dist/cli/{chunk-8g8ytmgx.js.map → chunk-2hsdwb9n.js.map} +1 -1
  6. package/dist/cli/{chunk-g698a744.js → chunk-2z928egk.js} +5 -5
  7. package/dist/cli/{chunk-ppzjqwx2.js → chunk-35d4wj9f.js} +17 -18
  8. package/dist/cli/{chunk-ppzjqwx2.js.map → chunk-35d4wj9f.js.map} +3 -3
  9. package/dist/cli/{chunk-mqc662a6.js → chunk-364znk6q.js} +2 -2
  10. package/dist/cli/{chunk-gs7r695n.js → chunk-3em5wd2y.js} +21 -8
  11. package/dist/cli/{chunk-gs7r695n.js.map → chunk-3em5wd2y.js.map} +3 -3
  12. package/dist/cli/{chunk-k7pj68a8.js → chunk-5f86nr5m.js} +15 -15
  13. package/dist/cli/{chunk-hr8ne106.js → chunk-5m5nmvyq.js} +68 -43
  14. package/dist/cli/chunk-5m5nmvyq.js.map +13 -0
  15. package/dist/cli/{chunk-91ws1n6j.js → chunk-5xvm6tfj.js} +14 -14
  16. package/dist/cli/{chunk-w4bxdvsa.js → chunk-6dsbexzp.js} +14 -14
  17. package/dist/cli/{chunk-00gs3wqs.js → chunk-8ktnccpt.js} +1 -1
  18. package/dist/cli/{chunk-j85scx15.js → chunk-9t7a85s3.js} +130 -44
  19. package/dist/cli/chunk-9t7a85s3.js.map +10 -0
  20. package/dist/cli/{chunk-bbnwccaz.js → chunk-a58773jm.js} +2 -2
  21. package/dist/cli/{chunk-nfcyttvj.js → chunk-acanzt5p.js} +9 -9
  22. package/dist/cli/{chunk-nfcyttvj.js.map → chunk-acanzt5p.js.map} +1 -1
  23. package/dist/cli/{chunk-wdrt2k2v.js → chunk-akbpwfxc.js} +90 -26
  24. package/dist/cli/chunk-akbpwfxc.js.map +10 -0
  25. package/dist/cli/{chunk-sqw4ekg1.js → chunk-b07cmahc.js} +2 -2
  26. package/dist/cli/{chunk-xh43dwgw.js → chunk-c8chx29p.js} +42 -29
  27. package/dist/cli/{chunk-xh43dwgw.js.map → chunk-c8chx29p.js.map} +9 -9
  28. package/dist/cli/{chunk-7mbqtmgb.js → chunk-crgn1q09.js} +21 -12
  29. package/dist/cli/chunk-crgn1q09.js.map +10 -0
  30. package/dist/cli/chunk-e04dxsz1.js +39 -0
  31. package/dist/cli/chunk-e04dxsz1.js.map +10 -0
  32. package/dist/cli/{chunk-4e9b9ra6.js → chunk-ey84smr6.js} +3 -3
  33. package/dist/cli/{chunk-d1v5rhy0.js → chunk-g4hq16wv.js} +13 -13
  34. package/dist/cli/{chunk-273ygyr4.js → chunk-ga0pf4aj.js} +7 -7
  35. package/dist/cli/{chunk-273ygyr4.js.map → chunk-ga0pf4aj.js.map} +3 -3
  36. package/dist/cli/{chunk-pbg5a4s3.js → chunk-hm3vjy5s.js} +64 -45
  37. package/dist/cli/chunk-hm3vjy5s.js.map +19 -0
  38. package/dist/cli/{chunk-5r8g91qn.js → chunk-j85vccga.js} +306 -107
  39. package/dist/cli/chunk-j85vccga.js.map +36 -0
  40. package/dist/cli/{chunk-n1yg3tj3.js → chunk-p3v96n38.js} +6 -6
  41. package/dist/cli/{chunk-n1yg3tj3.js.map → chunk-p3v96n38.js.map} +3 -3
  42. package/dist/cli/{chunk-7vtckvaw.js → chunk-p73c0m7w.js} +14 -14
  43. package/dist/cli/{chunk-7vtckvaw.js.map → chunk-p73c0m7w.js.map} +1 -1
  44. package/dist/cli/{chunk-qkb5a8sa.js → chunk-pehfxfta.js} +3 -3
  45. package/dist/cli/{chunk-v6ya5kcb.js → chunk-pv29h0wf.js} +1223 -978
  46. package/dist/cli/{chunk-v6ya5kcb.js.map → chunk-pv29h0wf.js.map} +47 -46
  47. package/dist/cli/{chunk-6dtt0zfn.js → chunk-q58y5e6a.js} +10 -10
  48. package/dist/cli/{chunk-6dtt0zfn.js.map → chunk-q58y5e6a.js.map} +3 -3
  49. package/dist/cli/{chunk-fsmrqk8a.js → chunk-r20tn01b.js} +1 -1
  50. package/dist/cli/{chunk-bfwp9vp6.js → chunk-r9rcc4w7.js} +7 -7
  51. package/dist/cli/{chunk-h7k3nq3v.js → chunk-tkacnehg.js} +2 -2
  52. package/dist/cli/{chunk-h2ez8dzb.js → chunk-tzmab476.js} +4 -4
  53. package/dist/cli/{chunk-3yce002v.js → chunk-vg9r4eb9.js} +6 -6
  54. package/dist/cli/{chunk-3yce002v.js.map → chunk-vg9r4eb9.js.map} +4 -4
  55. package/dist/cli/{chunk-hqp2ajnh.js → chunk-wrr3j9w9.js} +16 -17
  56. package/dist/cli/{chunk-hqp2ajnh.js.map → chunk-wrr3j9w9.js.map} +7 -7
  57. package/dist/cli/{chunk-ddndchfr.js → chunk-yfyb25rh.js} +38 -26
  58. package/dist/cli/chunk-yfyb25rh.js.map +14 -0
  59. package/dist/cli/index.js +20 -18
  60. package/dist/cli/index.js.map +3 -3
  61. package/dist/types/ai/link-headers.d.ts +8 -1
  62. package/dist/types/ai/openapi-components.d.ts +5 -2
  63. package/dist/types/ai/relative-links.d.ts +9 -7
  64. package/dist/types/ai/skills.d.ts +4 -1
  65. package/dist/types/ai/tar.d.ts +1 -3
  66. package/dist/types/analytics/index.d.ts +2 -0
  67. package/dist/types/analytics/one-dollar-stats.d.ts +48 -0
  68. package/dist/types/analytics/schema.d.ts +14 -0
  69. package/dist/types/astro/integration.d.ts +3 -2
  70. package/dist/types/core/base-path.d.ts +21 -7
  71. package/dist/types/core/config-input.d.ts +9 -7
  72. package/dist/types/core/directive-diagnostics.d.ts +12 -0
  73. package/dist/types/core/heading-markers.d.ts +5 -7
  74. package/dist/types/core/i18n.d.ts +2 -0
  75. package/dist/types/core/last-modified.d.ts +10 -0
  76. package/dist/types/core/meta.d.ts +8 -0
  77. package/dist/types/core/schema.d.ts +7 -0
  78. package/dist/types/core/sources/lower.d.ts +29 -16
  79. package/dist/types/core/sources/normalize.d.ts +13 -1
  80. package/dist/types/core/sources/watch.d.ts +12 -5
  81. package/dist/types/core/standard-schema.d.ts +5 -0
  82. package/dist/types/deploy/artifacts.d.ts +6 -4
  83. package/dist/types/deploy/headers.d.ts +5 -0
  84. package/dist/types/deploy/platforms/types.d.ts +8 -0
  85. package/dist/types/deploy/redirects.d.ts +24 -13
  86. package/dist/types/markdown/directives.d.ts +62 -0
  87. package/dist/types/markdown/features.d.ts +21 -0
  88. package/dist/types/markdown/mdast.d.ts +63 -0
  89. package/dist/types/openapi/asyncapi.d.ts +4 -2
  90. package/docs/02-deployment.mdx +4 -2
  91. package/docs/08-faq.mdx +1 -1
  92. package/docs/advanced/custom-pages.mdx +3 -3
  93. package/docs/cli/audit.mdx +17 -1
  94. package/docs/cli/evals.mdx +3 -3
  95. package/docs/cli/translate.mdx +3 -3
  96. package/docs/cli/version.mdx +1 -1
  97. package/docs/configuration/analytics.mdx +20 -1
  98. package/docs/configuration/customization.mdx +2 -0
  99. package/docs/configuration/search.mdx +1 -1
  100. package/docs/content/components.mdx +1 -1
  101. package/docs/content/frontmatter.mdx +1 -1
  102. package/docs/content/i18n.mdx +2 -0
  103. package/docs/content/index.mdx +4 -2
  104. package/docs/content/islands.mdx +1 -1
  105. package/docs/content/meta.mdx +3 -1
  106. package/docs/content/sources.mdx +1 -5
  107. package/docs/content/syntax.mdx +14 -0
  108. package/docs/content/versioning.mdx +1 -1
  109. package/docs/discoverability/agent-discovery.mdx +1 -1
  110. package/docs/discoverability/index.mdx +2 -1
  111. package/docs/discoverability/markdown.mdx +2 -2
  112. package/docs/discoverability/open-graph.mdx +5 -3
  113. package/docs/discoverability/rss.mdx +1 -1
  114. package/docs/references/asyncapi.mdx +1 -1
  115. package/docs/references/graphql.mdx +1 -1
  116. package/docs/references/openapi.mdx +5 -3
  117. package/package.json +1 -1
  118. package/skills/blume-migrate/references/fumadocs.md +1 -1
  119. package/skills/blume-migrate/references/nextra.md +1 -1
  120. package/skills/blume-migrate/scripts/mintlify-codemod.mjs +7 -2
  121. package/src/ai/agent-readability.ts +2 -2
  122. package/src/ai/ai-catalog.ts +6 -2
  123. package/src/ai/api/handlers.ts +2 -2
  124. package/src/ai/api/spec.ts +2 -2
  125. package/src/ai/api-catalog.ts +6 -2
  126. package/src/ai/changelog-markdown.ts +2 -2
  127. package/src/ai/link-headers.ts +14 -5
  128. package/src/ai/llms.ts +18 -7
  129. package/src/ai/mcp/discovery.ts +2 -2
  130. package/src/ai/mcp/query.ts +2 -2
  131. package/src/ai/mcp/server.ts +2 -2
  132. package/src/ai/openapi-components.ts +22 -6
  133. package/src/ai/relative-links.ts +42 -26
  134. package/src/ai/serializers.ts +2 -1
  135. package/src/ai/skills.ts +14 -1
  136. package/src/ai/tar.ts +139 -12
  137. package/src/analytics/head.ts +4 -0
  138. package/src/analytics/index.ts +5 -0
  139. package/src/analytics/one-dollar-stats.ts +86 -0
  140. package/src/analytics/schema.ts +2 -0
  141. package/src/astro/generate.ts +1 -1
  142. package/src/astro/include-hmr.ts +31 -12
  143. package/src/astro/include-refresh.ts +19 -8
  144. package/src/astro/integration.ts +108 -14
  145. package/src/astro/runtime-modules.ts +28 -13
  146. package/src/astro/templates.ts +81 -37
  147. package/src/audit/checks/links.ts +8 -1
  148. package/src/audit/graph.ts +3 -1
  149. package/src/audit/run.ts +12 -9
  150. package/src/audit/snapshot.ts +5 -0
  151. package/src/cli/args.ts +32 -0
  152. package/src/cli/commands/audit.ts +4 -1
  153. package/src/cli/commands/doctor.ts +3 -1
  154. package/src/cli/commands/eval.ts +19 -11
  155. package/src/cli/commands/translate.ts +9 -12
  156. package/src/cli/commands/validate.ts +3 -1
  157. package/src/cli/dev-lock.ts +157 -37
  158. package/src/cli/report-format.ts +11 -6
  159. package/src/components/colors.ts +19 -0
  160. package/src/components/content/Badge.astro +5 -3
  161. package/src/components/content/Component.astro +2 -2
  162. package/src/components/content/Tab.astro +0 -1
  163. package/src/components/content/Tabs.astro +4 -0
  164. package/src/components/content/badge-color.ts +5 -2
  165. package/src/components/content/base-href.ts +13 -36
  166. package/src/components/islands/assistant.tsx +21 -4
  167. package/src/components/islands/base-path.ts +47 -10
  168. package/src/components/islands/hooks.ts +35 -19
  169. package/src/components/islands/webmcp.ts +4 -2
  170. package/src/components/layout/Breadcrumbs.astro +5 -2
  171. package/src/components/layout/DiscoveryLinks.astro +9 -5
  172. package/src/components/layout/Header.astro +2 -2
  173. package/src/components/layout/LanguageSwitcher.astro +2 -2
  174. package/src/components/layout/NavSelector.astro +2 -2
  175. package/src/components/layout/NavTabMenu.astro +3 -3
  176. package/src/components/layout/NavTree.astro +16 -6
  177. package/src/components/layout/PageActions.astro +4 -3
  178. package/src/components/layout/PageLayout.astro +7 -6
  179. package/src/components/layout/Pagination.astro +3 -3
  180. package/src/components/layout/RootLayout.astro +5 -5
  181. package/src/components/layout/Search.astro +33 -13
  182. package/src/components/layout/VersionBanner.astro +2 -2
  183. package/src/components/layout/analytics-client.ts +12 -0
  184. package/src/components/layout/toc-active.ts +41 -0
  185. package/src/components/layout/toc-element.ts +8 -14
  186. package/src/components/openapi/ApiTagOperations.astro +2 -2
  187. package/src/components/openapi/AsyncApiOperation.astro +7 -4
  188. package/src/components/openapi/GraphqlChip.astro +2 -2
  189. package/src/components/openapi/Operation.astro +2 -0
  190. package/src/components/openapi/Playground.astro +4 -4
  191. package/src/components/openapi/RequestPanel.astro +5 -2
  192. package/src/components/openapi/SchemaTable.astro +7 -0
  193. package/src/components/openapi/async.ts +38 -6
  194. package/src/components/openapi/helpers.ts +19 -9
  195. package/src/components/openapi/message-composer.ts +6 -1
  196. package/src/components/openapi/message.ts +17 -2
  197. package/src/components/openapi/operation-model.ts +81 -11
  198. package/src/components/openapi/panel.ts +4 -2
  199. package/src/components/openapi/param-style.ts +181 -0
  200. package/src/components/openapi/playground-client.ts +73 -16
  201. package/src/components/openapi/request.ts +190 -26
  202. package/src/components/openapi/schema-tree.ts +30 -24
  203. package/src/components/openapi/snippets.ts +85 -6
  204. package/src/components/openapi/ws-client.ts +18 -2
  205. package/src/core/base-path.ts +62 -16
  206. package/src/core/config-input.ts +9 -7
  207. package/src/core/diagnostics.ts +2 -0
  208. package/src/core/directive-diagnostics.ts +99 -0
  209. package/src/core/frontmatter.ts +21 -18
  210. package/src/core/heading-markers.ts +5 -18
  211. package/src/core/i18n.ts +31 -3
  212. package/src/core/last-modified.ts +25 -3
  213. package/src/core/locale-links.ts +5 -1
  214. package/src/core/meta.ts +33 -19
  215. package/src/core/navigation.ts +28 -8
  216. package/src/core/project-graph.ts +27 -5
  217. package/src/core/schema.ts +24 -4
  218. package/src/core/sources/contentful-rich-text.ts +27 -20
  219. package/src/core/sources/filesystem.ts +20 -2
  220. package/src/core/sources/github-releases.ts +25 -6
  221. package/src/core/sources/lexical.ts +23 -18
  222. package/src/core/sources/lower.ts +201 -34
  223. package/src/core/sources/mdx-remote.ts +51 -16
  224. package/src/core/sources/normalize.ts +325 -90
  225. package/src/core/sources/notion.ts +52 -25
  226. package/src/core/sources/obsidian.ts +23 -6
  227. package/src/core/sources/portable-text.ts +38 -21
  228. package/src/core/sources/strapi-blocks.ts +20 -16
  229. package/src/core/sources/watch.ts +20 -7
  230. package/src/core/standard-schema.ts +10 -6
  231. package/src/core/version-cut.ts +52 -15
  232. package/src/deploy/artifacts.ts +27 -6
  233. package/src/deploy/cloudflare-negotiation.ts +4 -12
  234. package/src/deploy/headers.ts +8 -4
  235. package/src/deploy/node-headers.ts +1 -1
  236. package/src/deploy/platforms/cloudflare.ts +12 -3
  237. package/src/deploy/platforms/netlify.ts +1 -0
  238. package/src/deploy/platforms/node.ts +1 -0
  239. package/src/deploy/platforms/static.ts +1 -0
  240. package/src/deploy/platforms/types.ts +8 -0
  241. package/src/deploy/platforms/vercel.ts +1 -0
  242. package/src/deploy/redirects.ts +54 -18
  243. package/src/deploy/robots.ts +2 -2
  244. package/src/deploy/rss.ts +4 -3
  245. package/src/deploy/sitemap.ts +7 -5
  246. package/src/markdown/base-links.ts +34 -36
  247. package/src/markdown/directives.ts +242 -36
  248. package/src/markdown/features.ts +17 -0
  249. package/src/markdown/index.ts +7 -6
  250. package/src/markdown/mdast.ts +5 -2
  251. package/src/markdown/relative-links.ts +12 -4
  252. package/src/og/card.ts +129 -5
  253. package/src/og/derive.ts +41 -31
  254. package/src/og/index.ts +1 -0
  255. package/src/openapi/asyncapi.ts +4 -2
  256. package/src/openapi/model.ts +22 -14
  257. package/src/openapi/proxy.ts +63 -10
  258. package/src/openapi/render-mdx.ts +41 -2
  259. package/src/registry/eject.ts +181 -24
  260. package/src/search/adapters/version-scope.ts +30 -0
  261. package/src/search/documents.ts +67 -26
  262. package/src/search/popular.ts +2 -1
  263. package/src/seo/jsonld.ts +7 -3
  264. package/src/translate/meta.ts +68 -24
  265. package/src/translate/run.ts +3 -3
  266. package/src/translate/validate.ts +4 -1
  267. package/src/translate/work-list.ts +56 -17
  268. package/dist/cli/chunk-5r8g91qn.js.map +0 -34
  269. package/dist/cli/chunk-7mbqtmgb.js.map +0 -10
  270. package/dist/cli/chunk-ddndchfr.js.map +0 -14
  271. package/dist/cli/chunk-esh98wmb.js +0 -23
  272. package/dist/cli/chunk-esh98wmb.js.map +0 -10
  273. package/dist/cli/chunk-hr8ne106.js.map +0 -13
  274. package/dist/cli/chunk-j85scx15.js.map +0 -10
  275. package/dist/cli/chunk-pbg5a4s3.js.map +0 -19
  276. package/dist/cli/chunk-wdrt2k2v.js.map +0 -10
  277. /package/dist/cli/{chunk-6k4ftwze.js.map → chunk-1d7ve1dm.js.map} +0 -0
  278. /package/dist/cli/{chunk-zp79m0ts.js.map → chunk-2eytanqx.js.map} +0 -0
  279. /package/dist/cli/{chunk-g698a744.js.map → chunk-2z928egk.js.map} +0 -0
  280. /package/dist/cli/{chunk-mqc662a6.js.map → chunk-364znk6q.js.map} +0 -0
  281. /package/dist/cli/{chunk-k7pj68a8.js.map → chunk-5f86nr5m.js.map} +0 -0
  282. /package/dist/cli/{chunk-91ws1n6j.js.map → chunk-5xvm6tfj.js.map} +0 -0
  283. /package/dist/cli/{chunk-w4bxdvsa.js.map → chunk-6dsbexzp.js.map} +0 -0
  284. /package/dist/cli/{chunk-00gs3wqs.js.map → chunk-8ktnccpt.js.map} +0 -0
  285. /package/dist/cli/{chunk-bbnwccaz.js.map → chunk-a58773jm.js.map} +0 -0
  286. /package/dist/cli/{chunk-sqw4ekg1.js.map → chunk-b07cmahc.js.map} +0 -0
  287. /package/dist/cli/{chunk-4e9b9ra6.js.map → chunk-ey84smr6.js.map} +0 -0
  288. /package/dist/cli/{chunk-d1v5rhy0.js.map → chunk-g4hq16wv.js.map} +0 -0
  289. /package/dist/cli/{chunk-qkb5a8sa.js.map → chunk-pehfxfta.js.map} +0 -0
  290. /package/dist/cli/{chunk-fsmrqk8a.js.map → chunk-r20tn01b.js.map} +0 -0
  291. /package/dist/cli/{chunk-bfwp9vp6.js.map → chunk-r9rcc4w7.js.map} +0 -0
  292. /package/dist/cli/{chunk-h7k3nq3v.js.map → chunk-tkacnehg.js.map} +0 -0
  293. /package/dist/cli/{chunk-h2ez8dzb.js.map → chunk-tzmab476.js.map} +0 -0
@@ -1,8 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
3
  import GithubSlugger from "github-slugger";
4
+ import type { Nodes } from "mdast";
4
5
  import { extname } from "pathe";
6
+ import { markdownToMdast } from "satteri";
5
7
 
8
+ import { MARKDOWN_FEATURES } from "../../markdown/features.ts";
6
9
  import { mountBasePath } from "../base-path.ts";
7
10
  import { nextFenceState } from "../code-fences.ts";
8
11
  import type { FenceState } from "../code-fences.ts";
@@ -33,6 +36,14 @@ const stripNumericPrefix = (segment: string): string =>
33
36
  const groupLabel = (segment: string): string | null =>
34
37
  segment.match(GROUP_FOLDER)?.groups?.label ?? null;
35
38
 
39
+ /**
40
+ * {@link groupLabel} for a file or folder name, which may carry its ordering
41
+ * prefix outside the parentheses: `01-(guides)` is the `guides` group, sorted
42
+ * first, the way `01-guides` is the `guides` folder.
43
+ */
44
+ const orderedGroupLabel = (segment: string): string | null =>
45
+ groupLabel(segment) ?? groupLabel(stripNumericPrefix(segment));
46
+
36
47
  /**
37
48
  * Slugify a content/route slug (Sanity, Notion, frontmatter `slug`). Heading
38
49
  * anchor ids are *not* slugged here — they use a `github-slugger` in
@@ -74,11 +85,46 @@ const titleCase = (value: string): string =>
74
85
  * scheme (`Guide: Architecture.md` → `guide:`), which crashes Astro's
75
86
  * prerender write with "The URL must be of scheme file"; control characters
76
87
  * (an embedded newline in a filename) are silently dropped by the URL parser,
77
- * desyncing the route from its output path. Both are legal in macOS/Linux
78
- * filenames, so they are removed here rather than rejected.
88
+ * desyncing the route from its output path. `#` and `?` start a URL's
89
+ * fragment and query, so an unescaped `/sdks/c#` link lands on `/sdks/c`
90
+ * while Astro writes the page to `c%23/`; a `%` starts an escape, and a bare
91
+ * one is an invalid URL Astro can't decode (and one it escapes, `%25`, finds
92
+ * no static path). All are legal in macOS/Linux filenames, so they are
93
+ * removed here rather than rejected: `100%.md` publishes at `/100`, and a
94
+ * slug of `sdks/c#` at `/sdks/c`. A file whose own path holds `#` or `?`
95
+ * never gets this far — Astro can't load it (see
96
+ * {@link unloadablePathDiagnostic}).
79
97
  */
80
98
  const sanitizeSegment = (segment: string): string =>
81
- segment.replaceAll(/[:\p{Cc}]/gu, "");
99
+ segment.replaceAll(/[:#?%\p{Cc}]/gu, "");
100
+
101
+ // Astro's content loader reads each entry at `new URL("./" + encodeURI(entry),
102
+ // base)`. `encodeURI` escapes `%` but leaves `#` and `?` alone, so in a path
103
+ // they start the URL's fragment and query, and the read misses the file.
104
+ const UNLOADABLE_PATH = /[#?]/u;
105
+
106
+ /**
107
+ * The error for a content file Astro's content loader can't read, or
108
+ * undefined when it can. A `#` or `?` anywhere in the path the loader is
109
+ * handed (`sdks/c#.md`, `faq/why?.md`) truncates the file URL it reads
110
+ * through, so the read fails with ENOENT and the page renders "Page not
111
+ * found" at its route. A source leaves such a file out of its scan and
112
+ * reports this instead of publishing a route that can never render.
113
+ */
114
+ export const unloadablePathDiagnostic = (
115
+ path: string,
116
+ file: string
117
+ ): Diagnostic | undefined =>
118
+ UNLOADABLE_PATH.test(path)
119
+ ? {
120
+ code: "BLUME_UNLOADABLE_FILE_NAME",
121
+ file,
122
+ message: `"${path}" has a "#" or "?" in its path, which Astro's content loader reads as the start of a URL fragment or query, so it can't load the file. It was left out of the site.`,
123
+ severity: "error",
124
+ suggestion:
125
+ 'Rename the file (or its folder) without "#" or "?". Both are dropped from the page\'s URL anyway, so the page keeps its route.',
126
+ }
127
+ : undefined;
82
128
 
83
129
  /**
84
130
  * Fold one raw path part into the accumulating route segments/groups.
@@ -95,7 +141,7 @@ const addRouteSegment = (
95
141
  if (part === "") {
96
142
  return;
97
143
  }
98
- const group = groupLabel(part);
144
+ const group = ordered ? orderedGroupLabel(part) : groupLabel(part);
99
145
  if (group !== null) {
100
146
  groups.push(group);
101
147
  return;
@@ -166,7 +212,6 @@ const SETEXT_UNDERLINE = /^ {0,3}(?<marker>=+|-+)\s*$/u;
166
212
  const PARAGRAPH_INTERRUPT = /^ {0,3}(?:[-+*][ \t]|\d{1,9}[.)][ \t]|>)/u;
167
213
  const THEMATIC_BREAK =
168
214
  /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/u;
169
- const FRONT_MATTER_CLOSE = /^(?:-{3}|\.{3})\s*$/u;
170
215
  // `<Prompt>` renders its children into a permanently `hidden` DOM node (see
171
216
  // `Prompt.astro`) — the agent-facing prompt text is never visible page
172
217
  // content, only read by client JS for the copy button. Any `##` inside it
@@ -183,41 +228,13 @@ const PROMPT_OPEN = /^<Prompt(?![\w-])/u;
183
228
  // children text (`...copy this.</Prompt>`), not just sit on its own line.
184
229
  const PROMPT_CLOSE = /<\/Prompt>/u;
185
230
 
186
- /**
187
- * The body lines, minus a leading front matter block, plus the height of the
188
- * block that was dropped (`offset`) so line numbers can be reported against
189
- * the whole body. Bodies from the normalize pipeline are already
190
- * frontmatter-stripped, but `scanBody` also runs on raw documents — where a
191
- * leading `---` block (closed by `---` or `...`) is front matter, not a
192
- * thematic break whose closing `---` would underline the last metadata line
193
- * into a phantom setext heading.
194
- */
195
- const linesWithoutFrontMatter = (body: string) => {
196
- const lines = body.split("\n");
197
- if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
198
- return { lines, offset: 0 };
199
- }
200
- // A blank line directly after the dashes means the body *opens* with a
201
- // thematic break, not front matter — YAML metadata starts on the very next
202
- // line. Treating it as an unclosed block ate everything up to the next
203
- // `---`/`...` line of an already-stripped body.
204
- if ((lines[1] ?? "").trim() === "") {
205
- return { lines, offset: 0 };
206
- }
207
- const close = lines.findIndex(
208
- (line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
209
- );
210
- return close === -1
211
- ? { lines, offset: 0 }
212
- : { lines: lines.slice(close + 1), offset: close + 1 };
213
- };
214
-
215
231
  // A trailing `{#id}` heading marker written without a backslash escape. Both
216
- // pipelines resolve escapes before parsing markers (see `ESCAPED_PUNCTUATION`),
217
- // so `\{#id\}` is the same marker — and the only spelling that survives the
218
- // MDX parser, where a bare `{…}` is a JSX expression and `#id` is not a valid
219
- // one (`Could not parse expression with acorn`). Further bracket markers may
220
- // follow the brace (`{#id} [toc]`), nothing else.
232
+ // pipelines resolve escapes before parsing markers (the scan parses each
233
+ // heading with the renderer's grammar — see `renderHeading`), so `\{#id\}` is
234
+ // the same marker — and the only spelling that survives the MDX parser, where
235
+ // a bare `{…}` is a JSX expression and `#id` is not a valid one (`Could not
236
+ // parse expression with acorn`). Further bracket markers may follow the brace
237
+ // (`{#id} [toc]`), nothing else.
221
238
  const BARE_CURLY_MARKER =
222
239
  /(?<!\\)\{#(?<id>[^\s}]+)\}(?:\s*\[(?:#[^\s\]]+|!?toc)\])*\s*$/u;
223
240
 
@@ -311,12 +328,31 @@ const finishPromptTag = (
311
328
  * collapses `--`; github-slugger keeps it) and resolves repeated headings the
312
329
  * same way (`setup`, `setup-1`).
313
330
  */
314
- // CommonMark's escapable ASCII punctuation. The renderer only ever sees
315
- // heading text *after* the Markdown parser has resolved backslash escapes, so
316
- // `\[toc]` reaches the hast as plain `[toc]` and the marker still applies;
317
- // resolving escapes here keeps the two pipelines identical (there is no
318
- // inline way to write a literal trailing marker — use inline code instead).
319
- const ESCAPED_PUNCTUATION = /\\(?<char>[!-/:-@[-`{-~])/gu;
331
+ // A heading's inline Markdown is parsed with the renderer's own grammar:
332
+ // Blume's `.md` feature set plus the Astro defaults Blume never turns off, GFM
333
+ // and smart punctuation (Astro's `smartypants`). Front matter is off — the
334
+ // source is a lone heading. The display text is read without smart
335
+ // punctuation (see `toHeading`).
336
+ const HEADING_FEATURES = {
337
+ ...MARKDOWN_FEATURES,
338
+ frontmatter: false,
339
+ gfm: true,
340
+ };
341
+ const HEADING_PARSE = {
342
+ features: { ...HEADING_FEATURES, smartPunctuation: true },
343
+ };
344
+ const HEADING_TEXT_PARSE = {
345
+ features: { ...HEADING_FEATURES, smartPunctuation: false },
346
+ };
347
+
348
+ // Characters that can make a heading's rendered text differ from its source:
349
+ // escapes, code spans, emphasis/strikethrough/sub/superscript markers, link and
350
+ // image brackets, raw HTML and autolinks, entities, and the quotes, dashes, and
351
+ // ellipses smart punctuation rewrites. A heading with none of them renders as
352
+ // written, so it skips the parse.
353
+ const INLINE_MARKUP = /[\\`*_~^[\]<&'"]|--|\.\.\./u;
354
+ // The subset smart punctuation rewrites (`"a"` → `“a”`, `--` → `–`, `...` → `…`).
355
+ const SMART_PUNCTUATION = /['"]|--|\.\.\./u;
320
356
 
321
357
  // The start of a link-reference definition, as the renderer accepts it:
322
358
  // `[label]:` after up to 3 leading spaces, optionally inside block-quote or
@@ -334,10 +370,13 @@ const REF_DEFINITION =
334
370
 
335
371
  /**
336
372
  * The normalized labels of every link-reference definition in the body
337
- * (outside fenced code). A trailing heading bracket whose label is defined is
338
- * a CommonMark shortcut link, not a marker — the renderer leaves it in the
339
- * heading as an `<a>`, so the marker parse must skip it too. Labels match
340
- * case-insensitively with collapsed internal whitespace (CommonMark).
373
+ * (outside fenced code), footnote definitions (`^1`) included. A heading
374
+ * bracket whose label is defined is a CommonMark reference link —
375
+ * `[text][label]`, or a shortcut `[label]` that would otherwise read as a
376
+ * trailing `[toc]`/`[#id]` marker — or a GFM footnote reference, so each
377
+ * heading is parsed together with the definitions it names (see
378
+ * {@link definitionsFor}). Labels match case-insensitively with collapsed
379
+ * internal whitespace (CommonMark).
341
380
  *
342
381
  * Only a *valid* definition defines a label: the label must contain a
343
382
  * non-whitespace character, and a destination must follow — on the same line
@@ -369,14 +408,179 @@ const refDefinitionLabels = (lines: readonly string[]): Set<string> => {
369
408
  };
370
409
 
371
410
  /**
372
- * A heading record from raw heading text: escapes resolved and trailing
373
- * markers stripped, exactly as the renderer sees them. A `[#custom-id]` pin
374
- * becomes the slug verbatim and — matching the renderer — occupies its id in
375
- * the slugger, so a later heading whose auto-slug collides disambiguates
376
- * (`setup` → `setup-1`). `[!toc]`/`[toc]` headings stay in the record: their
377
- * ids exist in the rendered page, so links to them are valid anchors
378
- * regardless of TOC visibility. A heading that is nothing but markers keeps
379
- * them as literal text, mirroring the renderer.
411
+ * The link-reference and footnote definitions a heading's brackets may name,
412
+ * as source lines to parse the heading with. A definition the heading doesn't
413
+ * use changes nothing, so matching is loose (the label anywhere in the folded
414
+ * text). A footnote label (`^1`) is defined too, so `[^1]` parses as the
415
+ * footnote reference it renders as, not as literal text.
416
+ */
417
+ const definitionsFor = (raw: string, labels: ReadonlySet<string>): string => {
418
+ if (!raw.includes("[")) {
419
+ return "";
420
+ }
421
+ const folded = raw.replaceAll(/\s+/gu, " ").toLowerCase();
422
+ let definitions = "";
423
+ for (const label of labels) {
424
+ if (folded.includes(label)) {
425
+ definitions += `\n\n[${label}]: /`;
426
+ }
427
+ }
428
+ return definitions;
429
+ };
430
+
431
+ /**
432
+ * The number each footnote renders as, keyed by identifier. GFM numbers
433
+ * footnotes in the order the body first references them, whatever order their
434
+ * definitions sit in, so a `[^b]` cited before `[^a]` renders as 1. A
435
+ * reference inside a footnote definition is left out: the renderer reaches
436
+ * those only after the body.
437
+ */
438
+ const footnoteNumbers = (body: string): Map<string, number> => {
439
+ const numbers = new Map<string, number>();
440
+ const visit = (nodes: readonly Nodes[]): void => {
441
+ for (const node of nodes) {
442
+ if (node.type === "footnoteReference") {
443
+ if (!numbers.has(node.identifier)) {
444
+ numbers.set(node.identifier, numbers.size + 1);
445
+ }
446
+ } else if (node.type !== "footnoteDefinition" && "children" in node) {
447
+ visit(node.children);
448
+ }
449
+ }
450
+ };
451
+ const tree = markdownToMdast(body, HEADING_PARSE);
452
+ visit("children" in tree ? tree.children : []);
453
+ return numbers;
454
+ };
455
+
456
+ /** What reading one heading needs from the rest of its body. */
457
+ interface HeadingContext {
458
+ /**
459
+ * The number each footnote renders as (see {@link footnoteNumbers}),
460
+ * computed on first use: only a heading that cites a footnote needs it.
461
+ */
462
+ footnotes: () => ReadonlyMap<string, number>;
463
+ /** The body's link-reference and footnote definition labels. */
464
+ labels: ReadonlySet<string>;
465
+ }
466
+
467
+ /**
468
+ * The text content of mdast inline nodes, the way the rendered heading reads
469
+ * it: link and emphasis text, code-span contents, decoded entities, and a
470
+ * footnote reference's number. Raw HTML tags add no text (the text between
471
+ * them is its own node), and an image's alt is an attribute rather than text
472
+ * content.
473
+ */
474
+ const inlineText = (nodes: readonly Nodes[], context: HeadingContext): string =>
475
+ nodes
476
+ .map((node) => {
477
+ if (
478
+ node.type === "html" ||
479
+ node.type === "image" ||
480
+ node.type === "imageReference"
481
+ ) {
482
+ return "";
483
+ }
484
+ if (node.type === "break") {
485
+ return "\n";
486
+ }
487
+ if (node.type === "footnoteReference") {
488
+ // A reference the body's own parse never numbered (a heading the scan
489
+ // finds inside an HTML block) is no footnote to the renderer either.
490
+ const number = context.footnotes().get(node.identifier);
491
+ return number === undefined
492
+ ? `[^${node.label ?? node.identifier}]`
493
+ : String(number);
494
+ }
495
+ if ("children" in node) {
496
+ return inlineText(node.children, context);
497
+ }
498
+ return "value" in node ? node.value : "";
499
+ })
500
+ .join("");
501
+
502
+ /** A heading's rendered text plus the trailing markers the renderer strips. */
503
+ interface RenderedHeading {
504
+ /** Author-pinned anchor id from `[#id]`/`{#id}`, used verbatim. */
505
+ id?: string;
506
+ /** The text content the renderer slugs, markers stripped, untrimmed. */
507
+ text: string;
508
+ toc?: "hide" | "only";
509
+ }
510
+
511
+ /** A heading whose source is its text: one text node, markers stripped. */
512
+ const plainHeading = (raw: string): RenderedHeading => {
513
+ const markers = parseHeadingMarkers(raw);
514
+ // A heading that is nothing but markers keeps them as literal text.
515
+ return markers.text === ""
516
+ ? { text: raw }
517
+ : { id: markers.id, text: markers.text, toc: markers.toc };
518
+ };
519
+
520
+ /**
521
+ * Parse a heading's inline Markdown and read it the way
522
+ * `markdown/heading-anchors` does: the heading's text content, with trailing
523
+ * markers stripped from its last node only when that node is text (a heading
524
+ * ending in inline code or a link has no marker position) and some heading
525
+ * text remains. Parsing (rather than regex-stripping) resolves escapes —
526
+ * `\[toc]` still reaches the marker parse as `[toc]` — and emphasis the way
527
+ * CommonMark does, so `snake_case` stays whole while `_note_` loses its
528
+ * underscores.
529
+ */
530
+ const renderHeading = (
531
+ raw: string,
532
+ form: "atx" | "setext",
533
+ context: HeadingContext,
534
+ parse: typeof HEADING_PARSE
535
+ ): RenderedHeading => {
536
+ if (!INLINE_MARKUP.test(raw)) {
537
+ return plainHeading(raw);
538
+ }
539
+ const source = form === "atx" ? `# ${raw}` : `${raw}\n=`;
540
+ const tree = markdownToMdast(
541
+ `${source}${definitionsFor(raw, context.labels)}`,
542
+ parse
543
+ );
544
+ const heading = "children" in tree ? tree.children.at(0) : undefined;
545
+ // The scan's paragraph tracking is coarser than the parser's: underlined
546
+ // text that opens with an HTML block or a definition is no heading to the
547
+ // renderer. It is read as written.
548
+ if (heading?.type !== "heading") {
549
+ return plainHeading(raw);
550
+ }
551
+ const { children } = heading;
552
+ const text = inlineText(children, context);
553
+ const last = children.at(-1);
554
+ if (last?.type !== "text") {
555
+ return { text };
556
+ }
557
+ const markers = parseHeadingMarkers(last.value);
558
+ const stripped = last.value.length - markers.text.length;
559
+ if (stripped === 0 || (markers.text === "" && children.length === 1)) {
560
+ return { text };
561
+ }
562
+ return {
563
+ id: markers.id,
564
+ text: text.slice(0, text.length - stripped),
565
+ toc: markers.toc,
566
+ };
567
+ };
568
+
569
+ /**
570
+ * A heading record from raw heading text, read exactly as the renderer reads
571
+ * it: inline Markdown reduced to its rendered text (see {@link renderHeading})
572
+ * and trailing markers stripped. The slug is the renderer's slug of that text.
573
+ * A `[#custom-id]` pin becomes the slug verbatim and — matching the renderer —
574
+ * occupies its id in the slugger, so a later heading whose auto-slug collides
575
+ * disambiguates (`setup` → `setup-1`). `[!toc]`/`[toc]` headings stay in the
576
+ * record: their ids exist in the rendered page, so links to them are valid
577
+ * anchors regardless of TOC visibility.
578
+ *
579
+ * The record's `text` — a page's fallback title and sidebar label — is the
580
+ * same rendered text with whitespace collapsed, but read without smart
581
+ * punctuation: it keeps the straight quotes and double hyphens the author
582
+ * typed, the way a frontmatter `title` does, and the way an Obsidian
583
+ * `[[Note#It's here]]` heading link spells the heading it resolves against.
380
584
  */
381
585
  /** A scanned heading plus whether its id came from an author pin. */
382
586
  interface ScannedHeading {
@@ -387,23 +591,23 @@ interface ScannedHeading {
387
591
  const toHeading = (
388
592
  depth: number,
389
593
  raw: string,
594
+ form: "atx" | "setext",
390
595
  slugger: GithubSlugger,
391
- isRefDefined: (label: string) => boolean
596
+ context: HeadingContext
392
597
  ): ScannedHeading => {
393
- const unescaped = raw.replaceAll(ESCAPED_PUNCTUATION, "$<char>");
394
- const markers = parseHeadingMarkers(unescaped, isRefDefined);
395
- const text = markers.text.trim();
396
- if (text === "" && (markers.id !== undefined || markers.toc !== undefined)) {
397
- return {
398
- heading: { depth, slug: slugger.slug(unescaped), text: unescaped },
399
- pinned: false,
400
- };
598
+ const rendered = renderHeading(raw, form, context, HEADING_PARSE);
599
+ const display = SMART_PUNCTUATION.test(raw)
600
+ ? renderHeading(raw, form, context, HEADING_TEXT_PARSE).text
601
+ : rendered.text;
602
+ const text = display.replaceAll(/[\t\n\f\r ]+/gu, " ").trim();
603
+ if (rendered.id !== undefined) {
604
+ occupySlug(slugger, rendered.id);
605
+ return { heading: { depth, slug: rendered.id, text }, pinned: true };
401
606
  }
402
- if (markers.id !== undefined) {
403
- occupySlug(slugger, markers.id);
404
- return { heading: { depth, slug: markers.id, text }, pinned: true };
405
- }
406
- return { heading: { depth, slug: slugger.slug(text), text }, pinned: false };
607
+ return {
608
+ heading: { depth, slug: slugger.slug(rendered.text), text },
609
+ pinned: false,
610
+ };
407
611
  };
408
612
 
409
613
  /** Record a heading and where a trailing marker would be written for it. */
@@ -435,7 +639,7 @@ const scanContentLine = (
435
639
  state: HeadingScanState,
436
640
  slugger: GithubSlugger,
437
641
  headings: Heading[],
438
- isRefDefined: (label: string) => boolean
642
+ context: HeadingContext
439
643
  ): void => {
440
644
  // Inline code is masked so a documented `<a id="…">` isn't an anchor.
441
645
  state.anchorLines.push(line.replaceAll(INLINE_CODE, ""));
@@ -446,7 +650,7 @@ const scanContentLine = (
446
650
  pushHeading(
447
651
  headings,
448
652
  state,
449
- toHeading(depth, text, slugger, isRefDefined),
653
+ toHeading(depth, text, "atx", slugger, context),
450
654
  state.line
451
655
  );
452
656
  noteCurlyMarker(text, state.line, state);
@@ -456,15 +660,17 @@ const scanContentLine = (
456
660
  const setext = line.match(SETEXT_UNDERLINE);
457
661
  if (setext?.groups && state.paragraph.length > 0) {
458
662
  // Setext wins over thematic break when it closes a paragraph (CommonMark);
459
- // a multi-line paragraph renders as one heading, soft breaks as spaces.
663
+ // a multi-line paragraph renders as one heading. Its lines keep their line
664
+ // breaks, which the rendered text content keeps too (and the slugger
665
+ // drops, so `Multi\nline` anchors as `multiline`).
460
666
  const depth = setext.groups.marker?.startsWith("=") ? 1 : 2;
461
- const text = state.paragraph.join(" ").trim();
667
+ const text = state.paragraph.join("\n");
462
668
  // A setext heading's markers trail its last text line, just above the
463
669
  // underline — that is where a pin is appended.
464
670
  pushHeading(
465
671
  headings,
466
672
  state,
467
- toHeading(depth, text, slugger, isRefDefined),
673
+ toHeading(depth, text, "setext", slugger, context),
468
674
  state.line - 1
469
675
  );
470
676
  noteCurlyMarker(text, state.paragraphStart, state);
@@ -533,7 +739,7 @@ const scanHeadingLine = (
533
739
  state: HeadingScanState,
534
740
  slugger: GithubSlugger,
535
741
  headings: Heading[],
536
- isRefDefined: (label: string) => boolean
742
+ context: HeadingContext
537
743
  ): void => {
538
744
  // Comments hide fences too, and fences and prompts hide comments. A comment
539
745
  // line still goes to the anchor pass, which strips HTML comments itself.
@@ -577,7 +783,7 @@ const scanHeadingLine = (
577
783
  state.paragraph = [];
578
784
  return;
579
785
  }
580
- scanContentLine(line, state, slugger, headings, isRefDefined);
786
+ scanContentLine(line, state, slugger, headings, context);
581
787
  };
582
788
 
583
789
  /** Where a heading's text ends in the scanned text, for appending a marker. */
@@ -607,7 +813,10 @@ export interface BodyScan {
607
813
  /**
608
814
  * Scan a body for its headings, explicit HTML anchors, and unescaped `{#id}`
609
815
  * markers in one fence-aware walk (the same walk `extractHeadings` exposes for
610
- * headings alone).
816
+ * headings alone). The body is the page's content with its front matter
817
+ * already stripped — the renderers read front matter off the page and never
818
+ * again from its body — so a leading `---` block is a thematic break and
819
+ * content, in `.md` and `.mdx` alike.
611
820
  */
612
821
  export const scanBody = (body: string): BodyScan => {
613
822
  const headings: Heading[] = [];
@@ -625,13 +834,18 @@ export const scanBody = (body: string): BodyScan => {
625
834
  sites: [],
626
835
  };
627
836
 
628
- const { lines, offset } = linesWithoutFrontMatter(body);
629
- const definedLabels = refDefinitionLabels(lines);
630
- const isRefDefined = (label: string): boolean =>
631
- definedLabels.has(label.toLowerCase());
837
+ const lines = body.split("\n");
838
+ let footnotes: Map<string, number> | undefined;
839
+ const context: HeadingContext = {
840
+ footnotes: () => {
841
+ footnotes ??= footnoteNumbers(lines.join("\n"));
842
+ return footnotes;
843
+ },
844
+ labels: refDefinitionLabels(lines),
845
+ };
632
846
  for (const [index, line] of lines.entries()) {
633
- state.line = index + offset + 1;
634
- scanHeadingLine(line, state, slugger, headings, isRefDefined);
847
+ state.line = index + 1;
848
+ scanHeadingLine(line, state, slugger, headings, context);
635
849
  }
636
850
 
637
851
  const anchors = new Set<string>();
@@ -961,7 +1175,7 @@ const deriveTitle = (
961
1175
  // content root's own index has no folder to borrow from.
962
1176
  const folder = stem === "index" ? parts.at(-2) : undefined;
963
1177
  if (folder !== undefined) {
964
- return titleCase(stripNumericPrefix(groupLabel(folder) ?? folder));
1178
+ return titleCase(stripNumericPrefix(orderedGroupLabel(folder) ?? folder));
965
1179
  }
966
1180
  return titleCase(stem);
967
1181
  };
@@ -1180,6 +1394,25 @@ const validateCustomKeys = (
1180
1394
  };
1181
1395
  };
1182
1396
 
1397
+ // A `.` or `..` path segment, which a browser resolves away before requesting.
1398
+ const DOT_SEGMENT = /(?:^|\/)\.{1,2}(?:\/|$)/u;
1399
+
1400
+ /**
1401
+ * A frontmatter `slug` with a `.` or `..` segment names no reachable URL: a
1402
+ * browser normalizes the link (`guides/./x` → `guides/x`, `../x` → `/x`)
1403
+ * before requesting it, and `..` would have Astro write the page outside the
1404
+ * build output. Rejected alongside the schema's own errors.
1405
+ */
1406
+ const slugIssues = (slug: string | undefined): CustomKeyIssue[] =>
1407
+ slug !== undefined && DOT_SEGMENT.test(slug)
1408
+ ? [
1409
+ {
1410
+ message: `"${slug}" has a "." or ".." segment, which browsers resolve away, so no link could reach the page. Write the route out from the content root.`,
1411
+ path: ["slug"],
1412
+ },
1413
+ ]
1414
+ : [];
1415
+
1183
1416
  /**
1184
1417
  * Parse an entry's frontmatter: built-in keys through the strict page schema,
1185
1418
  * custom keys (`frontmatter.extend` plus the page type's
@@ -1218,8 +1451,12 @@ const parseEntryMeta = (
1218
1451
 
1219
1452
  const result = pageMetaSchema.safeParse(known);
1220
1453
  const customResult = extend ? validateCustomKeys(entry.data, extend) : null;
1454
+ const issues = [
1455
+ ...(result.success ? slugIssues(result.data.slug) : []),
1456
+ ...(customResult?.issues ?? []),
1457
+ ];
1221
1458
 
1222
- if (result.success && (customResult?.issues.length ?? 0) === 0) {
1459
+ if (result.success && issues.length === 0) {
1223
1460
  return { custom: customResult?.custom, meta: result.data };
1224
1461
  }
1225
1462
 
@@ -1239,9 +1476,7 @@ const parseEntryMeta = (
1239
1476
  return {
1240
1477
  diagnostics: [
1241
1478
  ...(result.success ? [] : diagnosticsFromZod(result.error, location)),
1242
- ...(customResult
1243
- ? diagnosticsFromIssues(customResult.issues, location)
1244
- : []),
1479
+ ...diagnosticsFromIssues(issues, location),
1245
1480
  ],
1246
1481
  };
1247
1482
  };