blume 0.3.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.
Files changed (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -0,0 +1,46 @@
1
+ import type { BlumeConfig } from "../../core/schema.ts";
2
+
3
+ /** Asset paths referenced by the resolved config (logo, favicon, backgrounds). */
4
+ const assetRefs = (config: BlumeConfig): unknown[] => {
5
+ const refs: unknown[] = ["/images"];
6
+ const logo = config.logo as
7
+ | string
8
+ | { dark?: string; light?: string }
9
+ | undefined;
10
+ if (typeof logo === "string") {
11
+ refs.push(logo);
12
+ } else if (logo) {
13
+ refs.push(logo.light, logo.dark);
14
+ }
15
+ const favicon = config.favicon as
16
+ | string
17
+ | { dark?: string; light?: string }
18
+ | undefined;
19
+ if (typeof favicon === "string") {
20
+ refs.push(favicon);
21
+ } else if (favicon) {
22
+ refs.push(favicon.light, favicon.dark);
23
+ }
24
+ refs.push(config.theme?.backgroundImage, config.theme?.backgroundImageDark);
25
+ return refs;
26
+ };
27
+
28
+ /**
29
+ * Top-level path segments referenced as static assets by a Mintlify config
30
+ * (the conventional `/images`, plus logo/favicon/background paths). These are
31
+ * the root-served folders Mintlify exposes at the site root; Blume serves them
32
+ * via `content.assets` (bridge) or relocates them under `public/` (migrator).
33
+ */
34
+ export const assetSegments = (config: BlumeConfig): string[] => {
35
+ const segments = new Set<string>();
36
+ for (const ref of assetRefs(config)) {
37
+ if (typeof ref !== "string" || !ref.startsWith("/")) {
38
+ continue;
39
+ }
40
+ const [segment] = ref.replace(/^\/+/u, "").split("/");
41
+ if (segment) {
42
+ segments.add(segment);
43
+ }
44
+ }
45
+ return [...segments];
46
+ };
@@ -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
- /** Component tags Blume has no equivalent for — reported for manual review. */
92
- const UNSUPPORTED_COMPONENTS = ["ParamField", "ResponseField"];
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[] =>
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { dirname, join } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import type { BlumeConfig } from "../../core/schema.ts";
8
+ import { assetSegments } from "./assets.ts";
8
9
  import { loadMintlifyConfig } from "./config.ts";
9
10
  import { mintlifyI18n } from "./i18n.ts";
10
11
  import { transformMintlifyContent } from "./transform.ts";
@@ -14,6 +15,56 @@ export interface MintlifyMigrationResult {
14
15
  warnings: string[];
15
16
  }
16
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
+
17
68
  /** Recursively drop `undefined`, empty arrays, and empty objects. */
18
69
  const prune = (value: unknown): unknown => {
19
70
  if (Array.isArray(value)) {
@@ -52,28 +103,37 @@ const writeBlumeConfig = async (
52
103
  await writeFile(join(root, "blume.config.ts"), body, "utf-8");
53
104
  };
54
105
 
55
- /** Move a referenced top-level asset path (file or dir) under `public/`. */
106
+ interface RelocatedAssets {
107
+ /** Top-level dirs served in place via `content.assets` (no files moved). */
108
+ served: string[];
109
+ /** Top-level files moved under `public/`. */
110
+ moved: string[];
111
+ }
112
+
113
+ /**
114
+ * Make referenced top-level assets resolvable in Blume. Directories (e.g.
115
+ * Mintlify's `images/`) are left in place and served via `content.assets`, so
116
+ * the migration doesn't churn every file under them; loose top-level files
117
+ * (a root `favicon.png`, `logo.png`) are moved under `public/` since a mount
118
+ * points at a directory.
119
+ */
56
120
  const relocateAssets = async (
57
121
  root: string,
58
- refs: unknown[]
59
- ): Promise<string[]> => {
60
- const segments = new Set<string>();
61
- for (const ref of refs) {
62
- if (typeof ref !== "string" || !ref.startsWith("/")) {
63
- continue;
64
- }
65
- const [segment] = ref.replace(/^\/+/u, "").split("/");
66
- if (segment) {
67
- segments.add(segment);
68
- }
69
- }
70
-
122
+ segments: string[]
123
+ ): Promise<RelocatedAssets> => {
124
+ const served: string[] = [];
71
125
  const moved: string[] = [];
72
126
  for (const segment of segments) {
73
127
  const source = join(root, segment);
74
128
  if (!existsSync(source) || segment === "public") {
75
129
  continue;
76
130
  }
131
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs stats
132
+ const stats = await stat(source);
133
+ if (stats.isDirectory()) {
134
+ served.push(segment);
135
+ continue;
136
+ }
77
137
  const dest = join(root, "public", segment);
78
138
  if (existsSync(dest)) {
79
139
  continue;
@@ -84,7 +144,32 @@ const relocateAssets = async (
84
144
  await rename(source, dest);
85
145
  moved.push(segment);
86
146
  }
87
- return moved;
147
+ return { moved, served };
148
+ };
149
+
150
+ /**
151
+ * Fold relocated assets into the config (served dirs become `content.assets`)
152
+ * and record what happened. Served dirs stay in place; only loose files moved.
153
+ */
154
+ const applyRelocatedAssets = (
155
+ config: BlumeConfig,
156
+ assets: RelocatedAssets,
157
+ warnings: string[]
158
+ ): void => {
159
+ if (assets.served.length > 0) {
160
+ config.content = {
161
+ ...config.content,
162
+ assets: [
163
+ ...new Set([...(config.content?.assets ?? []), ...assets.served]),
164
+ ],
165
+ };
166
+ warnings.push(
167
+ `Kept asset dir(s) in place, served via content.assets: ${assets.served.join(", ")}.`
168
+ );
169
+ }
170
+ if (assets.moved.length > 0) {
171
+ warnings.push(`Moved assets into public/: ${assets.moved.join(", ")}.`);
172
+ }
88
173
  };
89
174
 
90
175
  /**
@@ -121,31 +206,6 @@ const cleanupSnippets = async (
121
206
  }
122
207
  };
123
208
 
124
- /** Asset paths referenced by the resolved config (logo, favicon, backgrounds). */
125
- const assetRefs = (config: BlumeConfig): unknown[] => {
126
- const refs: unknown[] = ["/images"];
127
- const logo = config.logo as
128
- | string
129
- | { dark?: string; light?: string }
130
- | undefined;
131
- if (typeof logo === "string") {
132
- refs.push(logo);
133
- } else if (logo) {
134
- refs.push(logo.light, logo.dark);
135
- }
136
- const favicon = config.favicon as
137
- | string
138
- | { dark?: string; light?: string }
139
- | undefined;
140
- if (typeof favicon === "string") {
141
- refs.push(favicon);
142
- } else if (favicon) {
143
- refs.push(favicon.light, favicon.dark);
144
- }
145
- refs.push(config.theme?.backgroundImage, config.theme?.backgroundImageDark);
146
- return refs;
147
- };
148
-
149
209
  /**
150
210
  * Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
151
211
  * `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
@@ -180,6 +240,13 @@ export const migrateMintlifyProject = async (
180
240
  `Mapped ${i18n.locales.length} languages to i18n.locales (default: ${i18n.defaultLocale}); review the locale labels.`
181
241
  );
182
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));
183
250
  } else {
184
251
  warnings.push("No docs.json or mint.json found; writing a default config.");
185
252
  config = { content: { root: "." }, title: "Documentation" };
@@ -232,12 +299,13 @@ export const migrateMintlifyProject = async (
232
299
  moved += 1;
233
300
  }
234
301
 
235
- const movedAssets = await relocateAssets(root, assetRefs(config));
302
+ const assets = await relocateAssets(root, assetSegments(config));
236
303
  await cleanupSnippets(root, keptComponents, warnings);
237
304
 
238
305
  if (config.content?.exclude) {
239
306
  config.content.exclude = [...new Set(config.content.exclude)];
240
307
  }
308
+ applyRelocatedAssets(config, assets, warnings);
241
309
  await writeBlumeConfig(root, config);
242
310
 
243
311
  if (Object.keys(variables).length > 0) {
@@ -245,9 +313,6 @@ export const migrateMintlifyProject = async (
245
313
  `Inlined ${Object.keys(variables).length} docs.json variable(s) into content; Blume has no runtime variable substitution.`
246
314
  );
247
315
  }
248
- if (movedAssets.length > 0) {
249
- warnings.push(`Moved assets into public/: ${movedAssets.join(", ")}.`);
250
- }
251
316
  if (removedKeys.size > 0) {
252
317
  warnings.push(
253
318
  `Dropped unsupported page frontmatter keys: ${[...removedKeys].join(", ")}.`
@@ -255,7 +320,7 @@ export const migrateMintlifyProject = async (
255
320
  }
256
321
  if (unsupported.size > 0) {
257
322
  warnings.push(
258
- `Components without a Blume equivalent need manual review (use the OpenAPI reference instead): ${[...unsupported].join(", ")}.`
323
+ `Components without a Blume equivalent need manual review: ${[...unsupported].join(", ")}.`
259
324
  );
260
325
  }
261
326
  warnings.push(
@@ -1,11 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
 
4
- import { join } from "pathe";
4
+ import { isAbsolute, join, relative } from "pathe";
5
5
 
6
6
  import type { BlumeConfig } from "../core/schema.ts";
7
7
  import { pageMetaSchema } from "../core/schema.ts";
8
8
 
9
+ /**
10
+ * Whether `candidate` resolves to a path inside `root` (or is `root` itself).
11
+ * Guards migrators against `../` traversal in author-controlled source paths
12
+ * (`pages` entries, `<include>` targets) that would otherwise read or move
13
+ * files outside the docs tree.
14
+ */
15
+ export const isInsideRoot = (root: string, candidate: string): boolean => {
16
+ const rel = relative(root, candidate);
17
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
18
+ };
19
+
9
20
  /**
10
21
  * Framework-agnostic helpers shared by more than one migrator. Each piece here
11
22
  * was generalized from a migrator-specific implementation so Mintlify, Nextra,
@@ -86,32 +97,6 @@ export const rewriteFrameworkScripts = async (
86
97
  return changed;
87
98
  };
88
99
 
89
- /** A `.gitignore` line, normalized for comparison (trailing slashes dropped). */
90
- const gitignoreKey = (line: string): string => line.trim().replace(/\/+$/u, "");
91
-
92
- /**
93
- * Ensure `.gitignore` ignores each of `entries`, appending any that are missing
94
- * (creating the file when absent). Trailing-slash differences (`dist` vs
95
- * `dist/`) count as already present. Returns the entries actually added.
96
- */
97
- export const ensureGitignore = async (
98
- root: string,
99
- entries: string[]
100
- ): Promise<string[]> => {
101
- const path = join(root, ".gitignore");
102
- const existing = existsSync(path) ? await readFile(path, "utf-8") : "";
103
- const present = new Set(
104
- existing.split("\n").map(gitignoreKey).filter(Boolean)
105
- );
106
- const added = entries.filter((entry) => !present.has(gitignoreKey(entry)));
107
- if (added.length === 0) {
108
- return [];
109
- }
110
- const gap = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
111
- await writeFile(path, `${existing}${gap}${added.join("\n")}\n`, "utf-8");
112
- return added;
113
- };
114
-
115
100
  /** Of the candidate project-relative paths, the ones that still exist — the old
116
101
  * framework files a migration leaves behind for the user to remove by hand. */
117
102
  export const leftoverFiles = (root: string, candidates: string[]): string[] =>
package/src/og/card.ts CHANGED
@@ -56,8 +56,20 @@ const MUTED = "#737373";
56
56
  const FAINT = "#a3a3a3";
57
57
  const BORDER = "#e5e5e5";
58
58
 
59
- const truncate = (value: string, max: number): string =>
60
- value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
59
+ /**
60
+ * Truncate to `max` code points with an ellipsis. Slices by code points, not
61
+ * UTF-16 units, so cutting mid-emoji doesn't leave a lone surrogate (a broken
62
+ * glyph) before the ellipsis.
63
+ */
64
+ export const truncate = (value: string, max: number): string => {
65
+ const chars = [...value];
66
+ return chars.length > max
67
+ ? `${chars
68
+ .slice(0, max - 1)
69
+ .join("")
70
+ .trimEnd()}…`
71
+ : value;
72
+ };
61
73
 
62
74
  // Brand mark sizing: target this height, but scale down so a wide wordmark logo
63
75
  // stays within the lockup.