blume 1.0.4 → 1.1.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.
- package/CHANGELOG.md +80 -0
- package/dist/cli/index.js +13404 -10228
- package/dist/cli/index.js.map +94 -63
- package/dist/types/ai/component-markdown.d.ts +12 -1
- package/dist/types/core/config-input.d.ts +73 -4
- package/dist/types/core/data.d.ts +9 -0
- package/dist/types/core/deployment-env.d.ts +6 -0
- package/dist/types/core/diagnostics.d.ts +23 -0
- package/dist/types/core/i18n-ui.d.ts +8 -8
- package/dist/types/core/schema.d.ts +144 -22
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +41 -0
- package/dist/types/core/types.d.ts +20 -0
- package/dist/types/og/card.d.ts +63 -0
- package/dist/types/og/dimensions.d.ts +12 -0
- package/docs/01-quickstart.mdx +1 -1
- package/docs/02-deployment.mdx +9 -1
- package/docs/advanced/api-reference.mdx +11 -0
- package/docs/advanced/changelog.mdx +2 -2
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +3 -3
- package/docs/configuration/customization.mdx +1 -1
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +21 -1
- package/docs/configuration/search.mdx +28 -1
- package/docs/configuration/seo.mdx +37 -2
- package/docs/configuration/theming.mdx +1 -1
- package/docs/content/components.mdx +14 -0
- package/docs/content/index.mdx +1 -1
- package/docs/content/meta.mdx +1 -1
- package/docs/content/navigation.mdx +5 -1
- package/docs/content/sources.mdx +1 -1
- package/docs/reference/cli.mdx +80 -2
- package/docs/reference/frontmatter.mdx +31 -1
- package/package.json +4 -3
- package/skills/blume-migrate/SKILL.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/scripts/mintlify-codemod.mjs +16 -4
- package/src/ai/component-markdown.ts +39 -11
- package/src/ai/llms.ts +19 -2
- package/src/ai/markdown.ts +5 -1
- package/src/astro/adapter-root.ts +70 -0
- package/src/astro/generate.ts +124 -50
- package/src/astro/index.ts +1 -0
- package/src/astro/pages.ts +39 -8
- package/src/astro/templates.ts +93 -28
- package/src/audit/agent.ts +114 -0
- package/src/audit/catalog.ts +826 -0
- package/src/audit/checks/assets.ts +177 -0
- package/src/audit/checks/content.ts +231 -0
- package/src/audit/checks/duplicates.ts +131 -0
- package/src/audit/checks/i18n.ts +246 -0
- package/src/audit/checks/indexability.ts +213 -0
- package/src/audit/checks/links.ts +223 -0
- package/src/audit/checks/llms.ts +138 -0
- package/src/audit/checks/network.ts +272 -0
- package/src/audit/checks/og-image.ts +113 -0
- package/src/audit/checks/redirects.ts +87 -0
- package/src/audit/checks/robots.ts +114 -0
- package/src/audit/checks/sitemap.ts +229 -0
- package/src/audit/checks/social.ts +238 -0
- package/src/audit/crawl.ts +259 -0
- package/src/audit/graph.ts +74 -0
- package/src/audit/html.ts +54 -0
- package/src/audit/image-size.ts +63 -0
- package/src/audit/locate.ts +33 -0
- package/src/audit/redirects.ts +74 -0
- package/src/audit/report.ts +278 -0
- package/src/audit/run.ts +198 -0
- package/src/audit/snapshot.ts +189 -0
- package/src/audit/types.ts +214 -0
- package/src/audit/url.ts +103 -0
- package/src/cli/commands/audit.ts +205 -0
- package/src/cli/commands/build.ts +64 -13
- package/src/cli/index.ts +2 -0
- package/src/cli/prepare.ts +10 -2
- package/src/components/content/Tabs.astro +98 -15
- package/src/components/layout/Breadcrumbs.astro +1 -1
- package/src/components/layout/Header.astro +1 -0
- package/src/components/layout/PageFeedback.astro +1 -1
- package/src/components/layout/PageLayout.astro +5 -1
- package/src/components/layout/Pagination.astro +1 -1
- package/src/components/layout/RootLayout.astro +5 -3
- package/src/components/layout/Search.astro +35 -6
- package/src/components/layout/TableOfContents.astro +1 -1
- package/src/components/openapi/Authorization.astro +80 -0
- package/src/components/openapi/Operation.astro +19 -1
- package/src/components/openapi/ParametersTable.astro +1 -1
- package/src/components/openapi/security.ts +201 -0
- package/src/components/openapi/snippets.ts +42 -13
- package/src/core/config-input.ts +78 -4
- package/src/core/data.ts +9 -1
- package/src/core/deployment-env.ts +9 -0
- package/src/core/diagnostics.ts +61 -12
- package/src/core/graph.ts +23 -4
- package/src/core/links.ts +2 -91
- package/src/core/nav-diagnostics.ts +48 -4
- package/src/core/navigation.ts +169 -14
- package/src/core/probe.ts +136 -0
- package/src/core/project-graph.ts +54 -20
- package/src/core/schema.ts +93 -3
- package/src/core/sources/github-releases.ts +65 -2
- package/src/core/sources/normalize.ts +198 -25
- package/src/core/sources/types.ts +3 -1
- package/src/core/standard-schema.ts +54 -0
- package/src/core/types.ts +20 -0
- package/src/deploy/adapter-output.ts +27 -15
- package/src/deploy/headers.ts +66 -0
- package/src/deploy/redirects.ts +49 -9
- package/src/markdown/index.ts +1 -0
- package/src/markdown/twoslash.ts +60 -0
- package/src/og/card.ts +98 -33
- package/src/og/index.ts +1 -1
- package/src/registry/eject.ts +3 -1
- package/src/search/popular.ts +33 -0
- package/src/theme/entry.ts +6 -1
- /package/docs/{03-faq.mdx → 07-faq.mdx} +0 -0
|
@@ -15,7 +15,7 @@ import { resolveProjectContext } from "./project.ts";
|
|
|
15
15
|
import type { ResolvedConfig } from "./schema.ts";
|
|
16
16
|
import { normalizeEntry } from "./sources/normalize.ts";
|
|
17
17
|
import { resolveDocsCollection, resolveSources } from "./sources/resolve.ts";
|
|
18
|
-
import type { ContentSource } from "./sources/types.ts";
|
|
18
|
+
import type { ContentSource, SourceLoadResult } from "./sources/types.ts";
|
|
19
19
|
import type {
|
|
20
20
|
BlumeManifest,
|
|
21
21
|
ContentGraph,
|
|
@@ -70,6 +70,8 @@ export interface BlumeProject {
|
|
|
70
70
|
graph: ContentGraph;
|
|
71
71
|
manifest: BlumeManifest;
|
|
72
72
|
diagnostics: Diagnostic[];
|
|
73
|
+
/** Entries excluded from the graph because their frontmatter failed validation. */
|
|
74
|
+
droppedPages: number;
|
|
73
75
|
/** The instantiated content sources, for lazy entry reads (search/AI/raw). */
|
|
74
76
|
sources: ContentSource[];
|
|
75
77
|
}
|
|
@@ -116,6 +118,51 @@ const entryIdDiagnostics = (
|
|
|
116
118
|
return diagnostics;
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Funnel every loaded source's entries through the shared `normalizeEntry`,
|
|
123
|
+
* collecting pages, diagnostics, and the count of entries dropped outright —
|
|
124
|
+
* an entry that yields no pages but did yield diagnostics was rejected for
|
|
125
|
+
* invalid frontmatter, and callers surface that count so a build with missing
|
|
126
|
+
* pages can't read as clean.
|
|
127
|
+
*/
|
|
128
|
+
const normalizeLoadedEntries = (
|
|
129
|
+
loaded: ({ source: ContentSource } & SourceLoadResult)[],
|
|
130
|
+
config: ResolvedConfig
|
|
131
|
+
): { pages: PageRecord[]; diagnostics: Diagnostic[]; droppedPages: number } => {
|
|
132
|
+
// Only thread `frontmatter.extend` through when a project opts in, so the
|
|
133
|
+
// known-key split in `normalizeEntry` stays off the default path.
|
|
134
|
+
const frontmatterExtend =
|
|
135
|
+
Object.keys(config.frontmatter.extend).length > 0
|
|
136
|
+
? config.frontmatter.extend
|
|
137
|
+
: undefined;
|
|
138
|
+
|
|
139
|
+
const pages: PageRecord[] = [];
|
|
140
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
141
|
+
let droppedPages = 0;
|
|
142
|
+
for (const { source, entries, diagnostics } of loaded) {
|
|
143
|
+
allDiagnostics.push(...diagnostics);
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const normalized = normalizeEntry(entry, {
|
|
146
|
+
basePath: config.basePath,
|
|
147
|
+
defaultType: config.content.defaultType,
|
|
148
|
+
frontmatterExtend,
|
|
149
|
+
i18n: config.i18n,
|
|
150
|
+
source: {
|
|
151
|
+
name: source.name,
|
|
152
|
+
prefix: source.prefix,
|
|
153
|
+
staged: source.staged,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
if (normalized.pages.length === 0 && normalized.diagnostics.length > 0) {
|
|
157
|
+
droppedPages += 1;
|
|
158
|
+
}
|
|
159
|
+
pages.push(...normalized.pages);
|
|
160
|
+
allDiagnostics.push(...normalized.diagnostics);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { diagnostics: allDiagnostics, droppedPages, pages };
|
|
164
|
+
};
|
|
165
|
+
|
|
119
166
|
/**
|
|
120
167
|
* Run the full core pipeline for a project root: load config, resolve paths,
|
|
121
168
|
* discover content and folder meta, build the graph, and assemble the manifest.
|
|
@@ -190,25 +237,11 @@ export const scanProject = async (
|
|
|
190
237
|
discoverFolderMeta(metaSources, { localeDirs }),
|
|
191
238
|
]);
|
|
192
239
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const normalized = normalizeEntry(entry, {
|
|
199
|
-
basePath: config.basePath,
|
|
200
|
-
defaultType: config.content.defaultType,
|
|
201
|
-
i18n: config.i18n,
|
|
202
|
-
source: {
|
|
203
|
-
name: source.name,
|
|
204
|
-
prefix: source.prefix,
|
|
205
|
-
staged: source.staged,
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
allPages.push(...normalized.pages);
|
|
209
|
-
contentDiagnostics.push(...normalized.diagnostics);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
240
|
+
const {
|
|
241
|
+
diagnostics: contentDiagnostics,
|
|
242
|
+
droppedPages,
|
|
243
|
+
pages: allPages,
|
|
244
|
+
} = normalizeLoadedEntries(loaded, config);
|
|
212
245
|
|
|
213
246
|
// Drafts render in dev and in preview, but are excluded from production builds.
|
|
214
247
|
const pages =
|
|
@@ -259,6 +292,7 @@ export const scanProject = async (
|
|
|
259
292
|
...graph.diagnostics,
|
|
260
293
|
...i18nWarnings,
|
|
261
294
|
],
|
|
295
|
+
droppedPages,
|
|
262
296
|
graph,
|
|
263
297
|
manifest,
|
|
264
298
|
mode,
|
package/src/core/schema.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
|
|
|
7
7
|
import { normalizeBasePath } from "./base-path.ts";
|
|
8
8
|
import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
|
|
9
9
|
import type { ContentSource } from "./sources/types.ts";
|
|
10
|
+
import { isStandardSchema } from "./standard-schema.ts";
|
|
11
|
+
import type { StandardSchema } from "./standard-schema.ts";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Public Blume schemas.
|
|
@@ -514,6 +516,13 @@ const PROVIDER_CONFIG_KEY = {
|
|
|
514
516
|
typesense: "typesense",
|
|
515
517
|
} as const;
|
|
516
518
|
|
|
519
|
+
/** Curated link for the search dialog empty state (internal route or external URL). */
|
|
520
|
+
const searchPopularLinkSchema = z.strictObject({
|
|
521
|
+
href: z.string(),
|
|
522
|
+
icon: iconName.optional(),
|
|
523
|
+
label: z.string(),
|
|
524
|
+
});
|
|
525
|
+
|
|
517
526
|
const searchConfigSchema = z
|
|
518
527
|
.strictObject({
|
|
519
528
|
algolia: algoliaSearchSchema.optional(),
|
|
@@ -524,6 +533,8 @@ const searchConfigSchema = z
|
|
|
524
533
|
.default({}),
|
|
525
534
|
mixedbread: mixedbreadSearchSchema.optional(),
|
|
526
535
|
oramaCloud: oramaCloudSearchSchema.optional(),
|
|
536
|
+
/** Curated links for the Cmd+K empty state; defaults to the first sidebar pages. */
|
|
537
|
+
popular: z.array(searchPopularLinkSchema).default([]),
|
|
527
538
|
provider: z.enum(searchProviders).default("orama"),
|
|
528
539
|
typesense: typesenseSearchSchema.optional(),
|
|
529
540
|
})
|
|
@@ -802,9 +813,14 @@ const xConfigSchema = z.strictObject({
|
|
|
802
813
|
handle: xHandleSchema,
|
|
803
814
|
});
|
|
804
815
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
816
|
+
/**
|
|
817
|
+
* Any CSS color. Takumi parses the full grammar, so this stays unvalidated
|
|
818
|
+
* here and a bad value fails the OG prerender with a parse error naming it —
|
|
819
|
+
* the same fail-fast the card's accent relies on. Validating hex-only here
|
|
820
|
+
* would reject `oklch(…)`, which `theme.accent` (the card's default accent)
|
|
821
|
+
* already accepts.
|
|
822
|
+
*/
|
|
823
|
+
const ogColorSchema = z.string();
|
|
808
824
|
|
|
809
825
|
const ogPaletteSchema = z.strictObject({
|
|
810
826
|
accent: ogColorSchema.optional(),
|
|
@@ -814,6 +830,28 @@ const ogPaletteSchema = z.strictObject({
|
|
|
814
830
|
muted: ogColorSchema.optional(),
|
|
815
831
|
});
|
|
816
832
|
|
|
833
|
+
/**
|
|
834
|
+
* A Google Font family to load into the OG card renderer. A bare string is the
|
|
835
|
+
* family name; the object form pins the weight (a number, a list, or a variable
|
|
836
|
+
* range like `"100..900"`) and style. Fetched from Google Fonts at build and
|
|
837
|
+
* handed to Takumi, which does per-glyph fallback so a family covering a script
|
|
838
|
+
* (e.g. Noto Sans JP for CJK) fixes tofu without touching how Latin renders.
|
|
839
|
+
*/
|
|
840
|
+
const ogFontWeightSchema = z.union([
|
|
841
|
+
z.number().int().positive(),
|
|
842
|
+
z.array(z.number().int().positive()),
|
|
843
|
+
z.string().regex(/^\d+\.\.\d+$/u),
|
|
844
|
+
]);
|
|
845
|
+
const ogFontStyleSchema = z.enum(["normal", "italic"]);
|
|
846
|
+
const ogFontSchema = z.union([
|
|
847
|
+
z.string(),
|
|
848
|
+
z.strictObject({
|
|
849
|
+
name: z.string(),
|
|
850
|
+
style: z.union([ogFontStyleSchema, z.array(ogFontStyleSchema)]).optional(),
|
|
851
|
+
weight: ogFontWeightSchema.optional(),
|
|
852
|
+
}),
|
|
853
|
+
]);
|
|
854
|
+
|
|
817
855
|
const ogConfigSchema = z.strictObject({
|
|
818
856
|
/**
|
|
819
857
|
* Generate a per-page Open Graph image. Defaults to on once a deployment
|
|
@@ -822,10 +860,23 @@ const ogConfigSchema = z.strictObject({
|
|
|
822
860
|
* `loadConfig`. An explicit value here always wins.
|
|
823
861
|
*/
|
|
824
862
|
enabled: z.boolean().optional(),
|
|
863
|
+
/**
|
|
864
|
+
* Google Font families for the generated card, extending Takumi's Latin-only
|
|
865
|
+
* default so non-Latin titles (CJK, and so on) render instead of tofu.
|
|
866
|
+
* Fetched from Google Fonts at build.
|
|
867
|
+
*/
|
|
868
|
+
fonts: z.array(ogFontSchema).optional(),
|
|
825
869
|
/** Local SVG used in the generated card instead of the site logo. */
|
|
826
870
|
logo: z.string().optional(),
|
|
827
871
|
/** Optional generated-card colors. */
|
|
828
872
|
palette: ogPaletteSchema.optional(),
|
|
873
|
+
/**
|
|
874
|
+
* Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
|
|
875
|
+
* A custom page has no frontmatter to read, so its card is otherwise titled
|
|
876
|
+
* by humanizing its last URL segment (`/cli` → "Cli"); an entry here wins.
|
|
877
|
+
* Content pages always take their card headline from the page title.
|
|
878
|
+
*/
|
|
879
|
+
titles: z.record(z.string(), z.string()).optional(),
|
|
829
880
|
});
|
|
830
881
|
|
|
831
882
|
const rssConfigSchema = z.strictObject({
|
|
@@ -1041,6 +1092,41 @@ const asyncapiConfigSchema = z.strictObject({
|
|
|
1041
1092
|
theme: z.string().optional(),
|
|
1042
1093
|
});
|
|
1043
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Opt-in custom frontmatter keys. `extend` maps each extra key a project's
|
|
1097
|
+
* pages may carry (e.g. `owner`, `reviewedAt`) to a validation schema; the
|
|
1098
|
+
* page schema stays strict for everything else, so typo-catching is preserved.
|
|
1099
|
+
* Schemas are consumed through the Standard Schema `~standard` contract —
|
|
1100
|
+
* never Zod's own API — so the consumer's zod (any version), Valibot, or
|
|
1101
|
+
* ArkType all work (see `standard-schema.ts`). Every declared key is validated
|
|
1102
|
+
* on every page, absent ones included, so a required schema enforces the key
|
|
1103
|
+
* site-wide; mark it `.optional()` to validate only when present. Built-in
|
|
1104
|
+
* frontmatter fields can't be redeclared — they're load-bearing (routing,
|
|
1105
|
+
* sidebar, SEO), and shadowing one would silently change its semantics.
|
|
1106
|
+
*/
|
|
1107
|
+
const frontmatterConfigSchema = z.strictObject({
|
|
1108
|
+
extend: z
|
|
1109
|
+
.record(
|
|
1110
|
+
z.string(),
|
|
1111
|
+
z.custom<StandardSchema>(isStandardSchema, {
|
|
1112
|
+
message:
|
|
1113
|
+
"Expected a Standard Schema (e.g. a Zod schema — any Zod version works).",
|
|
1114
|
+
})
|
|
1115
|
+
)
|
|
1116
|
+
.default({})
|
|
1117
|
+
.superRefine((value, ctx) => {
|
|
1118
|
+
for (const key of Object.keys(value)) {
|
|
1119
|
+
if (Object.hasOwn(pageMetaBaseSchema.shape, key)) {
|
|
1120
|
+
ctx.addIssue({
|
|
1121
|
+
code: z.ZodIssueCode.custom,
|
|
1122
|
+
message: `"${key}" is a built-in frontmatter field and cannot be redeclared via frontmatter.extend.`,
|
|
1123
|
+
path: [key],
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}),
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1044
1130
|
/** Full user-facing config schema. All fields optional with defaults. */
|
|
1045
1131
|
/**
|
|
1046
1132
|
* Table-of-contents config. `true`/`false` toggles it; an object narrows the
|
|
@@ -1101,6 +1187,8 @@ export const blumeConfigSchema = z.strictObject({
|
|
|
1101
1187
|
examples: examplesConfigSchema.default("examples"),
|
|
1102
1188
|
export: exportConfigSchema.default(false),
|
|
1103
1189
|
feedback: z.boolean().default(true),
|
|
1190
|
+
/** Opt-in custom frontmatter keys, validated by user-supplied schemas. */
|
|
1191
|
+
frontmatter: frontmatterConfigSchema.default({}),
|
|
1104
1192
|
github: githubConfigSchema.optional(),
|
|
1105
1193
|
i18n: i18nConfigSchema.optional(),
|
|
1106
1194
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
@@ -1119,6 +1207,8 @@ export const blumeConfigSchema = z.strictObject({
|
|
|
1119
1207
|
|
|
1120
1208
|
/** Resolved config: every field present after defaults are applied. */
|
|
1121
1209
|
export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;
|
|
1210
|
+
/** Resolved `frontmatter.extend`: custom key → user-supplied schema. */
|
|
1211
|
+
export type FrontmatterExtend = Record<string, StandardSchema>;
|
|
1122
1212
|
/** Resolved i18n block (present only when the project opts into i18n). */
|
|
1123
1213
|
export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
|
|
1124
1214
|
/** A configured locale with display metadata. */
|
|
@@ -56,6 +56,63 @@ const LEADING_V = /^v/iu;
|
|
|
56
56
|
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
57
57
|
const EDGE_DASHES = /^-+|-+$/gu;
|
|
58
58
|
|
|
59
|
+
// `blume audit` grades meta descriptions against the 110–160 character search
|
|
60
|
+
// snippet range (audit/types.ts thresholds), so the derived summary aims for
|
|
61
|
+
// the longest word-boundary cut under the cap.
|
|
62
|
+
const DESCRIPTION_MAX = 160;
|
|
63
|
+
const DESCRIPTION_MIN = 110;
|
|
64
|
+
|
|
65
|
+
const CODE_FENCE = /```[\s\S]*?```/gu;
|
|
66
|
+
const HEADING_LINE = /^#{1,6}\s.*$/gmu;
|
|
67
|
+
const LIST_MARK = /^\s*(?:[-*+]|\d+[.)])\s+/u;
|
|
68
|
+
// Changesets-generated release bullets open with the changeset's short commit
|
|
69
|
+
// hash (`- cf8fa22: Fix …`) — noise in a search snippet.
|
|
70
|
+
const CHANGESET_HASH = /^[0-9a-f]{7,40}:\s+/u;
|
|
71
|
+
const IMAGE = /!\[[^\]]*\]\([^)]*\)/gu;
|
|
72
|
+
const LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
73
|
+
const INLINE_CODE = /`(?<code>[^`]+)`/gu;
|
|
74
|
+
// Tag-shaped only: a bare `<` in prose must not swallow text up to a later `>`.
|
|
75
|
+
const HTML_OR_JSX = /<\/?[a-zA-Z][^\n<>]*>|<\/?>/gu;
|
|
76
|
+
const MARKDOWN_PUNCT = /[*_~>]+/gu;
|
|
77
|
+
const WHITESPACE = /\s+/gu;
|
|
78
|
+
const TRAILING_FRAGMENT = /[\s,;:.—–-]+$/u;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derive a meta description from release notes: markdown reduced to plain
|
|
82
|
+
* text — section headings ("### Patch Changes") and changesets' commit-hash
|
|
83
|
+
* bullet prefixes dropped — then cut at a word boundary to fit the search
|
|
84
|
+
* snippet cap. Undefined when the notes have no prose at all.
|
|
85
|
+
*/
|
|
86
|
+
const releaseDescription = (body: string): string | undefined => {
|
|
87
|
+
const text = body
|
|
88
|
+
.replaceAll(CODE_FENCE, " ")
|
|
89
|
+
.replaceAll(HEADING_LINE, "")
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((line) => line.replace(LIST_MARK, "").replace(CHANGESET_HASH, ""))
|
|
92
|
+
.join("\n")
|
|
93
|
+
.replaceAll(IMAGE, " ")
|
|
94
|
+
.replaceAll(LINK, "$<text>")
|
|
95
|
+
.replaceAll(INLINE_CODE, "$<code>")
|
|
96
|
+
.replaceAll(HTML_OR_JSX, " ")
|
|
97
|
+
.replaceAll(MARKDOWN_PUNCT, " ")
|
|
98
|
+
.replaceAll(WHITESPACE, " ")
|
|
99
|
+
.trim();
|
|
100
|
+
if (!text) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
if (text.length <= DESCRIPTION_MAX) {
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
// Cut before the cap at a word boundary (kept only when it doesn't drop the
|
|
107
|
+
// summary under the minimum), shed any dangling punctuation, and mark the cut.
|
|
108
|
+
const slice = text.slice(0, DESCRIPTION_MAX - 1);
|
|
109
|
+
const boundary = slice.lastIndexOf(" ");
|
|
110
|
+
const head = (
|
|
111
|
+
boundary >= DESCRIPTION_MIN ? slice.slice(0, boundary) : slice
|
|
112
|
+
).replace(TRAILING_FRAGMENT, "");
|
|
113
|
+
return `${head}…`;
|
|
114
|
+
};
|
|
115
|
+
|
|
59
116
|
/** Slugify a tag into a stable, URL-safe source ref (`v1.2.0` -> `v1-2-0`). */
|
|
60
117
|
const slugifyTag = (tag: string): string =>
|
|
61
118
|
tag.toLowerCase().replaceAll(NON_SLUG, "-").replaceAll(EDGE_DASHES, "");
|
|
@@ -71,9 +128,10 @@ const githubHeaders = (): Headers => {
|
|
|
71
128
|
};
|
|
72
129
|
|
|
73
130
|
/**
|
|
74
|
-
* Lower one release to a staged Markdown entry: the notes become the body
|
|
131
|
+
* Lower one release to a staged Markdown entry: the notes become the body,
|
|
75
132
|
* `type: changelog` frontmatter (title/date/version/category) drives the
|
|
76
|
-
* generated `/changelog` timeline and RSS feed
|
|
133
|
+
* generated `/changelog` timeline and RSS feed, and a summary derived from the
|
|
134
|
+
* notes becomes the release page's meta description.
|
|
77
135
|
*/
|
|
78
136
|
const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
79
137
|
const version = release.tag_name.replace(LEADING_V, "");
|
|
@@ -81,9 +139,14 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
|
81
139
|
const date = release.published_at ?? release.created_at;
|
|
82
140
|
const category = release.prerelease ? "Prerelease" : "Release";
|
|
83
141
|
const body = (release.body ?? "").replaceAll("\r\n", "\n").trim();
|
|
142
|
+
// A summary in `seo.description` gives each release page a unique meta
|
|
143
|
+
// description (instead of the site-wide fallback) without also rendering the
|
|
144
|
+
// visible lede paragraph a top-level `description` would add.
|
|
145
|
+
const description = releaseDescription(body);
|
|
84
146
|
const data = {
|
|
85
147
|
changelog: { category, version },
|
|
86
148
|
date,
|
|
149
|
+
...(description ? { seo: { description } } : {}),
|
|
87
150
|
title,
|
|
88
151
|
type: "changelog",
|
|
89
152
|
};
|
|
@@ -4,10 +4,10 @@ import GithubSlugger from "github-slugger";
|
|
|
4
4
|
import { extname } from "pathe";
|
|
5
5
|
|
|
6
6
|
import { withBasePath } from "../base-path.ts";
|
|
7
|
-
import { diagnosticsFromZod } from "../diagnostics.ts";
|
|
7
|
+
import { diagnosticsFromIssues, diagnosticsFromZod } from "../diagnostics.ts";
|
|
8
8
|
import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
9
9
|
import { pageMetaSchema } from "../schema.ts";
|
|
10
|
-
import type { PageMeta } from "../schema.ts";
|
|
10
|
+
import type { FrontmatterExtend, PageMeta } from "../schema.ts";
|
|
11
11
|
import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
|
|
12
12
|
import type { NormalizeContext, SourceEntry } from "./types.ts";
|
|
13
13
|
|
|
@@ -131,6 +131,21 @@ const PARAGRAPH_INTERRUPT = /^ {0,3}(?:[-+*][ \t]|\d{1,9}[.)][ \t]|>)/u;
|
|
|
131
131
|
const THEMATIC_BREAK =
|
|
132
132
|
/^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/u;
|
|
133
133
|
const FRONT_MATTER_CLOSE = /^(?:-{3}|\.{3})\s*$/u;
|
|
134
|
+
// `<Prompt>` renders its children into a permanently `hidden` DOM node (see
|
|
135
|
+
// `Prompt.astro`) — the agent-facing prompt text is never visible page
|
|
136
|
+
// content, only read by client JS for the copy button. Any `##` inside it
|
|
137
|
+
// must not surface in the page's heading-derived table of contents. Tracked
|
|
138
|
+
// as an open/close depth, the same way fenced code blocks are tracked above.
|
|
139
|
+
// The opening tag is matched only at the start of a trimmed line: block-level
|
|
140
|
+
// JSX in MDX starts its own line, so a mention mid-prose or mid-heading —
|
|
141
|
+
// "the `<Prompt>` component", `## Using <Prompt>` — never opens a hidden
|
|
142
|
+
// region (an unanchored match here silently ate every heading after the
|
|
143
|
+
// mention). The lookahead rejects longer tag names that share the prefix,
|
|
144
|
+
// like `<PromptCard>` or `<Prompt-Custom>`.
|
|
145
|
+
const PROMPT_OPEN = /^<Prompt(?![\w-])/u;
|
|
146
|
+
// Unanchored: while inside a prompt the close tag may trail the hidden
|
|
147
|
+
// children text (`...copy this.</Prompt>`), not just sit on its own line.
|
|
148
|
+
const PROMPT_CLOSE = /<\/Prompt>/u;
|
|
134
149
|
|
|
135
150
|
/**
|
|
136
151
|
* The body lines, minus a leading front matter block. Bodies from the
|
|
@@ -155,13 +170,46 @@ interface HeadingScanState {
|
|
|
155
170
|
fence: FenceState;
|
|
156
171
|
/** Consecutive paragraph lines — the candidate text for a setext underline. */
|
|
157
172
|
paragraph: string[];
|
|
173
|
+
/** Nesting depth inside `<Prompt>...</Prompt>` — 0 when outside one. */
|
|
174
|
+
promptDepth: number;
|
|
175
|
+
/** True inside a multi-line `<Prompt` opening tag, awaiting its `>`. */
|
|
176
|
+
promptTag: boolean;
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Consume the rest of a `<Prompt` opening tag, scanning a trimmed line from
|
|
181
|
+
* `start`. The tag's attributes may spread over several lines
|
|
182
|
+
* (`state.promptTag` carries the search onto the next one), and until the
|
|
183
|
+
* terminating `>` arrives it isn't known whether the tag even has children —
|
|
184
|
+
* so the depth only rises once that `>` is found, and not when it turns out
|
|
185
|
+
* to be `/>` or when the element also closes on the same line
|
|
186
|
+
* (`<Prompt ...>copy this</Prompt>`). Attribute values containing `>` are not
|
|
187
|
+
* parsed: the first `>` ends the tag, which errs toward opening a region a
|
|
188
|
+
* real close tag will still exit.
|
|
189
|
+
*/
|
|
190
|
+
const finishPromptTag = (
|
|
191
|
+
line: string,
|
|
192
|
+
start: number,
|
|
193
|
+
state: HeadingScanState
|
|
194
|
+
): void => {
|
|
195
|
+
const end = line.indexOf(">", start);
|
|
196
|
+
if (end === -1) {
|
|
197
|
+
state.promptTag = true;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
state.promptTag = false;
|
|
201
|
+
if (line[end - 1] === "/" || line.includes("</Prompt>", end)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
state.promptDepth += 1;
|
|
205
|
+
};
|
|
206
|
+
|
|
160
207
|
/**
|
|
161
208
|
* Extract ATX and setext headings from a markdown body, skipping fenced code
|
|
162
|
-
* blocks, exactly as the renderer sees them: ATX
|
|
163
|
-
* to 3 spaces, and a paragraph underlined with
|
|
164
|
-
* heading. Each heading's anchor slug comes
|
|
209
|
+
* blocks and `<Prompt>` children, exactly as the renderer sees them: ATX
|
|
210
|
+
* headings may be indented up to 3 spaces, and a paragraph underlined with
|
|
211
|
+
* `=`/`-` is a level 1/2 setext heading. Each heading's anchor slug comes
|
|
212
|
+
* from a per-document
|
|
165
213
|
* `github-slugger` — the exact slugger the renderer uses
|
|
166
214
|
* (`markdown/heading-anchors`) — advanced over every heading in document
|
|
167
215
|
* order. Matching it (rather than a hand-rolled slugify) keeps the manifest's
|
|
@@ -185,6 +233,28 @@ const scanHeadingLine = (
|
|
|
185
233
|
state.paragraph = [];
|
|
186
234
|
return;
|
|
187
235
|
}
|
|
236
|
+
// Prompt tags may be indented arbitrarily (MDX has no indented code
|
|
237
|
+
// blocks), so they match against the trimmed line. A tag line can't also
|
|
238
|
+
// be a heading, so each just updates the state and moves on, same as a
|
|
239
|
+
// fence delimiter line. Outside a prompt, a `</Prompt>` line is plain text.
|
|
240
|
+
const trimmed = line.trimStart();
|
|
241
|
+
if (state.promptTag) {
|
|
242
|
+
finishPromptTag(trimmed, 0, state);
|
|
243
|
+
state.paragraph = [];
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (PROMPT_OPEN.test(trimmed)) {
|
|
247
|
+
finishPromptTag(trimmed, "<Prompt".length, state);
|
|
248
|
+
state.paragraph = [];
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (state.promptDepth > 0) {
|
|
252
|
+
if (PROMPT_CLOSE.test(line)) {
|
|
253
|
+
state.promptDepth -= 1;
|
|
254
|
+
}
|
|
255
|
+
state.paragraph = [];
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
188
258
|
const atx = line.match(ATX_HEADING);
|
|
189
259
|
if (atx?.groups) {
|
|
190
260
|
const depth = atx.groups.hashes?.length ?? 1;
|
|
@@ -220,7 +290,12 @@ const scanHeadingLine = (
|
|
|
220
290
|
export const extractHeadings = (body: string): Heading[] => {
|
|
221
291
|
const headings: Heading[] = [];
|
|
222
292
|
const slugger = new GithubSlugger();
|
|
223
|
-
const state: HeadingScanState = {
|
|
293
|
+
const state: HeadingScanState = {
|
|
294
|
+
fence: null,
|
|
295
|
+
paragraph: [],
|
|
296
|
+
promptDepth: 0,
|
|
297
|
+
promptTag: false,
|
|
298
|
+
};
|
|
224
299
|
|
|
225
300
|
for (const line of linesWithoutFrontMatter(body)) {
|
|
226
301
|
scanHeadingLine(line, state, slugger, headings);
|
|
@@ -364,6 +439,118 @@ const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
|
364
439
|
return clean ? `${clean}/${path}` : path;
|
|
365
440
|
};
|
|
366
441
|
|
|
442
|
+
/** A custom-key validation failure, lowered to a joinable diagnostic path. */
|
|
443
|
+
interface CustomKeyIssue {
|
|
444
|
+
message: string;
|
|
445
|
+
path: (string | number)[];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
|
|
449
|
+
const segmentKey = (
|
|
450
|
+
segment: PropertyKey | { readonly key: PropertyKey }
|
|
451
|
+
): string | number => {
|
|
452
|
+
const key =
|
|
453
|
+
typeof segment === "object" && segment !== null ? segment.key : segment;
|
|
454
|
+
return typeof key === "symbol" ? String(key) : key;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Validate the opt-in custom frontmatter keys (`frontmatter.extend`) through
|
|
459
|
+
* the Standard Schema contract — the consumer's own Zod (any version),
|
|
460
|
+
* Valibot, or ArkType, never Blume's bundled zod (see `standard-schema.ts`).
|
|
461
|
+
* Every declared key is checked, absent ones included, so a required schema
|
|
462
|
+
* enforces its key on every page. Async schemas are rejected with a
|
|
463
|
+
* diagnostic: this funnel is synchronous, and frontmatter validation has no
|
|
464
|
+
* business awaiting I/O.
|
|
465
|
+
*/
|
|
466
|
+
const validateCustomKeys = (
|
|
467
|
+
data: Record<string, unknown>,
|
|
468
|
+
extend: FrontmatterExtend
|
|
469
|
+
): { custom?: Record<string, unknown>; issues: CustomKeyIssue[] } => {
|
|
470
|
+
const custom: Record<string, unknown> = {};
|
|
471
|
+
const issues: CustomKeyIssue[] = [];
|
|
472
|
+
for (const [key, schema] of Object.entries(extend)) {
|
|
473
|
+
const outcome = schema["~standard"].validate(data[key]);
|
|
474
|
+
if (outcome instanceof Promise) {
|
|
475
|
+
issues.push({
|
|
476
|
+
message: "Async schemas are not supported in frontmatter.extend.",
|
|
477
|
+
path: [key],
|
|
478
|
+
});
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (outcome.issues !== undefined) {
|
|
482
|
+
issues.push(
|
|
483
|
+
...outcome.issues.map((issue) => ({
|
|
484
|
+
message: issue.message,
|
|
485
|
+
path: [key, ...(issue.path ?? []).map(segmentKey)],
|
|
486
|
+
}))
|
|
487
|
+
);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// Preserve the validated (schema-output) value; skip keys that are absent
|
|
491
|
+
// and stay absent, so `.optional()` extras don't materialize as undefined.
|
|
492
|
+
if (outcome.value !== undefined || Object.hasOwn(data, key)) {
|
|
493
|
+
custom[key] = outcome.value;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
custom: Object.keys(custom).length > 0 ? custom : undefined,
|
|
498
|
+
issues,
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Parse an entry's frontmatter: built-in keys through the strict page schema,
|
|
504
|
+
* custom keys (`frontmatter.extend`) through their user-supplied schemas. The
|
|
505
|
+
* custom keys are carved out before the strict parse, so the page schema stays
|
|
506
|
+
* strict for everything else and unknown-key typo catching is unchanged.
|
|
507
|
+
* Returns diagnostics instead of meta when either side rejects.
|
|
508
|
+
*/
|
|
509
|
+
const parseEntryMeta = (
|
|
510
|
+
entry: SourceEntry,
|
|
511
|
+
ctx: NormalizeContext
|
|
512
|
+
):
|
|
513
|
+
| { meta: PageMeta; custom?: Record<string, unknown>; diagnostics?: never }
|
|
514
|
+
| { meta?: never; diagnostics: Diagnostic[] } => {
|
|
515
|
+
const extend = ctx.frontmatterExtend;
|
|
516
|
+
const known = extend
|
|
517
|
+
? Object.fromEntries(
|
|
518
|
+
Object.entries(entry.data).filter(
|
|
519
|
+
([key]) => !Object.hasOwn(extend, key)
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
: entry.data;
|
|
523
|
+
|
|
524
|
+
const result = pageMetaSchema.safeParse(known);
|
|
525
|
+
const customResult = extend ? validateCustomKeys(entry.data, extend) : null;
|
|
526
|
+
|
|
527
|
+
if (result.success && (customResult?.issues.length ?? 0) === 0) {
|
|
528
|
+
return { custom: customResult?.custom, meta: result.data };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Source text lets the error carry a line/column into the frontmatter block:
|
|
532
|
+
// `entry.raw` for non-filesystem sources, else the file itself (read only on
|
|
533
|
+
// this rare error path, so filesystem entries stay cheap in the happy path).
|
|
534
|
+
const source =
|
|
535
|
+
entry.raw ??
|
|
536
|
+
(entry.sourcePath && existsSync(entry.sourcePath)
|
|
537
|
+
? readFileSync(entry.sourcePath, "utf-8")
|
|
538
|
+
: undefined);
|
|
539
|
+
const location = {
|
|
540
|
+
code: "BLUME_FRONTMATTER_INVALID",
|
|
541
|
+
file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
|
|
542
|
+
source,
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
diagnostics: [
|
|
546
|
+
...(result.success ? [] : diagnosticsFromZod(result.error, location)),
|
|
547
|
+
...(customResult
|
|
548
|
+
? diagnosticsFromIssues(customResult.issues, location)
|
|
549
|
+
: []),
|
|
550
|
+
],
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
367
554
|
/**
|
|
368
555
|
* Normalize one source entry into per-locale `PageRecord`s. This is the single
|
|
369
556
|
* funnel every adapter's entries pass through, so route mapping, heading/link
|
|
@@ -376,27 +563,12 @@ export const normalizeEntry = (
|
|
|
376
563
|
const { format } = entry.body;
|
|
377
564
|
const ext = format === "mdx" ? ".mdx" : ".md";
|
|
378
565
|
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
// `entry.raw` for non-filesystem sources, else the file itself (read only on
|
|
383
|
-
// this rare error path, so filesystem entries stay cheap in the happy path).
|
|
384
|
-
const source =
|
|
385
|
-
entry.raw ??
|
|
386
|
-
(entry.sourcePath && existsSync(entry.sourcePath)
|
|
387
|
-
? readFileSync(entry.sourcePath, "utf-8")
|
|
388
|
-
: undefined);
|
|
389
|
-
return {
|
|
390
|
-
diagnostics: diagnosticsFromZod(result.error, {
|
|
391
|
-
code: "BLUME_FRONTMATTER_INVALID",
|
|
392
|
-
file: entry.sourcePath ?? `${ctx.source.name}:${entry.ref}`,
|
|
393
|
-
source,
|
|
394
|
-
}),
|
|
395
|
-
pages: [],
|
|
396
|
-
};
|
|
566
|
+
const parsed = parseEntryMeta(entry, ctx);
|
|
567
|
+
if (parsed.diagnostics) {
|
|
568
|
+
return { diagnostics: parsed.diagnostics, pages: [] };
|
|
397
569
|
}
|
|
398
570
|
|
|
399
|
-
const meta =
|
|
571
|
+
const { meta } = parsed;
|
|
400
572
|
|
|
401
573
|
// Top-level `hidden`/`noindex` are accepted as shorthands for their nested
|
|
402
574
|
// equivalents — the schema declares them, so silently ignoring them would
|
|
@@ -439,6 +611,7 @@ export const normalizeEntry = (
|
|
|
439
611
|
componentsUsed:
|
|
440
612
|
format === "mdx" ? extractComponentTags(entry.body.text) : undefined,
|
|
441
613
|
contentType: meta.type ?? ctx.defaultType,
|
|
614
|
+
custom: parsed.custom,
|
|
442
615
|
description: meta.description,
|
|
443
616
|
editUrl: entry.editUrl,
|
|
444
617
|
entryId: staged ? `${ctx.source.name}/${entry.ref}` : undefined,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ResolvedI18nConfig } from "../schema.ts";
|
|
1
|
+
import type { FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
|
|
2
2
|
import type { Diagnostic } from "../types.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -109,5 +109,7 @@ export interface NormalizeContext {
|
|
|
109
109
|
/** Site-wide route mount point (`""` or `/seg`), prepended to every route. */
|
|
110
110
|
basePath?: string;
|
|
111
111
|
defaultType: string;
|
|
112
|
+
/** Opt-in custom frontmatter keys (`frontmatter.extend`), schema per key. */
|
|
113
|
+
frontmatterExtend?: FrontmatterExtend;
|
|
112
114
|
i18n?: ResolvedI18nConfig;
|
|
113
115
|
}
|