blume 1.4.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/cli/index.js +1621 -576
- package/dist/cli/index.js.map +109 -104
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +79 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +54 -29
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +152 -33
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/RootLayout.astro +18 -0
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +88 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +15 -3
- package/src/core/schema.ts +213 -67
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +30 -11
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
|
@@ -9,6 +9,7 @@ import { localePlacement, localizeRoute } from "../i18n.ts";
|
|
|
9
9
|
import { pageMetaSchema } from "../schema.ts";
|
|
10
10
|
import type { FrontmatterExtend, PageMeta } from "../schema.ts";
|
|
11
11
|
import type { Diagnostic, Heading, PageLink, PageRecord } from "../types.ts";
|
|
12
|
+
import { detectVersionRef, versionizeRoute } from "../versions.ts";
|
|
12
13
|
import type { NormalizeContext, SourceEntry } from "./types.ts";
|
|
13
14
|
|
|
14
15
|
const NUMERIC_PREFIX = /^\d+[-_.]/u;
|
|
@@ -102,10 +103,15 @@ const addRouteSegment = (
|
|
|
102
103
|
segments.push(safe);
|
|
103
104
|
};
|
|
104
105
|
|
|
106
|
+
/** URL + nav metadata mapped from one content-root-relative path. */
|
|
107
|
+
interface MappedRoute {
|
|
108
|
+
segments: string[];
|
|
109
|
+
groups: string[];
|
|
110
|
+
route: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
105
113
|
/** Convert a content-root-relative path into URL + nav metadata. */
|
|
106
|
-
const mapRoute = (
|
|
107
|
-
relativePath: string
|
|
108
|
-
): { segments: string[]; groups: string[]; route: string } => {
|
|
114
|
+
const mapRoute = (relativePath: string): MappedRoute => {
|
|
109
115
|
const withoutExt = relativePath.slice(
|
|
110
116
|
0,
|
|
111
117
|
relativePath.length - extname(relativePath).length
|
|
@@ -138,10 +144,10 @@ export type FenceState = "```" | "~~~" | null;
|
|
|
138
144
|
*/
|
|
139
145
|
export const nextFenceState = (line: string, fence: FenceState): FenceState => {
|
|
140
146
|
const trimmed = line.trimStart();
|
|
141
|
-
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (delimiter
|
|
147
|
+
const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter;
|
|
148
|
+
// The `delimiter` group matches exactly ``` or ~~~; comparing against both
|
|
149
|
+
// narrows it without a cast.
|
|
150
|
+
if (delimiter !== "```" && delimiter !== "~~~") {
|
|
145
151
|
return fence;
|
|
146
152
|
}
|
|
147
153
|
if (fence === null) {
|
|
@@ -519,6 +525,10 @@ const deriveTitle = (
|
|
|
519
525
|
const trimSlashes = (value: string): string =>
|
|
520
526
|
value.replaceAll(/^\/+|\/+$/gu, "");
|
|
521
527
|
|
|
528
|
+
/** Whether a raw frontmatter value is a string (e.g. the `type` override). */
|
|
529
|
+
const isStringValue = (value: SourceEntry["data"][string]): value is string =>
|
|
530
|
+
typeof value === "string";
|
|
531
|
+
|
|
522
532
|
const withPrefix = (prefix: string | undefined, path: string): string => {
|
|
523
533
|
const clean = prefix ? trimSlashes(prefix) : "";
|
|
524
534
|
return clean ? `${clean}/${path}` : path;
|
|
@@ -530,13 +540,27 @@ interface CustomKeyIssue {
|
|
|
530
540
|
path: (string | number)[];
|
|
531
541
|
}
|
|
532
542
|
|
|
543
|
+
/** The validated custom keys (if any survived) plus every failure found. */
|
|
544
|
+
interface CustomKeyValidation {
|
|
545
|
+
custom?: PageRecord["custom"];
|
|
546
|
+
issues: CustomKeyIssue[];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Whether a Standard Schema path segment is the wrapped `{ key }` form. */
|
|
550
|
+
const isKeyCarrier = (
|
|
551
|
+
segment: PropertyKey | { readonly key: PropertyKey }
|
|
552
|
+
): segment is { readonly key: PropertyKey } =>
|
|
553
|
+
typeof segment === "object" && segment !== null;
|
|
554
|
+
|
|
555
|
+
const isSymbolKey = (key: PropertyKey): key is symbol =>
|
|
556
|
+
typeof key === "symbol";
|
|
557
|
+
|
|
533
558
|
/** Lower a Standard Schema path segment (`key` or `{ key }`) for joining. */
|
|
534
559
|
const segmentKey = (
|
|
535
560
|
segment: PropertyKey | { readonly key: PropertyKey }
|
|
536
561
|
): string | number => {
|
|
537
|
-
const key =
|
|
538
|
-
|
|
539
|
-
return typeof key === "symbol" ? String(key) : key;
|
|
562
|
+
const key = isKeyCarrier(segment) ? segment.key : segment;
|
|
563
|
+
return isSymbolKey(key) ? String(key) : key;
|
|
540
564
|
};
|
|
541
565
|
|
|
542
566
|
/**
|
|
@@ -549,10 +573,10 @@ const segmentKey = (
|
|
|
549
573
|
* synchronous, and frontmatter validation has no business awaiting I/O.
|
|
550
574
|
*/
|
|
551
575
|
const validateCustomKeys = (
|
|
552
|
-
data:
|
|
576
|
+
data: SourceEntry["data"],
|
|
553
577
|
extend: FrontmatterExtend
|
|
554
|
-
):
|
|
555
|
-
const custom:
|
|
578
|
+
): CustomKeyValidation => {
|
|
579
|
+
const custom: NonNullable<PageRecord["custom"]> = {};
|
|
556
580
|
const issues: CustomKeyIssue[] = [];
|
|
557
581
|
for (const [key, schema] of Object.entries(extend)) {
|
|
558
582
|
const outcome = schema["~standard"].validate(data[key]);
|
|
@@ -597,13 +621,14 @@ const parseEntryMeta = (
|
|
|
597
621
|
entry: SourceEntry,
|
|
598
622
|
ctx: NormalizeContext
|
|
599
623
|
):
|
|
600
|
-
| { meta: PageMeta; custom?:
|
|
624
|
+
| { meta: PageMeta; custom?: PageRecord["custom"]; diagnostics?: never }
|
|
601
625
|
| { meta?: never; diagnostics: Diagnostic[] } => {
|
|
602
626
|
// Resolved the same way `contentType` is after parsing (`meta.type` falling
|
|
603
627
|
// back to `defaultType`); a non-string `type` fails the strict parse below,
|
|
604
628
|
// so which per-type map was merged for that entry never matters.
|
|
605
|
-
const entryType =
|
|
606
|
-
|
|
629
|
+
const entryType = isStringValue(entry.data.type)
|
|
630
|
+
? entry.data.type
|
|
631
|
+
: ctx.defaultType;
|
|
607
632
|
const typeExtend = ctx.typeFrontmatter?.[entryType];
|
|
608
633
|
// Config validation rejects a key declared both site-wide and per-type, so
|
|
609
634
|
// this merge never has to pick a winner.
|
|
@@ -649,6 +674,12 @@ const parseEntryMeta = (
|
|
|
649
674
|
};
|
|
650
675
|
};
|
|
651
676
|
|
|
677
|
+
/** The per-locale page records and diagnostics from one source entry. */
|
|
678
|
+
export interface NormalizedEntry {
|
|
679
|
+
pages: PageRecord[];
|
|
680
|
+
diagnostics: Diagnostic[];
|
|
681
|
+
}
|
|
682
|
+
|
|
652
683
|
/**
|
|
653
684
|
* Normalize one source entry into per-locale `PageRecord`s. This is the single
|
|
654
685
|
* funnel every adapter's entries pass through, so route mapping, heading/link
|
|
@@ -657,7 +688,7 @@ const parseEntryMeta = (
|
|
|
657
688
|
export const normalizeEntry = (
|
|
658
689
|
entry: SourceEntry,
|
|
659
690
|
ctx: NormalizeContext
|
|
660
|
-
):
|
|
691
|
+
): NormalizedEntry => {
|
|
661
692
|
const { format } = entry.body;
|
|
662
693
|
const ext = format === "mdx" ? ".mdx" : ".md";
|
|
663
694
|
|
|
@@ -678,14 +709,22 @@ export const normalizeEntry = (
|
|
|
678
709
|
meta.seo.noindex = true;
|
|
679
710
|
}
|
|
680
711
|
|
|
712
|
+
// The version is detected first: a snapshot directory is outermost on disk
|
|
713
|
+
// (`v1.0/fr/page.mdx`), so the locale parser and route mapping must see a
|
|
714
|
+
// version-stripped ref. The current version is `""` and lives at the root.
|
|
715
|
+
const { versions } = ctx;
|
|
716
|
+
const { version, rest: versionlessRef } = versions
|
|
717
|
+
? detectVersionRef(entry.ref, versions)
|
|
718
|
+
: { rest: entry.ref, version: "" };
|
|
719
|
+
|
|
681
720
|
// Locale and the locale-stripped nav path come from the entry's ref (a leading
|
|
682
721
|
// dir, or a filename suffix under the `dot` parser), not the slug — the slug is
|
|
683
722
|
// the logical, locale-agnostic path within a locale. A shared `$` file maps to
|
|
684
723
|
// every locale. Remote/CMS sources without i18n placement map to one locale.
|
|
685
724
|
const { i18n } = ctx;
|
|
686
725
|
const { navPath: rawNavPath, locales } = i18n
|
|
687
|
-
? localePlacement(
|
|
688
|
-
: { locales: [""], navPath:
|
|
726
|
+
? localePlacement(versionlessRef, ext, i18n)
|
|
727
|
+
: { locales: [""], navPath: versionlessRef };
|
|
689
728
|
|
|
690
729
|
const navPath = withPrefix(ctx.source.prefix, rawNavPath);
|
|
691
730
|
// Frontmatter `slug` wins, then the adapter-supplied `entry.slug` (the typed
|
|
@@ -699,7 +738,13 @@ export const normalizeEntry = (
|
|
|
699
738
|
slug ? `${slug}${ext}` : rawNavPath
|
|
700
739
|
);
|
|
701
740
|
|
|
702
|
-
|
|
741
|
+
// The version prefixes the mapped route *after* `mapRoute` runs: the mapped
|
|
742
|
+
// route is the version-agnostic key, the config id is prepended verbatim
|
|
743
|
+
// (never numeric-prefix-stripped), a frontmatter `slug` gets versionized so
|
|
744
|
+
// snapshots can't collide with the live page, and `translationKey` becomes
|
|
745
|
+
// version-specific for free.
|
|
746
|
+
const { segments, groups, route: versionKey } = mapRoute(routeInput);
|
|
747
|
+
const logicalRoute = versionizeRoute(versionKey, version);
|
|
703
748
|
const headings = extractHeadings(entry.body.text);
|
|
704
749
|
const { staged } = ctx.source;
|
|
705
750
|
|
|
@@ -729,6 +774,8 @@ export const normalizeEntry = (
|
|
|
729
774
|
sourcePath: entry.sourcePath,
|
|
730
775
|
title: deriveTitle(meta, headings, navPath),
|
|
731
776
|
translationKey: logicalRoute,
|
|
777
|
+
version,
|
|
778
|
+
versionKey,
|
|
732
779
|
} satisfies Omit<PageRecord, "locale" | "route">;
|
|
733
780
|
|
|
734
781
|
// One record per locale this entry maps to (one normally; every locale for a
|
|
@@ -47,11 +47,21 @@ interface NotionPage {
|
|
|
47
47
|
last_edited_time?: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** The per-type payload a block carries under the key matching its `type`. */
|
|
51
|
+
interface NotionBlockPayload {
|
|
52
|
+
caption?: NotionRichText[];
|
|
53
|
+
checked?: boolean;
|
|
54
|
+
external?: { url: string };
|
|
55
|
+
file?: { url: string };
|
|
56
|
+
language?: string;
|
|
57
|
+
rich_text?: NotionRichText[];
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
interface NotionBlock {
|
|
51
61
|
id: string;
|
|
52
62
|
type: string;
|
|
53
63
|
has_children?: boolean;
|
|
54
|
-
[key: string]:
|
|
64
|
+
[key: string]: NotionBlockPayload | boolean | string | undefined;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
interface NotionList<T> {
|
|
@@ -120,6 +130,17 @@ export interface NotionSourceOptions {
|
|
|
120
130
|
fetchImpl?: typeof fetch;
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
/** The frontmatter Blume derives from a Notion page's properties. */
|
|
134
|
+
interface NotionFrontmatter {
|
|
135
|
+
description?: string;
|
|
136
|
+
draft?: boolean;
|
|
137
|
+
sidebar?: { order: number };
|
|
138
|
+
title?: string;
|
|
139
|
+
// Frontmatter stays an open bag downstream (`SourceEntry.data`), so admit
|
|
140
|
+
// the value shapes this source writes under any future key.
|
|
141
|
+
[key: string]: boolean | string | { order: number } | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
const richToMarkdown = (rich: NotionRichText[] = []): string =>
|
|
124
145
|
rich
|
|
125
146
|
.map((node) => {
|
|
@@ -140,9 +161,18 @@ const richToMarkdown = (rich: NotionRichText[] = []): string =>
|
|
|
140
161
|
})
|
|
141
162
|
.join("");
|
|
142
163
|
|
|
164
|
+
const isBlockPayload = (
|
|
165
|
+
value: NotionBlockPayload | boolean | string | undefined
|
|
166
|
+
): value is NotionBlockPayload => typeof value === "object";
|
|
167
|
+
|
|
168
|
+
/** The payload object stored under a block's own `type` key, if present. */
|
|
169
|
+
const payloadOf = (block: NotionBlock): NotionBlockPayload | undefined => {
|
|
170
|
+
const value = block[block.type];
|
|
171
|
+
return isBlockPayload(value) ? value : undefined;
|
|
172
|
+
};
|
|
173
|
+
|
|
143
174
|
const blockField = (block: NotionBlock): NotionRichText[] =>
|
|
144
|
-
(
|
|
145
|
-
[]) as NotionRichText[];
|
|
175
|
+
payloadOf(block)?.rich_text ?? [];
|
|
146
176
|
|
|
147
177
|
const RATE_LIMITED = 429;
|
|
148
178
|
const MAX_RETRIES = 4;
|
|
@@ -165,10 +195,16 @@ const withNotionRetry = async <T>(
|
|
|
165
195
|
try {
|
|
166
196
|
return await call();
|
|
167
197
|
} catch (error) {
|
|
198
|
+
// SAFETY: Notion SDK failures are APIResponseError-shaped, carrying the
|
|
199
|
+
// failed request's HTTP status; anything else reads `undefined` and is
|
|
200
|
+
// rethrown below.
|
|
168
201
|
const { status } = error as { status?: number };
|
|
169
202
|
if (status !== RATE_LIMITED || attempt === MAX_RETRIES) {
|
|
170
203
|
throw error;
|
|
171
204
|
}
|
|
205
|
+
// SAFETY: same APIResponseError shape — `headers` maps lower-cased HTTP
|
|
206
|
+
// header names to their values; a missing header yields `NaN` and falls
|
|
207
|
+
// back to exponential backoff.
|
|
172
208
|
const retryAfter = Number(
|
|
173
209
|
(error as { headers?: Record<string, string> }).headers?.["retry-after"]
|
|
174
210
|
);
|
|
@@ -206,7 +242,7 @@ const isListItem = (block: NotionBlock | undefined): boolean =>
|
|
|
206
242
|
|
|
207
243
|
/** Render a leaf (non-container) block to Markdown, or null for containers. */
|
|
208
244
|
const renderLeaf = (block: NotionBlock): string | null => {
|
|
209
|
-
const data = (block
|
|
245
|
+
const data = payloadOf(block) ?? {};
|
|
210
246
|
const text = richToMarkdown(blockField(block));
|
|
211
247
|
switch (block.type) {
|
|
212
248
|
case "paragraph": {
|
|
@@ -237,16 +273,11 @@ const renderLeaf = (block: NotionBlock): string | null => {
|
|
|
237
273
|
return "---";
|
|
238
274
|
}
|
|
239
275
|
case "code": {
|
|
240
|
-
return `\`\`\`${
|
|
276
|
+
return `\`\`\`${data.language ?? ""}\n${text}\n\`\`\``;
|
|
241
277
|
}
|
|
242
278
|
case "image": {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
file?: { url: string };
|
|
246
|
-
caption?: NotionRichText[];
|
|
247
|
-
};
|
|
248
|
-
const url = media.external?.url ?? media.file?.url;
|
|
249
|
-
return url ? `` : "";
|
|
279
|
+
const url = data.external?.url ?? data.file?.url;
|
|
280
|
+
return url ? `` : "";
|
|
250
281
|
}
|
|
251
282
|
default: {
|
|
252
283
|
return null;
|
|
@@ -288,6 +319,9 @@ export const notionSource = (
|
|
|
288
319
|
}
|
|
289
320
|
let Client: new (config: { auth?: string }) => NotionClientLike;
|
|
290
321
|
try {
|
|
322
|
+
// SAFETY: `@notionhq/client` exports a `Client` class constructable with
|
|
323
|
+
// an `auth` token whose instances cover the NotionClientLike slice; the
|
|
324
|
+
// local type keeps the SDK mockable without importing its types.
|
|
291
325
|
({ Client } = (await import("@notionhq/client")) as {
|
|
292
326
|
Client: new (config: { auth?: string }) => NotionClientLike;
|
|
293
327
|
});
|
|
@@ -421,13 +455,11 @@ export const notionSource = (
|
|
|
421
455
|
|
|
422
456
|
const orderOf = (page: NotionPage): number | undefined => {
|
|
423
457
|
const order = page.properties[props.order ?? "Order"]?.number;
|
|
424
|
-
return
|
|
458
|
+
return order === null ? undefined : order;
|
|
425
459
|
};
|
|
426
460
|
|
|
427
|
-
const frontmatter = (
|
|
428
|
-
|
|
429
|
-
): { data: Record<string, unknown>; slug: string } => {
|
|
430
|
-
const data: Record<string, unknown> = {};
|
|
461
|
+
const frontmatter = (page: NotionPage) => {
|
|
462
|
+
const data: NotionFrontmatter = {};
|
|
431
463
|
const title = richToMarkdown(titleProperty(page)?.title);
|
|
432
464
|
if (title) {
|
|
433
465
|
data.title = title;
|
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
* Markdown text that flows through Blume's normal pipeline.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* A field value on a Portable Text node: the arbitrary JSON the CMS query
|
|
10
|
+
* returned (custom blocks carry whatever fields the studio schema defines).
|
|
11
|
+
* Spans and mark defs appear as members so the block's typed fields conform
|
|
12
|
+
* to its index signature.
|
|
13
|
+
*/
|
|
14
|
+
export type PortableTextValue =
|
|
15
|
+
| string
|
|
16
|
+
| number
|
|
17
|
+
| boolean
|
|
18
|
+
| null
|
|
19
|
+
| undefined
|
|
20
|
+
| PortableTextSpan
|
|
21
|
+
| PortableTextMarkDef
|
|
22
|
+
| PortableTextValue[]
|
|
23
|
+
| { [key: string]: PortableTextValue };
|
|
24
|
+
|
|
8
25
|
/** A single Portable Text node (block, image, or a custom type). */
|
|
9
26
|
export interface PortableTextBlock {
|
|
10
27
|
_type: string;
|
|
@@ -14,7 +31,7 @@ export interface PortableTextBlock {
|
|
|
14
31
|
level?: number;
|
|
15
32
|
children?: PortableTextSpan[];
|
|
16
33
|
markDefs?: PortableTextMarkDef[];
|
|
17
|
-
[key: string]:
|
|
34
|
+
[key: string]: PortableTextValue;
|
|
18
35
|
}
|
|
19
36
|
|
|
20
37
|
interface PortableTextSpan {
|
|
@@ -36,14 +53,14 @@ export interface PortableTextOptions {
|
|
|
36
53
|
serializers?: Record<string, (block: PortableTextBlock) => string>;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
const HEADING_STYLES
|
|
40
|
-
h1
|
|
41
|
-
h2
|
|
42
|
-
h3
|
|
43
|
-
h4
|
|
44
|
-
h5
|
|
45
|
-
h6
|
|
46
|
-
|
|
56
|
+
const HEADING_STYLES = new Map([
|
|
57
|
+
["h1", "# "],
|
|
58
|
+
["h2", "## "],
|
|
59
|
+
["h3", "### "],
|
|
60
|
+
["h4", "#### "],
|
|
61
|
+
["h5", "##### "],
|
|
62
|
+
["h6", "###### "],
|
|
63
|
+
]);
|
|
47
64
|
|
|
48
65
|
// Markdown/raw-HTML structure characters. Portable Text spans are *plain
|
|
49
66
|
// text* — formatting arrives as marks, never as syntax in the text — so a
|
|
@@ -108,6 +125,10 @@ const renderChildren = (block: PortableTextBlock): string => {
|
|
|
108
125
|
return (block.children ?? []).map((span) => renderSpan(span, defs)).join("");
|
|
109
126
|
};
|
|
110
127
|
|
|
128
|
+
/** Whether an image block's `alt` field is usable alt text (CMS JSON may hold anything). */
|
|
129
|
+
const isAltText = (value: PortableTextValue): value is string =>
|
|
130
|
+
typeof value === "string";
|
|
131
|
+
|
|
111
132
|
const renderBlock = (
|
|
112
133
|
block: PortableTextBlock,
|
|
113
134
|
options: PortableTextOptions
|
|
@@ -118,7 +139,7 @@ const renderBlock = (
|
|
|
118
139
|
}
|
|
119
140
|
if (block._type === "image") {
|
|
120
141
|
const url = options.imageUrl?.(block);
|
|
121
|
-
const alt =
|
|
142
|
+
const alt = isAltText(block.alt) ? block.alt : "";
|
|
122
143
|
return url ? `` : "";
|
|
123
144
|
}
|
|
124
145
|
if (block._type !== "block") {
|
|
@@ -134,7 +155,7 @@ const renderBlock = (
|
|
|
134
155
|
if (block.style === "blockquote") {
|
|
135
156
|
return `> ${inline}`;
|
|
136
157
|
}
|
|
137
|
-
return `${HEADING_STYLES
|
|
158
|
+
return `${HEADING_STYLES.get(block.style ?? "normal") ?? ""}${inline}`;
|
|
138
159
|
};
|
|
139
160
|
|
|
140
161
|
/** Serialize a Portable Text array into a Markdown string. */
|
|
@@ -59,12 +59,44 @@ export interface SanitySourceOptions {
|
|
|
59
59
|
|
|
60
60
|
const IMAGE_REF = /^image-(?<id>[a-f0-9]+)-(?<dims>\d+x\d+)-(?<ext>\w+)$/u;
|
|
61
61
|
|
|
62
|
+
/** A value in a fetched document: arbitrary JSON the GROQ query returned. */
|
|
63
|
+
type SanityValue =
|
|
64
|
+
| string
|
|
65
|
+
| number
|
|
66
|
+
| boolean
|
|
67
|
+
| null
|
|
68
|
+
| SanityValue[]
|
|
69
|
+
| { [key: string]: SanityValue };
|
|
70
|
+
|
|
71
|
+
/** A document as the GROQ query returns it. */
|
|
72
|
+
interface SanityDocument {
|
|
73
|
+
[key: string]: SanityValue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The frontmatter fields this adapter maps from a document. */
|
|
77
|
+
interface SanityFrontmatter {
|
|
78
|
+
description?: string;
|
|
79
|
+
title?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A node a dot path can descend into. Arrays pass too (matching their runtime
|
|
84
|
+
* string-key indexing, e.g. `items.0`), so the predicate types them as the
|
|
85
|
+
* keyed form both traverse through.
|
|
86
|
+
*/
|
|
87
|
+
const isTraversable = (
|
|
88
|
+
value: SanityValue | undefined
|
|
89
|
+
): value is SanityDocument => typeof value === "object" && value !== null;
|
|
90
|
+
|
|
62
91
|
/** Resolve a dot path (`slug.current`) against a document. */
|
|
63
|
-
const getPath = (
|
|
64
|
-
|
|
92
|
+
const getPath = (
|
|
93
|
+
doc: SanityDocument,
|
|
94
|
+
path: string
|
|
95
|
+
): SanityValue | undefined => {
|
|
96
|
+
let current: SanityValue | undefined = doc;
|
|
65
97
|
for (const key of path.split(".")) {
|
|
66
|
-
if (current
|
|
67
|
-
current =
|
|
98
|
+
if (isTraversable(current)) {
|
|
99
|
+
current = current[key];
|
|
68
100
|
} else {
|
|
69
101
|
return;
|
|
70
102
|
}
|
|
@@ -72,8 +104,11 @@ const getPath = (doc: Record<string, unknown>, path: string): unknown => {
|
|
|
72
104
|
return current;
|
|
73
105
|
};
|
|
74
106
|
|
|
75
|
-
const
|
|
76
|
-
typeof value === "string"
|
|
107
|
+
const isStringValue = (value: SanityValue | undefined): value is string =>
|
|
108
|
+
typeof value === "string";
|
|
109
|
+
|
|
110
|
+
const asString = (value: SanityValue | undefined): string | undefined =>
|
|
111
|
+
isStringValue(value) ? value : undefined;
|
|
77
112
|
|
|
78
113
|
/** Build a Sanity CDN URL from an image asset `_ref`. */
|
|
79
114
|
const imageUrlFromRef = (
|
|
@@ -89,6 +124,16 @@ const imageUrlFromRef = (
|
|
|
89
124
|
return `https://cdn.sanity.io/images/${projectId}/${dataset}/${id}-${dims}.${ext}`;
|
|
90
125
|
};
|
|
91
126
|
|
|
127
|
+
/** The `createClient` config slice this adapter passes. */
|
|
128
|
+
interface SanityClientConfig {
|
|
129
|
+
apiVersion: string;
|
|
130
|
+
dataset: string;
|
|
131
|
+
perspective: "previewDrafts" | "published";
|
|
132
|
+
projectId: string;
|
|
133
|
+
token: string | undefined;
|
|
134
|
+
useCdn: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
92
137
|
const resolveClient = async (
|
|
93
138
|
options: SanitySourceOptions,
|
|
94
139
|
preview: boolean
|
|
@@ -96,10 +141,13 @@ const resolveClient = async (
|
|
|
96
141
|
if (options.client) {
|
|
97
142
|
return options.client;
|
|
98
143
|
}
|
|
99
|
-
let createClient: (config:
|
|
144
|
+
let createClient: (config: SanityClientConfig) => SanityClientLike;
|
|
100
145
|
try {
|
|
146
|
+
// SAFETY: `@sanity/client` is an optional dependency; its `createClient`
|
|
147
|
+
// accepts a superset of this config slice and returns a client exposing
|
|
148
|
+
// the `fetch` method this adapter uses.
|
|
101
149
|
({ createClient } = (await import("@sanity/client")) as {
|
|
102
|
-
createClient: (config:
|
|
150
|
+
createClient: (config: SanityClientConfig) => SanityClientLike;
|
|
103
151
|
});
|
|
104
152
|
} catch {
|
|
105
153
|
throw new BlumeError({
|
|
@@ -135,7 +183,7 @@ export const sanitySource = (
|
|
|
135
183
|
);
|
|
136
184
|
let snapshot = new Map<string, SourceEntry>();
|
|
137
185
|
|
|
138
|
-
const toEntry = (doc:
|
|
186
|
+
const toEntry = (doc: SanityDocument): SourceEntry => {
|
|
139
187
|
const slugValue =
|
|
140
188
|
asString(getPath(doc, fields.slug ?? "slug.current")) ??
|
|
141
189
|
asString(doc._id) ??
|
|
@@ -148,7 +196,7 @@ export const sanitySource = (
|
|
|
148
196
|
const slug =
|
|
149
197
|
slugifyPath(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
|
|
150
198
|
|
|
151
|
-
const data:
|
|
199
|
+
const data: SanityFrontmatter = {};
|
|
152
200
|
const title = asString(getPath(doc, fields.title ?? "title"));
|
|
153
201
|
const description = asString(
|
|
154
202
|
getPath(doc, fields.description ?? "description")
|
|
@@ -160,11 +208,17 @@ export const sanitySource = (
|
|
|
160
208
|
data.description = description;
|
|
161
209
|
}
|
|
162
210
|
|
|
211
|
+
// SAFETY: the configured body field holds Portable Text blocks; a value of
|
|
212
|
+
// any other shape fails the `Array.isArray` check below and yields an
|
|
213
|
+
// empty body, and the serializer tolerates malformed blocks.
|
|
163
214
|
const blocks = (getPath(doc, fields.body ?? "body") ??
|
|
164
215
|
[]) as PortableTextBlock[];
|
|
165
216
|
const markdown = Array.isArray(blocks)
|
|
166
217
|
? portableTextToMarkdown(blocks, {
|
|
167
218
|
imageUrl: (block) => {
|
|
219
|
+
// SAFETY: `asset` on an image block is Sanity's asset reference
|
|
220
|
+
// object; any other shape yields no `_ref` and the image is
|
|
221
|
+
// skipped.
|
|
168
222
|
const ref = (block.asset as { _ref?: string } | undefined)?._ref;
|
|
169
223
|
return ref
|
|
170
224
|
? imageUrlFromRef(ref, options.projectId, options.dataset)
|
|
@@ -177,7 +231,9 @@ export const sanitySource = (
|
|
|
177
231
|
const raw = matter.stringify(markdown, data);
|
|
178
232
|
return {
|
|
179
233
|
body: { format: "md", text: markdown },
|
|
180
|
-
data
|
|
234
|
+
// Spread into a fresh literal: `SourceEntry.data` wants an
|
|
235
|
+
// index-signature type, which the named interface lacks.
|
|
236
|
+
data: { ...data },
|
|
181
237
|
hash: hashText(raw),
|
|
182
238
|
lastModified: asString(getPath(doc, fields.lastModified ?? "_updatedAt")),
|
|
183
239
|
raw,
|
|
@@ -193,9 +249,7 @@ export const sanitySource = (
|
|
|
193
249
|
cache,
|
|
194
250
|
async () => {
|
|
195
251
|
const client = await resolveClient(options, ctx?.preview ?? false);
|
|
196
|
-
const docs = await client.fetch<
|
|
197
|
-
options.query
|
|
198
|
-
);
|
|
252
|
+
const docs = await client.fetch<SanityDocument[]>(options.query);
|
|
199
253
|
return docs.map(toEntry);
|
|
200
254
|
},
|
|
201
255
|
refresh
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
FolderMeta,
|
|
3
3
|
FrontmatterExtend,
|
|
4
4
|
ResolvedI18nConfig,
|
|
5
|
+
ResolvedVersionsConfig,
|
|
5
6
|
} from "../schema.ts";
|
|
6
7
|
import type { Diagnostic } from "../types.ts";
|
|
7
8
|
|
|
@@ -16,6 +17,7 @@ export interface SourceEntry {
|
|
|
16
17
|
/** Logical route input; defaults to `ref` if omitted. May include slashes. */
|
|
17
18
|
slug?: string;
|
|
18
19
|
/** Frontmatter-equivalent metadata, validated against the Blume meta schema. */
|
|
20
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- pre-validation frontmatter from YAML/CMS payloads; the meta schema parses it downstream
|
|
19
21
|
data: Record<string, unknown>;
|
|
20
22
|
/** The renderable body as Markdown/MDX source text (frontmatter stripped). */
|
|
21
23
|
body: { format: "md" | "mdx"; text: string };
|
|
@@ -129,4 +131,6 @@ export interface NormalizeContext {
|
|
|
129
131
|
* applied to a page only when its resolved `type` matches.
|
|
130
132
|
*/
|
|
131
133
|
typeFrontmatter?: Record<string, FrontmatterExtend>;
|
|
134
|
+
/** Docs versioning config; a leading archived-version dir becomes the page's version. */
|
|
135
|
+
versions?: ResolvedVersionsConfig;
|
|
132
136
|
}
|
|
@@ -71,7 +71,7 @@ export const ignoringWatchListener = (
|
|
|
71
71
|
const ignore = new Set(ignoreDirs);
|
|
72
72
|
return (_event, filename) => {
|
|
73
73
|
if (
|
|
74
|
-
|
|
74
|
+
filename !== null &&
|
|
75
75
|
filename.split(/[/\\]/u).some((segment) => ignore.has(segment))
|
|
76
76
|
) {
|
|
77
77
|
return;
|
|
@@ -38,7 +38,7 @@ export interface StandardSchema<Input = unknown, Output = Input> {
|
|
|
38
38
|
readonly version: 1;
|
|
39
39
|
readonly vendor: string;
|
|
40
40
|
readonly validate: (
|
|
41
|
-
value:
|
|
41
|
+
value: Input
|
|
42
42
|
) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
43
43
|
readonly types?:
|
|
44
44
|
| { readonly input: Input; readonly output: Output }
|
|
@@ -46,9 +46,15 @@ export interface StandardSchema<Input = unknown, Output = Input> {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Whether a config-supplied value implements the `~standard` contract. Generic
|
|
51
|
+
* so it can decode any input at the config boundary (`z.custom` hands it the
|
|
52
|
+
* raw config value) while narrowing whatever type the caller holds.
|
|
53
|
+
*/
|
|
54
|
+
export const isStandardSchema = <T>(value: T): value is T & StandardSchema =>
|
|
51
55
|
typeof value === "object" &&
|
|
52
56
|
value !== null &&
|
|
57
|
+
// SAFETY: the assertion only widens the checked object for property probing;
|
|
58
|
+
// the trailing typeof check is what verifies `~standard.validate` exists.
|
|
53
59
|
typeof (value as { "~standard"?: { validate?: unknown } })["~standard"]
|
|
54
60
|
?.validate === "function";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The longest prefix of `text` that renders within `max` display columns
|
|
5
|
+
* without cutting inside a grapheme cluster. Columns are `string-width`'s: a
|
|
6
|
+
* fullwidth or wide character counts 2, a combining mark 0, everything else 1
|
|
7
|
+
* — the same measure `blume audit` grades titles and meta descriptions with,
|
|
8
|
+
* so text cut here stays inside the audit's thresholds for every script, not
|
|
9
|
+
* just Latin. Grapheme segmentation is rule-based (UAX #29), so unlike word
|
|
10
|
+
* segmentation it does not drift across ICU builds, and it can never split a
|
|
11
|
+
* surrogate pair or halve an emoji sequence.
|
|
12
|
+
*/
|
|
13
|
+
export const columnsPrefix = (text: string, max: number): string => {
|
|
14
|
+
let end = 0;
|
|
15
|
+
let used = 0;
|
|
16
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
17
|
+
for (const { index, segment } of graphemes.segment(text)) {
|
|
18
|
+
const width = stringWidth(segment);
|
|
19
|
+
if (used + width > max) {
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
used += width;
|
|
23
|
+
end = index + segment.length;
|
|
24
|
+
}
|
|
25
|
+
return text.slice(0, end);
|
|
26
|
+
};
|
|
@@ -30,16 +30,20 @@ const substituteConfigDir = (value: string, configDir: string): string =>
|
|
|
30
30
|
? join(configDir, value.slice(CONFIG_DIR_TEMPLATE.length))
|
|
31
31
|
: value;
|
|
32
32
|
|
|
33
|
+
/** Whether a `paths` fallback entry is a usable target (raw JSONC may lie). */
|
|
34
|
+
const isPathTarget = (target: string | undefined): target is string =>
|
|
35
|
+
typeof target === "string";
|
|
36
|
+
|
|
33
37
|
/** Convert one tsconfig `paths` mapping to a Vite alias, or null to skip. */
|
|
34
38
|
const toAlias = (
|
|
35
39
|
key: string,
|
|
36
|
-
value:
|
|
40
|
+
value: string | string[],
|
|
37
41
|
baseDir: string,
|
|
38
42
|
configDir: string
|
|
39
43
|
): { find: string; replacement: string } | null => {
|
|
40
44
|
// tsconfig allows a fallback array; Vite aliases are 1:1, so take the first.
|
|
41
45
|
const first = Array.isArray(value) ? value[0] : value;
|
|
42
|
-
if (
|
|
46
|
+
if (!isPathTarget(first)) {
|
|
43
47
|
return null;
|
|
44
48
|
}
|
|
45
49
|
const find = key.endsWith("/*") ? key.slice(0, -2) : key;
|
|
@@ -86,12 +90,12 @@ export const resolveTsconfigAliases = (
|
|
|
86
90
|
configDir,
|
|
87
91
|
substituteConfigDir(options?.baseUrl ?? ".", configDir)
|
|
88
92
|
);
|
|
89
|
-
const
|
|
93
|
+
const entries: [string, string][] = [];
|
|
90
94
|
for (const [key, value] of Object.entries(paths)) {
|
|
91
95
|
const alias = toAlias(key, value, baseDir, configDir);
|
|
92
96
|
if (alias) {
|
|
93
|
-
|
|
97
|
+
entries.push([alias.find, alias.replacement]);
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
|
-
return
|
|
100
|
+
return Object.fromEntries(entries);
|
|
97
101
|
};
|