blume 0.2.0 → 0.4.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 (119) hide show
  1. package/dist/cli/index.js +2429 -792
  2. package/dist/cli/index.js.map +63 -44
  3. package/dist/types/core/data.d.ts +16 -0
  4. package/dist/types/core/define-components.d.ts +9 -2
  5. package/dist/types/core/diagnostics.d.ts +5 -0
  6. package/dist/types/core/schema.d.ts +313 -778
  7. package/dist/types/core/types.d.ts +2 -2
  8. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  9. package/docs/01-quickstart.mdx +5 -16
  10. package/docs/02-deployment.mdx +26 -40
  11. package/docs/advanced/api-reference.mdx +10 -37
  12. package/docs/advanced/blog.mdx +9 -25
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +66 -61
  15. package/docs/configuration/ai.mdx +47 -91
  16. package/docs/configuration/analytics.mdx +20 -38
  17. package/docs/configuration/customization.mdx +92 -27
  18. package/docs/configuration/export.mdx +9 -34
  19. package/docs/configuration/index.mdx +78 -85
  20. package/docs/configuration/search.mdx +17 -54
  21. package/docs/configuration/seo.mdx +18 -44
  22. package/docs/configuration/theming.mdx +20 -42
  23. package/docs/content/components.mdx +42 -101
  24. package/docs/content/i18n.mdx +21 -72
  25. package/docs/content/index.mdx +18 -48
  26. package/docs/content/islands.mdx +79 -33
  27. package/docs/content/meta.mdx +23 -50
  28. package/docs/content/navigation.mdx +42 -56
  29. package/docs/content/sources.mdx +20 -83
  30. package/docs/content/syntax.mdx +37 -105
  31. package/docs/index.mdx +13 -51
  32. package/docs/reference/cli.mdx +49 -18
  33. package/docs/reference/frontmatter.mdx +2 -5
  34. package/package.json +3 -1
  35. package/src/ai/ask-context.ts +131 -0
  36. package/src/ai/ask-data.ts +25 -0
  37. package/src/astro/component-slots.ts +165 -0
  38. package/src/astro/generate.ts +132 -13
  39. package/src/astro/integration.ts +85 -3
  40. package/src/astro/islands.ts +6 -2
  41. package/src/astro/markdown-negotiation.ts +17 -3
  42. package/src/astro/pages.ts +11 -13
  43. package/src/astro/static-assets.ts +117 -0
  44. package/src/astro/templates.ts +120 -50
  45. package/src/blume-modules.d.ts +25 -0
  46. package/src/cli/args.ts +23 -0
  47. package/src/cli/commands/build.ts +209 -1
  48. package/src/cli/commands/check.ts +62 -0
  49. package/src/cli/commands/dev.ts +32 -3
  50. package/src/cli/commands/doctor.ts +32 -6
  51. package/src/cli/commands/eject.ts +3 -1
  52. package/src/cli/commands/init.ts +184 -16
  53. package/src/cli/commands/preview.ts +2 -1
  54. package/src/cli/commands/validate.ts +27 -2
  55. package/src/cli/dev-lock.ts +84 -0
  56. package/src/cli/index.ts +15 -0
  57. package/src/cli/internal-error.ts +63 -0
  58. package/src/cli/log.ts +41 -1
  59. package/src/cli/prepare.ts +17 -3
  60. package/src/cli/required-secrets.ts +44 -0
  61. package/src/components/BlumePage.astro +109 -0
  62. package/src/components/content/YouTube.astro +35 -0
  63. package/src/components/content/youtube.ts +46 -0
  64. package/src/components/index.ts +3 -3
  65. package/src/components/islands/ask-ai.tsx +29 -15
  66. package/src/components/islands/hooks.ts +188 -0
  67. package/src/components/layout/Empty.astro +6 -0
  68. package/src/components/layout/Header.astro +24 -39
  69. package/src/components/layout/Logo.astro +50 -0
  70. package/src/components/layout/NavSelector.astro +75 -0
  71. package/src/components/layout/PageLayout.astro +38 -2
  72. package/src/components/layout/RootLayout.astro +70 -4
  73. package/src/components/layout/hydration-hint.ts +30 -0
  74. package/src/components/layout/overrides.ts +6 -4
  75. package/src/components/props.ts +71 -0
  76. package/src/core/assets.ts +31 -0
  77. package/src/core/bridge.ts +10 -0
  78. package/src/core/builtin-tags.ts +40 -0
  79. package/src/core/component-diagnostics.ts +44 -0
  80. package/src/core/component-overrides.ts +478 -0
  81. package/src/core/config.ts +8 -0
  82. package/src/core/data.ts +14 -0
  83. package/src/core/define-components.ts +9 -2
  84. package/src/core/diagnostics.ts +95 -1
  85. package/src/core/gitignore.ts +30 -0
  86. package/src/core/graph.ts +7 -0
  87. package/src/core/links.ts +60 -19
  88. package/src/core/nav-diagnostics.ts +205 -0
  89. package/src/core/project-graph.ts +40 -1
  90. package/src/core/schema.ts +35 -96
  91. package/src/core/sources/mdx-remote.ts +54 -8
  92. package/src/core/sources/normalize.ts +57 -1
  93. package/src/core/sources/notion.ts +49 -5
  94. package/src/core/sources/sanity.ts +5 -1
  95. package/src/core/types.ts +2 -2
  96. package/src/deploy/redirects.ts +43 -0
  97. package/src/deploy/rss.ts +1 -8
  98. package/src/deploy/sitemap.ts +20 -1
  99. package/src/deploy/xml.ts +8 -0
  100. package/src/markdown/directives.ts +15 -7
  101. package/src/markdown/package-commands.ts +26 -4
  102. package/src/migrate/fumadocs/content.ts +14 -1
  103. package/src/migrate/fumadocs/groups.ts +7 -0
  104. package/src/migrate/fumadocs/index.ts +5 -2
  105. package/src/migrate/mintlify/assets.ts +46 -0
  106. package/src/migrate/mintlify/config.ts +1 -176
  107. package/src/migrate/mintlify/index.ts +53 -45
  108. package/src/migrate/shared.ts +12 -27
  109. package/src/migrate/starlight/config.ts +0 -4
  110. package/src/og/card.ts +175 -38
  111. package/src/registry/eject.ts +52 -12
  112. package/src/registry/registry.ts +172 -0
  113. package/src/registry/rewrite-imports.ts +31 -19
  114. package/src/runtime/index.ts +61 -0
  115. package/src/search/documents.ts +23 -5
  116. package/src/search/sync/algolia.ts +5 -1
  117. package/src/search/sync/typesense.ts +24 -16
  118. package/src/theme/palette.ts +26 -7
  119. package/src/vite-env.d.ts +14 -0
@@ -10,7 +10,14 @@ const WHITESPACE = /\s+/u;
10
10
  const WHITESPACE_RUN = /\s+/gu;
11
11
  const GLOBAL_FLAGS = new Set(["-g", "--global"]);
12
12
 
13
- type Operation = "add" | "create" | "exec" | "install" | "remove" | "run";
13
+ type Operation =
14
+ | "add"
15
+ | "ci"
16
+ | "create"
17
+ | "exec"
18
+ | "install"
19
+ | "remove"
20
+ | "run";
14
21
 
15
22
  interface Intent {
16
23
  args: string[];
@@ -26,6 +33,9 @@ const normalizeVerb = (verb: string): Operation | null => {
26
33
  case "install": {
27
34
  return "add";
28
35
  }
36
+ case "ci": {
37
+ return "ci";
38
+ }
29
39
  case "create":
30
40
  case "init": {
31
41
  return "create";
@@ -125,10 +135,22 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
125
135
  }
126
136
  return `${manager} dlx ${args}`;
127
137
  }
128
- case "remove": {
138
+ case "ci": {
139
+ // `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
129
140
  return manager === "npm"
130
- ? `npm uninstall ${args}`
131
- : `${manager} remove ${args}`;
141
+ ? "npm ci"
142
+ : `${manager} install --frozen-lockfile`;
143
+ }
144
+ case "remove": {
145
+ if (manager === "npm") {
146
+ return `npm uninstall ${args}`;
147
+ }
148
+ // Yarn Classic has no `remove -g`; the global form is `yarn global remove`.
149
+ if (manager === "yarn" && intent.args.some((a) => GLOBAL_FLAGS.has(a))) {
150
+ const pkgs = intent.args.filter((a) => !GLOBAL_FLAGS.has(a)).join(" ");
151
+ return `yarn global remove ${pkgs}`;
152
+ }
153
+ return `${manager} remove ${args}`;
132
154
  }
133
155
  case "run": {
134
156
  return `${manager} run ${args}`;
@@ -4,7 +4,12 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
4
4
  import { dirname, resolve } from "pathe";
5
5
 
6
6
  import matter from "../../core/frontmatter.ts";
7
- import { findOpenTagEnd, renameTag, rewriteCallouts } from "../shared.ts";
7
+ import {
8
+ findOpenTagEnd,
9
+ isInsideRoot,
10
+ renameTag,
11
+ rewriteCallouts,
12
+ } from "../shared.ts";
8
13
 
9
14
  /**
10
15
  * Source-to-source rewrites that turn Fumadocs-only MDX into idiomatic Blume
@@ -291,6 +296,8 @@ const INCLUDE = /<include\b[^>]*>(?<path>[\s\S]*?)<\/include>/gu;
291
296
  interface IncludeOptions {
292
297
  filePath: string;
293
298
  readFile?: (file: string) => Promise<string>;
299
+ /** Docs root the include must stay within; targets escaping it are skipped. */
300
+ root: string;
294
301
  seen?: Set<string>;
295
302
  }
296
303
 
@@ -321,6 +328,12 @@ export const inlineFumadocsIncludes = async (
321
328
  continue;
322
329
  }
323
330
  const target = resolve(dirname(options.filePath), rawPath);
331
+ if (!isInsideRoot(options.root, target)) {
332
+ warnings.push(
333
+ `<include> target "${rawPath}" is outside the docs tree — left as-is.`
334
+ );
335
+ continue;
336
+ }
324
337
  if (seen.has(target)) {
325
338
  warnings.push(`Circular <include> "${rawPath}" — left as-is.`);
326
339
  continue;
@@ -3,6 +3,7 @@ import { mkdir, rename, writeFile } from "node:fs/promises";
3
3
 
4
4
  import { basename, join } from "pathe";
5
5
 
6
+ import { isInsideRoot } from "../shared.ts";
6
7
  import { renderMetaModule } from "./meta.ts";
7
8
  import type {
8
9
  FumadocsPageItem,
@@ -47,6 +48,12 @@ const isDirectory = (path: string): boolean => {
47
48
 
48
49
  /** Resolve a `pages` name to its on-disk page file or folder under `docsDir`. */
49
50
  const resolveEntry = (docsDir: string, name: string): ResolvedEntry | null => {
51
+ // A `pages` entry is author-controlled; reject any that escapes `docsDir`
52
+ // (e.g. `"../../victim"`) so the later `rename` can't move a file out of the
53
+ // docs tree.
54
+ if (!isInsideRoot(docsDir, join(docsDir, name))) {
55
+ return null;
56
+ }
50
57
  for (const ext of PAGE_EXTS) {
51
58
  const file = join(docsDir, `${name}${ext}`);
52
59
  if (existsSync(file)) {
@@ -5,9 +5,9 @@ import { dirname, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import matter from "../../core/frontmatter.ts";
8
+ import { ensureGitignore } from "../../core/gitignore.ts";
8
9
  import type { FolderMeta } from "../../core/schema.ts";
9
10
  import {
10
- ensureGitignore,
11
11
  leftoverFiles,
12
12
  rewriteFrameworkScripts,
13
13
  writeBlumeConfig,
@@ -85,7 +85,10 @@ const movePage = async (
85
85
  }
86
86
 
87
87
  const raw = await readFile(abs, "utf-8");
88
- const included = await inlineFumadocsIncludes(raw, { filePath: abs });
88
+ const included = await inlineFumadocsIncludes(raw, {
89
+ filePath: abs,
90
+ root: base,
91
+ });
89
92
  let text = stripFumadocsImports(included.content);
90
93
  text = rewriteFumadocsCallouts(text);
91
94
  text = rewriteFumadocsContainers(text);
@@ -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
+ };
@@ -579,72 +579,6 @@ const mintlifySelectors = (spec: JsonObject): NavigationSelectors => {
579
579
  ].filter((selector) => selector.items.length > 0);
580
580
  };
581
581
 
582
- const navbarTypeLabel = (type: string | undefined): string | undefined => {
583
- if (type === "github") {
584
- return "GitHub";
585
- }
586
- if (type === "discord") {
587
- return "Discord";
588
- }
589
- return undefined;
590
- };
591
-
592
- const navbarLinkType = (
593
- type: string | undefined
594
- ): "github" | "discord" | undefined =>
595
- type === "github" || type === "discord" ? type : undefined;
596
-
597
- const navbarPrimaryType = (
598
- type: string | undefined
599
- ): "button" | "github" | "discord" =>
600
- type === "github" || type === "discord" ? type : "button";
601
-
602
- const mintlifyNavbar = (value: unknown): NonNullable<BlumeConfig["navbar"]> => {
603
- const object = asObject(value);
604
- if (!object) {
605
- return { links: [] };
606
- }
607
-
608
- const links = asArray(object.links).flatMap((item) => {
609
- const itemObject = asObject(item);
610
- const href = itemObject ? asString(itemObject.href) : undefined;
611
- const type = itemObject ? asString(itemObject.type) : undefined;
612
- const label = itemObject
613
- ? (asString(itemObject.label) ?? navbarTypeLabel(type))
614
- : undefined;
615
- if (!itemObject || !href || !label) {
616
- return [];
617
- }
618
- return [
619
- withoutUndefined({
620
- href,
621
- icon: asString(itemObject.icon),
622
- label,
623
- type: navbarLinkType(type),
624
- }),
625
- ];
626
- });
627
-
628
- const primaryObject = asObject(object.primary);
629
- const primaryHref = primaryObject ? asString(primaryObject.href) : undefined;
630
- const primaryType = primaryObject
631
- ? (asString(primaryObject.type) ?? "button")
632
- : undefined;
633
- const primaryLabel = primaryObject
634
- ? (asString(primaryObject.label) ?? navbarTypeLabel(primaryType))
635
- : undefined;
636
- const primary =
637
- primaryObject && primaryHref && primaryLabel
638
- ? withoutUndefined({
639
- href: primaryHref,
640
- label: primaryLabel,
641
- type: navbarPrimaryType(primaryType),
642
- })
643
- : undefined;
644
-
645
- return withoutUndefined({ links, primary });
646
- };
647
-
648
582
  const mintignorePatterns = async (root: string): Promise<string[]> => {
649
583
  try {
650
584
  const raw = await readFile(resolve(root, ".mintignore"), "utf-8");
@@ -678,78 +612,6 @@ const mintlifyRedirects = (
678
612
  return [{ from, to }];
679
613
  });
680
614
 
681
- const mintlifyContextual = (
682
- value: unknown
683
- ): NonNullable<BlumeConfig["contextual"]> => {
684
- const object = asObject(value);
685
- if (!object) {
686
- return { options: [] };
687
- }
688
-
689
- const display = object.display === "toc" ? "toc" : "header";
690
- const options: NonNullable<BlumeConfig["contextual"]>["options"] = [];
691
- for (const option of asArray(object.options)) {
692
- if (typeof option === "string") {
693
- options.push(option);
694
- continue;
695
- }
696
-
697
- const optionObject = asObject(option);
698
- const title = optionObject ? asString(optionObject.title) : undefined;
699
- if (!optionObject || !title) {
700
- continue;
701
- }
702
-
703
- options.push(
704
- withoutUndefined({
705
- description: asString(optionObject.description),
706
- href: asString(optionObject.href),
707
- icon: asString(optionObject.icon),
708
- title,
709
- })
710
- );
711
- }
712
-
713
- return { display, options };
714
- };
715
-
716
- const mintlifyFooter = (value: unknown): NonNullable<BlumeConfig["footer"]> => {
717
- const object = asObject(value);
718
- const socials = asObject(object?.socials);
719
- const links = asArray(object?.links)
720
- .flatMap((group) => {
721
- const groupObject = asObject(group);
722
- const items = asArray(groupObject?.items).flatMap((item) => {
723
- const itemObject = asObject(item);
724
- const label = itemObject ? asString(itemObject.label) : undefined;
725
- const href = itemObject ? asString(itemObject.href) : undefined;
726
- return label && href ? [{ href, label }] : [];
727
- });
728
- if (!groupObject || items.length === 0) {
729
- return [];
730
- }
731
- return [
732
- withoutUndefined({
733
- header: asString(groupObject.header),
734
- items,
735
- }),
736
- ];
737
- })
738
- .slice(0, 4);
739
-
740
- return {
741
- links,
742
- socials: socials
743
- ? Object.fromEntries(
744
- Object.entries(socials).flatMap(([label, href]) => {
745
- const hrefValue = asString(href);
746
- return hrefValue ? [[label, hrefValue]] : [];
747
- })
748
- )
749
- : {},
750
- };
751
- };
752
-
753
615
  const mintlifyLogo = (value: unknown): BlumeConfig["logo"] => {
754
616
  if (typeof value === "string") {
755
617
  return value;
@@ -826,21 +688,13 @@ const mintlifyChromeVariants = (spec: JsonObject): NavigationChromeVariants => {
826
688
  const banner = hasOwn(object, "banner")
827
689
  ? mintlifyBanner(object.banner)
828
690
  : undefined;
829
- const footer = hasOwn(object, "footer")
830
- ? mintlifyFooter(object.footer)
831
- : undefined;
832
- const navbar = hasOwn(object, "navbar")
833
- ? mintlifyNavbar(object.navbar)
834
- : undefined;
835
- if (!banner && !footer && !navbar) {
691
+ if (!banner) {
836
692
  return [];
837
693
  }
838
694
 
839
695
  return [
840
696
  withoutUndefined({
841
697
  banner,
842
- footer,
843
- navbar,
844
698
  path,
845
699
  }),
846
700
  ];
@@ -914,19 +768,6 @@ const mintlifyMarkdown = (
914
768
  });
915
769
  };
916
770
 
917
- const mintlifyStyling = (
918
- value: unknown
919
- ): NonNullable<BlumeConfig["styling"]> => {
920
- const object = asObject(value);
921
- const eyebrows = asString(object?.eyebrows);
922
- return withoutUndefined({
923
- eyebrows:
924
- eyebrows === "breadcrumbs" || eyebrows === "section"
925
- ? eyebrows
926
- : undefined,
927
- });
928
- };
929
-
930
771
  const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
931
772
  const object = asObject(value);
932
773
  const metatags = asObject(object?.metatags);
@@ -942,17 +783,6 @@ const mintlifySeo = (value: unknown): NonNullable<BlumeConfig["seo"]> => {
942
783
  };
943
784
  };
944
785
 
945
- const mintlifyIcons = (value: unknown): NonNullable<BlumeConfig["icons"]> => {
946
- const object = asObject(value);
947
- const library = object?.library;
948
- return withoutUndefined({
949
- library:
950
- library === "fontawesome" || library === "lucide" || library === "tabler"
951
- ? library
952
- : undefined,
953
- });
954
- };
955
-
956
786
  export const loadMintlifyConfig = async (
957
787
  root: string,
958
788
  file: string
@@ -993,14 +823,10 @@ export const loadMintlifyConfig = async (
993
823
  ],
994
824
  root: ".",
995
825
  },
996
- contextual: mintlifyContextual(spec.contextual),
997
826
  description: asString(spec.description),
998
827
  favicon: mintlifyFavicon(spec.favicon),
999
- footer: mintlifyFooter(spec.footer),
1000
- icons: mintlifyIcons(spec.icons),
1001
828
  logo: mintlifyLogo(spec.logo),
1002
829
  markdown: mintlifyMarkdown(spec.markdown, styling),
1003
- navbar: mintlifyNavbar(spec.navbar),
1004
830
  navigation: {
1005
831
  chromeVariants: mintlifyChromeVariants(spec),
1006
832
  selectors: mintlifySelectors(spec),
@@ -1016,7 +842,6 @@ export const loadMintlifyConfig = async (
1016
842
  prompt: asString(search.prompt),
1017
843
  },
1018
844
  seo: mintlifySeo(seo),
1019
- styling: mintlifyStyling(styling),
1020
845
  theme: {
1021
846
  accent: asString(colors.primary) ?? "blue",
1022
847
  accentDark: asString(colors.light),
@@ -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";
@@ -52,28 +53,37 @@ const writeBlumeConfig = async (
52
53
  await writeFile(join(root, "blume.config.ts"), body, "utf-8");
53
54
  };
54
55
 
55
- /** Move a referenced top-level asset path (file or dir) under `public/`. */
56
+ interface RelocatedAssets {
57
+ /** Top-level dirs served in place via `content.assets` (no files moved). */
58
+ served: string[];
59
+ /** Top-level files moved under `public/`. */
60
+ moved: string[];
61
+ }
62
+
63
+ /**
64
+ * Make referenced top-level assets resolvable in Blume. Directories (e.g.
65
+ * Mintlify's `images/`) are left in place and served via `content.assets`, so
66
+ * the migration doesn't churn every file under them; loose top-level files
67
+ * (a root `favicon.png`, `logo.png`) are moved under `public/` since a mount
68
+ * points at a directory.
69
+ */
56
70
  const relocateAssets = async (
57
71
  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
-
72
+ segments: string[]
73
+ ): Promise<RelocatedAssets> => {
74
+ const served: string[] = [];
71
75
  const moved: string[] = [];
72
76
  for (const segment of segments) {
73
77
  const source = join(root, segment);
74
78
  if (!existsSync(source) || segment === "public") {
75
79
  continue;
76
80
  }
81
+ // oxlint-disable-next-line no-await-in-loop -- sequential fs stats
82
+ const stats = await stat(source);
83
+ if (stats.isDirectory()) {
84
+ served.push(segment);
85
+ continue;
86
+ }
77
87
  const dest = join(root, "public", segment);
78
88
  if (existsSync(dest)) {
79
89
  continue;
@@ -84,7 +94,32 @@ const relocateAssets = async (
84
94
  await rename(source, dest);
85
95
  moved.push(segment);
86
96
  }
87
- return moved;
97
+ return { moved, served };
98
+ };
99
+
100
+ /**
101
+ * Fold relocated assets into the config (served dirs become `content.assets`)
102
+ * and record what happened. Served dirs stay in place; only loose files moved.
103
+ */
104
+ const applyRelocatedAssets = (
105
+ config: BlumeConfig,
106
+ assets: RelocatedAssets,
107
+ warnings: string[]
108
+ ): void => {
109
+ if (assets.served.length > 0) {
110
+ config.content = {
111
+ ...config.content,
112
+ assets: [
113
+ ...new Set([...(config.content?.assets ?? []), ...assets.served]),
114
+ ],
115
+ };
116
+ warnings.push(
117
+ `Kept asset dir(s) in place, served via content.assets: ${assets.served.join(", ")}.`
118
+ );
119
+ }
120
+ if (assets.moved.length > 0) {
121
+ warnings.push(`Moved assets into public/: ${assets.moved.join(", ")}.`);
122
+ }
88
123
  };
89
124
 
90
125
  /**
@@ -121,31 +156,6 @@ const cleanupSnippets = async (
121
156
  }
122
157
  };
123
158
 
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
159
  /**
150
160
  * Migrate a Mintlify project to Blume: translate `docs.json`/`mint.json` into
151
161
  * `blume.config.ts`, rewrite every page to idiomatic Blume MDX in place, and
@@ -232,12 +242,13 @@ export const migrateMintlifyProject = async (
232
242
  moved += 1;
233
243
  }
234
244
 
235
- const movedAssets = await relocateAssets(root, assetRefs(config));
245
+ const assets = await relocateAssets(root, assetSegments(config));
236
246
  await cleanupSnippets(root, keptComponents, warnings);
237
247
 
238
248
  if (config.content?.exclude) {
239
249
  config.content.exclude = [...new Set(config.content.exclude)];
240
250
  }
251
+ applyRelocatedAssets(config, assets, warnings);
241
252
  await writeBlumeConfig(root, config);
242
253
 
243
254
  if (Object.keys(variables).length > 0) {
@@ -245,9 +256,6 @@ export const migrateMintlifyProject = async (
245
256
  `Inlined ${Object.keys(variables).length} docs.json variable(s) into content; Blume has no runtime variable substitution.`
246
257
  );
247
258
  }
248
- if (movedAssets.length > 0) {
249
- warnings.push(`Moved assets into public/: ${movedAssets.join(", ")}.`);
250
- }
251
259
  if (removedKeys.size > 0) {
252
260
  warnings.push(
253
261
  `Dropped unsupported page frontmatter keys: ${[...removedKeys].join(", ")}.`
@@ -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[] =>
@@ -425,10 +425,6 @@ export const mapStarlightConfig = (
425
425
  }
426
426
 
427
427
  const social = mapSocial(options.social);
428
- if (Object.keys(social.socials).length > 0) {
429
- config.footer = { socials: social.socials };
430
- }
431
-
432
428
  const github = mapEditLink(options.editLink) ?? social.github;
433
429
  if (github) {
434
430
  config.github = github;