blume 1.1.2 → 1.1.4
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.
- package/CHANGELOG.md +47 -0
- package/dist/cli/index.js +284 -109
- package/dist/cli/index.js.map +33 -33
- package/dist/types/ai/component-markdown.d.ts +10 -0
- package/dist/types/core/config-input.d.ts +44 -0
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +24 -24
- package/dist/types/core/schema.d.ts +282 -114
- package/dist/types/core/types.d.ts +14 -0
- package/dist/types/openapi/references.d.ts +5 -0
- package/docs/advanced/api-reference.mdx +20 -0
- package/docs/configuration/index.mdx +27 -0
- package/package.json +1 -1
- package/src/ai/component-markdown.ts +28 -0
- package/src/ai/llms.ts +11 -2
- package/src/ai/markdown.ts +12 -6
- package/src/ai/mcp/server.ts +29 -6
- package/src/astro/examples.ts +13 -0
- package/src/astro/generate.ts +141 -58
- package/src/astro/templates.ts +65 -21
- package/src/audit/checks/duplicates.ts +15 -6
- package/src/audit/checks/indexability.ts +11 -2
- package/src/audit/checks/network.ts +22 -8
- package/src/audit/checks/sitemap.ts +42 -16
- package/src/audit/redirects.ts +12 -1
- package/src/audit/run.ts +13 -3
- package/src/audit/url.ts +21 -2
- package/src/cli/commands/audit.ts +21 -6
- package/src/cli/commands/dev.ts +19 -2
- package/src/components/content/Frame.astro +4 -1
- package/src/components/content/Prompt.astro +4 -1
- package/src/components/content/Tooltip.astro +4 -1
- package/src/components/content/Update.astro +45 -0
- package/src/components/islands/ask-ai.tsx +19 -2
- package/src/components/islands/hooks.ts +38 -11
- package/src/components/layout/Logo.astro +2 -2
- package/src/components/layout/RootLayout.astro +27 -7
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/components/openapi/ApiTagOperations.astro +17 -8
- package/src/core/config-input.ts +45 -0
- package/src/core/data.ts +2 -0
- package/src/core/date-format.ts +17 -0
- package/src/core/deployment-env.ts +7 -2
- package/src/core/graph.ts +7 -1
- package/src/core/i18n.ts +10 -2
- package/src/core/navigation.ts +7 -3
- package/src/core/project-graph.ts +9 -0
- package/src/core/schema.ts +64 -0
- package/src/core/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/core/types.ts +16 -0
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/references.ts +6 -0
- package/src/openapi/render-mdx.ts +12 -7
- package/src/openapi/scalar.ts +4 -0
- package/src/registry/eject.ts +6 -3
- package/src/theme/entry.ts +7 -0
- package/src/theme/twoslash.ts +10 -0
package/src/astro/generate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
lstat,
|
|
4
4
|
mkdir,
|
|
5
5
|
readFile,
|
|
6
|
+
readlink,
|
|
6
7
|
realpath,
|
|
7
8
|
rename,
|
|
8
9
|
rm,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
import { createRequire } from "node:module";
|
|
13
14
|
import { pathToFileURL } from "node:url";
|
|
14
15
|
|
|
15
|
-
import { basename, dirname, join, normalize, relative } from "pathe";
|
|
16
|
+
import { basename, dirname, join, normalize, relative, resolve } from "pathe";
|
|
16
17
|
import { glob } from "tinyglobby";
|
|
17
18
|
|
|
18
19
|
import { buildAskData } from "../ai/ask-data.ts";
|
|
@@ -39,7 +40,7 @@ import type { BlumeProject } from "../core/project-graph.ts";
|
|
|
39
40
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
40
41
|
import { resolveDocsCollection } from "../core/sources/resolve.ts";
|
|
41
42
|
import { resolveTsconfigAliases } from "../core/tsconfig-aliases.ts";
|
|
42
|
-
import type { Navigation } from "../core/types.ts";
|
|
43
|
+
import type { Diagnostic, Navigation, ProjectContext } from "../core/types.ts";
|
|
43
44
|
import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
44
45
|
import { resolveOgLogo } from "../og/logo.ts";
|
|
45
46
|
import { hasScalarReferences, referenceRoutes } from "../openapi/references.ts";
|
|
@@ -58,7 +59,7 @@ import { buildThemeCss } from "../theme/palette.ts";
|
|
|
58
59
|
import { twoslashCss } from "../theme/twoslash.ts";
|
|
59
60
|
import { planComponentSlots } from "./component-slots.ts";
|
|
60
61
|
import type { ComponentSlotPlan } from "./component-slots.ts";
|
|
61
|
-
import { discoverExamples } from "./examples.ts";
|
|
62
|
+
import { discoverExamples, exampleMarkdownLookup } from "./examples.ts";
|
|
62
63
|
import { discoverIslands } from "./islands.ts";
|
|
63
64
|
import {
|
|
64
65
|
customOgRoutes,
|
|
@@ -87,6 +88,7 @@ import {
|
|
|
87
88
|
ogEndpointTemplate,
|
|
88
89
|
rawMarkdownEndpointTemplate,
|
|
89
90
|
rssEndpointTemplate,
|
|
91
|
+
runtimeDirWithin,
|
|
90
92
|
staticJsonEndpointTemplate,
|
|
91
93
|
runtimeDependencies,
|
|
92
94
|
runtimePackageTemplate,
|
|
@@ -118,17 +120,18 @@ const canResolveFrom = (fromDir: string, spec: string): boolean => {
|
|
|
118
120
|
* that never installed the plugin directly. Resolving from `packageRoot()` binds
|
|
119
121
|
* to Blume's shipped copy regardless of the user's package manager or hoisting.
|
|
120
122
|
*/
|
|
121
|
-
const resolveReactCompiler = (
|
|
123
|
+
export const resolveReactCompiler = (
|
|
122
124
|
config: ResolvedConfig,
|
|
123
|
-
needsReact: boolean
|
|
125
|
+
needsReact: boolean,
|
|
126
|
+
pkgDir: string = packageRoot()
|
|
124
127
|
): string | null => {
|
|
125
128
|
if (!(needsReact && config.react.compiler)) {
|
|
126
129
|
return null;
|
|
127
130
|
}
|
|
128
131
|
try {
|
|
129
|
-
return createRequire(
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
+
return createRequire(pathToFileURL(join(pkgDir, "_.js")).href).resolve(
|
|
133
|
+
"babel-plugin-react-compiler"
|
|
134
|
+
);
|
|
132
135
|
} catch {
|
|
133
136
|
return null;
|
|
134
137
|
}
|
|
@@ -137,9 +140,9 @@ const resolveReactCompiler = (
|
|
|
137
140
|
/**
|
|
138
141
|
* Warning (as a spreadable list) for the case where the React Compiler was
|
|
139
142
|
* requested but its plugin couldn't be resolved — so the build silently drops
|
|
140
|
-
* to uncompiled output rather than failing.
|
|
143
|
+
* to uncompiled output rather than failing. Exported for testing.
|
|
141
144
|
*/
|
|
142
|
-
const reactCompilerWarnings = (
|
|
145
|
+
export const reactCompilerWarnings = (
|
|
143
146
|
config: ResolvedConfig,
|
|
144
147
|
needsReact: boolean,
|
|
145
148
|
compilerPath: string | null
|
|
@@ -160,8 +163,9 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
|
160
163
|
};
|
|
161
164
|
|
|
162
165
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
166
|
+
* The `astro` package reachable through the normal node_modules ancestor walk
|
|
167
|
+
* from a generated runtime — its realpath'd `package.json` plus the
|
|
168
|
+
* `node_modules` directory the walk found it in — or null when none resolves.
|
|
165
169
|
*
|
|
166
170
|
* This deliberately does not use `createRequire().resolve()`. pnpm's generated
|
|
167
171
|
* bin shim adds Blume's virtual-store dependencies to `NODE_PATH`, which
|
|
@@ -170,13 +174,21 @@ const resolveAstroPackageJson = (modulesDir: string): string | null => {
|
|
|
170
174
|
* reachable skips the dependency link and makes `import "astro/config"` fail.
|
|
171
175
|
* Walking the physical node_modules ancestors mirrors the lookup that config
|
|
172
176
|
* actually gets.
|
|
177
|
+
*
|
|
178
|
+
* The containing directory matters as much as the package: under an isolated
|
|
179
|
+
* linker the walk can find a store-deduped astro in a directory that holds
|
|
180
|
+
* nothing else of Blume's, so "the right astro resolves" does not imply "the
|
|
181
|
+
* integrations resolve" — callers must check where the hit came from.
|
|
173
182
|
*/
|
|
174
|
-
const
|
|
183
|
+
const resolvedAstroHit = (
|
|
184
|
+
fromDir: string
|
|
185
|
+
): { modulesDir: string; pkg: string } | null => {
|
|
175
186
|
let dir = normalize(fromDir);
|
|
176
187
|
while (true) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
const modulesDir = join(dir, "node_modules");
|
|
189
|
+
const pkg = resolveAstroPackageJson(modulesDir);
|
|
190
|
+
if (pkg) {
|
|
191
|
+
return { modulesDir, pkg };
|
|
180
192
|
}
|
|
181
193
|
const parent = dirname(dir);
|
|
182
194
|
if (parent === dir) {
|
|
@@ -186,6 +198,18 @@ const resolvedAstroPath = (fromDir: string): string | null => {
|
|
|
186
198
|
}
|
|
187
199
|
};
|
|
188
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Whether two paths name the same physical directory (realpath equality).
|
|
203
|
+
* Exported for testing.
|
|
204
|
+
*/
|
|
205
|
+
export const sameRealDir = (a: string, b: string): boolean => {
|
|
206
|
+
try {
|
|
207
|
+
return realpathSync(a) === realpathSync(b);
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
189
213
|
/**
|
|
190
214
|
* The two places an installer can put Blume's dependencies:
|
|
191
215
|
* - `<blume>/node_modules` — deps nested under the package (workspace source,
|
|
@@ -263,6 +287,17 @@ const linkDepsJunction = async (
|
|
|
263
287
|
if (!existing.isSymbolicLink()) {
|
|
264
288
|
return;
|
|
265
289
|
}
|
|
290
|
+
// Already pointing at the right target — leave it alone. This runs on
|
|
291
|
+
// every dev regeneration, and an unconditional rm+recreate opens a window
|
|
292
|
+
// in which the Vite server's module resolution races a missing
|
|
293
|
+
// `node_modules` and 500s intermittently.
|
|
294
|
+
try {
|
|
295
|
+
if (resolve(dirname(link), await readlink(link)) === resolve(depsDir)) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Unreadable link — replace it below.
|
|
300
|
+
}
|
|
266
301
|
await rm(link, { force: true });
|
|
267
302
|
}
|
|
268
303
|
await mkdir(dirname(link), { recursive: true });
|
|
@@ -358,7 +393,11 @@ const dropStaleDepsLink = async (
|
|
|
358
393
|
* install. An `overrides` pin plus an incremental `npm install` hoists
|
|
359
394
|
* astro to the project root (deleting Blume's nested copy) but leaves
|
|
360
395
|
* `@astrojs/mdx` and friends nested under `blume/node_modules`, where the
|
|
361
|
-
* upward walk from `.blume/` can't see them.
|
|
396
|
+
* upward walk from `.blume/` can't see them. The same shape arises under
|
|
397
|
+
* an isolated linker when the workspace itself declares astro at a version
|
|
398
|
+
* matching Blume's: the store dedupes both to one copy, so the walk finds
|
|
399
|
+
* the "correct" astro through the workspace's own direct-dep symlink — in
|
|
400
|
+
* a node_modules holding none of Blume's other deps.
|
|
362
401
|
*
|
|
363
402
|
* The repair is the same symlink: Blume's dependency directory linked in as
|
|
364
403
|
* `.blume/node_modules` so the generated config's bare specifiers (`astro`,
|
|
@@ -387,12 +426,22 @@ export const ensureDepsLink = async (
|
|
|
387
426
|
// that binds it to a superseded Blume — the probes below would otherwise
|
|
388
427
|
// pass right through it (same astro, older blume) and leave it in place.
|
|
389
428
|
await dropStaleDepsLink(join(outDir, "node_modules"), pkgDir);
|
|
390
|
-
const
|
|
429
|
+
const outDirHit = resolvedAstroHit(outDir);
|
|
391
430
|
// `.blume/` resolves the very same astro Blume's deps provide.
|
|
392
|
-
const astroCorrect = blumeAstro !== null &&
|
|
393
|
-
// Clean hoisted install: astro is correct
|
|
394
|
-
//
|
|
395
|
-
|
|
431
|
+
const astroCorrect = blumeAstro !== null && outDirHit?.pkg === blumeAstro;
|
|
432
|
+
// Clean hoisted install: astro is correct, found in Blume's own dependency
|
|
433
|
+
// directory, and the integrations sit beside it — the same walk resolves
|
|
434
|
+
// them too, so there is nothing to do. Requiring the walk to land in
|
|
435
|
+
// `astroDir` itself (not merely resolve an identical astro) matters under
|
|
436
|
+
// isolated linkers: a workspace that declares astro at a version matching
|
|
437
|
+
// Blume's gets a store-deduped symlink in its own node_modules, so the walk
|
|
438
|
+
// finds the "correct" astro in a directory holding only the workspace's
|
|
439
|
+
// direct deps — none of Blume's integrations (issue #103).
|
|
440
|
+
const walkLandsInDeps =
|
|
441
|
+
astroCorrect &&
|
|
442
|
+
outDirHit !== null &&
|
|
443
|
+
sameRealDir(outDirHit.modulesDir, astroDir);
|
|
444
|
+
if (walkLandsInDeps && mdxDir === astroDir) {
|
|
396
445
|
return null;
|
|
397
446
|
}
|
|
398
447
|
// Linking the integrations' directory yields a consistent set when it also
|
|
@@ -406,7 +455,7 @@ export const ensureDepsLink = async (
|
|
|
406
455
|
// Split layout: Blume's astro is nested (a conflicting astro took the root
|
|
407
456
|
// spot) but @astrojs/mdx hoisted away from it, binding to the shadow. Only a
|
|
408
457
|
// root pin fixes this — surface it.
|
|
409
|
-
return astroConflictWarning(blumeAstro,
|
|
458
|
+
return astroConflictWarning(blumeAstro, outDirHit?.pkg ?? null);
|
|
410
459
|
};
|
|
411
460
|
|
|
412
461
|
/**
|
|
@@ -555,6 +604,34 @@ const deploymentAdapterWarnings = (
|
|
|
555
604
|
return [];
|
|
556
605
|
};
|
|
557
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Warn when the configured search provider's SDK is missing. Provider SDKs are
|
|
609
|
+
* optional peers; warn (rather than fail opaquely in Vite) when the package
|
|
610
|
+
* isn't installed. A dep is available if the project installed it (resolves
|
|
611
|
+
* from the root) OR Blume ships it (resolves from the Blume package — the same
|
|
612
|
+
* set the `.blume` deps link exposes to the build). Resolving from the project
|
|
613
|
+
* root alone falsely flagged a shipped SDK like Orama (the default provider)
|
|
614
|
+
* as missing whenever it wasn't hoisted into the project, e.g. under isolated
|
|
615
|
+
* linkers. We resolve from each package's real location rather than through
|
|
616
|
+
* the `.blume` junction, which can't be traversed reliably for store-symlinked
|
|
617
|
+
* deps. `pkgDir` is injectable for testing.
|
|
618
|
+
*/
|
|
619
|
+
export const searchProviderWarnings = (
|
|
620
|
+
provider: ResolvedConfig["search"]["provider"],
|
|
621
|
+
root: string,
|
|
622
|
+
pkgDir: string = packageRoot()
|
|
623
|
+
): string[] => {
|
|
624
|
+
const warnings: string[] = [];
|
|
625
|
+
for (const dep of searchProviderMeta(provider).runtimeDeps) {
|
|
626
|
+
if (!(canResolveFrom(root, dep) || canResolveFrom(pkgDir, dep))) {
|
|
627
|
+
warnings.push(
|
|
628
|
+
`Search provider "${provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return warnings;
|
|
633
|
+
};
|
|
634
|
+
|
|
558
635
|
/** Absolute path to the configured `examples.css`, or null when unset. */
|
|
559
636
|
const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
|
|
560
637
|
config.examples.css ? join(root, config.examples.css) : null;
|
|
@@ -1014,6 +1091,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1014
1091
|
basePath: config.basePath,
|
|
1015
1092
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
1016
1093
|
codeWrap: config.markdown.code.wrap,
|
|
1094
|
+
dateFormat: config.dateFormat,
|
|
1017
1095
|
description: config.description,
|
|
1018
1096
|
favicon: resolveFavicon(project),
|
|
1019
1097
|
feedback: config.feedback,
|
|
@@ -1236,6 +1314,15 @@ const writeNotFoundPage = async (
|
|
|
1236
1314
|
await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
|
|
1237
1315
|
};
|
|
1238
1316
|
|
|
1317
|
+
/**
|
|
1318
|
+
* Flatten a diagnostic to a single warning line, appending the suggestion when
|
|
1319
|
+
* one exists. Exported for testing.
|
|
1320
|
+
*/
|
|
1321
|
+
export const diagnosticWarning = (diagnostic: Diagnostic): string =>
|
|
1322
|
+
diagnostic.suggestion
|
|
1323
|
+
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1324
|
+
: diagnostic.message;
|
|
1325
|
+
|
|
1239
1326
|
export interface GenerateResult {
|
|
1240
1327
|
/** Whether any structural file changed (config/page/content config). */
|
|
1241
1328
|
structuralChange: boolean;
|
|
@@ -1272,6 +1359,20 @@ const buildComponentSlots = async (
|
|
|
1272
1359
|
};
|
|
1273
1360
|
};
|
|
1274
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* Whether the docs glob-loader's watcher observes the runtime dir: a
|
|
1364
|
+
* filesystem collection whose base contains it (a migrated, `content.root:
|
|
1365
|
+
* "."` project) — the one layout where the dev watcher must be kept out of
|
|
1366
|
+
* Astro's cache dir. See `devWatchOption` in templates.ts.
|
|
1367
|
+
*/
|
|
1368
|
+
const contentWatchesRuntimeDir = (
|
|
1369
|
+
hasFilesystemSource: boolean,
|
|
1370
|
+
collectionBase: string,
|
|
1371
|
+
context: ProjectContext
|
|
1372
|
+
): boolean =>
|
|
1373
|
+
hasFilesystemSource &&
|
|
1374
|
+
runtimeDirWithin(collectionBase, context.outDir) !== null;
|
|
1375
|
+
|
|
1275
1376
|
/**
|
|
1276
1377
|
* Write (or update) the generated `.blume/` Astro runtime for a project.
|
|
1277
1378
|
* Only files whose content changed are rewritten so Vite HMR stays fast.
|
|
@@ -1335,6 +1436,9 @@ export const generateRuntime = async (
|
|
|
1335
1436
|
tags: overrideTags,
|
|
1336
1437
|
warnings: overrideWarnings,
|
|
1337
1438
|
} = componentSlots;
|
|
1439
|
+
// Expose the discovered examples for agent-facing Markdown downleveling
|
|
1440
|
+
// (`<Component>` → source) before any consumer (raw `.md`, MCP, llms) runs.
|
|
1441
|
+
project.examples = exampleMarkdownLookup(exampleDiscovery.examples);
|
|
1338
1442
|
|
|
1339
1443
|
// Each island/example framework enables its Astro renderer. React also
|
|
1340
1444
|
// switches on for any project `.tsx`/`.jsx` and for Ask AI; Vue/Svelte are
|
|
@@ -1372,6 +1476,7 @@ export const generateRuntime = async (
|
|
|
1372
1476
|
// sources, so the `docs` glob would otherwise scan (and watch) the whole
|
|
1373
1477
|
// project root for nothing — see contentConfigTemplate.
|
|
1374
1478
|
const hasFilesystemSource = project.sources.some((source) => !source.staged);
|
|
1479
|
+
const docsCollection = resolveDocsCollection(config, context);
|
|
1375
1480
|
|
|
1376
1481
|
// All of these write to distinct generated paths and never read one another's
|
|
1377
1482
|
// output, so the structural files, the per-convention hydration wrappers, and
|
|
@@ -1386,6 +1491,11 @@ export const generateRuntime = async (
|
|
|
1386
1491
|
askPath,
|
|
1387
1492
|
config,
|
|
1388
1493
|
contentRoutes: project.manifest.routes.map((route) => route.path),
|
|
1494
|
+
contentWatchesRuntimeDir: contentWatchesRuntimeDir(
|
|
1495
|
+
hasFilesystemSource,
|
|
1496
|
+
docsCollection.base,
|
|
1497
|
+
context
|
|
1498
|
+
),
|
|
1389
1499
|
context,
|
|
1390
1500
|
dataPath,
|
|
1391
1501
|
examplesPath,
|
|
@@ -1411,7 +1521,7 @@ export const generateRuntime = async (
|
|
|
1411
1521
|
write(
|
|
1412
1522
|
join(srcDir, "content.config.ts"),
|
|
1413
1523
|
contentConfigTemplate({
|
|
1414
|
-
collection:
|
|
1524
|
+
collection: docsCollection,
|
|
1415
1525
|
config,
|
|
1416
1526
|
context,
|
|
1417
1527
|
filesystem: hasFilesystemSource,
|
|
@@ -1631,11 +1741,7 @@ export const generateRuntime = async (
|
|
|
1631
1741
|
...[
|
|
1632
1742
|
...validateNavTargets(project.graph.navigation, navTargetRoutes),
|
|
1633
1743
|
...validateSearchPopularIcons(config.search.popular),
|
|
1634
|
-
].map(
|
|
1635
|
-
diagnostic.suggestion
|
|
1636
|
-
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1637
|
-
: diagnostic.message
|
|
1638
|
-
)
|
|
1744
|
+
].map(diagnosticWarning)
|
|
1639
1745
|
);
|
|
1640
1746
|
|
|
1641
1747
|
// Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
|
|
@@ -1644,40 +1750,17 @@ export const generateRuntime = async (
|
|
|
1644
1750
|
...islandDiscovery.islands.map((island) => island.name),
|
|
1645
1751
|
...overrideTags,
|
|
1646
1752
|
]);
|
|
1753
|
+
// Missing-dependency preflights: the search provider's SDK, the deployment
|
|
1754
|
+
// adapter's package, and — since React ships with Blume while Vue/Svelte
|
|
1755
|
+
// don't — any island framework's Astro integration. Warn early rather than
|
|
1756
|
+
// let Vite fail to resolve them opaquely.
|
|
1647
1757
|
warnings.push(
|
|
1648
1758
|
...validateUsedComponents(
|
|
1649
1759
|
project.graph.pages,
|
|
1650
1760
|
knownComponentTags,
|
|
1651
1761
|
new Set(registry.map((item) => item.name))
|
|
1652
|
-
).map(
|
|
1653
|
-
|
|
1654
|
-
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1655
|
-
: diagnostic.message
|
|
1656
|
-
)
|
|
1657
|
-
);
|
|
1658
|
-
|
|
1659
|
-
// Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
|
|
1660
|
-
// when the configured provider's package isn't installed. A dep is available
|
|
1661
|
-
// if the project installed it (resolves from the root) OR Blume ships it
|
|
1662
|
-
// (resolves from the Blume package — the same set the `.blume` deps link
|
|
1663
|
-
// exposes to the build). Resolving from the project root alone falsely flagged
|
|
1664
|
-
// a shipped SDK like Orama (the default provider) as missing whenever it
|
|
1665
|
-
// wasn't hoisted into the project, e.g. under isolated linkers. We resolve
|
|
1666
|
-
// from each package's real location rather than through the `.blume` junction,
|
|
1667
|
-
// which can't be traversed reliably for store-symlinked deps.
|
|
1668
|
-
for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
|
|
1669
|
-
if (
|
|
1670
|
-
!(canResolveFrom(context.root, dep) || canResolveFrom(packageRoot(), dep))
|
|
1671
|
-
) {
|
|
1672
|
-
warnings.push(
|
|
1673
|
-
`Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
|
|
1674
|
-
);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
// React ships with Blume; Vue/Svelte islands need their Astro integration
|
|
1679
|
-
// installed by the project. Warn early rather than let Vite fail to resolve it.
|
|
1680
|
-
warnings.push(
|
|
1762
|
+
).map(diagnosticWarning),
|
|
1763
|
+
...searchProviderWarnings(config.search.provider, context.root),
|
|
1681
1764
|
...deploymentAdapterWarnings(config.deployment, context.root),
|
|
1682
1765
|
...islandFrameworkWarnings(frameworks, context.root)
|
|
1683
1766
|
);
|
package/src/astro/templates.ts
CHANGED
|
@@ -261,6 +261,35 @@ const reactIntegration = (compilerPath: string | null | undefined): string =>
|
|
|
261
261
|
? `react({ babel: { plugins: [[${JSON.stringify(compilerPath)}, { target: "19" }]] }, ${REACT_EXCLUDE} })`
|
|
262
262
|
: `react({ ${REACT_EXCLUDE} })`;
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The `server.watch` block for the generated dev config. Keeps the watcher out
|
|
266
|
+
* of Astro's cache dir — but ONLY when the docs collection is rooted at a
|
|
267
|
+
* directory containing the runtime dir (a migrated, `content.root: "."`
|
|
268
|
+
* project). There, the glob loader's watcher match (`picomatch.isMatch(entry,
|
|
269
|
+
* pattern)` with array-OR semantics, where any negated pattern matches
|
|
270
|
+
* unrelated files) fires on every `.blume/.astro` write — "No entry type
|
|
271
|
+
* found" noise, and a `data-store.json` event can re-ingest the store file as
|
|
272
|
+
* a JSON entry and loop the sync. Everywhere else the watcher MUST see
|
|
273
|
+
* `.astro/data-store.json`: its change events are the only trigger for
|
|
274
|
+
* Astro's dev-time content invalidation (see vite-plugin-content-virtual-mod),
|
|
275
|
+
* and `.md` bodies are rendered into the store at load time — so ignoring the
|
|
276
|
+
* file serves stale `.md` HTML on every request until the server restarts,
|
|
277
|
+
* even though the loader logs a reload.
|
|
278
|
+
*/
|
|
279
|
+
const devWatchOption = (
|
|
280
|
+
outDir: string,
|
|
281
|
+
contentWatchesRuntimeDir: boolean | undefined
|
|
282
|
+
): string =>
|
|
283
|
+
contentWatchesRuntimeDir
|
|
284
|
+
? `
|
|
285
|
+
// Astro's cache dir sits inside the docs collection, whose watcher would
|
|
286
|
+
// otherwise churn (and can loop) on Astro's own writes. Trade-off: .md
|
|
287
|
+
// body edits need a dev-server restart in this layout.
|
|
288
|
+
watch: {
|
|
289
|
+
ignored: ${JSON.stringify([join(outDir, ".astro", "**")])},
|
|
290
|
+
},`
|
|
291
|
+
: "";
|
|
292
|
+
|
|
264
293
|
export const astroConfigTemplate = (options: {
|
|
265
294
|
context: ProjectContext;
|
|
266
295
|
config: ResolvedConfig;
|
|
@@ -286,6 +315,13 @@ export const astroConfigTemplate = (options: {
|
|
|
286
315
|
reactCompilerPath?: string | null;
|
|
287
316
|
/** Project tsconfig path aliases (`find` -> absolute dir), e.g. `@` -> src. */
|
|
288
317
|
aliases?: Record<string, string>;
|
|
318
|
+
/**
|
|
319
|
+
* Whether the filesystem `docs` collection is rooted at a directory that
|
|
320
|
+
* contains the runtime dir (a migrated, `content.root: "."` project) — the
|
|
321
|
+
* only layout where the dev watcher must be kept out of Astro's cache dir.
|
|
322
|
+
* See {@link devWatchOption} for why this must stay scoped.
|
|
323
|
+
*/
|
|
324
|
+
contentWatchesRuntimeDir?: boolean;
|
|
289
325
|
}): string => {
|
|
290
326
|
const { context, config, needsReact, pages, dataPath, themePath } = options;
|
|
291
327
|
const {
|
|
@@ -446,6 +482,11 @@ export const astroConfigTemplate = (options: {
|
|
|
446
482
|
`blumeIntegration(${JSON.stringify({ base: deployment.base, contentRoutes, pages })})`
|
|
447
483
|
);
|
|
448
484
|
|
|
485
|
+
const watchOption = devWatchOption(
|
|
486
|
+
context.outDir,
|
|
487
|
+
options.contentWatchesRuntimeDir
|
|
488
|
+
);
|
|
489
|
+
|
|
449
490
|
return `// Generated by Blume. Do not edit; this file is recreated on each run.
|
|
450
491
|
${defineConfigImport}
|
|
451
492
|
import mdx from "@astrojs/mdx";
|
|
@@ -522,16 +563,7 @@ export default defineConfig({
|
|
|
522
563
|
server: {
|
|
523
564
|
fs: {
|
|
524
565
|
allow: ${JSON.stringify(fsAllow)},
|
|
525
|
-
}
|
|
526
|
-
// Keep the file watcher out of Astro's own cache dir. In a migrated
|
|
527
|
-
// (root-rooted) project the docs collection is rooted at the project dir,
|
|
528
|
-
// so its glob-loader watcher would otherwise fire on every write Astro
|
|
529
|
-
// makes under .blume/.astro (data-store.json, content module manifests,
|
|
530
|
-
// self-hosted fonts) -- pure noise the loader logs as "No entry type
|
|
531
|
-
// found". Vite appends this to its default ignores.
|
|
532
|
-
watch: {
|
|
533
|
-
ignored: ${JSON.stringify([join(context.outDir, ".astro", "**")])},
|
|
534
|
-
},
|
|
566
|
+
},${watchOption}
|
|
535
567
|
},
|
|
536
568
|
},
|
|
537
569
|
});
|
|
@@ -542,6 +574,21 @@ export default defineConfig({
|
|
|
542
574
|
export const stagedContentDir = (outDir: string): string =>
|
|
543
575
|
join(outDir, "content");
|
|
544
576
|
|
|
577
|
+
/**
|
|
578
|
+
* The runtime dir relative to the docs collection `base` when it sits inside
|
|
579
|
+
* it (a migrated, `content.root: "."` project) — null when it lives elsewhere.
|
|
580
|
+
* Drives both the collection's negative glob (`contentConfigTemplate`) and
|
|
581
|
+
* whether the dev watcher is kept out of Astro's cache dir (the
|
|
582
|
+
* `contentWatchesRuntimeDir` option of `astroConfigTemplate`).
|
|
583
|
+
*/
|
|
584
|
+
export const runtimeDirWithin = (
|
|
585
|
+
base: string,
|
|
586
|
+
outDir: string
|
|
587
|
+
): string | null => {
|
|
588
|
+
const rel = relative(base, outDir);
|
|
589
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : null;
|
|
590
|
+
};
|
|
591
|
+
|
|
545
592
|
/**
|
|
546
593
|
* Astro's glob loader resolves `base` with `new URL(base, config.root)`. On
|
|
547
594
|
* Windows an absolute path like `C:\\docs\\content` makes `new URL` parse the
|
|
@@ -585,11 +632,8 @@ export const contentConfigTemplate = (options: {
|
|
|
585
632
|
// collection doesn't ingest ignored trees (`node_modules`, `snippets`, the
|
|
586
633
|
// staged bodies under `.blume/content`, …) as entries. This matters when
|
|
587
634
|
// the collection base is the project root (a migrated `.`-rooted project).
|
|
588
|
-
const outDirRel =
|
|
589
|
-
const outDirIgnore =
|
|
590
|
-
outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel)
|
|
591
|
-
? [`!${outDirRel}/**`]
|
|
592
|
-
: [];
|
|
635
|
+
const outDirRel = runtimeDirWithin(collectionBase, context.outDir);
|
|
636
|
+
const outDirIgnore = outDirRel ? [`!${outDirRel}/**`] : [];
|
|
593
637
|
|
|
594
638
|
// With no filesystem source, no route renders through `docs`, so glob nothing.
|
|
595
639
|
// Beyond skipping wasted work, this is the only thing that keeps Astro's
|
|
@@ -1445,6 +1489,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1445
1489
|
page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
|
|
1446
1490
|
headings={headings}
|
|
1447
1491
|
toc={data.config.toc}
|
|
1492
|
+
dateFormat={data.config.dateFormat}
|
|
1448
1493
|
themeMode={data.config.theme.mode}
|
|
1449
1494
|
fontCssVars={data.fontCssVars}
|
|
1450
1495
|
searchEnabled={data.config.search.enabled}
|
|
@@ -1502,6 +1547,7 @@ import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
|
1502
1547
|
import Update from "blume/components/content/Update.astro";
|
|
1503
1548
|
import { withBase } from "blume/components/islands/base-path.ts";
|
|
1504
1549
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
1550
|
+
import { resolveDateFormatOptions } from "blume/core/date-format.ts";
|
|
1505
1551
|
import { layoutOverrides } from "../generated/components.ts";
|
|
1506
1552
|
import data from "blume:data";
|
|
1507
1553
|
|
|
@@ -1529,8 +1575,9 @@ const localeMeta = i18n
|
|
|
1529
1575
|
const dir = localeMeta?.dir ?? "ltr";
|
|
1530
1576
|
const htmlLang = i18n ? i18n.defaultLocale : "en";
|
|
1531
1577
|
|
|
1532
|
-
// Formatted in the same locale as the chrome, and
|
|
1533
|
-
// per-page "last updated" stamp.
|
|
1578
|
+
// Formatted in the same locale as the chrome, and with the configured
|
|
1579
|
+
// \`dateFormat\` (UTC by default), to match the per-page "last updated" stamp.
|
|
1580
|
+
const dateFormatOptions = resolveDateFormatOptions(data.config.dateFormat);
|
|
1534
1581
|
const formatDate = (value: string | null | undefined) => {
|
|
1535
1582
|
if (!value) {
|
|
1536
1583
|
return;
|
|
@@ -1538,10 +1585,7 @@ const formatDate = (value: string | null | undefined) => {
|
|
|
1538
1585
|
const date = new Date(value);
|
|
1539
1586
|
return Number.isNaN(date.getTime())
|
|
1540
1587
|
? undefined
|
|
1541
|
-
: new Intl.DateTimeFormat(htmlLang,
|
|
1542
|
-
dateStyle: "long",
|
|
1543
|
-
timeZone: "UTC",
|
|
1544
|
-
}).format(date);
|
|
1588
|
+
: new Intl.DateTimeFormat(htmlLang, dateFormatOptions).format(date);
|
|
1545
1589
|
};
|
|
1546
1590
|
|
|
1547
1591
|
const slugify = (text: string) =>
|
|
@@ -1,17 +1,24 @@
|
|
|
1
|
+
import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import type { Diagnostic } from "../../core/types.ts";
|
|
2
3
|
import { finding } from "../catalog.ts";
|
|
3
4
|
import type { CheckId } from "../catalog.ts";
|
|
4
5
|
import { pageSite } from "../locate.ts";
|
|
5
6
|
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
7
|
+
import { decodePath } from "../url.ts";
|
|
6
8
|
|
|
7
|
-
const isNonCanonical = (page: PageSnapshot): boolean => {
|
|
9
|
+
const isNonCanonical = (page: PageSnapshot, deployBase: string): boolean => {
|
|
8
10
|
if (!page.canonical) {
|
|
9
11
|
return false;
|
|
10
12
|
}
|
|
11
13
|
try {
|
|
14
|
+
// Canonicals are emitted as `site + base + route`; page URLs carry no
|
|
15
|
+
// deployment base — without stripping it, every page of a subpath
|
|
16
|
+
// deployment would look non-canonical and escape these checks entirely.
|
|
12
17
|
return (
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
stripBasePath(
|
|
19
|
+
deployBase,
|
|
20
|
+
decodePath(new URL(page.canonical).pathname)
|
|
21
|
+
).replace(/\/$/u, "") !== page.url.replace(/\/$/u, "")
|
|
15
22
|
);
|
|
16
23
|
} catch {
|
|
17
24
|
return false;
|
|
@@ -19,8 +26,9 @@ const isNonCanonical = (page: PageSnapshot): boolean => {
|
|
|
19
26
|
};
|
|
20
27
|
|
|
21
28
|
/** Pages that can meaningfully be compared against each other for duplication. */
|
|
22
|
-
const comparable = (context: AuditContext): PageSnapshot[] =>
|
|
23
|
-
context.
|
|
29
|
+
const comparable = (context: AuditContext): PageSnapshot[] => {
|
|
30
|
+
const deployBase = normalizeBasePath(context.project.config.deployment.base);
|
|
31
|
+
return context.pages.filter(
|
|
24
32
|
(page) =>
|
|
25
33
|
page.indexable &&
|
|
26
34
|
// A fallback page renders the default locale's content at a localized URL.
|
|
@@ -29,8 +37,9 @@ const comparable = (context: AuditContext): PageSnapshot[] =>
|
|
|
29
37
|
!page.route?.fallback &&
|
|
30
38
|
// A page that points its canonical elsewhere has already declared itself a
|
|
31
39
|
// duplicate; that's the mechanism working, not a finding.
|
|
32
|
-
!isNonCanonical(page)
|
|
40
|
+
!isNonCanonical(page, deployBase)
|
|
33
41
|
);
|
|
42
|
+
};
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* Group pages by a value and report every group with more than one member.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { normalizeBasePath, stripBasePath } from "../../core/base-path.ts";
|
|
1
2
|
import { SITE_INFERRING_ADAPTERS } from "../../core/deployment-env.ts";
|
|
2
3
|
import type { Diagnostic } from "../../core/types.ts";
|
|
3
4
|
import { finding } from "../catalog.ts";
|
|
4
5
|
import { pageSite } from "../locate.ts";
|
|
5
6
|
import { ERROR_ROUTES } from "../types.ts";
|
|
6
7
|
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
7
|
-
import { normalizePath, siteOrigin } from "../url.ts";
|
|
8
|
+
import { decodePath, normalizePath, siteOrigin } from "../url.ts";
|
|
8
9
|
|
|
9
10
|
/** The canonical URL parsed, or null when it isn't a usable absolute URL. */
|
|
10
11
|
const parseCanonical = (page: PageSnapshot): URL | null => {
|
|
@@ -76,7 +77,15 @@ const canonicalChecks = (
|
|
|
76
77
|
return found;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
// Canonicals are emitted as `site + base + route`; page URLs and `byUrl`
|
|
81
|
+
// keys carry no deployment base, so strip it (and percent-encoding) before
|
|
82
|
+
// comparing.
|
|
83
|
+
const target = normalizePath(
|
|
84
|
+
stripBasePath(
|
|
85
|
+
normalizeBasePath(context.project.config.deployment.base),
|
|
86
|
+
decodePath(canonical.pathname)
|
|
87
|
+
)
|
|
88
|
+
);
|
|
80
89
|
if (target === normalizePath(page.url)) {
|
|
81
90
|
return found;
|
|
82
91
|
}
|
|
@@ -12,9 +12,17 @@ const SERVER_ERROR = 500;
|
|
|
12
12
|
/** Past this, a page is slow enough that it costs you crawl budget and readers. */
|
|
13
13
|
const SLOW_MS = 1500;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The live URL a built page is served at, under the `--url` origin. Page URLs
|
|
17
|
+
* come from the built file tree and carry no `deployment.base`, but the live
|
|
18
|
+
* site serves everything under it — probing without the base would 4xx every
|
|
19
|
+
* page of a healthy subpath deployment.
|
|
20
|
+
*/
|
|
21
|
+
const liveUrl = (
|
|
22
|
+
origin: string,
|
|
23
|
+
page: PageSnapshot,
|
|
24
|
+
deployBase: string
|
|
25
|
+
): string => new URL(`${deployBase}${page.url}`, origin).toString();
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Whether the response failed outright, and how. Null when the page is served.
|
|
@@ -144,17 +152,23 @@ export const networkChecks: CheckModule = {
|
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
const found: Diagnostic[] = [];
|
|
147
|
-
const
|
|
155
|
+
const deployBase = normalizeBasePath(
|
|
156
|
+
context.project.config.deployment.base
|
|
157
|
+
);
|
|
158
|
+
const targets = context.pages.map((page) =>
|
|
159
|
+
liveUrl(origin, page, deployBase)
|
|
160
|
+
);
|
|
148
161
|
// robots.txt and sitemap.xml are fetched alongside the pages: they're the
|
|
149
162
|
// two files a crawler asks for first, and a deploy that hides them silently
|
|
150
|
-
// undoes everything else the audit checks.
|
|
151
|
-
|
|
152
|
-
const
|
|
163
|
+
// undoes everything else the audit checks. They sit at the root of the
|
|
164
|
+
// build output, which the host serves under the deployment base.
|
|
165
|
+
const robotsUrl = new URL(`${deployBase}/robots.txt`, origin).toString();
|
|
166
|
+
const sitemapUrl = new URL(`${deployBase}/sitemap.xml`, origin).toString();
|
|
153
167
|
|
|
154
168
|
const results = await probeAll([...targets, robotsUrl, sitemapUrl]);
|
|
155
169
|
|
|
156
170
|
for (const page of context.pages) {
|
|
157
|
-
const result = results.get(liveUrl(origin, page));
|
|
171
|
+
const result = results.get(liveUrl(origin, page, deployBase));
|
|
158
172
|
if (!result) {
|
|
159
173
|
continue;
|
|
160
174
|
}
|