blume 1.6.5 → 1.7.0

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 (179) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/blume.mjs +3 -2
  3. package/dist/cli/chunk-0qhq7b8q.js +111 -0
  4. package/dist/cli/chunk-0qhq7b8q.js.map +11 -0
  5. package/dist/cli/chunk-18tjv4f7.js +96 -0
  6. package/dist/cli/chunk-18tjv4f7.js.map +10 -0
  7. package/dist/cli/chunk-27gtm2ym.js +69 -0
  8. package/dist/cli/chunk-27gtm2ym.js.map +11 -0
  9. package/dist/cli/chunk-2aj8ddew.js +72 -0
  10. package/dist/cli/chunk-2aj8ddew.js.map +10 -0
  11. package/dist/cli/chunk-3r94j3tc.js +221 -0
  12. package/dist/cli/chunk-3r94j3tc.js.map +10 -0
  13. package/dist/cli/chunk-4trphnvy.js +102 -0
  14. package/dist/cli/chunk-4trphnvy.js.map +11 -0
  15. package/dist/cli/chunk-4xyggvgf.js +21 -0
  16. package/dist/cli/chunk-4xyggvgf.js.map +10 -0
  17. package/dist/cli/chunk-5d4q7121.js +4064 -0
  18. package/dist/cli/chunk-5d4q7121.js.map +40 -0
  19. package/dist/cli/chunk-5hs6gb7n.js +32 -0
  20. package/dist/cli/chunk-5hs6gb7n.js.map +10 -0
  21. package/dist/cli/chunk-6kzzpsx8.js +26 -0
  22. package/dist/cli/chunk-6kzzpsx8.js.map +10 -0
  23. package/dist/cli/chunk-8gnpdsn1.js +952 -0
  24. package/dist/cli/chunk-8gnpdsn1.js.map +12 -0
  25. package/dist/cli/chunk-9qs6acpw.js +176 -0
  26. package/dist/cli/chunk-9qs6acpw.js.map +10 -0
  27. package/dist/cli/chunk-agy5rzxy.js +2453 -0
  28. package/dist/cli/chunk-agy5rzxy.js.map +15 -0
  29. package/dist/cli/chunk-bcy492zc.js +16 -0
  30. package/dist/cli/chunk-bcy492zc.js.map +10 -0
  31. package/dist/cli/chunk-btfr9yvw.js +41 -0
  32. package/dist/cli/chunk-btfr9yvw.js.map +10 -0
  33. package/dist/cli/chunk-cbjnx4s8.js +73 -0
  34. package/dist/cli/chunk-cbjnx4s8.js.map +10 -0
  35. package/dist/cli/chunk-cfw6x4rm.js +1967 -0
  36. package/dist/cli/chunk-cfw6x4rm.js.map +34 -0
  37. package/dist/cli/chunk-ckh3a410.js +277 -0
  38. package/dist/cli/chunk-ckh3a410.js.map +11 -0
  39. package/dist/cli/chunk-drke6t0h.js +259 -0
  40. package/dist/cli/chunk-drke6t0h.js.map +11 -0
  41. package/dist/cli/chunk-ev67ycx0.js +15 -0
  42. package/dist/cli/chunk-ev67ycx0.js.map +10 -0
  43. package/dist/cli/chunk-ey89bjj1.js +209 -0
  44. package/dist/cli/chunk-ey89bjj1.js.map +11 -0
  45. package/dist/cli/chunk-j6pxe0dt.js +69 -0
  46. package/dist/cli/chunk-j6pxe0dt.js.map +11 -0
  47. package/dist/cli/chunk-jk1zwka1.js +387 -0
  48. package/dist/cli/chunk-jk1zwka1.js.map +12 -0
  49. package/dist/cli/chunk-jtb45atp.js +467 -0
  50. package/dist/cli/chunk-jtb45atp.js.map +14 -0
  51. package/dist/cli/chunk-jxkxjsc1.js +76 -0
  52. package/dist/cli/chunk-jxkxjsc1.js.map +10 -0
  53. package/dist/cli/chunk-kwx90v78.js +81 -0
  54. package/dist/cli/chunk-kwx90v78.js.map +10 -0
  55. package/dist/cli/chunk-n0nyat6g.js +30 -0
  56. package/dist/cli/chunk-n0nyat6g.js.map +10 -0
  57. package/dist/cli/chunk-pxj10x8y.js +35 -0
  58. package/dist/cli/chunk-pxj10x8y.js.map +10 -0
  59. package/dist/cli/chunk-qq9nm3qd.js +1141 -0
  60. package/dist/cli/chunk-qq9nm3qd.js.map +19 -0
  61. package/dist/cli/chunk-s102bysw.js +5170 -0
  62. package/dist/cli/chunk-s102bysw.js.map +47 -0
  63. package/dist/cli/chunk-s5dsk8bj.js +769 -0
  64. package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
  65. package/dist/cli/chunk-s5e5jt53.js +227 -0
  66. package/dist/cli/chunk-s5e5jt53.js.map +11 -0
  67. package/dist/cli/chunk-sbdqrjbb.js +81 -0
  68. package/dist/cli/chunk-sbdqrjbb.js.map +10 -0
  69. package/dist/cli/chunk-tnskyrej.js +117 -0
  70. package/dist/cli/chunk-tnskyrej.js.map +10 -0
  71. package/dist/cli/chunk-v2ymm99c.js +1016 -0
  72. package/dist/cli/chunk-v2ymm99c.js.map +13 -0
  73. package/dist/cli/chunk-v5mm027v.js +185 -0
  74. package/dist/cli/chunk-v5mm027v.js.map +11 -0
  75. package/dist/cli/chunk-vt8fgygt.js +23 -0
  76. package/dist/cli/chunk-vt8fgygt.js.map +10 -0
  77. package/dist/cli/chunk-vxv4x1n8.js +17 -0
  78. package/dist/cli/chunk-vxv4x1n8.js.map +10 -0
  79. package/dist/cli/chunk-wd27zjcz.js +60 -0
  80. package/dist/cli/chunk-wd27zjcz.js.map +10 -0
  81. package/dist/cli/chunk-x66c5yjn.js +23 -0
  82. package/dist/cli/chunk-x66c5yjn.js.map +10 -0
  83. package/dist/cli/chunk-xv91q4nm.js +5314 -0
  84. package/dist/cli/chunk-xv91q4nm.js.map +58 -0
  85. package/dist/cli/chunk-y3g15rvv.js +679 -0
  86. package/dist/cli/chunk-y3g15rvv.js.map +15 -0
  87. package/dist/cli/chunk-ye9zdkgv.js +136 -0
  88. package/dist/cli/chunk-ye9zdkgv.js.map +10 -0
  89. package/dist/cli/chunk-ynacq3ev.js +1062 -0
  90. package/dist/cli/chunk-ynacq3ev.js.map +25 -0
  91. package/dist/cli/chunk-zr3ygrq3.js +54 -0
  92. package/dist/cli/chunk-zr3ygrq3.js.map +10 -0
  93. package/dist/cli/index.js +55 -27597
  94. package/dist/cli/index.js.map +5 -243
  95. package/dist/types/ai/ask-context.d.ts +26 -0
  96. package/dist/types/components/layout/nav-utils.d.ts +33 -1
  97. package/dist/types/core/code-fences.d.ts +11 -0
  98. package/dist/types/core/package-root.d.ts +1 -1
  99. package/dist/types/core/schema.d.ts +70 -0
  100. package/dist/types/theme/fonts.d.ts +22 -22
  101. package/docs/02-deployment.mdx +22 -1
  102. package/docs/configuration/ask-ai.mdx +1 -1
  103. package/docs/configuration/customization.mdx +2 -9
  104. package/docs/content/navigation.mdx +2 -0
  105. package/docs/content/syntax.mdx +1 -1
  106. package/docs/discoverability/open-graph.mdx +4 -0
  107. package/docs/reference/cli.mdx +1 -1
  108. package/package.json +4 -2
  109. package/src/ai/api/handlers.ts +4 -7
  110. package/src/ai/api/paths.ts +8 -0
  111. package/src/ai/api/spec.ts +2 -1
  112. package/src/ai/ask-context.ts +378 -22
  113. package/src/astro/generate.ts +161 -28
  114. package/src/astro/include-hmr.ts +10 -13
  115. package/src/astro/include-refresh.ts +0 -0
  116. package/src/astro/index.ts +6 -1
  117. package/src/astro/integration.ts +280 -53
  118. package/src/astro/module-types.ts +83 -0
  119. package/src/astro/templates.ts +256 -108
  120. package/src/audit/image-size.ts +10 -8
  121. package/src/cli/command-meta.ts +77 -0
  122. package/src/cli/commands/add.ts +2 -4
  123. package/src/cli/commands/audit.ts +2 -4
  124. package/src/cli/commands/build.ts +70 -346
  125. package/src/cli/commands/check.ts +2 -4
  126. package/src/cli/commands/dev.ts +31 -42
  127. package/src/cli/commands/doctor.ts +2 -4
  128. package/src/cli/commands/eject.ts +3 -41
  129. package/src/cli/commands/eval.ts +2 -5
  130. package/src/cli/commands/init.ts +2 -4
  131. package/src/cli/commands/mcp-stdio.ts +2 -5
  132. package/src/cli/commands/preview.ts +3 -5
  133. package/src/cli/commands/sync.ts +2 -4
  134. package/src/cli/commands/translate.ts +2 -5
  135. package/src/cli/commands/validate.ts +2 -4
  136. package/src/cli/commands/version.ts +2 -4
  137. package/src/cli/eject-scripts.ts +0 -45
  138. package/src/cli/host-args.ts +16 -0
  139. package/src/cli/index.ts +84 -35
  140. package/src/cli/lazy-command.ts +47 -0
  141. package/src/components/Icon.astro +24 -0
  142. package/src/components/content/GithubInfo.astro +4 -1
  143. package/src/components/icon-sprite-middleware.ts +41 -0
  144. package/src/components/icon-sprite.ts +93 -0
  145. package/src/components/layout/IconSprite.astro +11 -0
  146. package/src/components/layout/NavTree.astro +156 -188
  147. package/src/components/layout/NavTreeCache.astro +45 -0
  148. package/src/components/layout/NavTreeScript.astro +256 -0
  149. package/src/components/layout/PageActions.astro +11 -5
  150. package/src/components/layout/PageLayout.astro +21 -3
  151. package/src/components/layout/ReferenceLayout.astro +21 -4
  152. package/src/components/layout/RootLayout.astro +44 -6
  153. package/src/components/layout/nav-cache.ts +49 -0
  154. package/src/components/layout/nav-utils.ts +69 -1
  155. package/src/components/layout/page-locale.ts +29 -0
  156. package/src/core/api-name.ts +18 -0
  157. package/src/core/code-fences.ts +48 -0
  158. package/src/core/content-assets.ts +3 -7
  159. package/src/core/includes.ts +3 -7
  160. package/src/core/package-root.ts +1 -1
  161. package/src/core/schema.ts +19 -0
  162. package/src/core/sources/normalize.ts +2 -37
  163. package/src/core/sources/obsidian.ts +3 -2
  164. package/src/core/svg-dimensions.ts +97 -0
  165. package/src/core/version-cut.ts +2 -2
  166. package/src/deploy/artifacts.ts +370 -0
  167. package/src/deploy/cloudflare-negotiation.ts +97 -32
  168. package/src/deploy/function-bundle.ts +66 -20
  169. package/src/deploy/sitemap.ts +6 -0
  170. package/src/deploy/vercel-negotiation.ts +8 -30
  171. package/src/markdown/language-icon.ts +64 -20
  172. package/src/markdown/mermaid.ts +11 -0
  173. package/src/og/cache.ts +236 -0
  174. package/src/og/card.ts +18 -16
  175. package/src/og/index.ts +8 -1
  176. package/src/openapi/render-mdx.ts +9 -5
  177. package/src/registry/eject.ts +23 -10
  178. package/src/theme/entry.ts +41 -7
  179. package/src/theme/fonts.ts +30 -23
@@ -1,5 +1,5 @@
1
1
  import { isRootTab, isUnderPath } from "../../core/navigation.ts";
2
- import type { NavNode, NavTab } from "../../core/types.ts";
2
+ import type { NavNode, NavTab, Navigation } from "../../core/types.ts";
3
3
 
4
4
  /** A flat, ordered page reference used for previous/next pagination. */
5
5
  export interface FlatPage {
@@ -238,3 +238,71 @@ export const getPagination = (flat: FlatPage[], route: string) => {
238
238
  prev: index > 0 ? (flat[index - 1] ?? null) : null,
239
239
  };
240
240
  };
241
+
242
+ /**
243
+ * A stable id for every group in a sidebar — `g<n>` by pre-order position in
244
+ * the full tree. The layout hands `NavTree` a scoped view of that tree (a
245
+ * tab's section, or the sidebar minus the tab sections), so positions within
246
+ * the rendered slice differ from page to page; these ids name the same group
247
+ * everywhere, which the drill-in panels and the deferred-section fragments
248
+ * (`/blume-nav/…`) rely on. Keyed by node identity: the scoped views reuse
249
+ * the full tree's node objects.
250
+ */
251
+ export const navGroupIds = (sidebar: NavNode[]): Map<NavNode, string> => {
252
+ const ids = new Map<NavNode, string>();
253
+ const walk = (nodes: NavNode[]): void => {
254
+ for (const node of nodes) {
255
+ if (node.kind === "group") {
256
+ ids.set(node, `g${ids.size}`);
257
+ walk(node.children);
258
+ }
259
+ }
260
+ };
261
+ walk(sidebar);
262
+ return ids;
263
+ };
264
+
265
+ /** Whether any group in a sidebar renders as a disclosure or a drill-in panel. */
266
+ export const hasDeferrableGroups = (sidebar: NavNode[]): boolean =>
267
+ sidebar.some(
268
+ (node) =>
269
+ node.kind === "group" &&
270
+ ((node.display ?? "flat") !== "flat" ||
271
+ hasDeferrableGroups(node.children))
272
+ );
273
+
274
+ /** One of the navigation trees a site renders, by URL segment. */
275
+ export interface NavVariant {
276
+ /** `current`, or an archived version id. */
277
+ version: string;
278
+ /** `default`, or a locale code. */
279
+ locale: string;
280
+ navigation: Navigation;
281
+ }
282
+
283
+ /**
284
+ * Every navigation tree the runtime data holds — the default, each locale's,
285
+ * and each archived version's per locale — keyed the way the deferred
286
+ * sidebar fragments' URLs are (`/blume-nav/<version>/<locale>/…`). An
287
+ * unlocalized version tree is keyed by `""` in the data; it maps to
288
+ * `default` here.
289
+ */
290
+ export const navVariants = (data: {
291
+ navigation: Navigation;
292
+ navigationByLocale: Record<string, Navigation>;
293
+ navigationByVersion: Record<string, Record<string, Navigation>>;
294
+ }): NavVariant[] => [
295
+ { locale: "default", navigation: data.navigation, version: "current" },
296
+ ...Object.entries(data.navigationByLocale).map(([locale, navigation]) => ({
297
+ locale,
298
+ navigation,
299
+ version: "current",
300
+ })),
301
+ ...Object.entries(data.navigationByVersion).flatMap(([version, byLocale]) =>
302
+ Object.entries(byLocale).map(([locale, navigation]) => ({
303
+ locale: locale || "default",
304
+ navigation,
305
+ version,
306
+ }))
307
+ ),
308
+ ];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Locale defaults for the document shells (`RootLayout`, `PageLayout`,
3
+ * `ReferenceLayout`) when the page doesn't pass `locale`/`dir` itself.
4
+ *
5
+ * The content catch-all always passes both, resolved from the route manifest
6
+ * (which also knows about fallback pages). Custom `.astro` pages, the 404
7
+ * page, and the reference shell don't have that data, so they fall back to
8
+ * what Astro's own i18n routing resolved for the request — `Astro.currentLocale`
9
+ * — which the generated config enables with Blume's locale list. Before this,
10
+ * a custom page under `/fr/` rendered `<html lang="en">` unless it threaded
11
+ * the locale through by hand.
12
+ */
13
+
14
+ /**
15
+ * The page's locale: the caller's explicit value, else the one Astro resolved
16
+ * from the URL, else the site's default locale, else `en` (no i18n).
17
+ */
18
+ export const pageLocale = (
19
+ i18n: { defaultLocale: string } | null,
20
+ explicit?: string,
21
+ current?: string
22
+ ): string => explicit ?? current ?? i18n?.defaultLocale ?? "en";
23
+
24
+ /** The configured text direction of a locale; `ltr` when unknown. */
25
+ export const pageDirection = (
26
+ i18n: { locales: { code: string; dir: "ltr" | "rtl" }[] } | null,
27
+ locale: string
28
+ ): "ltr" | "rtl" =>
29
+ i18n?.locales.find((entry) => entry.code === locale)?.dir ?? "ltr";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Whether a name already reads as an API's name: it contains `API` or `APIs`
3
+ * as a whole word, anywhere. Word-bounded on both sides, so "OpenAPI" and
4
+ * "AsyncAPI" are product names rather than API names and still take the
5
+ * suffix; unanchored at the end, so "Petstore API v2" is left alone too.
6
+ */
7
+ const NAMES_AN_API = /\bAPIs?\b/iu;
8
+
9
+ /**
10
+ * `name` as the phrase "the ___" in generated prose — the spec title or
11
+ * reference label, followed by ` API` unless it already says so. Keeps
12
+ * "Petstore" reading as "Petstore API" without turning "Petstore API" into
13
+ * "Petstore API API".
14
+ */
15
+ export const apiNamePhrase = (name: string): string => {
16
+ const trimmed = name.trim();
17
+ return NAMES_AN_API.test(trimmed) ? trimmed : `${trimmed} API`;
18
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Fenced-code state for line scanners. Kept free of imports so request-time
3
+ * code (the Ask AI grounding excerpts) can share it with the build-time source
4
+ * scanners without pulling the filesystem and schema modules along.
5
+ *
6
+ * CommonMark allows backtick *and* tilde fences, three or more characters
7
+ * long. The scanners track which delimiter opened the current fence and how
8
+ * long its run was (`null` when outside one), so a ``` line inside a ~~~
9
+ * block — or inside a ````-delimited block (the wrapper `codeBlockLines`
10
+ * emits around code that contains its own ``` fence) — is content, not a
11
+ * toggle.
12
+ */
13
+ const CODE_FENCE = /^(?<run>`{3,}|~{3,})/u;
14
+
15
+ /** The open fence's delimiter char and run length, or null outside one. */
16
+ export type FenceState = { delimiter: "`" | "~"; length: number } | null;
17
+
18
+ /**
19
+ * Advance the fenced-code state for one line: an opening fence records its
20
+ * delimiter and run length, only a bare run of the same character at least as
21
+ * long closes it (CommonMark), and any other line leaves the state untouched.
22
+ */
23
+ export const nextFenceState = (line: string, fence: FenceState): FenceState => {
24
+ const trimmed = line.trimStart();
25
+ const run = trimmed.match(CODE_FENCE)?.groups?.run;
26
+ if (run === undefined) {
27
+ return fence;
28
+ }
29
+ const rest = trimmed.slice(run.length);
30
+ const delimiter = run.startsWith("`") ? ("`" as const) : ("~" as const);
31
+ if (fence === null) {
32
+ // A backtick fence's info string cannot itself contain a backtick
33
+ // (CommonMark) — a line-leading ```inline``` span is a paragraph, and
34
+ // opening a phantom fence on it would swallow every heading and link
35
+ // after it. Tilde fences carry no such rule.
36
+ if (delimiter === "`" && rest.includes("`")) {
37
+ return fence;
38
+ }
39
+ return { delimiter, length: run.length };
40
+ }
41
+ // A closing fence may be followed only by whitespace: a same-length
42
+ // ```js line inside an open ``` block is content, not a closer.
43
+ return fence.delimiter === delimiter &&
44
+ run.length >= fence.length &&
45
+ rest.trim() === ""
46
+ ? null
47
+ : fence;
48
+ };
@@ -11,14 +11,10 @@ import {
11
11
  } from "pathe";
12
12
 
13
13
  import { normalizeBasePath } from "./base-path.ts";
14
+ import { nextFenceState } from "./code-fences.ts";
15
+ import type { FenceState } from "./code-fences.ts";
14
16
  import { hashText } from "./sources/cache.ts";
15
- import type { FenceState } from "./sources/normalize.ts";
16
- import {
17
- INLINE_CODE,
18
- MD_IMAGE,
19
- nextFenceState,
20
- targetOffsetIn,
21
- } from "./sources/normalize.ts";
17
+ import { INLINE_CODE, MD_IMAGE, targetOffsetIn } from "./sources/normalize.ts";
22
18
  import { readExpandedEntryText } from "./sources/read.ts";
23
19
  import type { ContentSource } from "./sources/types.ts";
24
20
  import type { PageRecord } from "./types.ts";
@@ -2,14 +2,10 @@ import { readFile } from "node:fs/promises";
2
2
 
3
3
  import { dirname, extname, join, relative, resolve } from "pathe";
4
4
 
5
+ import { nextFenceState } from "./code-fences.ts";
6
+ import type { FenceState } from "./code-fences.ts";
5
7
  import matter from "./frontmatter.ts";
6
- import type { FenceState } from "./sources/normalize.ts";
7
- import {
8
- INLINE_CODE,
9
- MD_IMAGE,
10
- nextFenceState,
11
- targetOffsetIn,
12
- } from "./sources/normalize.ts";
8
+ import { INLINE_CODE, MD_IMAGE, targetOffsetIn } from "./sources/normalize.ts";
13
9
  import type { Diagnostic } from "./types.ts";
14
10
 
15
11
  /**
@@ -28,7 +28,7 @@ let cached: string | undefined;
28
28
  * Anchoring here — rather than at a fixed offset from `import.meta` — keeps the
29
29
  * package's own `src/`, assets, and `node_modules` locatable whether the code
30
30
  * runs from source under Bun (`src/...`) or from the published, bundled CLI
31
- * (`dist/cli/index.js`). The two layouts sit at different depths, so a relative
31
+ * (`dist/cli/*.js`). The two layouts sit at different depths, so a relative
32
32
  * `../..` resolves to different places; locating `package.json` does not.
33
33
  */
34
34
  export const packageRoot = (): string => {
@@ -176,6 +176,25 @@ export const pageMetaSchema = pageMetaBaseSchema;
176
176
  export type PageMeta = z.infer<typeof pageMetaBaseSchema>;
177
177
  export type PageMetaInput = z.input<typeof pageMetaBaseSchema>;
178
178
 
179
+ /** The fully-defaulted front matter of a page that declares nothing. */
180
+ const EMPTY_PAGE_META: PageMeta = pageMetaBaseSchema.parse({});
181
+
182
+ /**
183
+ * The page schema as the generated content collections declare it, so
184
+ * `entry.data` is typed and normalized (dates as ISO strings, X handles with
185
+ * their `@`) the same way the scan's `PageMeta` is. Two deliberate loosenings
186
+ * over {@link pageMetaSchema}: custom keys (`frontmatter.extend`, per-type
187
+ * maps) pass through instead of failing the strict parse, and a page the
188
+ * strict parse rejects resolves to the empty defaults instead of throwing.
189
+ * The scan has already dropped such a page with a `BLUME_FRONTMATTER_INVALID`
190
+ * diagnostic — Blume continues without it unless `--strict` — so the
191
+ * collection must not turn that dropped page into a failed content sync.
192
+ */
193
+ export const pageCollectionSchema = pageMetaBaseSchema
194
+ .loose()
195
+ // oxlint-disable-next-line promise/prefer-await-to-then -- zod's catch, not a promise
196
+ .catch(() => EMPTY_PAGE_META);
197
+
179
198
  /** Built-in page frontmatter keys; custom keys must never redeclare one. */
180
199
  const BUILT_IN_PAGE_META_KEYS = new Set<string>(
181
200
  pageMetaBaseSchema.keyof().options
@@ -4,6 +4,8 @@ import GithubSlugger from "github-slugger";
4
4
  import { extname } from "pathe";
5
5
 
6
6
  import { withBasePath } from "../base-path.ts";
7
+ import { nextFenceState } from "../code-fences.ts";
8
+ import type { FenceState } from "../code-fences.ts";
7
9
  import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
8
10
  import { occupySlug, parseHeadingMarkers } from "../heading-markers.ts";
9
11
  import { localePlacement, localizeRoute } from "../i18n.ts";
@@ -139,43 +141,6 @@ const mapRoute = (relativePath: string): MappedRoute => {
139
141
  return { groups, route, segments };
140
142
  };
141
143
 
142
- // CommonMark allows backtick *and* tilde fences, three or more characters
143
- // long. The scanners track which delimiter opened the current fence and how
144
- // long its run was (`null` when outside one), so a ``` line inside a ~~~
145
- // block — or inside a ````-delimited block (the wrapper `codeBlockLines`
146
- // emits around code that contains its own ``` fence) — is content, not a
147
- // toggle. See `nextFenceState`.
148
- const CODE_FENCE = /^(?<run>`{3,}|~{3,})/u;
149
-
150
- /** The open fence's delimiter char and run length, or null outside one. */
151
- export type FenceState = { delimiter: "`" | "~"; length: number } | null;
152
-
153
- /**
154
- * Advance the fenced-code state for one line: an opening fence records its
155
- * delimiter and run length, only a run of the same character at least as long
156
- * closes it (CommonMark), and any other line leaves the state untouched.
157
- */
158
- export const nextFenceState = (line: string, fence: FenceState): FenceState => {
159
- const trimmed = line.trimStart();
160
- const run = trimmed.match(CODE_FENCE)?.groups?.run;
161
- if (run === undefined) {
162
- return fence;
163
- }
164
- const delimiter = run.startsWith("`") ? ("`" as const) : ("~" as const);
165
- if (fence === null) {
166
- // A backtick fence's info string cannot itself contain a backtick
167
- // (CommonMark) — a line-leading ```inline``` span is a paragraph, and
168
- // opening a phantom fence on it would swallow every heading and link
169
- // after it. Tilde fences carry no such rule.
170
- if (delimiter === "`" && trimmed.slice(run.length).includes("`")) {
171
- return fence;
172
- }
173
- return { delimiter, length: run.length };
174
- }
175
- return fence.delimiter === delimiter && run.length >= fence.length
176
- ? null
177
- : fence;
178
- };
179
144
  // A closing hash sequence must be preceded by whitespace (CommonMark), so a
180
145
  // heading like `## What is C#` keeps its trailing `#`. Up to 3 leading spaces
181
146
  // are allowed; 4+ is an indented code block.
@@ -4,18 +4,19 @@ import { readdir, readFile, stat } from "node:fs/promises";
4
4
 
5
5
  import { basename, isAbsolute, join, relative, resolve } from "pathe";
6
6
 
7
+ import { nextFenceState } from "../code-fences.ts";
8
+ import type { FenceState } from "../code-fences.ts";
7
9
  import { BlumeError } from "../diagnostics.ts";
8
10
  import matter from "../frontmatter.ts";
9
11
  import { pageMetaSchema } from "../schema.ts";
10
12
  import type { ResolvedI18nConfig, ResolvedVersionsConfig } from "../schema.ts";
11
13
  import type { Diagnostic } from "../types.ts";
12
14
  import { hashText } from "./cache.ts";
13
- import type { EntryPlacement, FenceState } from "./normalize.ts";
15
+ import type { EntryPlacement } from "./normalize.ts";
14
16
  import {
15
17
  extractHeadings,
16
18
  isStringValue,
17
19
  localizedRoute,
18
- nextFenceState,
19
20
  placeEntryRef,
20
21
  resolveEntryRoute,
21
22
  slugifyPath,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pixel dimensions declared by an SVG document's root element.
3
+ *
4
+ * Blume needs an SVG's size in two places — the header logo's reserved
5
+ * `<img>` box and the OG card's brand mark — and both read it through this
6
+ * one parser so the header and the card can't disagree about one logo. It
7
+ * inspects the root `<svg …>` tag only: explicit `width`/`height` win, a
8
+ * `viewBox` fills in whatever is missing (scaled to a lone explicit length
9
+ * when there is one), and lengths carry CSS units at 96dpi. Percentages have
10
+ * no pixel meaning and are ignored, so a percent-sized SVG measures by its
11
+ * viewBox alone.
12
+ */
13
+ export interface SvgDimensions {
14
+ height: number;
15
+ width: number;
16
+ }
17
+
18
+ /** The root tag, tolerating `>` inside a quoted attribute value. */
19
+ const ROOT = /<svg\s(?:[^>"']|"[^"]*"|'[^']*')*>/u;
20
+ const WIDTH = /\swidth=(?<quote>['"])(?<value>[^%]+?)\k<quote>/u;
21
+ const HEIGHT = /\sheight=(?<quote>['"])(?<value>[^%]+?)\k<quote>/u;
22
+ const VIEWBOX = /\sviewBox=(?<quote>['"])(?<value>.+?)\k<quote>/iu;
23
+
24
+ /** Pixels per CSS unit at 96dpi; an omitted unit means pixels. */
25
+ const INCH_CM = 2.54;
26
+ const UNITS = new Map([
27
+ ["cm", 96 / INCH_CM],
28
+ ["em", 16],
29
+ ["ex", 8],
30
+ ["in", 96],
31
+ ["m", (96 / INCH_CM) * 100],
32
+ ["mm", 96 / INCH_CM / 10],
33
+ ["pc", (96 / 72) * 12],
34
+ ["pt", 96 / 72],
35
+ ["px", 1],
36
+ ]);
37
+ const LENGTH =
38
+ /^(?<number>[0-9.]+(?:e\d+)?)(?<unit>in|cm|em|ex|m|mm|pc|pt|px)?$/u;
39
+
40
+ /** A CSS length as whole pixels, or undefined when it isn't one. */
41
+ const parseLength = (value: string | undefined): number | undefined => {
42
+ const groups = value === undefined ? undefined : LENGTH.exec(value)?.groups;
43
+ if (!groups) {
44
+ return;
45
+ }
46
+ const pixels = Math.round(
47
+ Number(groups.number) * (UNITS.get(groups.unit ?? "px") ?? 1)
48
+ );
49
+ return pixels > 0 ? pixels : undefined;
50
+ };
51
+
52
+ /** An attribute's raw value on the root tag, or undefined when absent. */
53
+ const attribute = (root: string, pattern: RegExp): string | undefined =>
54
+ pattern.exec(root)?.groups?.value;
55
+
56
+ /** The viewBox's own width and height (its third and fourth numbers). */
57
+ const viewBoxSize = (root: string): SvgDimensions | null => {
58
+ const raw = attribute(root, VIEWBOX);
59
+ if (raw === undefined) {
60
+ return null;
61
+ }
62
+ const bounds = raw.split(" ");
63
+ const width = parseLength(bounds[2]);
64
+ const height = parseLength(bounds[3]);
65
+ return width && height ? { height, width } : null;
66
+ };
67
+
68
+ /**
69
+ * The SVG's pixel size, or null when the root carries no usable size: no
70
+ * root tag, neither explicit dimensions nor a viewBox, a viewBox that isn't
71
+ * space-separated, or a zero or unparsable length.
72
+ */
73
+ export const svgDimensions = (svg: string): SvgDimensions | null => {
74
+ const root = ROOT.exec(svg)?.[0];
75
+ if (root === undefined) {
76
+ return null;
77
+ }
78
+ const width = parseLength(attribute(root, WIDTH));
79
+ const height = parseLength(attribute(root, HEIGHT));
80
+ if (width && height) {
81
+ return { height, width };
82
+ }
83
+ const box = viewBoxSize(root);
84
+ if (!box) {
85
+ return null;
86
+ }
87
+ const ratio = box.width / box.height;
88
+ if (width) {
89
+ const scaled = Math.floor(width / ratio);
90
+ return scaled > 0 ? { height: scaled, width } : null;
91
+ }
92
+ if (height) {
93
+ const scaled = Math.floor(height * ratio);
94
+ return scaled > 0 ? { height, width: scaled } : null;
95
+ }
96
+ return box;
97
+ };
@@ -4,13 +4,13 @@ import { cp, readdir, readFile, rm } from "node:fs/promises";
4
4
  import { join, relative } from "pathe";
5
5
 
6
6
  import { stripBasePath } from "./base-path.ts";
7
+ import { nextFenceState } from "./code-fences.ts";
8
+ import type { FenceState } from "./code-fences.ts";
7
9
  import { writeTextAtomic } from "./fs-atomic.ts";
8
10
  import { localizeRoute } from "./i18n.ts";
9
11
  import type { BlumeProject } from "./project-graph.ts";
10
12
  import { isWithin, scanProject } from "./project-graph.ts";
11
13
  import { VERSION_ID } from "./schema.ts";
12
- import { nextFenceState } from "./sources/normalize.ts";
13
- import type { FenceState } from "./sources/normalize.ts";
14
14
  import { VERSION_LIKE, versionizeRoute } from "./versions.ts";
15
15
 
16
16
  /** What `cutVersion` did, for the CLI to report. */