blume 1.1.3 → 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 +35 -0
- package/dist/cli/index.js +189 -88
- package/dist/cli/index.js.map +22 -22
- package/dist/types/core/data.d.ts +2 -0
- package/package.json +1 -1
- package/src/ai/mcp/server.ts +29 -6
- package/src/astro/generate.ts +94 -46
- package/src/astro/templates.ts +59 -15
- 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/RootLayout.astro +13 -2
- package/src/components/layout/Search.astro +5 -1
- package/src/components/layout/head-scripts.ts +22 -5
- package/src/core/data.ts +2 -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/sources/normalize.ts +69 -8
- package/src/core/sources/notion.ts +4 -2
- package/src/core/sources/sanity.ts +5 -3
- package/src/markdown/code-title.ts +7 -1
- package/src/openapi/model.ts +31 -2
- package/src/openapi/render-mdx.ts +12 -7
|
@@ -104,6 +104,8 @@ export interface BlumeDataConfig {
|
|
|
104
104
|
codeThemes: ResolvedConfig["markdown"]["codeBlocks"]["theme"];
|
|
105
105
|
/** `markdown.code.wrap`: wrap long code lines instead of scrolling. */
|
|
106
106
|
codeWrap: boolean;
|
|
107
|
+
/** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
|
|
108
|
+
dateFormat: ResolvedConfig["dateFormat"];
|
|
107
109
|
description: string | undefined;
|
|
108
110
|
favicon: BlumeFavicon;
|
|
109
111
|
feedback: boolean;
|
package/package.json
CHANGED
package/src/ai/mcp/server.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ListToolsRequestSchema,
|
|
6
6
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
|
|
8
|
-
import { withBasePath } from "../../core/base-path.ts";
|
|
8
|
+
import { stripBasePath, withBasePath } from "../../core/base-path.ts";
|
|
9
9
|
import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
|
|
10
10
|
import type { OramaDoc } from "../../search/orama-index.ts";
|
|
11
11
|
import type { McpData } from "./data.ts";
|
|
@@ -81,12 +81,32 @@ const asLimit = (value: unknown): number => {
|
|
|
81
81
|
return Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT);
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
|
|
86
|
+
* Accepts a full URL too — `search_docs` hits and llms.txt entries carry
|
|
87
|
+
* `site` + `deployment.base`, and an agent following "pass a route from
|
|
88
|
+
* `search_docs`" will hand one straight back — reducing it to its base-less,
|
|
89
|
+
* percent-decoded path.
|
|
90
|
+
*/
|
|
91
|
+
const normalizeRoute = (input: string, data: McpData): string => {
|
|
92
|
+
let value = input.trim();
|
|
93
|
+
if (/^https?:\/\//iu.test(value)) {
|
|
94
|
+
try {
|
|
95
|
+
value = new URL(value).pathname;
|
|
96
|
+
} catch {
|
|
97
|
+
// Not parseable as a URL after all; treat it as a path.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
value = decodeURI(value);
|
|
102
|
+
} catch {
|
|
103
|
+
// Malformed percent sequence — compare it as written.
|
|
104
|
+
}
|
|
105
|
+
const noTrailing = value.replace(/\/+$/u, "");
|
|
87
106
|
const noSuffix = noTrailing.replace(/\.mdx?$/u, "");
|
|
88
107
|
const withSlash = noSuffix.startsWith("/") ? noSuffix : `/${noSuffix}`;
|
|
89
|
-
|
|
108
|
+
const based = stripBasePath(data.base, withSlash);
|
|
109
|
+
return based === "" ? "/" : based;
|
|
90
110
|
};
|
|
91
111
|
|
|
92
112
|
/** Build the absolute (or root-relative) URL for a route. */
|
|
@@ -141,8 +161,11 @@ const buildServer = (
|
|
|
141
161
|
asString(args.query),
|
|
142
162
|
asLimit(args.limit)
|
|
143
163
|
);
|
|
164
|
+
// `route` is the key `get_page` takes (the tool descriptions promise
|
|
165
|
+
// it); `url` is where the page is served.
|
|
144
166
|
const results = hits.map((doc: OramaDoc) => ({
|
|
145
167
|
excerpt: excerptFor(doc),
|
|
168
|
+
route: doc.route,
|
|
146
169
|
title: doc.title,
|
|
147
170
|
url: urlFor(doc.route, data),
|
|
148
171
|
}));
|
|
@@ -150,7 +173,7 @@ const buildServer = (
|
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
if (name === "get_page") {
|
|
153
|
-
const key = normalizeRoute(asString(args.route));
|
|
176
|
+
const key = normalizeRoute(asString(args.route), data);
|
|
154
177
|
const markdown = data.pages[key];
|
|
155
178
|
if (markdown === undefined) {
|
|
156
179
|
return text(
|
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";
|
|
@@ -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
|
|
@@ -195,8 +198,11 @@ const resolvedAstroHit = (
|
|
|
195
198
|
}
|
|
196
199
|
};
|
|
197
200
|
|
|
198
|
-
/**
|
|
199
|
-
|
|
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 => {
|
|
200
206
|
try {
|
|
201
207
|
return realpathSync(a) === realpathSync(b);
|
|
202
208
|
} catch {
|
|
@@ -281,6 +287,17 @@ const linkDepsJunction = async (
|
|
|
281
287
|
if (!existing.isSymbolicLink()) {
|
|
282
288
|
return;
|
|
283
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
|
+
}
|
|
284
301
|
await rm(link, { force: true });
|
|
285
302
|
}
|
|
286
303
|
await mkdir(dirname(link), { recursive: true });
|
|
@@ -587,6 +604,34 @@ const deploymentAdapterWarnings = (
|
|
|
587
604
|
return [];
|
|
588
605
|
};
|
|
589
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
|
+
|
|
590
635
|
/** Absolute path to the configured `examples.css`, or null when unset. */
|
|
591
636
|
const examplesCssFile = (root: string, config: ResolvedConfig): string | null =>
|
|
592
637
|
config.examples.css ? join(root, config.examples.css) : null;
|
|
@@ -1046,6 +1091,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1046
1091
|
basePath: config.basePath,
|
|
1047
1092
|
codeThemes: config.markdown.codeBlocks.theme,
|
|
1048
1093
|
codeWrap: config.markdown.code.wrap,
|
|
1094
|
+
dateFormat: config.dateFormat,
|
|
1049
1095
|
description: config.description,
|
|
1050
1096
|
favicon: resolveFavicon(project),
|
|
1051
1097
|
feedback: config.feedback,
|
|
@@ -1268,6 +1314,15 @@ const writeNotFoundPage = async (
|
|
|
1268
1314
|
await write(join(srcDir, "pages", "404.astro"), notFoundPageTemplate());
|
|
1269
1315
|
};
|
|
1270
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
|
+
|
|
1271
1326
|
export interface GenerateResult {
|
|
1272
1327
|
/** Whether any structural file changed (config/page/content config). */
|
|
1273
1328
|
structuralChange: boolean;
|
|
@@ -1304,6 +1359,20 @@ const buildComponentSlots = async (
|
|
|
1304
1359
|
};
|
|
1305
1360
|
};
|
|
1306
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
|
+
|
|
1307
1376
|
/**
|
|
1308
1377
|
* Write (or update) the generated `.blume/` Astro runtime for a project.
|
|
1309
1378
|
* Only files whose content changed are rewritten so Vite HMR stays fast.
|
|
@@ -1407,6 +1476,7 @@ export const generateRuntime = async (
|
|
|
1407
1476
|
// sources, so the `docs` glob would otherwise scan (and watch) the whole
|
|
1408
1477
|
// project root for nothing — see contentConfigTemplate.
|
|
1409
1478
|
const hasFilesystemSource = project.sources.some((source) => !source.staged);
|
|
1479
|
+
const docsCollection = resolveDocsCollection(config, context);
|
|
1410
1480
|
|
|
1411
1481
|
// All of these write to distinct generated paths and never read one another's
|
|
1412
1482
|
// output, so the structural files, the per-convention hydration wrappers, and
|
|
@@ -1421,6 +1491,11 @@ export const generateRuntime = async (
|
|
|
1421
1491
|
askPath,
|
|
1422
1492
|
config,
|
|
1423
1493
|
contentRoutes: project.manifest.routes.map((route) => route.path),
|
|
1494
|
+
contentWatchesRuntimeDir: contentWatchesRuntimeDir(
|
|
1495
|
+
hasFilesystemSource,
|
|
1496
|
+
docsCollection.base,
|
|
1497
|
+
context
|
|
1498
|
+
),
|
|
1424
1499
|
context,
|
|
1425
1500
|
dataPath,
|
|
1426
1501
|
examplesPath,
|
|
@@ -1446,7 +1521,7 @@ export const generateRuntime = async (
|
|
|
1446
1521
|
write(
|
|
1447
1522
|
join(srcDir, "content.config.ts"),
|
|
1448
1523
|
contentConfigTemplate({
|
|
1449
|
-
collection:
|
|
1524
|
+
collection: docsCollection,
|
|
1450
1525
|
config,
|
|
1451
1526
|
context,
|
|
1452
1527
|
filesystem: hasFilesystemSource,
|
|
@@ -1666,11 +1741,7 @@ export const generateRuntime = async (
|
|
|
1666
1741
|
...[
|
|
1667
1742
|
...validateNavTargets(project.graph.navigation, navTargetRoutes),
|
|
1668
1743
|
...validateSearchPopularIcons(config.search.popular),
|
|
1669
|
-
].map(
|
|
1670
|
-
diagnostic.suggestion
|
|
1671
|
-
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1672
|
-
: diagnostic.message
|
|
1673
|
-
)
|
|
1744
|
+
].map(diagnosticWarning)
|
|
1674
1745
|
);
|
|
1675
1746
|
|
|
1676
1747
|
// Unknown-component check: a `<Tag>` in MDX that isn't a built-in, an island,
|
|
@@ -1679,40 +1750,17 @@ export const generateRuntime = async (
|
|
|
1679
1750
|
...islandDiscovery.islands.map((island) => island.name),
|
|
1680
1751
|
...overrideTags,
|
|
1681
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.
|
|
1682
1757
|
warnings.push(
|
|
1683
1758
|
...validateUsedComponents(
|
|
1684
1759
|
project.graph.pages,
|
|
1685
1760
|
knownComponentTags,
|
|
1686
1761
|
new Set(registry.map((item) => item.name))
|
|
1687
|
-
).map(
|
|
1688
|
-
|
|
1689
|
-
? `${diagnostic.message} ${diagnostic.suggestion}`
|
|
1690
|
-
: diagnostic.message
|
|
1691
|
-
)
|
|
1692
|
-
);
|
|
1693
|
-
|
|
1694
|
-
// Provider SDKs are optional peers; warn (rather than fail opaquely in Vite)
|
|
1695
|
-
// when the configured provider's package isn't installed. A dep is available
|
|
1696
|
-
// if the project installed it (resolves from the root) OR Blume ships it
|
|
1697
|
-
// (resolves from the Blume package — the same set the `.blume` deps link
|
|
1698
|
-
// exposes to the build). Resolving from the project root alone falsely flagged
|
|
1699
|
-
// a shipped SDK like Orama (the default provider) as missing whenever it
|
|
1700
|
-
// wasn't hoisted into the project, e.g. under isolated linkers. We resolve
|
|
1701
|
-
// from each package's real location rather than through the `.blume` junction,
|
|
1702
|
-
// which can't be traversed reliably for store-symlinked deps.
|
|
1703
|
-
for (const dep of searchProviderMeta(config.search.provider).runtimeDeps) {
|
|
1704
|
-
if (
|
|
1705
|
-
!(canResolveFrom(context.root, dep) || canResolveFrom(packageRoot(), dep))
|
|
1706
|
-
) {
|
|
1707
|
-
warnings.push(
|
|
1708
|
-
`Search provider "${config.search.provider}" needs "${dep}", which isn't installed. Run \`npm install ${dep}\` (or your package manager's equivalent).`
|
|
1709
|
-
);
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
// React ships with Blume; Vue/Svelte islands need their Astro integration
|
|
1714
|
-
// installed by the project. Warn early rather than let Vite fail to resolve it.
|
|
1715
|
-
warnings.push(
|
|
1762
|
+
).map(diagnosticWarning),
|
|
1763
|
+
...searchProviderWarnings(config.search.provider, context.root),
|
|
1716
1764
|
...deploymentAdapterWarnings(config.deployment, context.root),
|
|
1717
1765
|
...islandFrameworkWarnings(frameworks, context.root)
|
|
1718
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
|
|
@@ -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
|
}
|