blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -1,8 +1,67 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { fileURLToPath } from "node:url";
2
3
 
3
4
  import type { AstroIntegration } from "astro";
4
5
 
6
+ import type { AssetMount } from "../core/assets.ts";
7
+ import { enrichDiagnostic } from "../core/diagnostics.ts";
8
+ import type { Diagnostic } from "../core/types.ts";
5
9
  import { markdownVariantUrl, prefersMarkdown } from "./markdown-negotiation.ts";
10
+ import { copyAssetMounts, serveAssetMounts } from "./static-assets.ts";
11
+
12
+ /** The dev server's HMR channel — either `.ws` (Vite ≤5) or `.hot` (Vite 6+). */
13
+ interface OverlayChannel {
14
+ send: (payload: unknown) => void;
15
+ }
16
+ interface OverlayServer {
17
+ hot?: OverlayChannel;
18
+ ws?: OverlayChannel;
19
+ }
20
+
21
+ // Set on `astro:server:setup`; read by `showBlumeErrorOverlay` so the CLI's
22
+ // regeneration can push Blume diagnostics into Vite's browser error overlay.
23
+ // Same-process module singleton (dev and the integration share the instance).
24
+ let overlayServer: OverlayServer | null = null;
25
+
26
+ const overlayChannel = (): OverlayChannel | undefined =>
27
+ overlayServer?.ws ?? overlayServer?.hot;
28
+
29
+ /**
30
+ * Surface Blume's own diagnostics (config/frontmatter/content errors) in the
31
+ * Vite/Astro browser error overlay during `blume dev`, so they don't hide in the
32
+ * terminal. A no-op when there are no errors or the dev server isn't up. The
33
+ * overlay clears itself on the next successful HMR update.
34
+ */
35
+ export const showBlumeErrorOverlay = (diagnostics: Diagnostic[]): void => {
36
+ const errors = diagnostics
37
+ .filter((diagnostic) => diagnostic.severity === "error")
38
+ .map(enrichDiagnostic);
39
+ const channel = overlayChannel();
40
+ if (errors.length === 0 || !channel) {
41
+ return;
42
+ }
43
+ const body = errors
44
+ .map((diagnostic) => {
45
+ const where = diagnostic.file
46
+ ? `\n at ${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}` : ""}`
47
+ : "";
48
+ const fix = diagnostic.suggestion
49
+ ? `\n fix: ${diagnostic.suggestion}`
50
+ : "";
51
+ const docs = diagnostic.docsUrl ? `\n docs: ${diagnostic.docsUrl}` : "";
52
+ return `[${diagnostic.code}] ${diagnostic.message}${where}${fix}${docs}`;
53
+ })
54
+ .join("\n\n");
55
+ channel.send({
56
+ err: {
57
+ id: errors[0]?.file,
58
+ message: `Blume found ${errors.length} error(s):\n\n${body}`,
59
+ plugin: "blume",
60
+ stack: "",
61
+ },
62
+ type: "error",
63
+ });
64
+ };
6
65
 
7
66
  /** A user page mounted into the generated runtime. */
8
67
  export interface BlumePageRoute {
@@ -16,6 +75,13 @@ export interface BlumeIntegrationOptions {
16
75
  pages: BlumePageRoute[];
17
76
  /** Page routes that have a raw-Markdown variant (the content manifest). */
18
77
  contentRoutes: string[];
78
+ /** Configured `deployment.base`, stripped from dev URLs before matching. */
79
+ base?: string;
80
+ /**
81
+ * `content.assets` mounts: top-level dirs served at the site root in dev and
82
+ * copied into the build output, so root-served assets need no relocation.
83
+ */
84
+ assets?: AssetMount[];
19
85
  }
20
86
 
21
87
  /**
@@ -27,13 +93,13 @@ export interface BlumeIntegrationOptions {
27
93
  * keep serving HTML.
28
94
  */
29
95
  const negotiateMarkdown =
30
- (routes: ReadonlySet<string>) =>
96
+ (routes: ReadonlySet<string>, base?: string) =>
31
97
  (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
32
98
  if (
33
99
  (req.method === "GET" || req.method === "HEAD") &&
34
100
  prefersMarkdown(req.headers.accept)
35
101
  ) {
36
- const variant = markdownVariantUrl(req.url, routes);
102
+ const variant = markdownVariantUrl(req.url, routes, base);
37
103
  if (variant) {
38
104
  res.setHeader("Vary", "Accept");
39
105
  req.url = variant;
@@ -52,6 +118,12 @@ export const blumeIntegration = (
52
118
  options: BlumeIntegrationOptions
53
119
  ): AstroIntegration => ({
54
120
  hooks: {
121
+ "astro:build:done": async ({ dir }) => {
122
+ // Copy in-place asset mounts into the build output, mirroring publicDir.
123
+ if (options.assets?.length) {
124
+ await copyAssetMounts(options.assets, fileURLToPath(dir));
125
+ }
126
+ },
55
127
  "astro:config:setup": ({ injectRoute }) => {
56
128
  for (const page of options.pages) {
57
129
  injectRoute({
@@ -62,12 +134,22 @@ export const blumeIntegration = (
62
134
  }
63
135
  },
64
136
  "astro:server:setup": ({ server }) => {
137
+ // Keep a handle on the dev server so Blume diagnostics can be pushed to
138
+ // its browser error overlay (see `showBlumeErrorOverlay`).
139
+ overlayServer = server as unknown as OverlayServer;
65
140
  // Prepend so the rewrite happens before Astro's own request handler,
66
141
  // letting the rewritten URL resolve to the `.md` endpoint.
67
142
  server.middlewares.stack.unshift({
68
- handle: negotiateMarkdown(new Set(options.contentRoutes)),
143
+ handle: negotiateMarkdown(new Set(options.contentRoutes), options.base),
69
144
  route: "",
70
145
  });
146
+ // Serve `content.assets` mounts (Astro only serves publicDir in dev).
147
+ if (options.assets?.length) {
148
+ server.middlewares.stack.unshift({
149
+ handle: serveAssetMounts(options.assets),
150
+ route: "",
151
+ });
152
+ }
71
153
  },
72
154
  },
73
155
  name: "blume",
@@ -105,9 +105,13 @@ export const discoverIslands = async (
105
105
  continue;
106
106
  }
107
107
  const name = base.replace(ISLAND_FILE, "");
108
- if (!/^[A-Z]/u.test(name)) {
108
+ // The name is used verbatim as both an MDX tag and an unquoted object key
109
+ // in the generated island map, so it must be a plain PascalCase identifier
110
+ // — a `-`, `.`, or space (e.g. `Time-Picker.tsx`) would otherwise emit a
111
+ // syntax-error module and fail the whole build with no pointer to the file.
112
+ if (!/^[A-Z][A-Za-z0-9_]*$/u.test(name)) {
109
113
  warnings.push(
110
- `Island "${file}" must have a PascalCase filename to be used in MDX (e.g. Counter.tsx → <Counter />); skipping it.`
114
+ `Island "${file}" must have a PascalCase identifier filename to be used in MDX (letters, digits, and underscores only, e.g. Counter.tsx → <Counter />); skipping it.`
111
115
  );
112
116
  continue;
113
117
  }
@@ -50,7 +50,8 @@ export const prefersMarkdown = (accept: string | null | undefined): boolean => {
50
50
  */
51
51
  export const markdownVariantUrl = (
52
52
  rawUrl: string | null | undefined,
53
- routes: ReadonlySet<string>
53
+ routes: ReadonlySet<string>,
54
+ base?: string
54
55
  ): string | null => {
55
56
  if (!rawUrl) {
56
57
  return null;
@@ -58,11 +59,24 @@ export const markdownVariantUrl = (
58
59
  const queryIndex = rawUrl.indexOf("?");
59
60
  const query = queryIndex === -1 ? "" : rawUrl.slice(queryIndex);
60
61
  const rawPath = queryIndex === -1 ? rawUrl : rawUrl.slice(0, queryIndex);
62
+
63
+ // A non-root `deployment.base` prefixes the dev-server URL but not the logical
64
+ // content routes, so strip it before matching and re-add it to the variant.
65
+ const prefix = base && base !== "/" ? base.replace(/\/$/u, "") : "";
66
+ let path = rawPath;
67
+ if (prefix) {
68
+ if (path === prefix || path.startsWith(`${prefix}/`)) {
69
+ path = path.slice(prefix.length) || "/";
70
+ } else {
71
+ return null;
72
+ }
73
+ }
74
+
61
75
  const pathname =
62
- rawPath !== "/" && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath;
76
+ path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
63
77
  if (!routes.has(pathname)) {
64
78
  return null;
65
79
  }
66
80
  const target = pathname === "/" ? "/index" : pathname;
67
- return `${target}.md${query}`;
81
+ return `${prefix}${target}.md${query}`;
68
82
  };
@@ -21,7 +21,12 @@ export const discoverPages = async (
21
21
  return files.map((file) => {
22
22
  const rel = relative(pagesRoot, file);
23
23
  const withoutExt = rel.slice(0, rel.length - extname(rel).length);
24
- const parts = withoutExt.split("/").filter((part) => part !== "index");
24
+ const parts = withoutExt.split("/");
25
+ // Only a trailing `index` maps to its parent dir; a folder literally named
26
+ // `index` (e.g. `index/foo.astro`) must keep its segment.
27
+ if (parts.at(-1) === "index") {
28
+ parts.pop();
29
+ }
25
30
  const pattern = parts.length === 0 ? "/" : `/${parts.join("/")}`;
26
31
  return { entrypoint: file, pattern };
27
32
  });
@@ -45,10 +50,8 @@ export const routeIsTaken = (
45
50
  export interface OgCustomRoute {
46
51
  /** `og/<slug>.png` path segment; `index` for the site root. */
47
52
  slug: string;
48
- /** Card title. */
53
+ /** Card headline. */
49
54
  title: string;
50
- /** Small eyebrow line above the title. */
51
- eyebrow?: string;
52
55
  }
53
56
 
54
57
  /** Skip private (`_partial`, `.well-known`) and Astro dynamic (`[param]`) parts. */
@@ -68,13 +71,12 @@ const humanizeSegment = (segment: string): string =>
68
71
  *
69
72
  * Dynamic (`[param]`) routes and private segments (`_partials`, `.well-known`)
70
73
  * are skipped: they aren't shareable pages. The home is titled with the site
71
- * title (eyebrow: the site description); a deeper page is titled from its last
72
- * path segment.
74
+ * title; a deeper page is titled from its last path segment. The card's brand
75
+ * lockup, description, and footer come from the resolved config at render time.
73
76
  */
74
77
  export const customOgRoutes = (
75
78
  pages: BlumePageRoute[],
76
- siteTitle: string,
77
- siteDescription?: string
79
+ siteTitle: string
78
80
  ): OgCustomRoute[] => {
79
81
  const seen = new Set<string>();
80
82
  const routes: OgCustomRoute[] = [];
@@ -91,11 +93,7 @@ export const customOgRoutes = (
91
93
  }
92
94
  seen.add(slug);
93
95
  const last = segments.at(-1);
94
- routes.push(
95
- last
96
- ? { eyebrow: siteTitle, slug, title: humanizeSegment(last) }
97
- : { eyebrow: siteDescription, slug, title: siteTitle }
98
- );
96
+ routes.push({ slug, title: last ? humanizeSegment(last) : siteTitle });
99
97
  }
100
98
  return routes;
101
99
  };
@@ -0,0 +1,117 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import type { Stats } from "node:fs";
3
+ import { cp } from "node:fs/promises";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+
6
+ import { extname, join, relative, resolve, sep } from "pathe";
7
+
8
+ import type { AssetMount } from "../core/assets.ts";
9
+
10
+ /** Content types for the asset extensions a docs project commonly serves. */
11
+ const MIME_TYPES: Record<string, string> = {
12
+ ".avif": "image/avif",
13
+ ".css": "text/css; charset=utf-8",
14
+ ".gif": "image/gif",
15
+ ".ico": "image/x-icon",
16
+ ".jpeg": "image/jpeg",
17
+ ".jpg": "image/jpeg",
18
+ ".js": "text/javascript; charset=utf-8",
19
+ ".json": "application/json; charset=utf-8",
20
+ ".mp4": "video/mp4",
21
+ ".otf": "font/otf",
22
+ ".pdf": "application/pdf",
23
+ ".png": "image/png",
24
+ ".svg": "image/svg+xml",
25
+ ".ttf": "font/ttf",
26
+ ".txt": "text/plain; charset=utf-8",
27
+ ".webm": "video/webm",
28
+ ".webp": "image/webp",
29
+ ".woff": "font/woff",
30
+ ".woff2": "font/woff2",
31
+ };
32
+
33
+ const mimeType = (file: string): string =>
34
+ MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
35
+
36
+ /** Whether `child` is `parent` itself or a path nested under it. */
37
+ const isContained = (parent: string, child: string): boolean => {
38
+ const rel = relative(parent, child);
39
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
40
+ };
41
+
42
+ /** Resolve a request URL to an on-disk file within one of the mounts, if any. */
43
+ const resolveRequest = (url: string, mounts: AssetMount[]): string | null => {
44
+ const pathname = decodeURIComponent(
45
+ (url.split("?")[0] ?? "").split("#")[0] ?? ""
46
+ );
47
+ for (const mount of mounts) {
48
+ if (pathname !== mount.url && !pathname.startsWith(`${mount.url}/`)) {
49
+ continue;
50
+ }
51
+ // `.` + rel keeps the join relative so an absolute-looking suffix can't
52
+ // escape the mount; the containment check rejects `..` traversal outright.
53
+ const file = resolve(mount.dir, `.${pathname.slice(mount.url.length)}`);
54
+ if (isContained(mount.dir, file)) {
55
+ return file;
56
+ }
57
+ }
58
+ return null;
59
+ };
60
+
61
+ /** `statSync` that returns null instead of throwing on a missing file. */
62
+ const statFile = (file: string): Stats | null => {
63
+ try {
64
+ return statSync(file);
65
+ } catch {
66
+ return null;
67
+ }
68
+ };
69
+
70
+ /**
71
+ * A dev-server middleware that serves `content.assets` mounts (top-level dirs
72
+ * kept in place, e.g. a migrated `images/`) at their site URL. Astro only serves
73
+ * `publicDir` in dev, so without this those references would 404. Non-matching
74
+ * requests, and any path that isn't a real file, fall through to Astro. Stat is
75
+ * synchronous — this is dev-only middleware and mirrors how sirv serves statics.
76
+ */
77
+ export const serveAssetMounts =
78
+ (mounts: AssetMount[]) =>
79
+ (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
80
+ if ((req.method !== "GET" && req.method !== "HEAD") || !req.url) {
81
+ next();
82
+ return;
83
+ }
84
+ const file = resolveRequest(req.url, mounts);
85
+ const stats = file ? statFile(file) : null;
86
+ if (!(file && stats?.isFile())) {
87
+ next();
88
+ return;
89
+ }
90
+ res.setHeader("Content-Type", mimeType(file));
91
+ res.setHeader("Content-Length", String(stats.size));
92
+ if (req.method === "HEAD") {
93
+ res.end();
94
+ return;
95
+ }
96
+ createReadStream(file).pipe(res);
97
+ };
98
+
99
+ /**
100
+ * Copy every asset mount into the build output, mirroring what Astro does with
101
+ * `publicDir`. Runs on `astro:build:done` so in-place asset dirs ship in the
102
+ * final `dist/`. A missing source dir is skipped (it may be referenced but
103
+ * absent); `cp` handles both directories and single files.
104
+ */
105
+ export const copyAssetMounts = async (
106
+ mounts: AssetMount[],
107
+ outDir: string
108
+ ): Promise<void> => {
109
+ for (const mount of mounts) {
110
+ if (!existsSync(mount.dir)) {
111
+ continue;
112
+ }
113
+ const dest = join(outDir, mount.url.replace(/^\/+/u, ""));
114
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs copies
115
+ await cp(mount.dir, dest, { recursive: true });
116
+ }
117
+ };