blume 0.4.0 → 0.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/dist/cli/index.js +1137 -722
- package/dist/cli/index.js.map +28 -23
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +154 -15
- package/dist/types/core/types.d.ts +7 -0
- package/docs/advanced/api-reference.mdx +33 -23
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/index.mdx +1 -1
- package/docs/content/components.mdx +55 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/syntax.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +29 -1
- package/docs/reference/frontmatter.mdx +5 -0
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/templates.ts +28 -4
- package/src/cli/commands/build.ts +107 -63
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/dev-lock.ts +13 -5
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +6 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/core/builtin-tags.ts +5 -0
- package/src/core/data.ts +2 -0
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +47 -6
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/types.ts +7 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +58 -1
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +15 -2
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ResolvedConfig,
|
|
10
10
|
SidebarItemConfig,
|
|
11
11
|
} from "../../core/schema.ts";
|
|
12
|
+
import { GOOGLE_FONTS } from "../../theme/fonts.ts";
|
|
12
13
|
|
|
13
14
|
type JsonObject = Record<string, unknown>;
|
|
14
15
|
type NavigationSelectors = ResolvedConfig["navigation"]["selectors"];
|
|
@@ -593,6 +594,16 @@ const mintignorePatterns = async (root: string): Promise<string[]> => {
|
|
|
593
594
|
}
|
|
594
595
|
};
|
|
595
596
|
|
|
597
|
+
// path-to-regexp param (:slug, :slug*, :id?) → Astro dynamic segment.
|
|
598
|
+
// *,+ (repeatable) → spread [...name]; bare/? → single [name]. Name must start
|
|
599
|
+
// with a letter/underscore so URL ports (:8080) and protocols (https:) are left alone.
|
|
600
|
+
const REDIRECT_PARAM = /:(?<name>[A-Za-z_]\w*)(?<modifier>[*+?])?/gu;
|
|
601
|
+
|
|
602
|
+
const toAstroRedirectPath = (path: string): string =>
|
|
603
|
+
path.replaceAll(REDIRECT_PARAM, (_match, name: string, modifier?: string) =>
|
|
604
|
+
modifier === "*" || modifier === "+" ? `[...${name}]` : `[${name}]`
|
|
605
|
+
);
|
|
606
|
+
|
|
596
607
|
const mintlifyRedirects = (
|
|
597
608
|
spec: JsonObject
|
|
598
609
|
): NonNullable<BlumeConfig["redirects"]> =>
|
|
@@ -609,9 +620,100 @@ const mintlifyRedirects = (
|
|
|
609
620
|
if (!from || !to) {
|
|
610
621
|
return [];
|
|
611
622
|
}
|
|
612
|
-
return [{ from, to }];
|
|
623
|
+
return [{ from: toAstroRedirectPath(from), to: toAstroRedirectPath(to) }];
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
interface OpenApiSourceDraft {
|
|
627
|
+
label?: string;
|
|
628
|
+
route?: string;
|
|
629
|
+
spec: string;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// A Mintlify `{ source, directory }` object's `directory` is the URL path its
|
|
633
|
+
// generated pages mount under; map it to a Blume per-source `route`.
|
|
634
|
+
const openapiRouteFromDirectory = (value: unknown): string | undefined => {
|
|
635
|
+
const directory = normalizeDirectory(asString(value) ?? "");
|
|
636
|
+
return directory.length > 0 ? `/${directory}` : undefined;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
// Resolve a Mintlify `openapi` value (a spec string, an array, or a
|
|
640
|
+
// `{ source, directory }` object) into spec sources. Endpoint refs (`GET /path`)
|
|
641
|
+
// are skipped — Blume's native renderer generates those pages from the spec.
|
|
642
|
+
const openapiSourcesFromValue = (
|
|
643
|
+
value: unknown,
|
|
644
|
+
context: { label?: string; route?: string }
|
|
645
|
+
): OpenApiSourceDraft[] => {
|
|
646
|
+
if (typeof value === "string") {
|
|
647
|
+
if (value.length === 0 || API_ENDPOINT_REF.test(value)) {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
return [
|
|
651
|
+
withoutUndefined({
|
|
652
|
+
label: context.label,
|
|
653
|
+
route: context.route,
|
|
654
|
+
spec: value,
|
|
655
|
+
}),
|
|
656
|
+
];
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(value)) {
|
|
659
|
+
return value.flatMap((item) => openapiSourcesFromValue(item, context));
|
|
660
|
+
}
|
|
661
|
+
const object = asObject(value);
|
|
662
|
+
if (!object) {
|
|
663
|
+
return [];
|
|
664
|
+
}
|
|
665
|
+
return openapiSourcesFromValue(object.source ?? object.openapi, {
|
|
666
|
+
label: context.label,
|
|
667
|
+
route: openapiRouteFromDirectory(object.directory) ?? context.route,
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Walk the navigation tree collecting every `openapi` source — a group or tab
|
|
672
|
+
// can declare one alongside its pages — then fold in the top-level specs
|
|
673
|
+
// (legacy `mint.json` `openapi`, newer `api.openapi`) and dedupe by spec so a
|
|
674
|
+
// Mintlify API reference maps to Blume's native renderer instead of dropping.
|
|
675
|
+
const mintlifyOpenapi = (spec: JsonObject): BlumeConfig["openapi"] => {
|
|
676
|
+
const drafts: OpenApiSourceDraft[] = [];
|
|
677
|
+
|
|
678
|
+
const visit = (node: unknown): void => {
|
|
679
|
+
if (Array.isArray(node)) {
|
|
680
|
+
for (const item of node) {
|
|
681
|
+
visit(item);
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const object = asObject(node);
|
|
686
|
+
if (!object) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (hasOwn(object, "openapi")) {
|
|
690
|
+
drafts.push(
|
|
691
|
+
...openapiSourcesFromValue(object.openapi, {
|
|
692
|
+
label: labelForNavItem(object),
|
|
693
|
+
})
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
for (const children of childNavigationArrays(object)) {
|
|
697
|
+
visit(children);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
visit(spec.navigation);
|
|
702
|
+
drafts.push(...openapiSourcesFromValue(spec.openapi, {}));
|
|
703
|
+
drafts.push(...openapiSourcesFromValue(asObject(spec.api)?.openapi, {}));
|
|
704
|
+
|
|
705
|
+
const seen = new Set<string>();
|
|
706
|
+
const sources = drafts.flatMap((draft) => {
|
|
707
|
+
if (seen.has(draft.spec)) {
|
|
708
|
+
return [];
|
|
709
|
+
}
|
|
710
|
+
seen.add(draft.spec);
|
|
711
|
+
return [draft];
|
|
613
712
|
});
|
|
614
713
|
|
|
714
|
+
return sources.length > 0 ? { enabled: true, sources } : undefined;
|
|
715
|
+
};
|
|
716
|
+
|
|
615
717
|
const mintlifyLogo = (value: unknown): BlumeConfig["logo"] => {
|
|
616
718
|
if (typeof value === "string") {
|
|
617
719
|
return value;
|
|
@@ -645,6 +747,16 @@ const mintlifyFavicon = (value: unknown): BlumeConfig["favicon"] => {
|
|
|
645
747
|
return withoutUndefined({ dark, light });
|
|
646
748
|
};
|
|
647
749
|
|
|
750
|
+
// Mintlify defaults to Font Awesome, so a migrated site's bare `icon` names are
|
|
751
|
+
// FA names unless it opted into Lucide/Tabler. Set the default library to match.
|
|
752
|
+
const mintlifyIcons = (value: unknown): BlumeConfig["icons"] => {
|
|
753
|
+
const library = asString(asObject(value)?.library);
|
|
754
|
+
return {
|
|
755
|
+
library:
|
|
756
|
+
library === "lucide" || library === "tabler" ? library : "fontawesome",
|
|
757
|
+
};
|
|
758
|
+
};
|
|
759
|
+
|
|
648
760
|
const mintlifyBanner = (value: unknown): BlumeConfig["banner"] => {
|
|
649
761
|
const object = asObject(value);
|
|
650
762
|
const content = object ? asString(object.content) : undefined;
|
|
@@ -768,6 +880,43 @@ const mintlifyMarkdown = (
|
|
|
768
880
|
});
|
|
769
881
|
};
|
|
770
882
|
|
|
883
|
+
type ThemeFonts = NonNullable<NonNullable<BlumeConfig["theme"]>["fonts"]>;
|
|
884
|
+
|
|
885
|
+
// Blume's curated Google-font family names, indexed by lowercased family so a
|
|
886
|
+
// Mintlify `fonts.family: "Space Grotesk"` resolves to the `space-grotesk` slug.
|
|
887
|
+
const FAMILY_TO_SLUG: Record<string, string> = Object.fromEntries(
|
|
888
|
+
Object.entries(GOOGLE_FONTS).map(([slug, def]) => [
|
|
889
|
+
def.family.toLowerCase(),
|
|
890
|
+
slug,
|
|
891
|
+
])
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
const fontSlugForFamily = (value: unknown): string | undefined => {
|
|
895
|
+
const family = asString(value);
|
|
896
|
+
return family ? FAMILY_TO_SLUG[family.toLowerCase()] : undefined;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Map Mintlify `fonts` onto Blume `theme.fonts`. Mintlify sets one family for
|
|
901
|
+
* everything (`fonts.family`) or splits heading/body (`fonts.heading.family`,
|
|
902
|
+
* `fonts.body.family`); each maps to a curated Blume slug when one matches.
|
|
903
|
+
* Families outside Blume's set are left unset (defaults) and warned about.
|
|
904
|
+
*/
|
|
905
|
+
const mintlifyFonts = (value: unknown): ThemeFonts | undefined => {
|
|
906
|
+
const object = asObject(value);
|
|
907
|
+
if (!object) {
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
const heading = asObject(object.heading);
|
|
911
|
+
const body = asObject(object.body);
|
|
912
|
+
const fonts = withoutUndefined({
|
|
913
|
+
body: fontSlugForFamily(body?.family) ?? fontSlugForFamily(object.family),
|
|
914
|
+
display:
|
|
915
|
+
fontSlugForFamily(heading?.family) ?? fontSlugForFamily(object.family),
|
|
916
|
+
});
|
|
917
|
+
return Object.keys(fonts).length > 0 ? (fonts as ThemeFonts) : undefined;
|
|
918
|
+
};
|
|
919
|
+
|
|
771
920
|
const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
|
|
772
921
|
const object = asObject(value);
|
|
773
922
|
const metatags = asObject(object?.metatags);
|
|
@@ -825,6 +974,7 @@ export const loadMintlifyConfig = async (
|
|
|
825
974
|
},
|
|
826
975
|
description: asString(spec.description),
|
|
827
976
|
favicon: mintlifyFavicon(spec.favicon),
|
|
977
|
+
icons: mintlifyIcons(spec.icons),
|
|
828
978
|
logo: mintlifyLogo(spec.logo),
|
|
829
979
|
markdown: mintlifyMarkdown(spec.markdown, styling),
|
|
830
980
|
navigation: {
|
|
@@ -834,6 +984,7 @@ export const loadMintlifyConfig = async (
|
|
|
834
984
|
sidebarVariants: await mintlifySidebarVariants(spec),
|
|
835
985
|
tabs: mintlifyTabs(spec),
|
|
836
986
|
},
|
|
987
|
+
openapi: mintlifyOpenapi(spec),
|
|
837
988
|
redirects: mintlifyRedirects(spec),
|
|
838
989
|
search: {
|
|
839
990
|
indexing: {
|
|
@@ -851,6 +1002,7 @@ export const loadMintlifyConfig = async (
|
|
|
851
1002
|
backgroundDecoration: mintlifyBackgroundDecoration(spec.background),
|
|
852
1003
|
backgroundImage: backgroundImage.light,
|
|
853
1004
|
backgroundImageDark: backgroundImage.dark,
|
|
1005
|
+
fonts: mintlifyFonts(spec.fonts ?? spec.font),
|
|
854
1006
|
mode:
|
|
855
1007
|
appearance.default === "light" ||
|
|
856
1008
|
appearance.default === "dark" ||
|
|
@@ -88,8 +88,14 @@ export const rewriteSnippetImports = (
|
|
|
88
88
|
return { components, source: next };
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Component tags Blume has no equivalent for — reported for manual review.
|
|
93
|
+
* `<ParamField>`/`<ResponseField>`/`<RequestField>` are no longer here: Blume
|
|
94
|
+
* ships compat components for them, so migrated docs render as-is. Mintlify's
|
|
95
|
+
* `<Update>` changelog entry has no component form in Blume (changelog is
|
|
96
|
+
* frontmatter-driven via `type: changelog`), so it stays flagged.
|
|
97
|
+
*/
|
|
98
|
+
const UNSUPPORTED_COMPONENTS = ["Update"];
|
|
93
99
|
|
|
94
100
|
/** Names of Mintlify components in `source` that need manual attention. */
|
|
95
101
|
export const unsupportedMintlifyComponents = (source: string): string[] =>
|
|
@@ -15,6 +15,56 @@ export interface MintlifyMigrationResult {
|
|
|
15
15
|
warnings: string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
19
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? (value as Record<string, unknown>)
|
|
21
|
+
: undefined;
|
|
22
|
+
|
|
23
|
+
const hasFontFamily = (value: unknown): boolean => {
|
|
24
|
+
const object = asRecord(value);
|
|
25
|
+
if (!object) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const named = (child: unknown): boolean =>
|
|
29
|
+
typeof asRecord(child)?.family === "string";
|
|
30
|
+
return (
|
|
31
|
+
typeof object.family === "string" ||
|
|
32
|
+
named(object.heading) ||
|
|
33
|
+
named(object.body)
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Warn about Mintlify site chrome that Blume's config doesn't model, so it isn't
|
|
39
|
+
* dropped silently: header links (`navbar.links`/`navbar.primary`), footer
|
|
40
|
+
* socials (`footer.socials`), and fonts outside Blume's curated Google set. The
|
|
41
|
+
* contextual page menu and last-updated timestamp are covered by Blume defaults
|
|
42
|
+
* (page actions, git-derived dates), so they need no warning.
|
|
43
|
+
*/
|
|
44
|
+
const droppedChromeWarnings = (
|
|
45
|
+
spec: Record<string, unknown>,
|
|
46
|
+
config: BlumeConfig
|
|
47
|
+
): string[] => {
|
|
48
|
+
const warnings: string[] = [];
|
|
49
|
+
const navbar = asRecord(spec.navbar);
|
|
50
|
+
if (navbar && (navbar.links || navbar.primary)) {
|
|
51
|
+
warnings.push(
|
|
52
|
+
"Header links (navbar.links/navbar.primary) have no blume.config equivalent and were dropped; re-add them with navigation.tabs or a Header layout override."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (asRecord(spec.footer)?.socials) {
|
|
56
|
+
warnings.push(
|
|
57
|
+
"Footer social links (footer.socials) have no blume.config equivalent and were dropped; add them with a Footer layout override."
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (hasFontFamily(spec.fonts ?? spec.font) && !config.theme?.fonts) {
|
|
61
|
+
warnings.push(
|
|
62
|
+
"docs.json font family isn't in Blume's curated Google Fonts set; set theme.fonts to a supported slug or add @font-face rules in theme.css."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return warnings;
|
|
66
|
+
};
|
|
67
|
+
|
|
18
68
|
/** Recursively drop `undefined`, empty arrays, and empty objects. */
|
|
19
69
|
const prune = (value: unknown): unknown => {
|
|
20
70
|
if (Array.isArray(value)) {
|
|
@@ -190,6 +240,13 @@ export const migrateMintlifyProject = async (
|
|
|
190
240
|
`Mapped ${i18n.locales.length} languages to i18n.locales (default: ${i18n.defaultLocale}); review the locale labels.`
|
|
191
241
|
);
|
|
192
242
|
}
|
|
243
|
+
const openapiSources = config.openapi?.sources ?? [];
|
|
244
|
+
if (openapiSources.length > 0) {
|
|
245
|
+
warnings.push(
|
|
246
|
+
`Mapped ${openapiSources.length} OpenAPI spec source(s) to openapi.sources (native reference renderer); verify each spec path or URL resolves.`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
warnings.push(...droppedChromeWarnings(spec, config));
|
|
193
250
|
} else {
|
|
194
251
|
warnings.push("No docs.json or mint.json found; writing a default config.");
|
|
195
252
|
config = { content: { root: "." }, title: "Documentation" };
|
|
@@ -263,7 +320,7 @@ export const migrateMintlifyProject = async (
|
|
|
263
320
|
}
|
|
264
321
|
if (unsupported.size > 0) {
|
|
265
322
|
warnings.push(
|
|
266
|
-
`Components without a Blume equivalent need manual review
|
|
323
|
+
`Components without a Blume equivalent need manual review: ${[...unsupported].join(", ")}.`
|
|
267
324
|
);
|
|
268
325
|
}
|
|
269
326
|
warnings.push(
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document,
|
|
3
|
+
OperationObject,
|
|
4
|
+
PathItemObject,
|
|
5
|
+
} from "@scalar/openapi-types/3.1";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Blume's own OpenAPI model. Specs are parsed and upgraded to 3.1 (see
|
|
9
|
+
* `parse.ts`) with internal `$ref`s left intact — the document stays
|
|
10
|
+
* JSON-serializable (a fully dereferenced graph can be circular), and the schema
|
|
11
|
+
* components resolve refs against `document.components.schemas` at render time.
|
|
12
|
+
* Each operation is flattened into an {@link ApiOperationRef} with a real,
|
|
13
|
+
* per-operation route so it becomes a first-class Blume page.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A normalized OpenAPI 3.1 document, internal `$ref`s intact. */
|
|
17
|
+
export type ApiDocument = Document;
|
|
18
|
+
|
|
19
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
20
|
+
const SLUG_EDGES = /^-+|-+$/gu;
|
|
21
|
+
|
|
22
|
+
/** Lowercase, URL-safe slug: `Add a Pet!` -> `add-a-pet`. */
|
|
23
|
+
export const slugify = (text: string): string =>
|
|
24
|
+
text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
|
|
25
|
+
|
|
26
|
+
/** The HTTP methods an OpenAPI path item may declare, in spec order. */
|
|
27
|
+
export const HTTP_METHODS = [
|
|
28
|
+
"get",
|
|
29
|
+
"put",
|
|
30
|
+
"post",
|
|
31
|
+
"delete",
|
|
32
|
+
"options",
|
|
33
|
+
"head",
|
|
34
|
+
"patch",
|
|
35
|
+
"trace",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
39
|
+
|
|
40
|
+
/** Group used for operations that declare no tag. */
|
|
41
|
+
const UNTAGGED = "Operations";
|
|
42
|
+
|
|
43
|
+
/** A stable, URL-safe key for an operation: its `operationId`, else method+path. */
|
|
44
|
+
export const operationKey = (
|
|
45
|
+
method: string,
|
|
46
|
+
path: string,
|
|
47
|
+
operationId?: string
|
|
48
|
+
): string => {
|
|
49
|
+
const fromId = operationId ? slugify(operationId) : "";
|
|
50
|
+
return fromId || slugify(`${method}-${path}`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** One operation, flattened out of the paths object and mapped to a route. */
|
|
54
|
+
export interface ApiOperationRef {
|
|
55
|
+
/** Stable key, unique within a spec; matches the MDX `<Operation id>`. */
|
|
56
|
+
key: string;
|
|
57
|
+
method: HttpMethod;
|
|
58
|
+
/** Templated path, e.g. `/pets/{id}`. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** Full site route for this operation's page, e.g. `/reference/pet/add-pet`. */
|
|
61
|
+
route: string;
|
|
62
|
+
/** Display tag name (first tag, or `Operations` when untagged). */
|
|
63
|
+
tag: string;
|
|
64
|
+
tagSlug: string;
|
|
65
|
+
summary: string;
|
|
66
|
+
description: string;
|
|
67
|
+
operationId?: string;
|
|
68
|
+
deprecated: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A tag/section, in first-seen order. */
|
|
72
|
+
export interface ApiTagRef {
|
|
73
|
+
slug: string;
|
|
74
|
+
name: string;
|
|
75
|
+
description: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything the runtime needs for one spec, serialized into `blume:openapi`. */
|
|
79
|
+
export interface ApiSpecData {
|
|
80
|
+
/** Unique token used as the `<Operation source>` and the data-module key. */
|
|
81
|
+
slug: string;
|
|
82
|
+
/** Base route the spec's operations hang off, e.g. `/reference`. */
|
|
83
|
+
route: string;
|
|
84
|
+
label: string;
|
|
85
|
+
title: string;
|
|
86
|
+
version: string;
|
|
87
|
+
description: string;
|
|
88
|
+
document: ApiDocument;
|
|
89
|
+
/** Operations keyed by {@link ApiOperationRef.key}. */
|
|
90
|
+
operations: Record<string, ApiOperationRef>;
|
|
91
|
+
tags: ApiTagRef[];
|
|
92
|
+
/** Code-sample languages to render per operation. */
|
|
93
|
+
codeSamples: string[];
|
|
94
|
+
/** Whether nested schema rows start expanded. */
|
|
95
|
+
expandSchemas: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
99
|
+
export type OpenApiData = Record<string, ApiSpecData>;
|
|
100
|
+
|
|
101
|
+
const isOperation = (value: unknown): value is OperationObject =>
|
|
102
|
+
typeof value === "object" && value !== null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
106
|
+
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
107
|
+
* a repeated `operationId` still yields distinct routes.
|
|
108
|
+
*/
|
|
109
|
+
export const extractOperations = (
|
|
110
|
+
document: ApiDocument,
|
|
111
|
+
baseRoute: string
|
|
112
|
+
): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
|
|
113
|
+
const operations: ApiOperationRef[] = [];
|
|
114
|
+
const tagOrder: string[] = [];
|
|
115
|
+
const tagMeta = new Map(
|
|
116
|
+
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
117
|
+
);
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
121
|
+
const item = rawItem as PathItemObject | undefined;
|
|
122
|
+
if (!item || "$ref" in item) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const method of HTTP_METHODS) {
|
|
126
|
+
const operation = item[method];
|
|
127
|
+
if (!isOperation(operation)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const tag = operation.tags?.[0] ?? UNTAGGED;
|
|
131
|
+
const tagSlug = slugify(tag) || "operations";
|
|
132
|
+
if (!tagOrder.includes(tag)) {
|
|
133
|
+
tagOrder.push(tag);
|
|
134
|
+
}
|
|
135
|
+
let key = operationKey(method, path, operation.operationId);
|
|
136
|
+
while (seen.has(key)) {
|
|
137
|
+
key = `${key}-${method}`;
|
|
138
|
+
}
|
|
139
|
+
seen.add(key);
|
|
140
|
+
operations.push({
|
|
141
|
+
deprecated: operation.deprecated ?? false,
|
|
142
|
+
description: operation.description ?? "",
|
|
143
|
+
key,
|
|
144
|
+
method,
|
|
145
|
+
operationId: operation.operationId,
|
|
146
|
+
path,
|
|
147
|
+
route: `${baseRoute}/${tagSlug}/${key}`,
|
|
148
|
+
summary: operation.summary ?? "",
|
|
149
|
+
tag,
|
|
150
|
+
tagSlug,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tags: ApiTagRef[] = tagOrder.map((name) => ({
|
|
156
|
+
description: tagMeta.get(name) ?? "",
|
|
157
|
+
name,
|
|
158
|
+
slug: slugify(name) || "operations",
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
return { operations, tags };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Resolve the operation object for a ref out of its document. */
|
|
165
|
+
export const operationObject = (
|
|
166
|
+
spec: ApiSpecData,
|
|
167
|
+
ref: ApiOperationRef
|
|
168
|
+
): OperationObject | undefined => {
|
|
169
|
+
const item = (spec.document.paths?.[ref.path] ?? undefined) as
|
|
170
|
+
| PathItemObject
|
|
171
|
+
| undefined;
|
|
172
|
+
const operation = item?.[ref.method];
|
|
173
|
+
return isOperation(operation) ? operation : undefined;
|
|
174
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { normalize, upgrade } from "@scalar/openapi-parser";
|
|
4
|
+
import { isAbsolute, join } from "pathe";
|
|
5
|
+
|
|
6
|
+
import type { ApiDocument } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Spec loading and normalization. Blume reuses Scalar's parser
|
|
10
|
+
* (`@scalar/openapi-parser`) to read a spec (YAML or JSON), then upgrade Swagger
|
|
11
|
+
* 2.0 / OpenAPI 3.0 documents to 3.1 so the renderer only handles one shape.
|
|
12
|
+
* Internal `$ref`s are deliberately left in place (see `model.ts`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const URL_SPEC = /^https?:\/\//u;
|
|
16
|
+
|
|
17
|
+
export interface ParsedSpec {
|
|
18
|
+
document: ApiDocument;
|
|
19
|
+
warnings: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read a spec's raw text from an `http(s)` URL or a local (project-relative) path. */
|
|
23
|
+
const readSpecText = async (spec: string, root: string): Promise<string> => {
|
|
24
|
+
if (URL_SPEC.test(spec)) {
|
|
25
|
+
const response = await fetch(spec);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`${spec} -> ${response.status} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
return await response.text();
|
|
30
|
+
}
|
|
31
|
+
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
32
|
+
return await readFile(absolute, "utf-8");
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read, normalize, and upgrade a spec to an OpenAPI 3.1 document. Throws when the
|
|
37
|
+
* spec can't be read; callers turn that into a source diagnostic rather than a
|
|
38
|
+
* hard failure so a broken spec doesn't take down the whole build.
|
|
39
|
+
*/
|
|
40
|
+
export const parseSpec = async (
|
|
41
|
+
spec: string,
|
|
42
|
+
root: string
|
|
43
|
+
): Promise<ParsedSpec> => {
|
|
44
|
+
const text = await readSpecText(spec, root);
|
|
45
|
+
const normalized = normalize(text);
|
|
46
|
+
const { specification } = upgrade(normalized);
|
|
47
|
+
return { document: specification as ApiDocument, warnings: [] };
|
|
48
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
import type { NavTab } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
6
|
+
* labels, and a renderer choice — no file IO, so the content source, the nav
|
|
7
|
+
* tabs, the Scalar page generator, and the `blume:openapi` data module all share
|
|
8
|
+
* one source of truth. Kept free of any Astro/template imports so `core` can
|
|
9
|
+
* depend on it without a cycle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ReferenceKind = "openapi" | "asyncapi";
|
|
13
|
+
|
|
14
|
+
/** Who renders a reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
15
|
+
export type ReferenceRenderer = "blume" | "scalar";
|
|
16
|
+
|
|
17
|
+
/** Per-block display options for the Blume renderer. */
|
|
18
|
+
export interface ReferenceDisplay {
|
|
19
|
+
/** Code-sample languages shown per operation. */
|
|
20
|
+
codeSamples: string[];
|
|
21
|
+
/** Whether nested schema rows start expanded. */
|
|
22
|
+
expandSchemas: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A spec source resolved to a concrete route, label, and renderer. */
|
|
26
|
+
export interface ReferenceSource {
|
|
27
|
+
kind: ReferenceKind;
|
|
28
|
+
renderer: ReferenceRenderer;
|
|
29
|
+
/** Unique token derived from the route; the `<Operation source>` / data key. */
|
|
30
|
+
slug: string;
|
|
31
|
+
/** Normalized route the reference mounts at, e.g. `/reference`. */
|
|
32
|
+
route: string;
|
|
33
|
+
label: string;
|
|
34
|
+
/** Local path or `http(s)` URL, verbatim from config. */
|
|
35
|
+
spec: string;
|
|
36
|
+
/** Per-block Scalar theme name override, if any (Scalar renderer only). */
|
|
37
|
+
theme?: string;
|
|
38
|
+
/** Display options carried through to the Blume renderer. */
|
|
39
|
+
display: ReferenceDisplay;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
43
|
+
const SLUG_EDGES = /^-+|-+$/gu;
|
|
44
|
+
const ROUTE_EDGES = /^\/+|\/+$/gu;
|
|
45
|
+
const TRAILING_SLASH = /\/+$/u;
|
|
46
|
+
|
|
47
|
+
export const slugify = (text: string): string =>
|
|
48
|
+
text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
|
|
49
|
+
|
|
50
|
+
/** Normalize a configured route to a single leading slash, no trailing slash. */
|
|
51
|
+
export const normalizeRoute = (route: string): string => {
|
|
52
|
+
const trimmed = route.trim();
|
|
53
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
54
|
+
const noTrailing = withSlash.replace(TRAILING_SLASH, "");
|
|
55
|
+
return noTrailing === "" ? "/" : noTrailing;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A stable per-reference token from its route: `/api/events` -> `api-events`. */
|
|
59
|
+
const routeSlug = (route: string): string =>
|
|
60
|
+
slugify(route.replace(ROUTE_EDGES, "")) || "reference";
|
|
61
|
+
|
|
62
|
+
type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
|
|
63
|
+
|
|
64
|
+
/** A spec is a single source (`spec` shorthand prepended to any `sources`). */
|
|
65
|
+
const sourcesOf = (
|
|
66
|
+
block: Block
|
|
67
|
+
): { label?: string; route?: string; spec: string }[] => {
|
|
68
|
+
const sources = [...block.sources];
|
|
69
|
+
if (block.spec) {
|
|
70
|
+
sources.unshift({ spec: block.spec });
|
|
71
|
+
}
|
|
72
|
+
return sources;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const referencesFor = (
|
|
76
|
+
kind: ReferenceKind,
|
|
77
|
+
block: Block,
|
|
78
|
+
defaultLabel: string,
|
|
79
|
+
renderer: ReferenceRenderer,
|
|
80
|
+
display: ReferenceDisplay
|
|
81
|
+
): ReferenceSource[] => {
|
|
82
|
+
if (!block.enabled) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const sources = sourcesOf(block);
|
|
86
|
+
const base = normalizeRoute(block.route);
|
|
87
|
+
|
|
88
|
+
return sources.map((source, index) => {
|
|
89
|
+
const label =
|
|
90
|
+
source.label ??
|
|
91
|
+
(sources.length > 1 ? `${defaultLabel} ${index + 1}` : defaultLabel);
|
|
92
|
+
|
|
93
|
+
let route: string;
|
|
94
|
+
if (source.route) {
|
|
95
|
+
route = normalizeRoute(source.route);
|
|
96
|
+
} else if (sources.length === 1) {
|
|
97
|
+
route = base;
|
|
98
|
+
} else {
|
|
99
|
+
const suffix = source.label ? slugify(source.label) : "";
|
|
100
|
+
route = normalizeRoute(`${base}/${suffix || index + 1}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
display,
|
|
105
|
+
kind,
|
|
106
|
+
label,
|
|
107
|
+
renderer,
|
|
108
|
+
route,
|
|
109
|
+
slug: routeSlug(route),
|
|
110
|
+
spec: source.spec,
|
|
111
|
+
theme: block.theme,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
|
|
120
|
+
* by default); AsyncAPI is always rendered by Scalar for now.
|
|
121
|
+
*/
|
|
122
|
+
export const resolveReferences = (
|
|
123
|
+
config: ResolvedConfig
|
|
124
|
+
): ReferenceSource[] => [
|
|
125
|
+
...referencesFor(
|
|
126
|
+
"openapi",
|
|
127
|
+
config.openapi,
|
|
128
|
+
"API Reference",
|
|
129
|
+
config.openapi.renderer,
|
|
130
|
+
{
|
|
131
|
+
codeSamples: config.openapi.codeSamples,
|
|
132
|
+
expandSchemas: config.openapi.expandSchemas,
|
|
133
|
+
}
|
|
134
|
+
),
|
|
135
|
+
...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/** Nav tabs (header links) for every reference, regardless of renderer. */
|
|
139
|
+
export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
140
|
+
resolveReferences(config).map((ref) => ({
|
|
141
|
+
label: ref.label,
|
|
142
|
+
path: ref.route,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
146
|
+
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const result: ReferenceSource[] = [];
|
|
149
|
+
for (const ref of resolveReferences(config)) {
|
|
150
|
+
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (seen.has(ref.route)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
seen.add(ref.route);
|
|
157
|
+
result.push(ref);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Whether any reference is Scalar-rendered (gates the `@scalar/astro` dep + pages). */
|
|
163
|
+
export const hasScalarReferences = (config: ResolvedConfig): boolean =>
|
|
164
|
+
resolveReferences(config).some((ref) => ref.renderer === "scalar");
|