From 954c28f12900338c46b979b18effd8633e558f9d Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 14 Aug 2026 13:08:32 +0700 Subject: [PATCH] fix(library): derive the manifest instead of asking for it twice `layouts.library.json` required an `exports` array naming every public component. That list is the same information as the package's own entry files, written a second time by hand, so it could only ever drift from them. It did, and silently. A library renamed eleven components across its source, its recipes and its barrel, rebuilt from a wiped `dist`, and the manifest still carried the old names, because nothing had told the list. The application compiler validates against that manifest, so consumers would have been told the new names were not components while the old ones, which no longer existed, resolved fine. The same list had also never heard about two components added that morning. The names now come from the package's own `exports` map: each target under the output directory maps back to its source entry, and the exported value names are read from there. Source rather than built output, because the manifest is emitted alongside a build rather than after one. Types are excluded, because a type is not a component, and SCREAMING_SNAKE is excluded, because a package that exports `FLAVORS` beside `Flavor` is exporting a value rather than a component. On the library this was found in: 173 hand-declared, 187 derived, every declared name present and the fourteen extras all genuinely exported. --- packages/solid-layouts-oxc/library.js | 79 ++++++++++++++++++++-- packages/solid-layouts-oxc/library.test.js | 40 ++++++++++- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/solid-layouts-oxc/library.js b/packages/solid-layouts-oxc/library.js index 053007f..58cdcd9 100644 --- a/packages/solid-layouts-oxc/library.js +++ b/packages/solid-layouts-oxc/library.js @@ -225,9 +225,6 @@ function generateLibrarySource(options = {}) { if (config.mode !== "source") { throw new Error(`${configPath || root} must set mode to "source" for adjacent generation`); } - if (!Array.isArray(config.exports) || config.exports.length === 0) { - throw new Error(`${configPath} must declare its public Layout exports`); - } const lint = lintLibrary({ ...options, root }); if (lint.failed) { throw new Error(lint.diagnostics.map((item) => @@ -251,14 +248,82 @@ function generateLibrarySource(options = {}) { return { ...lint, changed }; } +/** + * Every value a consumer can import from this package. + * + * Derived rather than declared. This used to be an `exports` array in + * `layouts.library.json`, which is the same list as the package's own entry + * files and can therefore only ever drift from them. It did: a library renamed + * eleven components, rebuilt, and the manifest still carried the old names, so + * the application compiler rejected the new ones and accepted names that no + * longer existed. Nothing reported it, because nothing compared the two. + * + * Read from source rather than from the built output, because the manifest is + * emitted alongside a build rather than after one, and a bundler that has not + * run yet leaves nothing to parse. + */ +function publicComponentNames(root, config) { + const packageJson = readJson(resolve(root, "package.json")); + const outputDir = config.output || "dist"; + const targets = new Set(); + const collect = (value) => { + if (typeof value === "string") return targets.add(value); + if (value && typeof value === "object") { + for (const nested of Object.values(value)) collect(nested); + } + }; + collect(packageJson.exports ?? {}); + for (const field of ["module", "main", "types"]) { + if (typeof packageJson[field] === "string") targets.add(packageJson[field]); + } + + const names = new Set(); + const seen = new Set(); + for (const target of targets) { + const relativeTarget = target.replace(/^\.\//, ""); + if (!relativeTarget.startsWith(`${outputDir}/`)) continue; + const stem = relativeTarget.slice(outputDir.length + 1).replace(/\.(js|mjs|cjs|d\.ts)$/, ""); + // The built entry maps back to the source file of the same name. A package + // laid out differently still works: the built file is the fallback. + const candidates = [ + resolve(root, "src", `${stem}.ts`), + resolve(root, "src", `${stem}.tsx`), + resolve(root, relativeTarget), + ]; + const entry = candidates.find((path) => existsSync(path)); + if (!entry || seen.has(entry)) continue; + seen.add(entry); + for (const name of namedExportsOf(readFileSync(entry, "utf8"))) names.add(name); + } + return [...names].sort(); +} + +/** Exported value names, ignoring types: a type is not a component. */ +function namedExportsOf(source) { + const names = new Set(); + for (const match of source.matchAll(/export\s*\{([^}]+)\}/g)) { + const isTypeOnly = /export\s*type\s*\{$/.test(source.slice(0, match.index + match[0].indexOf("{") + 1).trimEnd()); + for (const value of match[1].split(",")) { + const item = value.trim(); + if (!item || item.startsWith("type ")) continue; + const exported = item.split(/\s+as\s+/).at(-1)?.trim(); + if (!exported || isTypeOnly) continue; + // PascalCase only. SCREAMING_SNAKE is a constant, and a package that + // exports `FLAVORS` beside `Flavor` is exporting a value, not a component. + if (/^[A-Z]/.test(exported) && !/^[A-Z0-9_]+$/.test(exported)) names.add(exported); + } + } + return names; +} + function emitSourceManifest(options = {}) { const root = resolve(options.root || process.cwd()); const { configPath, config } = readLibraryConfig(root, options); if (config.mode !== "source") throw new Error(`${configPath || root} is not a source library config`); - const exports = config.exports || []; - if (exports.length === 0) throw new Error(`${configPath} must declare its public Layout exports`); - const duplicates = exports.filter((name, index) => exports.indexOf(name) !== index); - if (duplicates.length) throw new Error(`${configPath} repeats Layout export ${duplicates[0]}`); + const exports = publicComponentNames(root, config); + if (exports.length === 0) { + throw new Error(`${root}: no public component exports found through package.json`); + } const sourcePackage = readJson(resolve(root, "package.json")); const outputRoot = resolve(root, config.output || "dist"); const components = Object.fromEntries( diff --git a/packages/solid-layouts-oxc/library.test.js b/packages/solid-layouts-oxc/library.test.js index f298297..bfa5bba 100644 --- a/packages/solid-layouts-oxc/library.test.js +++ b/packages/solid-layouts-oxc/library.test.js @@ -3,6 +3,7 @@ const { afterEach, expect, test } = require("bun:test"); const { cpSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -10,7 +11,9 @@ const { } = require("node:fs"); const { tmpdir } = require("node:os"); const { join, resolve } = require("node:path"); -const { compileLibrary, lintLibrary } = require("./library.js"); +const { compileLibrary, lintLibrary , + emitSourceManifest, +} = require("./library.js"); const temporary = []; @@ -136,3 +139,38 @@ test("accepts a formatted multiline Layout annotation", () => { expect(() => compileLibrary({ root })).not.toThrow(); }); + +test("the manifest is derived from the package's own entries, not a declared list", () => { + // The list used to live in `layouts.library.json` as an `exports` array, which + // is the same information as the package's entry files and could only drift + // from them. It did: a library renamed eleven components and the manifest kept + // the old names, so the application compiler rejected every new one and + // accepted names that no longer existed. + const root = mkdtempSync(join(tmpdir(), "layouts-manifest-")); + mkdirSync(join(root, "src/components/alert"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "@scope/kit", version: "1.0.0", exports: { ".": "./dist/index.js" } }), + ); + writeFileSync( + join(root, "layouts.library.json"), + JSON.stringify({ mode: "source", source: "src/components", output: "dist" }), + ); + writeFileSync( + join(root, "src/index.ts"), + [ + 'export { Alert } from "./components/alert";', + 'export type { AlertProps } from "./components/alert";', + 'export { FLAVORS } from "./constants";', + ].join("\n"), + ); + + const { manifest } = emitSourceManifest({ root }); + const names = Object.keys(manifest.components); + + expect(names).toContain("Alert"); + // A type is not a component. + expect(names).not.toContain("AlertProps"); + // Neither is a constant. + expect(names).not.toContain("FLAVORS"); +});