blume 0.3.0 → 0.5.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 (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -6,6 +6,7 @@ import { join } from "pathe";
6
6
 
7
7
  import { loadConfig } from "../../core/config.ts";
8
8
  import { resolveProjectContext } from "../../core/project.ts";
9
+ import { parsePort } from "../args.ts";
9
10
  import { logger } from "../log.ts";
10
11
 
11
12
  export const previewCommand = defineCommand({
@@ -32,7 +33,7 @@ export const previewCommand = defineCommand({
32
33
  root: context.outDir,
33
34
  server: {
34
35
  host: args.host ?? false,
35
- port: args.port ? Number(args.port) : undefined,
36
+ port: parsePort(args.port),
36
37
  },
37
38
  });
38
39
  },
@@ -3,12 +3,18 @@ import { existsSync } from "node:fs";
3
3
  import { defineCommand } from "citty";
4
4
  import { join } from "pathe";
5
5
 
6
+ import { resolveAssetMounts } from "../../core/assets.ts";
6
7
  import { BlumeError } from "../../core/diagnostics.ts";
7
8
  import { validateLinks } from "../../core/links.ts";
8
9
  import { scanProject } from "../../core/project-graph.ts";
9
10
  import type { Diagnostic } from "../../core/types.ts";
10
11
  import { reportInternalError } from "../internal-error.ts";
11
- import { logger, reportDiagnostics, reportDiagnosticsJson } from "../log.ts";
12
+ import {
13
+ flushStdout,
14
+ logger,
15
+ reportDiagnostics,
16
+ reportDiagnosticsJson,
17
+ } from "../log.ts";
12
18
 
13
19
  export const validateCommand = defineCommand({
14
20
  args: {
@@ -42,6 +48,7 @@ export const validateCommand = defineCommand({
42
48
  const publicDir = join(root, "public");
43
49
  diagnostics.push(
44
50
  ...(await validateLinks(project.graph, {
51
+ assetMounts: resolveAssetMounts(root, project.config.content.assets),
45
52
  checkExternal: Boolean(args.external),
46
53
  publicDir: existsSync(publicDir) ? publicDir : null,
47
54
  redirects: project.config.redirects,
@@ -57,8 +64,12 @@ export const validateCommand = defineCommand({
57
64
  }
58
65
 
59
66
  if (args.json) {
67
+ // Drain stdout before exiting non-zero: `process.exit` would otherwise
68
+ // truncate the JSON payload mid-write when stdout is a pipe — exactly how
69
+ // `--json` is consumed in CI/editors.
60
70
  const hadErrors = reportDiagnosticsJson(diagnostics, root);
61
71
  if (hadErrors || (Boolean(args.strict) && diagnostics.length > 0)) {
72
+ await flushStdout();
62
73
  process.exit(1);
63
74
  }
64
75
  return;
@@ -0,0 +1,92 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+
9
+ import { join } from "pathe";
10
+
11
+ import { resolveRuntimeDir } from "../core/project.ts";
12
+ import { logger } from "./log.ts";
13
+
14
+ /**
15
+ * A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
16
+ * regenerates and serves `.blume` continuously, so a concurrent `build`,
17
+ * `eject`, or `sync --force` that regenerates or deletes it out from under the
18
+ * running Vite server corrupts the dev session. The lock lets those commands
19
+ * detect a live dev server and refuse.
20
+ */
21
+
22
+ const lockPath = (outDir: string): string => join(outDir, "dev.lock");
23
+
24
+ /**
25
+ * Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
26
+ * process that has since exited (stale) is treated as absent.
27
+ */
28
+ export const isDevLocked = (outDir: string): boolean => {
29
+ const path = lockPath(outDir);
30
+ if (!existsSync(path)) {
31
+ return false;
32
+ }
33
+ const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
34
+ if (!(Number.isInteger(pid) && pid > 0)) {
35
+ return false;
36
+ }
37
+ try {
38
+ // Signal 0 probes liveness without actually signaling the process.
39
+ process.kill(pid, 0);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Write the current process's dev lock into `outDir` and return a release
48
+ * function. The release only removes the file if it's still ours, so a newer
49
+ * dev server's lock is never clobbered.
50
+ */
51
+ export const acquireDevLock = (outDir: string): (() => void) => {
52
+ const path = lockPath(outDir);
53
+ mkdirSync(outDir, { recursive: true });
54
+ writeFileSync(path, String(process.pid));
55
+ let released = false;
56
+ return () => {
57
+ if (released) {
58
+ return;
59
+ }
60
+ released = true;
61
+ try {
62
+ if (
63
+ existsSync(path) &&
64
+ readFileSync(path, "utf-8").trim() === String(process.pid)
65
+ ) {
66
+ rmSync(path, { force: true });
67
+ }
68
+ } catch {
69
+ // Best-effort cleanup; a stale lock is handled by the liveness check.
70
+ }
71
+ };
72
+ };
73
+
74
+ /**
75
+ * Exit with an error when a live `blume dev` owns the runtime dir under `root`.
76
+ * `action` names the operation being refused (e.g. "building"). `runtimeDir`
77
+ * relocates the checked dir: an isolated verify (`.blume-verify`) targets a dir
78
+ * dev never locks, so it proceeds; a default or `--runtime-dir .blume` run still
79
+ * refuses.
80
+ */
81
+ export const refuseIfDevRunning = (
82
+ root: string,
83
+ action: string,
84
+ runtimeDir?: string
85
+ ): void => {
86
+ if (isDevLocked(resolveRuntimeDir(root, runtimeDir))) {
87
+ logger.error(
88
+ `A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server, or re-run with --isolated to build/verify against .blume-verify without touching it.`
89
+ );
90
+ process.exit(1);
91
+ }
92
+ };
package/src/cli/log.ts CHANGED
@@ -11,6 +11,17 @@ import type { Diagnostic } from "../core/types.ts";
11
11
 
12
12
  export const logger = consola.withTag("blume");
13
13
 
14
+ /**
15
+ * Resolve once stdout has drained. `process.exit` doesn't flush a piped stdout,
16
+ * so await this before exiting non-zero after writing machine-readable output
17
+ * (e.g. `--json`), otherwise the payload can be truncated in CI.
18
+ */
19
+ export const flushStdout = (): Promise<void> =>
20
+ // oxlint-disable-next-line promise/avoid-new -- adapt stdout's write callback
21
+ new Promise((resolve) => {
22
+ process.stdout.write("", () => resolve());
23
+ });
24
+
14
25
  /**
15
26
  * Print diagnostics as a JSON document on stdout for CI and editors: each is
16
27
  * enriched with its `docsUrl` and its `file` made root-relative. Returns whether
@@ -24,6 +24,8 @@ export interface PrepareOptions {
24
24
  refresh?: boolean;
25
25
  /** CLI config overrides (e.g. `--output`, `--content-dir`). */
26
26
  overrides?: ConfigOverrides;
27
+ /** Relocate the generated runtime (e.g. `.blume-verify` for `--isolated`). */
28
+ runtimeDir?: string;
27
29
  }
28
30
 
29
31
  /**
@@ -45,6 +47,7 @@ export const prepareProject = async (
45
47
  overrides: options.overrides,
46
48
  preview: options.preview,
47
49
  refresh: options.refresh,
50
+ runtimeDir: options.runtimeDir,
48
51
  });
49
52
  } catch (error) {
50
53
  if (error instanceof BlumeError) {
@@ -38,7 +38,10 @@ import FileTree from "./content/FileTree.astro";
38
38
  import Frame from "./content/Frame.astro";
39
39
  import GithubInfo from "./content/GithubInfo.astro";
40
40
  import Panel from "./content/Panel.astro";
41
+ import ParamField from "./content/ParamField.astro";
41
42
  import Prompt from "./content/Prompt.astro";
43
+ import RequestField from "./content/RequestField.astro";
44
+ import ResponseField from "./content/ResponseField.astro";
42
45
  import Step from "./content/Step.astro";
43
46
  import Steps from "./content/Steps.astro";
44
47
  import Tab from "./content/Tab.astro";
@@ -50,6 +53,7 @@ import TreeFile from "./content/TreeFile.astro";
50
53
  import TreeFolder from "./content/TreeFolder.astro";
51
54
  import TypeTable from "./content/TypeTable.astro";
52
55
  import Visibility from "./content/Visibility.astro";
56
+ import YouTube from "./content/YouTube.astro";
53
57
 
54
58
  interface Props {
55
59
  /** Astro collection the entry lives in. Defaults to `"docs"`. */
@@ -86,7 +90,10 @@ const components = {
86
90
  GithubInfo,
87
91
  Icon,
88
92
  Panel,
93
+ ParamField,
89
94
  Prompt,
95
+ RequestField,
96
+ ResponseField,
90
97
  Step,
91
98
  Steps,
92
99
  Tab,
@@ -96,6 +103,7 @@ const components = {
96
103
  Tree,
97
104
  TypeTable,
98
105
  Visibility,
106
+ YouTube,
99
107
  ...extra,
100
108
  };
101
109
 
@@ -1,10 +1,13 @@
1
1
  ---
2
+ import data from "blume:data";
2
3
  import { resolveIcon } from "../theme/icons.ts";
3
4
 
4
5
  interface Props {
5
6
  name?: unknown;
6
7
  icon?: unknown;
7
8
  iconType?: string;
9
+ /** Icon library for a bare name; defaults to the project's `icons.library`. */
10
+ library?: string;
8
11
  size?: number;
9
12
  class?: string;
10
13
  className?: string;
@@ -16,6 +19,7 @@ const {
16
19
  color,
17
20
  icon,
18
21
  iconType,
22
+ library,
19
23
  name,
20
24
  size = 16,
21
25
  class: astroClass,
@@ -37,8 +41,12 @@ const isImageIcon = (value: string): boolean =>
37
41
  /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu.test(value);
38
42
  const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
39
43
  const resolvedIcon =
40
- iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName, iconType) : null;
41
- const markup = resolvedIcon?.markup;
44
+ iconName && !(imageSrc || rawSvg)
45
+ ? resolveIcon(iconName, {
46
+ iconType,
47
+ library: library ?? data.config.icons.library,
48
+ })
49
+ : null;
42
50
  const resolvedClass = astroClass ?? className;
43
51
  const customStyle = `display:inline-flex;width:${size}px;height:${size}px;${
44
52
  color ? `color:${color}` : ""
@@ -76,21 +84,16 @@ const style = color ? `color:${color}` : undefined;
76
84
  width={size}
77
85
  />
78
86
  ) : (
79
- markup && (
87
+ resolvedIcon && (
80
88
  <svg
81
89
  aria-hidden={label ? undefined : "true"}
82
90
  aria-label={label}
83
91
  class={resolvedClass}
84
- fill="none"
85
92
  height={size}
86
93
  role={label ? "img" : undefined}
87
- set:html={markup}
88
- stroke="currentColor"
89
- stroke-linecap="round"
90
- stroke-linejoin="round"
91
- stroke-width="2"
94
+ set:html={resolvedIcon.body}
92
95
  style={style}
93
- viewBox="0 0 24 24"
96
+ viewBox={resolvedIcon.viewBox}
94
97
  width={size}
95
98
  xmlns="http://www.w3.org/2000/svg"
96
99
  />
@@ -0,0 +1,75 @@
1
+ ---
2
+ // Shared field row behind the Mintlify-compat <ParamField>/<ResponseField>/
3
+ // <RequestField> components: a labeled name + type + badges, with the field's
4
+ // description rendered from the slot (which may hold rich MDX, including nested
5
+ // <Expandable> or further fields). Styled to match the native OpenAPI reference
6
+ // rows (src/components/openapi/), so a migrated site reads consistently.
7
+ interface Props {
8
+ default?: unknown;
9
+ deprecated?: unknown;
10
+ location?: string;
11
+ name?: string;
12
+ required?: unknown;
13
+ type?: string;
14
+ }
15
+
16
+ const {
17
+ default: defaultValue,
18
+ deprecated,
19
+ location,
20
+ name,
21
+ required,
22
+ type,
23
+ } = Astro.props;
24
+
25
+ // Booleans arrive as MDX shorthand (`required`) or strings (`required="true"`).
26
+ const isTrue = (value: unknown): boolean => value === true || value === "true";
27
+ const hasDefault =
28
+ defaultValue !== undefined && defaultValue !== null && defaultValue !== "";
29
+ const hasDescription = Astro.slots.has("default");
30
+ ---
31
+
32
+ <div class="not-prose my-2 rounded-blume border border-border px-4 py-3">
33
+ <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
34
+ {name && <code class="font-mono text-foreground text-sm">{name}</code>}
35
+ {
36
+ location && (
37
+ <span class="rounded bg-muted px-1.5 py-0.5 font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide">
38
+ {location}
39
+ </span>
40
+ )
41
+ }
42
+ {type && <span class="text-muted-foreground text-xs">{type}</span>}
43
+ {
44
+ isTrue(required) && (
45
+ <span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
46
+ required
47
+ </span>
48
+ )
49
+ }
50
+ {
51
+ isTrue(deprecated) && (
52
+ <span class="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide line-through">
53
+ deprecated
54
+ </span>
55
+ )
56
+ }
57
+ </div>
58
+ {
59
+ hasDefault && (
60
+ <div class="mt-1 text-muted-foreground text-xs">
61
+ Default:{" "}
62
+ <code class="rounded bg-muted px-1 py-0.5 text-foreground">
63
+ {String(defaultValue)}
64
+ </code>
65
+ </div>
66
+ )
67
+ }
68
+ {
69
+ hasDescription && (
70
+ <div class="mt-1.5 text-muted-foreground text-sm [&>:first-child]:mt-0 [&>:last-child]:mb-0">
71
+ <slot />
72
+ </div>
73
+ )
74
+ }
75
+ </div>
@@ -0,0 +1,39 @@
1
+ ---
2
+ // Mintlify-compat <ParamField>. Mintlify encodes the parameter's location in the
3
+ // attribute name (`<ParamField path="id" />`, `query`, `header`, `body`); the
4
+ // attribute's value is the field name. Falls back to a plain `name` prop.
5
+ import ApiField from "./ApiField.astro";
6
+
7
+ const {
8
+ body,
9
+ default: defaultValue,
10
+ deprecated,
11
+ header,
12
+ name,
13
+ path,
14
+ query,
15
+ required,
16
+ type,
17
+ } = Astro.props;
18
+
19
+ const locations = [
20
+ { key: "path", value: path },
21
+ { key: "query", value: query },
22
+ { key: "header", value: header },
23
+ { key: "body", value: body },
24
+ ] as const;
25
+ const located = locations.find((entry) => typeof entry.value === "string");
26
+ const fieldName = located ? located.value : name;
27
+ const location = located?.key;
28
+ ---
29
+
30
+ <ApiField
31
+ default={defaultValue}
32
+ deprecated={deprecated}
33
+ location={location}
34
+ name={fieldName}
35
+ required={required}
36
+ type={type}
37
+ >
38
+ <slot />
39
+ </ApiField>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Mintlify-compat <RequestField>: same shape as <ResponseField> — a named field
3
+ // (`name`, `type`, `required`, `default`, `deprecated`) with a slotted body.
4
+ import ApiField from "./ApiField.astro";
5
+
6
+ const {
7
+ default: defaultValue,
8
+ deprecated,
9
+ name,
10
+ required,
11
+ type,
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <ApiField
16
+ default={defaultValue}
17
+ deprecated={deprecated}
18
+ name={name}
19
+ required={required}
20
+ type={type}
21
+ >
22
+ <slot />
23
+ </ApiField>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // Mintlify-compat <ResponseField>: a named field (`name`, `type`, `required`,
3
+ // `default`, `deprecated`) with its description in the slot.
4
+ import ApiField from "./ApiField.astro";
5
+
6
+ const {
7
+ default: defaultValue,
8
+ deprecated,
9
+ name,
10
+ required,
11
+ type,
12
+ } = Astro.props;
13
+ ---
14
+
15
+ <ApiField
16
+ default={defaultValue}
17
+ deprecated={deprecated}
18
+ name={name}
19
+ required={required}
20
+ type={type}
21
+ >
22
+ <slot />
23
+ </ApiField>
@@ -18,7 +18,7 @@ const canRenderIcon =
18
18
  icon !== undefined &&
19
19
  (/^\s*<svg[\s\S]*<\/svg>\s*$/u.test(icon) ||
20
20
  isImageIcon(icon) ||
21
- hasIcon(icon, iconType));
21
+ hasIcon(icon, { iconType }));
22
22
  ---
23
23
 
24
24
  <div
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { parseYouTubeId, youtubeEmbedUrl } from "./youtube.ts";
3
+
4
+ interface Props {
5
+ /** A YouTube video id (e.g. `dQw4w9WgXcQ`). */
6
+ id?: string;
7
+ /** Start playback this many seconds in. */
8
+ start?: number;
9
+ /** Accessible title for the embedded player. */
10
+ title?: string;
11
+ /** A full YouTube URL to extract the id from, as an alternative to `id`. */
12
+ url?: string;
13
+ }
14
+
15
+ const { id, start, title = "YouTube video player", url } = Astro.props;
16
+
17
+ const videoId = parseYouTubeId(id ?? url ?? "");
18
+ const src = videoId ? youtubeEmbedUrl(videoId, { start }) : null;
19
+ ---
20
+
21
+ {
22
+ src && (
23
+ <div class="not-prose my-6 aspect-video overflow-hidden rounded-blume border border-border bg-muted/30">
24
+ <iframe
25
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
26
+ allowfullscreen
27
+ class="h-full w-full"
28
+ loading="lazy"
29
+ referrerpolicy="strict-origin-when-cross-origin"
30
+ src={src}
31
+ title={title}
32
+ />
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Helpers for the `<YouTube>` content component. Kept in a sibling `.ts` (like
3
+ * `diff.ts`/`github-info.ts`) so the id parsing and embed-URL building are pure,
4
+ * unit-testable functions — the `.astro` file stays a thin presentational shell.
5
+ */
6
+
7
+ // A YouTube video id is 11 characters of [A-Za-z0-9_-].
8
+ const BARE_ID = /^[\w-]{11}$/u;
9
+
10
+ // Pull the id out of any common YouTube URL: youtu.be/<id>, watch?v=<id>,
11
+ // /embed/<id>, /shorts/<id>, /live/<id>.
12
+ const URL_ID =
13
+ /(?:youtu\.be\/|\/embed\/|\/shorts\/|\/live\/|[?&]v=)(?<id>[\w-]{11})/u;
14
+
15
+ /**
16
+ * Resolve a YouTube video id from either a bare id or a full URL. Returns `null`
17
+ * when nothing that looks like an id can be found, so the component can render
18
+ * nothing rather than a broken embed.
19
+ */
20
+ export const parseYouTubeId = (input: string): string | null => {
21
+ const value = input.trim();
22
+ if (!value) {
23
+ return null;
24
+ }
25
+ if (BARE_ID.test(value)) {
26
+ return value;
27
+ }
28
+ return URL_ID.exec(value)?.groups?.id ?? null;
29
+ };
30
+
31
+ /**
32
+ * Build a privacy-enhanced (`youtube-nocookie.com`) embed URL, optionally
33
+ * starting at `start` seconds.
34
+ */
35
+ export const youtubeEmbedUrl = (
36
+ id: string,
37
+ options: { start?: number } = {}
38
+ ): string => {
39
+ const base = `https://www.youtube-nocookie.com/embed/${id}`;
40
+ const { start } = options;
41
+ if (start && start > 0) {
42
+ const params = new URLSearchParams({ start: String(Math.floor(start)) });
43
+ return `${base}?${params.toString()}`;
44
+ }
45
+ return base;
46
+ };
@@ -93,21 +93,21 @@ const AskAI = ({ strings }: { strings?: UIStrings["ask"] }) => {
93
93
  headers: { "content-type": "application/json" },
94
94
  method: "POST",
95
95
  });
96
- const reader = response.body?.getReader();
96
+ // A 4xx/5xx still has a body; without this guard its error text would be
97
+ // decoded and shown as the assistant's answer instead of the error notice.
98
+ if (!(response.ok && response.body)) {
99
+ throw new Error(`Ask AI request failed (${response.status}).`);
100
+ }
101
+ const reader = response.body.getReader();
97
102
  const decoder = new TextDecoder();
98
- if (reader) {
99
- let done = false;
100
- while (!done) {
101
- // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
102
- const chunk = await reader.read();
103
- ({ done } = chunk);
104
- if (chunk.value) {
105
- assistant.content += decoder.decode(chunk.value);
106
- setMessages((current) => [
107
- ...current.slice(0, -1),
108
- { ...assistant },
109
- ]);
110
- }
103
+ let done = false;
104
+ while (!done) {
105
+ // oxlint-disable-next-line no-await-in-loop -- sequential stream reads
106
+ const chunk = await reader.read();
107
+ ({ done } = chunk);
108
+ if (chunk.value) {
109
+ assistant.content += decoder.decode(chunk.value);
110
+ setMessages((current) => [...current.slice(0, -1), { ...assistant }]);
111
111
  }
112
112
  }
113
113
  } catch {
@@ -4,9 +4,11 @@ import type { Crumb } from "./nav-utils.ts";
4
4
  interface Props {
5
5
  /** Full breadcrumb trail from the site root to the current page. */
6
6
  crumbs: Crumb[];
7
+ /** Left-align full width (for the wide API layout) instead of the prose measure. */
8
+ wide?: boolean;
7
9
  }
8
10
 
9
- const { crumbs } = Astro.props;
11
+ const { crumbs, wide = false } = Astro.props;
10
12
 
11
13
  // The built-in shows a single "eyebrow" crumb — the parent group — rather than
12
14
  // the whole trail. An override receives the complete list and can render more.
@@ -17,7 +19,10 @@ const eyebrowCrumb = crumbs.length > 1 ? crumbs[crumbs.length - 2] : null;
17
19
  eyebrowCrumb && (
18
20
  <nav
19
21
  aria-label="Breadcrumb"
20
- class="mx-auto mb-2 max-w-[42rem] text-muted-foreground text-sm"
22
+ class:list={[
23
+ "mb-2 text-muted-foreground text-sm",
24
+ wide ? "max-w-none" : "mx-auto max-w-[42rem]",
25
+ ]}
21
26
  >
22
27
  {eyebrowCrumb.route ? (
23
28
  <a class="hover:text-foreground" href={eyebrowCrumb.route}>