blume 1.5.3 → 1.6.1

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 (209) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/cli/index.js +3949 -1403
  3. package/dist/cli/index.js.map +111 -96
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +33 -2
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +10 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +122 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +29 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +26 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/07-faq.mdx +9 -9
  21. package/docs/_snippets/include-demo.mdx +7 -0
  22. package/docs/advanced/api-reference.mdx +13 -4
  23. package/docs/advanced/custom-pages.mdx +4 -2
  24. package/docs/advanced/graphql.mdx +84 -0
  25. package/docs/advanced/meta.ts +8 -1
  26. package/docs/configuration/ai.mdx +25 -3
  27. package/docs/configuration/index.mdx +24 -0
  28. package/docs/configuration/search.mdx +13 -1
  29. package/docs/configuration/seo.mdx +30 -3
  30. package/docs/configuration/theming.mdx +23 -0
  31. package/docs/content/components.mdx +15 -1
  32. package/docs/content/includes.mdx +68 -0
  33. package/docs/content/meta.ts +1 -0
  34. package/docs/content/navigation.mdx +25 -0
  35. package/docs/content/sources.mdx +42 -1
  36. package/docs/content/syntax.mdx +69 -1
  37. package/docs/content/versioning.mdx +15 -9
  38. package/docs/reference/cli.mdx +2 -1
  39. package/package.json +66 -57
  40. package/skills/blume-migrate/SKILL.md +16 -7
  41. package/skills/blume-migrate/references/docusaurus.md +5 -3
  42. package/skills/blume-migrate/references/fumadocs.md +10 -2
  43. package/skills/blume-migrate/references/mintlify.md +3 -2
  44. package/skills/blume-migrate/references/nextra.md +2 -2
  45. package/skills/blume-migrate/references/starlight.md +1 -1
  46. package/src/ai/agent-readability.ts +2 -1
  47. package/src/ai/ask-data.ts +2 -1
  48. package/src/ai/component-markdown.ts +199 -36
  49. package/src/ai/llms.ts +93 -6
  50. package/src/ai/markdown.ts +2 -2
  51. package/src/ai/mcp/discovery.ts +10 -2
  52. package/src/ai/mcp/server.ts +74 -2
  53. package/src/astro/examples.ts +29 -2
  54. package/src/astro/generate.ts +282 -177
  55. package/src/astro/include-hmr.ts +81 -0
  56. package/src/astro/include-refresh.ts +0 -0
  57. package/src/astro/index.ts +10 -5
  58. package/src/astro/markdown-negotiation.ts +1 -1
  59. package/src/astro/runtime-modules.ts +196 -0
  60. package/src/astro/templates.ts +365 -113
  61. package/src/cli/commands/build.ts +91 -16
  62. package/src/cli/commands/dev.ts +6 -3
  63. package/src/cli/host-args.ts +18 -0
  64. package/src/cli/index.ts +2 -1
  65. package/src/cli/init/questions.ts +1 -0
  66. package/src/cli/init/scaffold.ts +27 -4
  67. package/src/components/colors.ts +142 -0
  68. package/src/components/content/Badge.astro +5 -12
  69. package/src/components/content/Callout.astro +19 -36
  70. package/src/components/content/Card.astro +15 -21
  71. package/src/components/content/Component.astro +10 -1
  72. package/src/components/content/GithubInfo.astro +28 -9
  73. package/src/components/content/Tabs.astro +27 -5
  74. package/src/components/content/github-info.ts +20 -5
  75. package/src/components/copy-feedback.ts +93 -9
  76. package/src/components/dropdown-dismiss.ts +122 -0
  77. package/src/components/islands/ask-ai.tsx +4 -1
  78. package/src/components/islands/hooks.ts +3 -1
  79. package/src/components/layout/Fonts.astro +15 -8
  80. package/src/components/layout/Header.astro +44 -0
  81. package/src/components/layout/LanguageSwitcher.astro +9 -1
  82. package/src/components/layout/NavSelector.astro +12 -3
  83. package/src/components/layout/NavTree.astro +6 -18
  84. package/src/components/layout/PageActions.astro +54 -22
  85. package/src/components/layout/PageLayout.astro +2 -0
  86. package/src/components/layout/ReferenceLayout.astro +6 -1
  87. package/src/components/layout/RootLayout.astro +42 -15
  88. package/src/components/layout/Search.astro +36 -4
  89. package/src/components/layout/TableOfContents.astro +8 -2
  90. package/src/components/layout/head-scripts.ts +30 -1
  91. package/src/components/openapi/ApiOverview.astro +13 -3
  92. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  93. package/src/components/openapi/GraphqlChip.astro +33 -0
  94. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  95. package/src/components/openapi/GraphqlOperation.astro +186 -0
  96. package/src/components/openapi/GraphqlType.astro +154 -0
  97. package/src/components/openapi/MethodBadge.astro +3 -14
  98. package/src/components/openapi/Operation.astro +12 -5
  99. package/src/components/openapi/OperationPanel.astro +43 -0
  100. package/src/components/openapi/RequestPanel.astro +5 -10
  101. package/src/components/openapi/Responses.astro +1 -16
  102. package/src/components/openapi/graphql-helpers.ts +466 -0
  103. package/src/components/openapi/playground-client.ts +15 -0
  104. package/src/components/openapi/sample-panels.ts +45 -0
  105. package/src/components/openapi/snippets.ts +13 -35
  106. package/src/core/base-path.ts +11 -0
  107. package/src/core/config-input.ts +209 -2
  108. package/src/core/config.ts +6 -4
  109. package/src/core/content-assets.ts +15 -4
  110. package/src/core/data.ts +28 -3
  111. package/src/core/define-components.ts +2 -0
  112. package/src/core/diagnostics.ts +8 -0
  113. package/src/core/frontmatter.ts +20 -8
  114. package/src/core/github.ts +71 -0
  115. package/src/core/graph.ts +22 -8
  116. package/src/core/heading-markers.ts +96 -0
  117. package/src/core/i18n-ui.ts +12 -0
  118. package/src/core/includes.ts +633 -0
  119. package/src/core/last-modified.ts +36 -11
  120. package/src/core/links.ts +79 -13
  121. package/src/core/manifest.ts +10 -0
  122. package/src/core/meta.ts +2 -1
  123. package/src/core/nav-diagnostics.ts +11 -2
  124. package/src/core/navigation.ts +27 -6
  125. package/src/core/project-graph.ts +61 -9
  126. package/src/core/schema.ts +235 -36
  127. package/src/core/server-features.ts +5 -9
  128. package/src/core/sources/github-releases.ts +2 -2
  129. package/src/core/sources/normalize.ts +502 -115
  130. package/src/core/sources/notion.ts +43 -8
  131. package/src/core/sources/obsidian.ts +1038 -0
  132. package/src/core/sources/read.ts +36 -1
  133. package/src/core/sources/resolve.ts +34 -1
  134. package/src/core/sources/types.ts +28 -6
  135. package/src/core/sources/watch.ts +12 -8
  136. package/src/core/tsconfig-aliases.ts +48 -35
  137. package/src/core/types.ts +31 -2
  138. package/src/core/ui-packs/ar.ts +2 -0
  139. package/src/core/ui-packs/bg.ts +3 -0
  140. package/src/core/ui-packs/bn.ts +2 -0
  141. package/src/core/ui-packs/ca.ts +3 -0
  142. package/src/core/ui-packs/cs.ts +2 -0
  143. package/src/core/ui-packs/da.ts +2 -0
  144. package/src/core/ui-packs/de.ts +3 -0
  145. package/src/core/ui-packs/el.ts +3 -0
  146. package/src/core/ui-packs/es.ts +3 -0
  147. package/src/core/ui-packs/fa.ts +2 -0
  148. package/src/core/ui-packs/fi.ts +2 -0
  149. package/src/core/ui-packs/fr.ts +3 -0
  150. package/src/core/ui-packs/he.ts +2 -0
  151. package/src/core/ui-packs/hi.ts +2 -0
  152. package/src/core/ui-packs/hr.ts +3 -0
  153. package/src/core/ui-packs/hu.ts +3 -0
  154. package/src/core/ui-packs/id.ts +3 -0
  155. package/src/core/ui-packs/it.ts +2 -0
  156. package/src/core/ui-packs/ja.ts +3 -0
  157. package/src/core/ui-packs/ko.ts +3 -0
  158. package/src/core/ui-packs/nl.ts +3 -0
  159. package/src/core/ui-packs/no.ts +3 -0
  160. package/src/core/ui-packs/pl.ts +3 -0
  161. package/src/core/ui-packs/pt-br.ts +3 -0
  162. package/src/core/ui-packs/pt.ts +3 -0
  163. package/src/core/ui-packs/ro.ts +3 -0
  164. package/src/core/ui-packs/ru.ts +3 -0
  165. package/src/core/ui-packs/sk.ts +2 -0
  166. package/src/core/ui-packs/sr.ts +2 -0
  167. package/src/core/ui-packs/sv.ts +3 -0
  168. package/src/core/ui-packs/th.ts +2 -0
  169. package/src/core/ui-packs/tr.ts +3 -0
  170. package/src/core/ui-packs/uk.ts +3 -0
  171. package/src/core/ui-packs/vi.ts +2 -0
  172. package/src/core/ui-packs/zh-tw.ts +2 -0
  173. package/src/core/ui-packs/zh.ts +2 -0
  174. package/src/core/version-cut.ts +26 -6
  175. package/src/core/yaml.ts +26 -0
  176. package/src/deploy/function-bundle.ts +251 -0
  177. package/src/deploy/vercel-negotiation.ts +49 -6
  178. package/src/eval/schema.ts +3 -1
  179. package/src/markdown/code-title.ts +22 -16
  180. package/src/markdown/features.ts +21 -0
  181. package/src/markdown/fence-meta.ts +50 -0
  182. package/src/markdown/heading-anchors.ts +198 -37
  183. package/src/markdown/include.ts +247 -0
  184. package/src/markdown/index.ts +43 -34
  185. package/src/markdown/language-icon.ts +2 -2
  186. package/src/markdown/mdast.ts +7 -3
  187. package/src/markdown/ts2js.ts +264 -0
  188. package/src/og/card.ts +1 -1
  189. package/src/openapi/asyncapi.ts +4 -1
  190. package/src/openapi/graphql-build.ts +293 -0
  191. package/src/openapi/graphql.ts +212 -0
  192. package/src/openapi/model.ts +38 -5
  193. package/src/openapi/parse.ts +34 -0
  194. package/src/openapi/proxy.ts +30 -5
  195. package/src/openapi/references.ts +97 -13
  196. package/src/openapi/render-mdx.ts +66 -12
  197. package/src/openapi/scalar.ts +5 -16
  198. package/src/openapi/source.ts +91 -23
  199. package/src/registry/eject.ts +47 -17
  200. package/src/search/documents.ts +229 -37
  201. package/src/search/orama-index.ts +9 -5
  202. package/src/seo/jsonld.ts +293 -51
  203. package/src/theme/code-block-padding.ts +16 -0
  204. package/src/theme/entry.ts +67 -13
  205. package/src/theme/fonts.ts +189 -16
  206. package/src/theme/sources.ts +49 -0
  207. package/src/translate/prompts.ts +2 -0
  208. package/src/translate/run.ts +7 -0
  209. package/src/translate/work-list.ts +0 -0
@@ -25,6 +25,7 @@ import {
25
25
  buildSkillsIndex,
26
26
  collectSkills,
27
27
  } from "../../ai/skills.ts";
28
+ import type { SkillArtifact } from "../../ai/skills.ts";
28
29
  import {
29
30
  buildSignaturesDirectory,
30
31
  SIGNATURES_DIRECTORY_PATH,
@@ -46,6 +47,11 @@ import {
46
47
  injectWorkerNegotiation,
47
48
  NEGOTIATION_WORKER_FILE,
48
49
  } from "../../deploy/cloudflare-negotiation.ts";
50
+ import {
51
+ auditVercelFunctions,
52
+ blumeDependencyNames,
53
+ functionBundleVerdict,
54
+ } from "../../deploy/function-bundle.ts";
49
55
  import { buildNetlifyHeaders } from "../../deploy/headers.ts";
50
56
  import {
51
57
  buildNetlifyRedirects,
@@ -195,29 +201,29 @@ export const emitHeaderFiles = async (
195
201
  };
196
202
 
197
203
  /**
198
- * Publish the configured Agent Skills: copy each skill artifact under
199
- * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
200
- * `public/.well-known/agent-skills/index.json` takes over the whole surface,
201
- * matching every other generated artifact.
204
+ * Collect the Agent Skills `ai.skills` publishes, once per build, so both the
205
+ * skills surface and llms.txt (which lists them) read the same set. Empty
206
+ * when the feature is off, the directory is missing, nothing in it is
207
+ * publishable (each with a warning), or a user-shipped
208
+ * `public/.well-known/agent-skills/index.json` already owns the surface.
202
209
  */
203
- const emitAgentSkills = async (
210
+ const collectConfiguredSkills = async (
204
211
  project: BlumeProject,
205
212
  distDir: string
206
- ): Promise<void> => {
213
+ ): Promise<SkillArtifact[]> => {
207
214
  const configured = project.config.ai.skills;
208
215
  if (!configured) {
209
- return;
216
+ return [];
210
217
  }
211
218
  const dir = resolve(project.context.root, configured);
212
219
  if (!existsSync(dir)) {
213
220
  logger.warn(
214
221
  `ai.skills points at "${configured}" (${dir}), which does not exist; no skills published.`
215
222
  );
216
- return;
223
+ return [];
217
224
  }
218
- const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
219
- if (existsSync(join(outDir, "index.json"))) {
220
- return;
225
+ if (existsSync(join(distDir, AGENT_SKILLS_DIR.slice(1), "index.json"))) {
226
+ return [];
221
227
  }
222
228
  const { skills, warnings } = await collectSkills(dir);
223
229
  for (const warning of warnings) {
@@ -225,8 +231,26 @@ const emitAgentSkills = async (
225
231
  }
226
232
  if (skills.length === 0) {
227
233
  logger.warn(`ai.skills: no publishable skills found in "${configured}".`);
234
+ }
235
+ return skills;
236
+ };
237
+
238
+ /**
239
+ * Publish the collected Agent Skills: copy each skill artifact under
240
+ * `.well-known/agent-skills/` and emit the discovery index. A user-shipped
241
+ * `public/.well-known/agent-skills/index.json` takes over the whole surface
242
+ * (the collector returns nothing then), matching every other generated
243
+ * artifact.
244
+ */
245
+ const emitAgentSkills = async (
246
+ project: BlumeProject,
247
+ distDir: string,
248
+ skills: readonly SkillArtifact[]
249
+ ): Promise<void> => {
250
+ if (skills.length === 0) {
228
251
  return;
229
252
  }
253
+ const outDir = join(distDir, AGENT_SKILLS_DIR.slice(1));
230
254
  await Promise.all(
231
255
  skills.map(async (skill) => {
232
256
  const target = join(outDir, skill.path);
@@ -310,12 +334,18 @@ const emitVercelNegotiation = async (
310
334
  // endpoint stamps it on dev/server-rendered responses itself.
311
335
  const rawMarkdown = await buildRawMarkdown(project);
312
336
  const home = rawMarkdown["/"];
337
+ // The Markdown 404 routes point at the prerendered `404.md`; only wire them
338
+ // when the build actually emitted it (a project that owns `/404` gets none).
339
+ const notFoundMarkdown = existsSync(
340
+ join(root, ".vercel", "output", "static", "404.md")
341
+ );
313
342
  const injected = injectNegotiationRoutes(
314
343
  await readFile(configPath, "utf-8"),
315
344
  routePaths,
316
345
  buildHomeLinkHeader(config, routePaths),
317
346
  overrides,
318
- home ? markdownTokenCount(agentMarkdown(home)) : undefined
347
+ home ? markdownTokenCount(agentMarkdown(home)) : undefined,
348
+ notFoundMarkdown
319
349
  );
320
350
  if (injected === null) {
321
351
  logger.warn(
@@ -329,6 +359,38 @@ const emitVercelNegotiation = async (
329
359
  );
330
360
  };
331
361
 
362
+ /**
363
+ * Refuse to ship a Vercel function bundle that would crash at runtime: a bare
364
+ * import the adapter's dependency trace silently dropped (see
365
+ * `deploy/function-bundle.ts`). A missing package that is one of Blume's own
366
+ * dependencies is fatal — the generated runtime imports it, so every request
367
+ * would die; a project's own external import is reported as a warning and left
368
+ * to the author.
369
+ */
370
+ const checkVercelFunctionBundles = async (
371
+ outputDir: string,
372
+ root: string
373
+ ): Promise<void> => {
374
+ const audits = await auditVercelFunctions(outputDir);
375
+ if (audits.length === 0) {
376
+ return;
377
+ }
378
+ const own = blumeDependencyNames();
379
+ let fatal = false;
380
+ for (const audit of audits) {
381
+ const verdict = functionBundleVerdict(audit, root, own);
382
+ if (verdict.fatal) {
383
+ fatal = true;
384
+ logger.error(verdict.message);
385
+ } else {
386
+ logger.warn(verdict.message);
387
+ }
388
+ }
389
+ if (fatal) {
390
+ process.exit(1);
391
+ }
392
+ };
393
+
332
394
  const warnCloudflareNegotiationSkipped = (): void =>
333
395
  logger.warn(
334
396
  "Could not wire Accept: text/markdown negotiation into dist/server/wrangler.json — raw Markdown stays available at the .md URLs."
@@ -559,7 +621,8 @@ export const isolatedStaticDir = (
559
621
  */
560
622
  const publishLlmsFiles = async (
561
623
  project: BlumeProject,
562
- distDir: string
624
+ distDir: string,
625
+ skills: readonly SkillArtifact[]
563
626
  ): Promise<void> => {
564
627
  const indexPath = join(distDir, "llms.txt");
565
628
  const fullPath = join(distDir, "llms-full.txt");
@@ -568,7 +631,7 @@ const publishLlmsFiles = async (
568
631
  if (!(writeIndex || writeFull)) {
569
632
  return;
570
633
  }
571
- const { index, full } = await buildLlmsFiles(project);
634
+ const { index, full } = await buildLlmsFiles(project, { skills });
572
635
  const writes: Promise<void>[] = [];
573
636
  if (writeIndex) {
574
637
  writes.push(writeFile(indexPath, index, "utf-8"));
@@ -613,8 +676,10 @@ const publishBuildArtifacts = async (
613
676
  warn: (message) => logger.warn(message),
614
677
  });
615
678
 
679
+ // Collected once: llms.txt lists the skills the build publishes below.
680
+ const skills = await collectConfiguredSkills(project, distDir);
616
681
  if (project.config.ai.llmsTxt.enabled) {
617
- await publishLlmsFiles(project, distDir);
682
+ await publishLlmsFiles(project, distDir, skills);
618
683
  }
619
684
 
620
685
  // A user's own public/ file (copied into dist by Astro) always wins.
@@ -652,7 +717,7 @@ const publishBuildArtifacts = async (
652
717
  }
653
718
 
654
719
  await emitWellKnownFiles(project.config, distDir);
655
- await emitAgentSkills(project, distDir);
720
+ await emitAgentSkills(project, distDir, skills);
656
721
 
657
722
  await emitRedirectFiles(project.config, distDir);
658
723
  await emitHeaderFiles(project, distDir);
@@ -798,6 +863,15 @@ export const buildCommand = defineCommand({
798
863
  // 100` exiting 0 without measuring anything would be a silent false pass
799
864
  // in CI.
800
865
  if (runtimeDir) {
866
+ if (
867
+ project.config.deployment.output === "server" &&
868
+ project.config.deployment.adapter === "vercel"
869
+ ) {
870
+ await checkVercelFunctionBundles(
871
+ isolatedOutputDir(project.config, project.context),
872
+ root
873
+ );
874
+ }
801
875
  await runClientAssetChecks(
802
876
  isolatedStaticDir(project.config, project.context),
803
877
  args
@@ -831,6 +905,7 @@ export const buildCommand = defineCommand({
831
905
  }
832
906
 
833
907
  if (project.config.deployment.output === "server" && adapter === "vercel") {
908
+ await checkVercelFunctionBundles(join(root, ".vercel", "output"), root);
834
909
  await emitVercelNegotiation(project, markdownRoutePaths(project), root);
835
910
  }
836
911
 
@@ -19,8 +19,9 @@ import { prepareProject } from "../prepare.ts";
19
19
 
20
20
  /**
21
21
  * Resolve a `--host` flag value into what Astro/Vite's `server.host` expects.
22
- * citty (0.1) has no mixed string/boolean arg type, so `host` is declared as a
23
- * string and a bare `--host` parses as `""` — Node would bind all interfaces
22
+ * citty has no mixed string/boolean arg type, so `host` is declared as a
23
+ * string and a bare `--host` parses as `""` (the CLI entry rewrites it to
24
+ * `--host=` first; see `host-args.ts`) — Node would bind all interfaces
24
25
  * for `""`, but Vite's `resolveHostname` treats it as a literal hostname and
25
26
  * prints malformed URLs like `http://:4321/`. Match Astro's own `--host`
26
27
  * semantics instead: bare flag → `true` (bind all interfaces), `--host
@@ -219,7 +220,9 @@ export const devCommand = defineCommand({
219
220
  }).on("all", regenerate);
220
221
  const disposers = [
221
222
  ...project.sources.map((source) => source.watch?.(regenerate)),
222
- () => void projectWatcher.close(),
223
+ () => {
224
+ void projectWatcher.close();
225
+ },
223
226
  ].filter((dispose) => dispose !== undefined);
224
227
 
225
228
  const shutdown = async () => {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Rewrite a bare `--host` in the raw argv to `--host=` before citty parses it.
3
+ *
4
+ * `host` is a string arg (citty has no mixed string/boolean type), and citty
5
+ * 0.2 parses with `node:util.parseArgs`, where a string option consumes the
6
+ * next token as its value even when that token is another flag: `blume dev
7
+ * --host --open` would bind the literal hostname "--open" and drop `--open`.
8
+ * The `--host=` spelling parses as `""` without touching its neighbor, which
9
+ * `normalizeHost` then maps to Astro's "bind all interfaces".
10
+ */
11
+ export const normalizeHostArgs = (rawArgs: readonly string[]): string[] =>
12
+ rawArgs.map((arg, index) => {
13
+ if (arg !== "--host") {
14
+ return arg;
15
+ }
16
+ const next = rawArgs[index + 1];
17
+ return next === undefined || next.startsWith("-") ? "--host=" : arg;
18
+ });
package/src/cli/index.ts CHANGED
@@ -17,6 +17,7 @@ import { translateCommand } from "./commands/translate.ts";
17
17
  import { validateCommand } from "./commands/validate.ts";
18
18
  import { versionCommand } from "./commands/version.ts";
19
19
  import { loadEnvFiles } from "./env.ts";
20
+ import { normalizeHostArgs } from "./host-args.ts";
20
21
  import { reportInternalError } from "./internal-error.ts";
21
22
 
22
23
  const main = defineCommand({
@@ -60,4 +61,4 @@ process.on("unhandledRejection", (error) => {
60
61
  process.exit(1);
61
62
  });
62
63
 
63
- runMain(main);
64
+ runMain(main, { rawArgs: normalizeHostArgs(process.argv.slice(2)) });
@@ -111,6 +111,7 @@ export const collectAnswers = async (
111
111
  message: "Where does your content live?",
112
112
  options: [
113
113
  { hint: "Local .mdx files", label: "filesystem", value: "filesystem" },
114
+ { hint: "An Obsidian vault", label: "obsidian", value: "obsidian" },
114
115
  {
115
116
  hint: "Changelog from GitHub Releases",
116
117
  label: "github-releases",
@@ -15,6 +15,7 @@ export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
15
15
  /** The content-source kinds `init` can scaffold a config block for. */
16
16
  export const SOURCE_KINDS = [
17
17
  "filesystem",
18
+ "obsidian",
18
19
  "github-releases",
19
20
  "notion",
20
21
  "sanity",
@@ -209,8 +210,12 @@ export const titleize = (raw: string): string => {
209
210
  .join(" ");
210
211
  };
211
212
 
212
- /** True when any selected source is remote (everything except `filesystem`). */
213
- const hasRemoteSource = (sources: SourceKind[]): boolean =>
213
+ /**
214
+ * True when any selected source needs an explicit `content.sources` array —
215
+ * everything except `filesystem`, which is what the implicit default already
216
+ * desugars to.
217
+ */
218
+ const needsExplicitSources = (sources: SourceKind[]): boolean =>
214
219
  sources.some((source) => source !== "filesystem");
215
220
 
216
221
  /**
@@ -239,6 +244,14 @@ const SOURCE_SNIPPETS = {
239
244
  database: "your-database-id",
240
245
  prefix: "notion",
241
246
  },`,
247
+ obsidian: ` // An Obsidian vault, read in place. No export step, and no
248
+ // generated notes in your repo. Point \`vault\` at your vault directory,
249
+ // relative to this config file.
250
+ {
251
+ type: "obsidian",
252
+ vault: "vault",
253
+ prefix: "notes",
254
+ },`,
242
255
  sanity: ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
243
256
  // from the environment.
244
257
  {
@@ -258,7 +271,7 @@ const SOURCE_SNIPPETS = {
258
271
  const contentBlockFor = (answers: InitAnswers): string => {
259
272
  const sources =
260
273
  answers.sources.length === 0 ? ["filesystem" as const] : answers.sources;
261
- if (!hasRemoteSource(sources)) {
274
+ if (!needsExplicitSources(sources)) {
262
275
  return answers.contentDir === "docs"
263
276
  ? ""
264
277
  : `
@@ -267,7 +280,7 @@ const contentBlockFor = (answers: InitAnswers): string => {
267
280
  },`;
268
281
  }
269
282
  // Explicit sources replace the implicit filesystem desugar, so the local
270
- // content dir must be listed alongside the remote sources to stay included.
283
+ // content dir must be listed alongside the other sources to stay included.
271
284
  const entries = SOURCE_KINDS.filter((kind) => sources.includes(kind)).map(
272
285
  (kind) =>
273
286
  kind === "filesystem"
@@ -328,6 +341,16 @@ export const buildPlan = (
328
341
  .map((file) => ({ ...file, path: join(root, file.path) }))
329
342
  );
330
343
  }
344
+ // The obsidian snippet points at `vault/`; seed the directory with a first
345
+ // note so the scaffolded project passes the source's `validate()` and boots
346
+ // before the user has opened Obsidian at all.
347
+ if (answers.sources.includes("obsidian")) {
348
+ files.push({
349
+ content:
350
+ "# Welcome\n\nThis folder is read by Blume's `obsidian` source. Open it as a vault in Obsidian and write notes — `[[Wikilinks]]` become site links.\n",
351
+ path: join(root, "vault", "Welcome.md"),
352
+ });
353
+ }
331
354
  return files;
332
355
  };
333
356
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Color classes shared by every component that draws a label over a tint of
3
+ * its own hue — method badges, response status chips, `<Badge>`, the sidebar's
4
+ * method badges — and by the typed callout/card icons. One table so the pairs
5
+ * can't drift between components.
6
+ *
7
+ * Contrast rule (light mode): text over a 15% tint of its hue must clear 4.5:1,
8
+ * the WCAG AA bar for text this size. `-700` does for most hues; green and
9
+ * orange need `-800`, the way yellow already does over its 20% tint. Dark mode
10
+ * clears the bar at `-300` throughout. Check any hue added here.
11
+ */
12
+
13
+ export type Hue =
14
+ | "blue"
15
+ | "green"
16
+ | "orange"
17
+ | "purple"
18
+ | "red"
19
+ | "teal"
20
+ | "violet"
21
+ | "yellow";
22
+
23
+ /** Label over a translucent tint of the same hue. */
24
+ export const TINT = {
25
+ blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
26
+ green: "bg-green-500/15 text-green-800 dark:text-green-300",
27
+ orange: "bg-orange-500/15 text-orange-800 dark:text-orange-300",
28
+ purple: "bg-purple-500/15 text-purple-700 dark:text-purple-300",
29
+ red: "bg-red-500/15 text-red-700 dark:text-red-300",
30
+ teal: "bg-teal-500/15 text-teal-700 dark:text-teal-300",
31
+ violet: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
32
+ yellow: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
33
+ } satisfies Record<Hue, string>;
34
+
35
+ /**
36
+ * Label with a translucent border of the same hue and no fill. The label
37
+ * inherits whatever surface it sits on (a callout, a card, the muted panel), so
38
+ * it is held to the same values as `TINT`: green at `-700` is 4.47:1 on the
39
+ * default muted surface.
40
+ */
41
+ export const STROKE = {
42
+ blue: "border-blue-500/40 text-blue-700 dark:text-blue-300",
43
+ green: "border-green-500/40 text-green-800 dark:text-green-300",
44
+ orange: "border-orange-500/40 text-orange-800 dark:text-orange-300",
45
+ purple: "border-purple-500/40 text-purple-700 dark:text-purple-300",
46
+ red: "border-red-500/40 text-red-700 dark:text-red-300",
47
+ teal: "border-teal-500/40 text-teal-700 dark:text-teal-300",
48
+ violet: "border-violet-500/40 text-violet-700 dark:text-violet-300",
49
+ yellow: "border-yellow-500/40 text-yellow-800 dark:text-yellow-300",
50
+ } satisfies Record<Hue, string>;
51
+
52
+ /** The neutral fallback every table falls through to. */
53
+ export const MUTED = "bg-muted text-muted-foreground";
54
+
55
+ /**
56
+ * HTTP methods, AsyncAPI actions, and GraphQL root-field kinds, as shown on an
57
+ * operation's badge and in a reference's sidebar. Type-page kinds fall through
58
+ * to the muted default so operation badges stay the loud ones.
59
+ */
60
+ export const METHOD_COLORS = {
61
+ DELETE: TINT.red,
62
+ GET: TINT.green,
63
+ HEAD: MUTED,
64
+ MUTATION: TINT.blue,
65
+ OPTIONS: MUTED,
66
+ PATCH: TINT.yellow,
67
+ POST: TINT.blue,
68
+ PUT: TINT.orange,
69
+ QUERY: TINT.green,
70
+ RECEIVE: TINT.teal,
71
+ SEND: TINT.violet,
72
+ SUBSCRIPTION: TINT.violet,
73
+ } satisfies Record<string, string>;
74
+
75
+ const isMethod = (key: string): key is keyof typeof METHOD_COLORS =>
76
+ Object.hasOwn(METHOD_COLORS, key);
77
+
78
+ export const methodColor = (method: string): string => {
79
+ const key = method.toUpperCase();
80
+ return isMethod(key) ? METHOD_COLORS[key] : MUTED;
81
+ };
82
+
83
+ /** Response status chips, by the status code's class. */
84
+ export const statusColor = (status: string): string => {
85
+ if (status.startsWith("2")) {
86
+ return TINT.green;
87
+ }
88
+ if (status.startsWith("3")) {
89
+ return TINT.blue;
90
+ }
91
+ if (status.startsWith("4")) {
92
+ return TINT.orange;
93
+ }
94
+ if (status.startsWith("5")) {
95
+ return TINT.red;
96
+ }
97
+ return MUTED;
98
+ };
99
+
100
+ /**
101
+ * The small `deprecated` label beside an operation's path or field name. It
102
+ * draws on the page background, where orange needs `-700` for 4.5:1.
103
+ */
104
+ export const DEPRECATED_LABEL_CLASS =
105
+ "font-medium text-[0.625rem] text-orange-700 uppercase tracking-wide dark:text-orange-400";
106
+
107
+ /** The typed admonitions `<Callout>` and `<Card>` render; `check` is an alias of `success`. */
108
+ export type AdmonitionType =
109
+ | "info"
110
+ | "note"
111
+ | "tip"
112
+ | "success"
113
+ | "warning"
114
+ | "danger";
115
+
116
+ export const admonitionType = (
117
+ type: AdmonitionType | "check"
118
+ ): AdmonitionType => (type === "check" ? "success" : type);
119
+
120
+ export const ADMONITION_ICON = {
121
+ danger: "circle-x",
122
+ info: "info",
123
+ note: "info",
124
+ success: "circle-check",
125
+ tip: "lightbulb",
126
+ warning: "triangle-alert",
127
+ } satisfies Record<AdmonitionType, string>;
128
+
129
+ /**
130
+ * An admonition's icon names its type, so it is meaningful UI held to the 3:1
131
+ * non-text bar against the tint behind it: green and amber need `-700` over
132
+ * their 10% tint, and the note icon is full-strength muted-foreground (at 70%
133
+ * it fell under the bar on the muted surface).
134
+ */
135
+ export const ADMONITION_ICON_CLASS = {
136
+ danger: "text-red-600 dark:text-red-400",
137
+ info: "text-blue-600 dark:text-blue-400",
138
+ note: "text-muted-foreground",
139
+ success: "text-green-700 dark:text-green-400",
140
+ tip: "text-accent",
141
+ warning: "text-amber-700 dark:text-amber-400",
142
+ } satisfies Record<AdmonitionType, string>;
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { STROKE, TINT } from "../colors.ts";
2
3
  import Icon from "../Icon.astro";
3
4
 
4
5
  type BadgeVariant = "default" | "accent" | "success" | "warning" | "danger";
@@ -44,34 +45,26 @@ const colorName =
44
45
  }[variant];
45
46
  const customColor = colorName.startsWith("#") ? colorName : null;
46
47
 
48
+ // The hue rows come from the shared table (see colors.ts for the contrast
49
+ // rule); the neutral and surface rows are Badge's own.
47
50
  const filledColorClass: Record<string, string> = {
48
- blue: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
51
+ ...TINT,
49
52
  gray: "bg-muted text-muted-foreground",
50
- green: "bg-green-500/15 text-green-700 dark:text-green-300",
51
- orange: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
52
- purple: "bg-purple-500/15 text-purple-700 dark:text-purple-300",
53
- red: "bg-red-500/15 text-red-700 dark:text-red-300",
54
53
  surface: "bg-background text-muted-foreground ring-1 ring-border",
55
54
  "surface-destructive":
56
55
  "bg-background text-red-700 ring-1 ring-red-500/30 dark:text-red-300",
57
56
  white: "bg-white text-muted-foreground ring-1 ring-border dark:bg-white/10",
58
57
  "white-destructive":
59
58
  "bg-white text-red-700 ring-1 ring-red-500/30 dark:bg-white/10 dark:text-red-300",
60
- yellow: "bg-yellow-500/20 text-yellow-800 dark:text-yellow-300",
61
59
  };
62
60
 
63
61
  const strokeColorClass: Record<string, string> = {
64
- blue: "border-blue-500/40 text-blue-700 dark:text-blue-300",
62
+ ...STROKE,
65
63
  gray: "border-border text-muted-foreground",
66
- green: "border-green-500/40 text-green-700 dark:text-green-300",
67
- orange: "border-orange-500/40 text-orange-700 dark:text-orange-300",
68
- purple: "border-purple-500/40 text-purple-700 dark:text-purple-300",
69
- red: "border-red-500/40 text-red-700 dark:text-red-300",
70
64
  surface: "border-border text-muted-foreground",
71
65
  "surface-destructive": "border-red-500/40 text-red-700 dark:text-red-300",
72
66
  white: "border-border text-muted-foreground",
73
67
  "white-destructive": "border-red-500/40 text-red-700 dark:text-red-300",
74
- yellow: "border-yellow-500/40 text-yellow-800 dark:text-yellow-300",
75
68
  };
76
69
 
77
70
  const sizeClass: Record<BadgeSize, string> = {
@@ -1,64 +1,47 @@
1
1
  ---
2
+ import {
3
+ ADMONITION_ICON,
4
+ ADMONITION_ICON_CLASS,
5
+ type AdmonitionType,
6
+ admonitionType,
7
+ } from "../colors.ts";
2
8
  import Icon from "../Icon.astro";
3
9
 
4
- type CalloutType =
5
- | "info"
6
- | "note"
7
- | "tip"
8
- | "success"
9
- | "check"
10
- | "warning"
11
- | "danger";
12
-
13
10
  interface Props {
14
- type?: CalloutType;
11
+ type?: AdmonitionType | "check";
15
12
  title?: string;
16
13
  icon?: unknown;
17
14
  color?: string;
18
15
  }
19
16
 
20
17
  const { color, icon, type = "info", title } = Astro.props;
18
+ // `check` is an alias of `success`; fold it once so the tables need one row.
19
+ const kind = admonitionType(type);
21
20
 
22
- const iconByType: Record<CalloutType, string> = {
23
- check: "circle-check",
24
- danger: "circle-x",
25
- info: "info",
26
- note: "info",
27
- success: "circle-check",
28
- tip: "lightbulb",
29
- warning: "triangle-alert",
30
- };
31
-
32
- const variantClass: Record<CalloutType, string> = {
33
- check: "border-green-500/25 bg-green-500/10",
21
+ const variantClass: Record<AdmonitionType, string> = {
34
22
  danger: "border-red-500/25 bg-red-500/10",
35
23
  info: "border-blue-500/25 bg-blue-500/10",
36
24
  note: "border-border bg-muted",
37
25
  success: "border-green-500/25 bg-green-500/10",
38
- tip: "border-accent/25 bg-accent/10",
26
+ // 6% rather than the 10% the other variants use: this tint comes from the
27
+ // user-configurable accent, whose default is near black, and muted body
28
+ // text on a 10% black tint sits at 4.25:1. 6% clears WCAG AA (4.5:1)
29
+ // against any accent — 4.62:1 worst case on pure black.
30
+ tip: "border-accent/25 bg-accent/6",
39
31
  warning: "border-amber-500/25 bg-amber-500/10",
40
32
  };
41
-
42
- const iconClass: Record<CalloutType, string> = {
43
- check: "text-green-600 dark:text-green-400",
44
- danger: "text-red-600 dark:text-red-400",
45
- info: "text-blue-600 dark:text-blue-400",
46
- note: "text-muted-foreground/70",
47
- success: "text-green-600 dark:text-green-400",
48
- tip: "text-accent",
49
- warning: "text-amber-600 dark:text-amber-400",
50
- };
51
33
  ---
52
34
 
53
35
  <aside
54
36
  class:list={[
55
37
  "not-prose my-5 flex flex-row items-start gap-2 rounded-blume border px-4 py-3 font-sans text-muted-foreground text-sm leading-6 [&_a]:underline [&_a]:decoration-current/50 [&_code]:font-mono",
56
- color ? "bg-background" : variantClass[type],
38
+ color ? "bg-background" : variantClass[kind],
57
39
  ]}
40
+ data-blume-callout={kind}
58
41
  style={color ? `border:1px solid ${color};color:${color}` : undefined}
59
42
  >
60
- <span class:list={["mt-0.5 shrink-0", color ? "" : iconClass[type]]}>
61
- <Icon color={color} icon={icon ?? iconByType[type]} size={16} />
43
+ <span class:list={["mt-0.5 shrink-0", color ? "" : ADMONITION_ICON_CLASS[kind]]}>
44
+ <Icon color={color} icon={icon ?? ADMONITION_ICON[kind]} size={16} />
62
45
  </span>
63
46
  {/* The global prose rule leaks a 1rem margin onto these paragraphs/lists even
64
47
  though the callout is not-prose; with a title the body isn't the first
@@ -1,4 +1,9 @@
1
1
  ---
2
+ import {
3
+ ADMONITION_ICON,
4
+ ADMONITION_ICON_CLASS,
5
+ admonitionType,
6
+ } from "../colors.ts";
2
7
  import Icon from "../Icon.astro";
3
8
  import { withBase } from "../islands/base-path.ts";
4
9
  import { contentHref } from "./base-href.ts";
@@ -22,31 +27,20 @@ const external = href?.startsWith("http");
22
27
  const isHorizontal = horizontal === true || horizontal === "true";
23
28
  const showArrow =
24
29
  href && arrow === undefined ? external : arrow === true || arrow === "true";
25
- const iconByType = {
26
- check: "circle-check",
27
- danger: "circle-x",
28
- info: "info",
29
- note: "info",
30
- tip: "lightbulb",
31
- warning: "triangle-alert",
32
- };
33
- const iconClass = {
34
- check: "text-green-600 dark:text-green-400",
35
- danger: "text-red-600 dark:text-red-400",
36
- info: "text-blue-600 dark:text-blue-400",
37
- note: "text-muted-foreground/70",
38
- tip: "text-accent",
39
- warning: "text-amber-600 dark:text-amber-400",
40
- };
41
- const iconName = icon ?? (type ? iconByType[type] : undefined);
30
+ // The typed icon shares Callout's table (and its contrast rule); the surface
31
+ // tints are Card's own, a shade lighter than a callout's.
32
+ const kind = type ? admonitionType(type) : undefined;
33
+ const iconName = icon ?? (kind ? ADMONITION_ICON[kind] : undefined);
42
34
  const variantClass = {
43
- check: "border-green-500/30 bg-green-500/10",
44
35
  danger: "border-red-500/30 bg-red-500/10",
45
36
  info: "border-blue-500/30 bg-blue-500/10",
46
37
  note: "border-border bg-muted/40",
47
- tip: "border-accent/30 bg-accent/10",
38
+ success: "border-green-500/30 bg-green-500/10",
39
+ // 6% for the same reason as Callout: the accent defaults to near black, and
40
+ // muted body text on a 10% black tint misses WCAG AA.
41
+ tip: "border-accent/30 bg-accent/6",
48
42
  warning: "border-amber-500/30 bg-amber-500/10",
49
- }[type ?? "note"];
43
+ }[kind ?? "note"];
50
44
  ---
51
45
 
52
46
  <Tag
@@ -75,7 +69,7 @@ const variantClass = {
75
69
  <div class="p-5">
76
70
  {
77
71
  iconName && (
78
- <div class:list={["mb-2.5", color ? "" : type ? iconClass[type] : "text-accent"]}>
72
+ <div class:list={["mb-2.5", color ? "" : kind ? ADMONITION_ICON_CLASS[kind] : "text-accent"]}>
79
73
  <Icon color={color} name={iconName} size={20} />
80
74
  </div>
81
75
  )