diff --git a/cli/commands/generate/command.ts b/cli/commands/generate/command.ts index 8ee84383e4..25bc7b4940 100644 --- a/cli/commands/generate/command.ts +++ b/cli/commands/generate/command.ts @@ -6,6 +6,8 @@ import { exists, join, readTextFile } from "veryfront/fs"; import { generateIntegration } from "./integration-generator.ts"; import { isScaffoldType, scaffoldProjectFile } from "../../scaffold/engine.ts"; +const MDX_EXTENSION_PACKAGE = "@veryfront/ext-content-mdx"; + const PROJECT_MARKERS = [ "veryfront.config.ts", "veryfront.config.js", @@ -130,4 +132,51 @@ export async function generateCommand( } for (const file of result.files) cliLogger.info(`Created ${file.path}`); + await warnIfMdxExtensionMissing(projectDir, result.files.map((file) => file.path)); +} + +/** + * Tell the user to install the MDX extension when we have just written an + * `.mdx` file into a project that does not declare it. + * + * The pages router scaffolds `.mdx` for `page` and `layout`. Since + * `@veryfront/ext-content-mdx` became an optional peer of the npm package, a + * project that never installed it renders those routes as an error — while + * this command has just reported "Created" and exited 0. The compile path + * already throws a typed error naming the package, but by then the developer + * is debugging a route they were told was fine. + * + * Best-effort: a project without a readable package.json (a Deno project, say) + * gets no warning rather than a false one. + */ +async function warnIfMdxExtensionMissing( + projectDir: string, + paths: string[], +): Promise { + if (!paths.some((path) => path.endsWith(".mdx"))) return; + try { + const raw = await readTextFile(join(projectDir, "package.json")); + const manifest = JSON.parse(raw) as Record | undefined>; + const declared = [ + manifest.dependencies, + manifest.devDependencies, + manifest.peerDependencies, + manifest.optionalDependencies, + ].some((group) => group?.[MDX_EXTENSION_PACKAGE] !== undefined); + if (declared) return; + // Lockfile-aware: hard-coding `npm install` in a pnpm/yarn/bun project + // writes a competing package-lock.json and leaves the real lockfile stale. + const { detectProjectInstallTarget, formatInstallCommand } = await import( + "#veryfront/extensions/install-command.ts" + ); + const install = formatInstallCommand( + MDX_EXTENSION_PACKAGE, + detectProjectInstallTarget(projectDir), + ); + cliLogger.warn( + `This project does not depend on ${MDX_EXTENSION_PACKAGE}, so the generated .mdx file will not render. Install it with: ${install}`, + ); + } catch { + // No readable package.json: say nothing rather than warn wrongly. + } } diff --git a/cli/shared/ensure-content-processor.test.ts b/cli/shared/ensure-content-processor.test.ts new file mode 100644 index 0000000000..b46d19551f --- /dev/null +++ b/cli/shared/ensure-content-processor.test.ts @@ -0,0 +1,109 @@ +import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { resolve, tryResolve, unregister } from "../../src/extensions/contracts.ts"; +import type { ContentProcessor } from "veryfront/extensions/content"; +import { ensureBuiltinContentProcessor } from "./ensure-content-processor.ts"; + +/** The error Node raises for an uninstalled optional peer dependency. */ +function missingPackageError(): Error { + return Object.assign( + new Error( + "Cannot find package '@veryfront/ext-content-mdx' imported from " + + "/app/node_modules/veryfront/esm/cli/shared/ensure-content-processor.js", + ), + { code: "ERR_MODULE_NOT_FOUND" }, + ); +} + +class StubContentProcessor { + compileMdx() { + return Promise.reject(new Error("unused")); + } + compileMarkdown() { + return Promise.reject(new Error("unused")); + } + getRemarkPlugins() { + return []; + } + getRehypePlugins() { + return []; + } +} + +describe("cli/shared/ensure-content-processor", () => { + it("registers the MDX processor when the extension is installed", async () => { + try { + await ensureBuiltinContentProcessor(() => + Promise.resolve({ MdxContentProcessor: StubContentProcessor }) + ); + + assertEquals( + tryResolve("ContentProcessor") instanceof StubContentProcessor, + true, + ); + } finally { + unregister("ContentProcessor"); + } + }); + + // @veryfront/ext-content-mdx is an optional peer, so a plain + // `npm install veryfront` does not install it. Server startup calls this + // unconditionally (cli/shared/server-startup.ts), so throwing here would + // break `npx veryfront dev` for every project — including the ones with no + // .mdx file at all. + it("does not fail startup when the MDX extension is not installed", async () => { + await ensureBuiltinContentProcessor(() => Promise.reject(missingPackageError())); + + assertEquals(tryResolve("ContentProcessor"), undefined); + }); + + // With no processor registered, the compile path is what reports the + // problem, and it names the package to install. + it("leaves the actionable install message to the content compile path", async () => { + await ensureBuiltinContentProcessor(() => Promise.reject(missingPackageError())); + + let message = ""; + try { + resolve("ContentProcessor"); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + assertStringIncludes(message, "@veryfront/ext-content-mdx"); + }); + + // What importFirstPartyExtensionModule actually throws: the raw resolution + // error is wrapped, its message gains an install hint, and the original pair + // (package + workspace source) lands on `cause` as an AggregateError. The + // classifier's message patterns are anchored, so the wrapper itself never + // matches — only the cause chain does. + it("tolerates the wrapped install-hint error the real loader throws", async () => { + const packageError = new Error( + "Cannot find package '@veryfront/ext-content-mdx' imported from " + + "/app/node_modules/veryfront/esm/src/extensions/first-party-import.js", + ); + const sourceError = new Error( + "Cannot find module " + + "'/app/node_modules/veryfront/esm/extensions/ext-content-mdx/src/index.ts'", + ); + const wrapped = new Error( + `${packageError.message} First-party extension "ext-content-mdx" is not ` + + "installed; install @veryfront/ext-content-mdx alongside veryfront to enable it.", + { cause: new AggregateError([packageError, sourceError], packageError.message) }, + ); + + await ensureBuiltinContentProcessor(() => Promise.reject(wrapped)); + + assertEquals(tryResolve("ContentProcessor"), undefined); + }); + + // A broken transitive dependency inside an *installed* extension must not be + // mistaken for "not installed" and silently swallowed. + it("rethrows real load failures from an installed extension", async () => { + await assertRejects( + () => ensureBuiltinContentProcessor(() => Promise.reject(new Error("boom"))), + Error, + "boom", + ); + }); +}); diff --git a/cli/shared/ensure-content-processor.ts b/cli/shared/ensure-content-processor.ts index 26d90448f2..3139e10f56 100644 --- a/cli/shared/ensure-content-processor.ts +++ b/cli/shared/ensure-content-processor.ts @@ -1,18 +1,42 @@ import { tryResolve } from "veryfront/extensions"; import { register } from "../../src/extensions/contracts.ts"; import type { ContentProcessor } from "veryfront/extensions/content"; -import { importFirstPartyExtensionModule } from "veryfront/extensions/first-party-import"; +import { + firstPartyExtensionSourceSpecifiers, + importFirstPartyExtensionModule, + isMissingFirstPartyExtensionModule, +} from "veryfront/extensions/first-party-import"; type ContentMdxExtensionModule = { MdxContentProcessor: new () => ContentProcessor; }; +const CONTENT_MDX_DIRECTORY = "ext-content-mdx"; +const CONTENT_MDX_PACKAGE = `@veryfront/${CONTENT_MDX_DIRECTORY}`; + +/** + * Specifiers a "not installed" failure is allowed to name: the npm package and + * the workspace source entries the loader tries first. Derived rather than + * written out so they stay in step with the loader, and so this module keeps + * naming no extension source path of its own. + * + * A load failure naming anything else — a broken transitive dependency inside + * an installed ext-content-mdx, say — is a real error and must not be + * swallowed. + */ +const CONTENT_MDX_SPECIFIERS = [ + CONTENT_MDX_PACKAGE, + ...firstPartyExtensionSourceSpecifiers(CONTENT_MDX_DIRECTORY).map((specifier) => + specifier.replace(/^(?:\.\.\/)+/, "") + ), +]; + let contentMdxModulePromise: Promise | undefined; function loadContentMdxModule(): Promise { contentMdxModulePromise ??= importFirstPartyExtensionModule( - "ext-content-mdx", - "@veryfront/ext-content-mdx", + CONTENT_MDX_DIRECTORY, + CONTENT_MDX_PACKAGE, ).catch((error) => { contentMdxModulePromise = undefined; throw error; @@ -40,9 +64,29 @@ export function prefetchBuiltinContentProcessor(): void { * `setupAll` to `teardownAll` to `reset()` clears the contract registry, so this * must run *after* the server-start (or `getConfig`) call returns. We skip * registration when a user-provided extension already supplied the contract. + * + * The npm distribution declares @veryfront/ext-content-mdx as an *optional + * peer* (see scripts/build/npm-package-metadata.ts), so a plain + * `npm install veryfront` does not install it. Every server start calls this, + * including projects with no .mdx or .md file at all, so a missing package must + * not be fatal here. Leaving the contract unregistered defers the report to the + * compile path, which throws the typed MISSING_EXTENSION_ERROR naming + * @veryfront/ext-content-mdx only when content is actually rendered. + * + * `load` is a test seam and defaults to the real module loader. */ -export async function ensureBuiltinContentProcessor(): Promise { +export async function ensureBuiltinContentProcessor( + load: () => Promise = loadContentMdxModule, +): Promise { if (tryResolve("ContentProcessor")) return; - const { MdxContentProcessor } = await loadContentMdxModule(); - register("ContentProcessor", new MdxContentProcessor()); + + let module: ContentMdxExtensionModule; + try { + module = await load(); + } catch (error) { + if (isMissingFirstPartyExtensionModule(error, CONTENT_MDX_SPECIFIERS)) return; + throw error; + } + + register("ContentProcessor", new module.MdxContentProcessor()); } diff --git a/cli/shared/project-creation.test.ts b/cli/shared/project-creation.test.ts index b2f0cf1c0d..298b65c3b3 100644 --- a/cli/shared/project-creation.test.ts +++ b/cli/shared/project-creation.test.ts @@ -14,6 +14,7 @@ import { STARTER_TEMPLATE_NAMES } from "../../templates/types.ts"; import { createProject, type CreateProjectRequest, + materializeScaffold, type ProjectCreationEvent, } from "./project-creation.ts"; @@ -502,3 +503,52 @@ describe("createProject", () => { } }); }); + +describe("cli/project-creation MDX extension declaration", () => { + // Raised in review on #3783. `firstPartyExtensions` came only from the + // template config, but the `mdx` feature adds app/docs/*.mdx on top of ANY + // template — so `--template ai-agent --features mdx` scaffolded MDX routes + // with no extension declared, and every one of them failed at runtime. + it("declares ext-content-mdx when the mdx feature is selected on a non-mdx template", async () => { + const scaffold = await materializeScaffold({ + template: "ai-agent", + features: ["mdx"], + projectName: "mdx-feature-probe", + }); + + // The mdx feature scaffolds no files: it sets `mdx.enabled` and tips the + // user to author `.mdx` themselves. Selecting it still has to declare the + // extension, or following that tip fails at runtime. + assertEquals( + scaffold.files.filter((file) => file.path.endsWith(".mdx")).length, + 0, + "the mdx feature is config-and-tips only; update this if it starts shipping files", + ); + + const packageJson = JSON.parse( + scaffold.files.find((file) => file.path === "package.json")?.content ?? "{}", + ); + const declared = Object.keys(packageJson.dependencies ?? {}); + assertEquals( + declared.includes("@veryfront/ext-content-mdx"), + true, + `expected @veryfront/ext-content-mdx to be declared, got ${declared.join(", ")}`, + ); + }); + + it("does not declare ext-content-mdx for a template with no mdx files", async () => { + const scaffold = await materializeScaffold({ + template: "ai-agent", + projectName: "no-mdx-probe", + }); + + const mdxFiles = scaffold.files.filter((file) => file.path.endsWith(".mdx")); + assertEquals(mdxFiles.length, 0, "ai-agent alone should ship no .mdx"); + + const packageJson = JSON.parse( + scaffold.files.find((file) => file.path === "package.json")?.content ?? "{}", + ); + const declared = Object.keys(packageJson.dependencies ?? {}); + assertEquals(declared.includes("@veryfront/ext-content-mdx"), false); + }); +}); diff --git a/cli/shared/project-creation.ts b/cli/shared/project-creation.ts index 81a1e67010..b2e52c6d12 100644 --- a/cli/shared/project-creation.ts +++ b/cli/shared/project-creation.ts @@ -213,6 +213,8 @@ function dedupeEnvVars(envVars: EnvVarConfig[]): EnvVarConfig[] { }); } +const MDX_EXTENSION_PACKAGE = "@veryfront/ext-content-mdx"; + async function loadTemplateFiles( template: InitTemplate, ): Promise< @@ -253,6 +255,36 @@ async function loadTemplateFiles( }; } +/** + * Declare `@veryfront/ext-content-mdx` whenever the assembled project actually + * contains an `.mdx` file. + * + * The extension is an optional peer of the npm package — it drags + * `@types/mdx`, which breaks `tsc --noEmit` for every library consumer — so a + * project that renders MDX has to install it or those routes fail at runtime. + * + * Two ways a project ends up needing it, and the template config sees neither + * on its own: + * + * - A scaffolded `.mdx` file. The `minimal` starter ships `app/about/page.mdx`. + * - The `mdx` feature. It scaffolds no files — it sets `mdx.enabled` in the + * config and tips the user to "Create .mdx files in app/ directory" — so a + * user who follows that advice on any template would hit a runtime failure + * with nothing in `package.json` to explain it. + */ +function withMdxExtension( + firstPartyExtensions: string[] | undefined, + files: TemplateFile[], + features: FeatureName[], +): string[] | undefined { + const needsMdx = features.includes("mdx") || + files.some((file) => file.path.endsWith(".mdx")); + if (!needsMdx) return firstPartyExtensions; + const existing = firstPartyExtensions ?? []; + if (existing.includes(MDX_EXTENSION_PACKAGE)) return existing; + return [...existing, MDX_EXTENSION_PACKAGE]; +} + async function assembleFeatureFiles( features: FeatureName[], templateFiles: TemplateFile[], @@ -491,7 +523,11 @@ async function assembleScaffold(request: { tips: [...featureAssembly.tips, ...integrationAssembly.tips], packageJsonOptions: { dependencies: template.dependencies, - firstPartyExtensions: template.firstPartyExtensions, + firstPartyExtensions: withMdxExtension( + template.firstPartyExtensions, + integrationAssembly.files, + request.features, + ), integrations: integrationAssembly.loadedIntegrations.map((integration) => ({ name: integration.config.name, npmDependencies: integration.config.npmDependencies, diff --git a/docs/api-reference/veryfront/scaffold.md b/docs/api-reference/veryfront/scaffold.md index e0c955843d..dd7b5f75bf 100644 --- a/docs/api-reference/veryfront/scaffold.md +++ b/docs/api-reference/veryfront/scaffold.md @@ -38,20 +38,20 @@ for (const file of files) { | Name | Description | Source | | --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `SCAFFOLD_TEMPLATE_ALIASES` | Slugs other product surfaces use for a template this CLI names differently. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L588) | +| `SCAFFOLD_TEMPLATE_ALIASES` | Slugs other product surfaces use for a template this CLI names differently. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L624) | ### Functions | Name | Description | Source | | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `listScaffoldTemplates` | Every template slug a caller may ask for, canonical names and aliases. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L603) | -| `materializeScaffold` | Produce the complete contents of a new project without touching a disk. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L643) | -| `resolveScaffoldTemplate` | Canonical starter template for a slug, or `null` when nothing matches. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L595) | +| `listScaffoldTemplates` | Every template slug a caller may ask for, canonical names and aliases. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L639) | +| `materializeScaffold` | Produce the complete contents of a new project without touching a disk. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L679) | +| `resolveScaffoldTemplate` | Canonical starter template for a slug, or `null` when nothing matches. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L631) | ### Types | Name | Description | Source | | ---------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `MaterializedScaffold` | A new project: every file it starts with, plus anything worth telling the author. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L625) | -| `MaterializeScaffoldRequest` | What to build: which starter, under what name, for which runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L608) | +| `MaterializedScaffold` | A new project: every file it starts with, plus anything worth telling the author. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L661) | +| `MaterializeScaffoldRequest` | What to build: which starter, under what name, for which runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/cli/shared/project-creation.ts#L644) | | `TemplateFile` | | [source](https://github.com/veryfront/veryfront-code/blob/main/templates/types.ts#L17) | diff --git a/docs/getting-started/add-to-existing-project.md b/docs/getting-started/add-to-existing-project.md index 21a2ce9e5c..358acb8bca 100644 --- a/docs/getting-started/add-to-existing-project.md +++ b/docs/getting-started/add-to-existing-project.md @@ -78,17 +78,30 @@ app/a.ts(1,23): error TS2307: Cannot find module 'veryfront/agent' or its corres **`jsx`** is needed because Veryfront routes are `.tsx` files. -**`skipLibCheck`** is required, not merely recommended. Veryfront's MDX support -depends on `@types/mdx`, which refers to a global `JSX` namespace that React 19 -no longer declares globally. Without it, `tsc` fails on a dependency you do not -import: +**`skipLibCheck`** is recommended rather than required. Installing `veryfront` +on its own no longer pulls MDX into your dependency tree, so a strict project +typechecks clean without it. + +It becomes necessary once you opt into MDX: + +```bash +npm install @veryfront/ext-content-mdx +``` + +That package depends on `@types/mdx`, which refers to a global `JSX` namespace +React 19 no longer declares. Everything under `node_modules/@types` is included +by `tsc` automatically, so it fails on a dependency you never import: ```text node_modules/@types/mdx/types.d.ts(23,38): error TS2503: Cannot find namespace 'JSX'. ``` -Scaffolded projects set all three already, which is why these only surface when -adding Veryfront to a project you already have. +Scaffolded projects set all three already, which is why the first two only +surface when adding Veryfront to a project you already have. + +Without `@veryfront/ext-content-mdx` installed, `.mdx` and `.md` routes report a +missing `ContentProcessor` naming the package to install. Every other route +works. The compiled `veryfront` binary embeds MDX support and is unaffected. ## Add an entry route diff --git a/scripts/build/npm-package-metadata.test.ts b/scripts/build/npm-package-metadata.test.ts index fdd19dff93..760678c223 100644 --- a/scripts/build/npm-package-metadata.test.ts +++ b/scripts/build/npm-package-metadata.test.ts @@ -203,6 +203,74 @@ it("npm publish version bump pins first-party extension dependencies to the publ } }); +// @veryfront/ext-content-mdx is published as an optional *peer* of the root +// package. RC builds publish every package under a -rc.N version, so if the +// bump skips peerDependencies the root package ships pointing at a version that +// was never published and the optional peer can never be installed. +it("npm publish version bump pins first-party extension peers to the publish version", async () => { + const packageDir = await Deno.makeTempDir(); + const packagePath = `${packageDir}/package.json`; + const publishVersion = "0.1.1240-rc.7"; + + try { + await Deno.writeTextFile( + packagePath, + JSON.stringify( + { + name: "veryfront", + version: "0.1.1240", + dependencies: { + "@veryfront/ext-css-tailwind": "0.1.1240", + }, + peerDependencies: { + "@veryfront/ext-content-mdx": "0.1.1240", + "@huggingface/transformers": "^4.2.0", + react: "^19.0.0", + }, + peerDependenciesMeta: { + "@veryfront/ext-content-mdx": { optional: true }, + }, + }, + null, + 2, + ), + ); + + const output = await new Deno.Command("bash", { + args: [ + "-c", + [ + "set -euo pipefail", + 'source "$SCRIPT_PATH"', + 'VERSION="$PUBLISH_VERSION" update_package_version "$PACKAGE_DIR"', + ].join("\n"), + ], + env: { + PACKAGE_DIR: packageDir, + PUBLISH_VERSION: publishVersion, + SCRIPT_PATH: `${Deno.cwd()}/scripts/ci/publish-npm-packages.sh`, + }, + stderr: "piped", + stdout: "piped", + }).output(); + + assertEquals(output.code, 0, new TextDecoder().decode(output.stderr)); + + const pkg = JSON.parse(await Deno.readTextFile(packagePath)); + assertEquals(pkg.peerDependencies, { + "@veryfront/ext-content-mdx": publishVersion, + // Third-party optional peers keep their compatibility ranges. + "@huggingface/transformers": "^4.2.0", + react: "^19.0.0", + }); + assertEquals(pkg.peerDependenciesMeta, { + "@veryfront/ext-content-mdx": { optional: true }, + }); + } finally { + await Deno.remove(packageDir, { recursive: true }); + } +}); + it("npm publish orders extensions before the root package", async () => { const packageRoot = await Deno.makeTempDir(); @@ -394,6 +462,47 @@ describe("normalizeNpmPackageMetadata", () => { assertEquals(pkg.files, ["esm", "script", "bin", "README.md"]); }); + // veryfront@0.1.1239 listed @veryfront/ext-content-mdx under runtime + // `dependencies`, which drags @mdx-js/mdx -> @types/mdx@2.0.14 into every + // consumer's node_modules/@types. That file references the *global* JSX + // namespace, which @types/react@19 no longer declares, so `npm install + // veryfront` broke a previously-clean `tsc --noEmit` in projects that do not + // set skipLibCheck. Nothing in the four TS2503 errors names Veryfront. + // + // `optionalDependencies` does NOT fix this: npm installs optional + // dependencies by default and only tolerates their *failure*. Verified with + // npm 11.12.1 — a package.json whose sole entry is + // `optionalDependencies: { "@mdx-js/mdx": "3.1.1" }` still produces + // node_modules/@types/mdx and still fails `tsc --noEmit` with the same four + // errors. Only an optional peer keeps the package out of the tree, which is + // the mechanism ROOT_OPTIONAL_RUNTIME_PEERS already uses. + it("keeps the MDX content extension out of automatic npm installs", () => { + const pkg = normalizeNpmPackageMetadata({ + dependencies: { + "@veryfront/ext-bundler-esbuild": "0.1.1239", + "@veryfront/ext-content-mdx": "0.1.1239", + "@veryfront/ext-css-tailwind": "0.1.1239", + zod: "4.3.6", + }, + }); + + assertEquals(pkg.dependencies, { + "@veryfront/ext-bundler-esbuild": "0.1.1239", + "@veryfront/ext-css-tailwind": "0.1.1239", + zod: "4.3.6", + }); + // An optionalDependency would still be installed, so the move has to land + // on peerDependencies + peerDependenciesMeta.optional. + assertEquals(pkg.optionalDependencies, undefined); + assertEquals( + pkg.peerDependencies?.["@veryfront/ext-content-mdx"], + "0.1.1239", + ); + assertEquals(pkg.peerDependenciesMeta?.["@veryfront/ext-content-mdx"], { + optional: true, + }); + }); + it("keeps opt-in feature packages out of automatic npm installs", () => { const pkg = normalizeNpmPackageMetadata({ dependencies: { diff --git a/scripts/build/npm-package-metadata.ts b/scripts/build/npm-package-metadata.ts index 06d3bc5043..037d29e000 100644 --- a/scripts/build/npm-package-metadata.ts +++ b/scripts/build/npm-package-metadata.ts @@ -25,6 +25,33 @@ const ROOT_OPTIONAL_RUNTIME_PEER_FALLBACK_RANGES: Record = { "@huggingface/transformers": `^${OPAQUE_DEPENDENCY_VERSIONS["@huggingface/transformers"]}`, }; +/** + * CLI-only first-party extensions that must not land in a library consumer's + * dependency tree. + * + * @veryfront/ext-content-mdx drags @mdx-js/mdx -> @types/mdx@2.0.14, and that + * file references the *global* JSX namespace that @types/react@19 no longer + * declares. Because tsc auto-includes everything under node_modules/@types, + * `npm install veryfront` broke a previously-clean `tsc --noEmit` in any + * consumer project without skipLibCheck, naming a package the developer never + * imported. It is also roughly 45% of the installed tree. + * + * `optionalDependencies` is not the right home: npm installs optional + * dependencies by default and only tolerates their *installation failure*, so + * @types/mdx would still be there. An optional peer is the only declaration npm + * leaves uninstalled, which is why this reuses movePackageToOptionalPeer. + * + * The compiled binary is unaffected — scripts/build/compile-binary.ts embeds + * extensions/ext-content-mdx/src/index.ts as a compile-time include, with no + * reference to npm dependency metadata. For `npx veryfront dev`, MDX becomes + * opt-in: cli/shared/ensure-content-processor.ts tolerates the missing package + * so the server still starts, and the ContentProcessor compile path reports the + * actionable install message when an .mdx/.md file is actually rendered. + */ +export const ROOT_OPTIONAL_EXTENSION_PEERS = [ + "@veryfront/ext-content-mdx", +] as const; + export const EXTENSION_OWNED_DEPENDENCIES = [ "@aws-sdk/client-s3", "@aws-sdk/lib-storage", @@ -116,7 +143,7 @@ export function normalizeNpmPackageMetadata(pkg: PackageJson): PackageJson { pkg.files = pkg.files.filter((entry) => entry !== "src" && entry !== "/src"); } - for (const name of ROOT_OPTIONAL_RUNTIME_PEERS) { + for (const name of [...ROOT_OPTIONAL_RUNTIME_PEERS, ...ROOT_OPTIONAL_EXTENSION_PEERS]) { movePackageToOptionalPeer(pkg, name); } diff --git a/scripts/ci/publish-npm-packages.sh b/scripts/ci/publish-npm-packages.sh index dabef9d779..5a7a8bc0d0 100755 --- a/scripts/ci/publish-npm-packages.sh +++ b/scripts/ci/publish-npm-packages.sh @@ -66,10 +66,14 @@ package_names_from_workspace() { update_package_version() { PACKAGE_DIR="$1" + # CLI-only extensions ship as optional peers of the root package, so the + # first-party pin has to cover peerDependencies too. Missing it would publish + # an RC root pointing at a version that was never published, leaving the + # optional peer permanently uninstallable. jq --arg v "$VERSION" ' - def update_first_party_extension_deps: - if .dependencies then - .dependencies |= with_entries( + def update_first_party_extension_deps(section): + if .[section] then + .[section] |= with_entries( if (.key | startswith("@veryfront/ext-")) then .value = $v else . end ) else . end; @@ -77,7 +81,9 @@ update_package_version() { .version = $v | if .peerDependencies?.veryfront then .peerDependencies.veryfront = "^" + $v else . end | if .dependencies?.veryfront then .dependencies.veryfront = "^" + $v else . end - | update_first_party_extension_deps + | update_first_party_extension_deps("dependencies") + | update_first_party_extension_deps("optionalDependencies") + | update_first_party_extension_deps("peerDependencies") ' "${PACKAGE_DIR}/package.json" > "${PACKAGE_DIR}/package.json.tmp" mv "${PACKAGE_DIR}/package.json.tmp" "${PACKAGE_DIR}/package.json" } diff --git a/templates/index.test.ts b/templates/index.test.ts index e13ee064cb..13cfa96a5e 100644 --- a/templates/index.test.ts +++ b/templates/index.test.ts @@ -132,6 +132,26 @@ describe("templates", () => { ); }); + // @veryfront/ext-content-mdx is an optional peer of the npm package, so it is + // no longer installed by a plain `npm install veryfront`. A starter that ships + // a .mdx route has to ask for it, or `npx veryfront dev` serves every route + // except that one and reports a missing ContentProcessor for it. + it("installs the MDX content extension for every starter that ships an .mdx route", async () => { + for (const templateName of STARTER_TEMPLATE_NAMES) { + const files = await getTemplate(templateName); + assertExists(files); + if (!files.some((file) => file.path.endsWith(".mdx"))) continue; + + assertEquals( + templateConfigs[templateName]?.firstPartyExtensions?.includes( + "@veryfront/ext-content-mdx", + ), + true, + `${templateName} ships an .mdx route but does not install @veryfront/ext-content-mdx`, + ); + } + }); + it("does not make baseline framework extensions starter-specific", async () => { const files = await getTemplate("saas-starter"); assertExists(files); diff --git a/templates/index.ts b/templates/index.ts index eecc863ad7..331799a625 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -59,6 +59,13 @@ const CHAT_MARKDOWN_DEPENDENCIES: Record = { }; export const templateConfigs: Partial> = { + // `minimal` ships app/about/page.mdx. @veryfront/ext-content-mdx is an + // optional peer of the npm package (it drags @types/mdx, which breaks + // `tsc --noEmit` for every library consumer), so a starter that renders MDX + // has to install it or that one route 500s under `npx veryfront dev`. + minimal: { + firstPartyExtensions: ["@veryfront/ext-content-mdx"], + }, "ai-agent": { npmDependencies: { ...CHAT_MARKDOWN_DEPENDENCIES }, },