@thallylabs/migrate 0.2.5 → 0.2.7

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/README.md CHANGED
@@ -15,10 +15,87 @@ compatible page frontmatter, site identity, and OpenAPI configuration. For publi
15
15
  sites, the URL adapter reads the structured configuration embedded in the
16
16
  rendered application before falling back to DOM link inference.
17
17
 
18
+ An `openapi` (or `asyncapi`) reference is resolved wherever Mintlify allows
19
+ it — the top-level `api.openapi`, or on any individual
20
+ tab/anchor/dropdown/product/version, e.g. `navigation.tabs[].openapi` — and
21
+ binds to that exact tab, not only the first one found. Remote `http://` and
22
+ `https://` specs are not downloaded during migration. The report names each
23
+ remote URL and tells you to add its spec to `public/`, then set the API source
24
+ in `docs.json` to that local path. Missing local specs also produce warnings
25
+ instead of silently dropping that API tab's reference. AsyncAPI has no Thally
26
+ renderer, so it always produces a clear warning naming the spec instead of
27
+ disappearing.
28
+
18
29
  Docusaurus sidebars are parsed as bounded, data-only JSON5—never executed—and
19
30
  preserve explicit categories, autogenerated slices, document IDs/slugs,
20
31
  positions, generated indexes, admonitions, tabs, and `static/` assets where
21
- supported.
32
+ supported. Every `@docusaurus/plugin-content-docs` instance declared in
33
+ `docusaurus.config` (not just the default one) is imported as its own tab,
34
+ routed under its `routeBasePath`; a `path` is resolved the way Docusaurus
35
+ itself resolves it (relative to the detected project root), with a fallback
36
+ to the repository root for monorepos that build by copying the project
37
+ root's contents up into the repository root before generating the site
38
+ (Playwright's own `nodejs/` is one real example) — a `path` still not found
39
+ under either root is reported with a warning naming the plugin instance
40
+ instead of being silently dropped. The same repository-root fallback applies
41
+ to `@site/...` aliases, relative component imports, and relative asset
42
+ `require()` calls in copied components; a `require()` that still can't be
43
+ resolved is replaced with an empty string (a broken image, not a build
44
+ failure) and warned about instead of left dangling. `@docusaurus/plugin-client-redirects`
45
+ rules are imported whether declared inline in `docusaurus.config` or in a
46
+ separate module it imports (a named export, e.g. `redirects.ts`); a `from`
47
+ may be a single path or an array of paths. Its `createRedirects` function
48
+ computes redirects dynamically and can't be evaluated statically, so its
49
+ presence is reported with a warning instead of being silently ignored. A
50
+ static `--ifm-color-primary` accent color in the classic theme's `customCss`
51
+ file is imported the same way Mintlify's and Fern's theme colors are.
52
+
53
+ Fern repositories are located by their `fern/docs.yml` + `fern/fern.config.json`
54
+ pair. `docs.yml` is parsed as bounded YAML—never executed—and preserves tabs,
55
+ nested sections (including `slug`/`skip-slug`), navbar links, redirects, the
56
+ default Fern version's navigation, and theme accent colors. Only pages
57
+ referenced from `docs.yml` are imported, matching Fern's own publishing model.
58
+ Every `api:` section's OpenAPI document is resolved from its own
59
+ `generators.yml` (`api.specs[].openapi`, or the legacy `api:` string) and
60
+ imported into its own tab — a docs.yml with several API sections (e.g. a REST
61
+ API and a WebSocket API in separate tabs) gets a spec per tab, not just the
62
+ first. The per-API `fern/apis/<name>/` layout is checked first, keyed by the
63
+ node's `api-name` (the folder name) rather than `api` (its display title in
64
+ the nav) — the two are commonly different. The project-root `generators.yml`
65
+ is used only when the node has no `api-name` or the repo has no `fern/apis/`
66
+ folder. `openapi:` is conventionally relative to `generators.yml`'s own
67
+ directory rather than the Fern root, so it commonly resolves outside the API
68
+ folder (or outside `fern/` entirely, e.g. `../../../openapi.yaml`); the spec
69
+ is still found as long as it stays inside the repository checkout, and a path
70
+ that escapes the checkout is reported with a warning naming it, instead of
71
+ the API being silently dropped. A Fern Definition (a non-OpenAPI API with no
72
+ document to attach), an AsyncAPI/OpenRPC-only `api:` section (Thally has no
73
+ renderer for either), and an `api:` section for which no spec could be
74
+ resolved at all, are each reported as a warning naming the API instead of the
75
+ tab being silently dropped, as is `changelog`, which has no equivalent Thally
76
+ field yet.
77
+
78
+ A section/page/tab's explicit `slug` is used as literal Fern routing text
79
+ (e.g. `slug: baml_client`, matching a `baml_client/` folder) even when
80
+ Thally's own route still hyphenates it for consistency (`baml-client`); an
81
+ in-body link written against that literal, unslugified form — which Fern's
82
+ own file-path-based routing tolerates — gets a redirect to the route Thally
83
+ actually serves, instead of 404ing after migration.
84
+
85
+ KaTeX-style `$$...$$` math (block or inline) has no Thally renderer yet.
86
+ Rather than crashing the MDX compiler and silently excluding the whole page,
87
+ a block becomes a fenced ```` ```math ```` code block and an inline span
88
+ becomes an inline code span — both preserve the original math text — with a
89
+ warning.
90
+
91
+ A `products:` docs.yml (Fern's multi-product/product-switcher layout, as on
92
+ buildwithfern.com/learn) imports every product: each product's own
93
+ `navigation:`/`tabs:` becomes a top-level Thally tab named by its
94
+ `display-name`, routed under its `slug` (or a slugified display-name), with
95
+ its pages read from that product's own directory. Local component imports
96
+ from Fern MDX pages (`../../components/...`, resolved against the Fern
97
+ project root) are migrated the same way Mintlify and Docusaurus components
98
+ are — see "Mintlify compatibility" below.
22
99
 
23
100
  ## Mintlify compatibility
24
101
 
@@ -38,9 +115,81 @@ sections are normalized without discarding original URL redirects.
38
115
 
39
116
  Simple interactive HTML blocks in MDX are extracted into client components.
40
117
  The Mintlify `search-bar-entry` click trigger is mapped to Thally's search
41
- shortcut. Markdown mixed inside interactive JSX, computed imports, namespace
42
- imports, and external packages that require installation are reported for
43
- manual review. Unsupported source is preserved, not replaced with empty stubs.
44
- File paths cannot escape the documentation root; symlinks and oversized graphs
45
- are rejected. This is compatibility analysis, **not a code sandbox**: imported
46
- JavaScript executes when the developer builds or runs the resulting site.
118
+ shortcut. Markdown mixed inside interactive JSX, computed imports, and
119
+ namespace imports are reported for manual review; unsupported source is
120
+ preserved, not replaced with empty stubs. An external package's JSX usage is
121
+ dropped and replaced with a safe fallback (with a warning); if that package's
122
+ binding is instead referenced outside JSX (an expression, a prop, or an
123
+ inline declaration) the import can't be rewritten the same way, so the whole
124
+ page is excluded — instead of shipping with an import `next build` can't
125
+ resolve — and pruned from navigation, with a warning naming the page, the
126
+ package, and the binding. A local/relative component import is fully owned by
127
+ the migration instead: the whole file (and its own local dependency graph) is
128
+ copied, keeping every binding's original name, so it makes no difference
129
+ whether a binding is used as a bare JSX tag or only referenced elsewhere (an
130
+ enum passed into a sibling component's prop, a member tag, a wrapping
131
+ declaration, ...) — the page is excluded only when the copy itself genuinely
132
+ fails (a missing file, a computed/dynamic dependency, a cycle, or a budget
133
+ overrun), with a warning naming the page and the component. Packages the starter already installs (`next`,
134
+ `react-dom`, `clsx`, `lucide-react`, `tailwind-merge`, and their subpaths) are
135
+ kept as-is (except `react-dom/server`, whose string renderers throw under
136
+ Next) and copied into any extracted client module. A source site's own
137
+ `@/…` path aliases are treated like an unavailable package. A page that
138
+ passes a page-authored function (including a class, or a function written as
139
+ children) as a prop into a component confirmed to cross the server/client boundary (one this
140
+ importer extracted as a client module, or a Thally runtime built-in already
141
+ backed by one, such as `Accordion`/`Panel`/`Tabs`) is excluded the same way;
142
+ an unconfirmed target is warned instead of dropped.
143
+ File paths cannot escape the repository checkout; symlinks and oversized
144
+ graphs are rejected. This is compatibility analysis, **not a code sandbox**:
145
+ imported JavaScript executes when the developer builds or runs the resulting
146
+ site.
147
+
148
+ A copied Mintlify `.jsx` snippet that uses the implicit `MintlifyComponents`
149
+ global (`const { Card } = MintlifyComponents`, Mintlify's own no-import
150
+ convention for its built-ins) gets that binding provided automatically,
151
+ reusing Thally's own built-in MDX component registry — the same way an
152
+ implicit `React`/hook global is already provided.
153
+
154
+ A copied component is prepended with `// @ts-nocheck`: the source
155
+ platform's own build never ran `next build`'s full `tsc --noEmit` type
156
+ check, so loose types, ambient globals (`window.ethereum`), and implicit
157
+ `any` parameters that were fine there would otherwise fail the migrated
158
+ build. A locally-owned wrapper component whose own body imports a known
159
+ YouTube-embed package (e.g. a custom `LiteYouTube` wrapping
160
+ `react-lite-youtube-embed`) gets the same working `<iframe>` fallback as a
161
+ page that imports that package directly, instead of losing the embed to the
162
+ generic unknown-component fallback.
163
+
164
+ ## Branding
165
+
166
+ A source site's logo, favicon, and theme accent color(s) are extracted where
167
+ each platform's config exposes them (Mintlify's `colors`, Fern's
168
+ `colors.accent-primary`, Docusaurus's classic theme `customCss`). A valid
169
+ accent color is wired into the scaffolded project's `brandPreset` the same
170
+ way `create-thally-docs`'s own `--colors` flag does. Logo and favicon files
171
+ are copied into `public/`, but Thally's site configuration has no static
172
+ field for them — branding is managed from the admin dashboard at runtime —
173
+ so each site gets one warning naming the copied files and pointing at the
174
+ dashboard's branding settings instead of leaving the loss silent.
175
+
176
+ ## Cloning a GitHub repository
177
+
178
+ `cloneGitHubRepository` retries a transient network-class failure with
179
+ backoff, and each underlying git process (the clone, and any submodule
180
+ fetch) is killed and treated as a retryable failure if it stalls — a
181
+ generous 10-minute default, configurable via
182
+ `THALLY_MIGRATE_CLONE_TIMEOUT_MS` (milliseconds) for an unusually large
183
+ repository on a slow link. The clone neutralizes the `filter.lfs.*`
184
+ smudge/clean/process filter driver for that one process only (via
185
+ `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`, never the user's
186
+ global git config), so a repository that uses Git LFS still clones even when
187
+ the host has no `git-lfs` binary installed — including a host where
188
+ `filter.lfs.*` is still registered globally from a previous install,
189
+ pointing at a command that no longer exists; an asset that's still a Git
190
+ LFS pointer file afterward (not its real content) is skipped with a warning
191
+ instead of being copied as if it were the real file. Submodules are
192
+ initialized one at a time, best-effort,
193
+ after a successful clone — a submodule that can't be fetched (private,
194
+ deleted, network trouble) is named in a warning instead of failing the
195
+ whole migration.
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * import paths. Callers materialize this bundle only after discovery and
4
4
  * normalization finish, so a partial crawl can never leave a half-written site.
5
5
  */
6
- type MigrationPlatform = 'mintlify' | 'docusaurus' | 'gitbook' | 'nextra' | 'vitepress' | 'starlight' | 'thally' | 'generic' | 'unknown';
6
+ type MigrationPlatform = 'mintlify' | 'docusaurus' | 'fern' | 'gitbook' | 'nextra' | 'vitepress' | 'starlight' | 'thally' | 'generic' | 'unknown';
7
7
  interface MigrationNavigationGroup {
8
8
  group: string;
9
9
  icon?: string;
@@ -66,6 +66,10 @@ interface MigrationDocsConfig {
66
66
  display?: 'tabs' | 'dropdown';
67
67
  };
68
68
  theme?: 'default' | 'maple' | 'sharp' | 'minimal';
69
+ /** Icon set for content `icon` names; Mintlify's `icons.library` carries through. */
70
+ icons?: {
71
+ library?: 'lucide' | 'fontawesome' | 'tabler';
72
+ };
69
73
  banner?: MigrationBannerConfig;
70
74
  navbar?: MigrationNavbarConfig;
71
75
  footer?: MigrationFooterConfig;
@@ -150,6 +154,18 @@ interface MigrationBundle {
150
154
  site?: {
151
155
  name?: string;
152
156
  description?: string;
157
+ /**
158
+ * Source theme accent color(s), each a `#rrggbb`/`#rgb` hex string.
159
+ * Follows Mintlify's own `colors` schema: `light` is the color used in
160
+ * dark mode and `dark` is the color used in light mode. Fern's colors
161
+ * are normal (its `light`/`dark` match the mode they paint), so
162
+ * extraction swaps them onto this shape for a single downstream contract.
163
+ */
164
+ colors?: {
165
+ primary?: string;
166
+ light?: string;
167
+ dark?: string;
168
+ };
153
169
  };
154
170
  warnings: Array<MigrationWarning>;
155
171
  stats: {
@@ -178,9 +194,13 @@ interface RenderedMigrationFile {
178
194
  }
179
195
 
180
196
  /**
181
- * Repository adapter for the shared migration engine. Traversal is bounded,
182
- * symbolic links are ignored, and Git is always invoked with an argument array
183
- * so source-controlled branch names can never become shell commands.
197
+ * Repository adapter for the shared migration engine. Traversal is bounded;
198
+ * `scanFiles` follows a symbolic link only when its resolved real path stays
199
+ * inside the repository checkout (this covers a submodule mounted as a
200
+ * symlink, e.g. Oasis's `docs/core -> ../external/oasis-core/docs`) and
201
+ * guards against a cycle, but every other directory walk in this file still
202
+ * skips symlinks outright. Git is always invoked with an argument array so
203
+ * source-controlled branch names can never become shell commands.
184
204
  */
185
205
 
186
206
  interface GitHubRepositorySource {
@@ -203,11 +223,26 @@ interface RepositoryMigrationOptions {
203
223
  docusaurusSkipSidebar?: boolean;
204
224
  /** @internal Static assets are shared across docs-plugin instances. */
205
225
  docusaurusSkipAssets?: boolean;
226
+ /** @internal Redirects are global config, read once, not per plugin instance. */
227
+ docusaurusSkipRedirects?: boolean;
206
228
  }
207
229
  /** Parse and validate a public GitHub repository URL. */
208
230
  declare function parseGitHubRepositoryUrl(rawUrl: string): GitHubRepositorySource;
209
- /** Clone a repository without a shell; callers own and remove `targetDir`. */
210
- declare function cloneGitHubRepository(source: GitHubRepositorySource, targetDir: string): Promise<void>;
231
+ /** Every submodule path declared in a cloned repository's `.gitmodules`, in file order. */
232
+ declare function gitmodulePaths(targetDir: string): Array<string>;
233
+ /**
234
+ * Clone a repository without a shell; callers own and remove `targetDir`. A
235
+ * large repository on a flaky connection can drop mid-clone (`RPC failed`,
236
+ * `Recv failure`, a `curl 56`, …) — retry a network-class failure a couple
237
+ * of times with backoff instead of surfacing a hard failure on the first
238
+ * blip. A partial checkout from the failed attempt is removed first, or
239
+ * `git clone` refuses to reuse the (now non-empty) target directory. A
240
+ * stalled clone/submodule-init is killed by `runGit`'s own timeout, which
241
+ * surfaces as a retryable error. `warnings`, if given, collects a
242
+ * submodule-fetch-failure warning (this function otherwise returns exactly
243
+ * as before, so existing callers are unaffected).
244
+ */
245
+ declare function cloneGitHubRepository(source: GitHubRepositorySource, targetDir: string, warnings?: Array<MigrationWarning>): Promise<void>;
211
246
  /** Detect a supported repository docs platform from unambiguous config files. */
212
247
  declare function detectRepositoryPlatform(repositoryDir: string, docsDir?: string): MigrationPlatform;
213
248
  /** Pick the first conventional content root containing Markdown. */
@@ -253,6 +288,64 @@ interface GeneratedNavigationOptions {
253
288
  }
254
289
  /** Build deterministic fallback navigation from imported default-locale pages. */
255
290
  declare function buildNavigationFromPages(pages: Array<MigrationPage>, options?: GeneratedNavigationOptions): MigrationDocsConfig;
291
+ /**
292
+ * A source page can be excluded after navigation is projected (invalid MDX,
293
+ * a client-boundary function prop, a duplicate id). Its reference still sits
294
+ * in the projected tabs/groups at that point, across every tab, group, and
295
+ * locale, since the nav tree and the page list are built from the same
296
+ * source config independently. Drop those dangling references here so
297
+ * `thally check` never reports a nav entry with no backing MDX file, and
298
+ * drop any group left with no pages as a result. Shared by every platform's
299
+ * navigation projection (Mintlify, Fern, Docusaurus).
300
+ */
301
+ declare function pruneMissingNavigationPages(config: MigrationDocsConfig, availableIds: ReadonlySet<string>): MigrationDocsConfig;
302
+
303
+ /**
304
+ * Static Fern (`fern/docs.yml`) repository projection. Fern navigation and
305
+ * generator config are plain YAML data files, so this adapter only ever
306
+ * parses them with `yaml` and never imports or executes JavaScript.
307
+ */
308
+
309
+ interface FernPageDescriptor {
310
+ /** Path to the referenced page file, relative to the Fern root. */
311
+ sourcePath: string;
312
+ navigationId: string;
313
+ }
314
+ /** One `api:` navigation node, resolved to the name/tab Thally needs to bind its spec. */
315
+ interface FernApiSection {
316
+ /** The node's own value (a display name or, in multi-API repos, a `fern/apis/<name>` identifier). */
317
+ name: string;
318
+ /** True when `name` came from an explicit `api-name` field rather than falling back to the `api:` display title. */
319
+ nameExplicit: boolean;
320
+ /** Label of the tab that owns this node, if that tab also has other content and survived projection. */
321
+ tabLabel?: string;
322
+ /**
323
+ * Route segments (section slugs) leading to this node, used only to
324
+ * disambiguate two `api:` nodes that land on the same `tabLabel` — a
325
+ * "Production API Reference" and "Testnet API Reference" section both
326
+ * nested under one tab both want that tab's label; without this, the
327
+ * second one silently overwrites the first tab's binding instead of
328
+ * getting its own tab.
329
+ */
330
+ routeSegments: Array<string>;
331
+ }
332
+ interface FernNavigationResult {
333
+ docsConfig: MigrationDocsConfig;
334
+ descriptors: Array<FernPageDescriptor>;
335
+ warnings: Array<MigrationWarning>;
336
+ /** Every `api:` node found in navigation, in document order. A repo can declare several (e.g. a REST and a WebSocket API in separate tabs). */
337
+ apiSections: Array<FernApiSection>;
338
+ }
339
+ /** Read `fern/docs.yml` without executing anything. */
340
+ declare function readFernConfig(fernRoot: string): {
341
+ config: Record<string, unknown>;
342
+ sourcePath: string;
343
+ } | null;
344
+ /** Project a Fern `docs.yml` navigation tree into Thally tabs/groups. */
345
+ declare function projectFernNavigation(input: {
346
+ config: Record<string, unknown>;
347
+ fernRoot: string;
348
+ }): FernNavigationResult;
256
349
 
257
350
  /** Markdown/MDX normalization that preserves every component Thally supports. */
258
351
 
@@ -262,7 +355,7 @@ interface MarkdownPageIdentity {
262
355
  locale?: string;
263
356
  }
264
357
  /** Normalize only syntax Thally cannot render; supported source JSX stays intact. */
265
- declare function normalizeMdx(body: string): string;
358
+ declare function normalizeMdx(body: string, platform?: MigrationPlatform): string;
266
359
  /** Parse source Markdown or MDX into the canonical page representation. */
267
360
  declare function parseMarkdownPage(input: {
268
361
  id: string;
@@ -270,6 +363,8 @@ declare function parseMarkdownPage(input: {
270
363
  locale?: string;
271
364
  raw: string;
272
365
  source: string;
366
+ /** Gates platform-specific textual renames in `normalizeMdx`; omitted runs them all. */
367
+ platform?: MigrationPlatform;
273
368
  /** Resolve platform-specific routes from the already-parsed frontmatter. */
274
369
  resolveIdentity?: (frontmatter: Record<string, unknown>, fallback: MarkdownPageIdentity) => MarkdownPageIdentity;
275
370
  }): MigrationPage | null;
@@ -306,4 +401,4 @@ declare const defaultMigrationFetcher: MigrationFetcher;
306
401
  /** Crawl one public docs origin into a canonical, caller-independent bundle. */
307
402
  declare function migrateUrl(options: UrlMigrationOptions): Promise<MigrationBundle>;
308
403
 
309
- export { type GitHubRepositorySource, type MigrationAsset, type MigrationBundle, type MigrationDocsConfig, type MigrationFetchRequest, type MigrationFetchResponse, type MigrationFetcher, type MigrationNavigationGroup, type MigrationNavigationTab, type MigrationPage, type MigrationPlatform, type MigrationWarning, type RenderedMigrationFile, type RepositoryMigrationOptions, type UrlMigrationOptions, addMintlifyDirectoryRedirects, buildNavigationFromPages, cloneGitHubRepository, defaultMigrationFetcher, detectRepositoryDocsDir, detectRepositoryPlatform, mergeMigrationConfig, migrateRepository, migrateUrl, normalizeMdx, parseGitHubRepositoryUrl, parseMarkdownPage, projectMintlifyNavigation, readMintlifyConfig, renderMigrationFiles, validateMigrationUrl };
404
+ export { type GitHubRepositorySource, type MigrationAsset, type MigrationBundle, type MigrationDocsConfig, type MigrationFetchRequest, type MigrationFetchResponse, type MigrationFetcher, type MigrationNavigationGroup, type MigrationNavigationTab, type MigrationPage, type MigrationPlatform, type MigrationWarning, type RenderedMigrationFile, type RepositoryMigrationOptions, type UrlMigrationOptions, addMintlifyDirectoryRedirects, buildNavigationFromPages, cloneGitHubRepository, defaultMigrationFetcher, detectRepositoryDocsDir, detectRepositoryPlatform, gitmodulePaths, mergeMigrationConfig, migrateRepository, migrateUrl, normalizeMdx, parseGitHubRepositoryUrl, parseMarkdownPage, projectFernNavigation, projectMintlifyNavigation, pruneMissingNavigationPages, readFernConfig, readMintlifyConfig, renderMigrationFiles, validateMigrationUrl };