213 lines
10 KiB
JavaScript
213 lines
10 KiB
JavaScript
// Non-storybook `package` adapter. Bundles dist/ when present (the authoritative
|
|
// component list comes from shipped .d.ts; with no dist it synthesizes an
|
|
// entry from src/ as a last resort) and opportunistically enriches each
|
|
// component from src/ — JSDoc and dir-derived group. Every enrichment miss
|
|
// degrades to the plain-dist behaviour.
|
|
//
|
|
// Discovery is heuristic-based; each heuristic has a `.design-sync/config.json`
|
|
// override (ASSUMPTION comments below name them) so repos that don't match the
|
|
// defaults write config, not code. `componentSrcMap` is the single override
|
|
// knob for component inclusion: non-null value = add/pin src path, null =
|
|
// exclude a .d.ts-exported internal.
|
|
|
|
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
import { Project, Node, ts } from 'ts-morph';
|
|
// forked from design-sync lib/source-kit.mjs — synth-entry process shim (Next.js app reads process.env.NEXT_PUBLIC_*)
|
|
import { leadingJsdoc, readText, slash, walk } from '../../.ds-sync/lib/common.mjs';
|
|
import { resolveDistEntry } from '../../.ds-sync/lib/bundle.mjs';
|
|
import { exportedNames, isComponentName } from '../../.ds-sync/lib/dts.mjs';
|
|
|
|
const NON_IMPL_RX = /\.(stories|test|spec)\./;
|
|
const SRC_IMPL_RX = /\.(tsx|jsx)$/;
|
|
// Dir names that don't usefully group components — skip so the emitted path
|
|
// is `components/<group>/<Name>` not `components/components/<Name>`.
|
|
const GENERIC_DIR = new Set(['components', 'component', 'src', 'lib', 'ui', 'packages', 'react']);
|
|
const slug = (s) => s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'general';
|
|
|
|
// No .d.ts → scan src files for PascalCase value exports via ts-morph.
|
|
function deriveComponentsFromSrc(srcFiles) {
|
|
const project = new Project({
|
|
skipAddingFilesFromTsConfig: true,
|
|
compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true, skipLibCheck: true },
|
|
});
|
|
const seen = new Set();
|
|
for (const p of srcFiles) {
|
|
if (NON_IMPL_RX.test(p) || !SRC_IMPL_RX.test(p)) continue;
|
|
const sf = project.addSourceFileAtPathIfExists(p);
|
|
if (!sf) continue;
|
|
for (const [name, decls] of sf.getExportedDeclarations()) {
|
|
// `export default function Button()` is keyed as 'default' — recover
|
|
// the declared name from the function/class node.
|
|
const real = name === 'default'
|
|
? decls.map((d) => d.getName?.()).find((n) => n && n !== 'default')
|
|
: name;
|
|
if (!real || !/^[A-Z][A-Za-z0-9]*$/.test(real)) continue;
|
|
if (decls.some((d) => Node.isVariableDeclaration(d) || Node.isFunctionDeclaration(d) || Node.isClassDeclaration(d))) {
|
|
seen.add(real);
|
|
}
|
|
}
|
|
}
|
|
return [...seen].sort().map((name) => ({ name, group: 'general' }));
|
|
}
|
|
|
|
export async function resolvePackage(ctx) {
|
|
const { PKG_DIR, pkgJson, ENTRY_OVERRIDE, PKG, OUT, cfg } = ctx;
|
|
const srcMap = cfg.componentSrcMap ?? {};
|
|
|
|
// ── 1. src/ discovery (best-effort; feeds enrichment + synth-entry fallback).
|
|
// ASSUMPTION: source root is first of src/ | lib/ | components/. Override: cfg.srcDir.
|
|
const srcRoot = [cfg.srcDir, 'src', 'lib', 'components']
|
|
.map((d) => d && resolve(PKG_DIR, d))
|
|
.find((d) => d && existsSync(d));
|
|
const srcFiles = srcRoot ? walk(srcRoot, (n) => /\.(tsx|jsx|mdx?)$/.test(n)) : [];
|
|
|
|
// ── 2. entry: dist if it exists, else synthesize from src/ (last resort).
|
|
let entry = resolveDistEntry({ pkgDir: PKG_DIR, pkgJson, override: ENTRY_OVERRIDE, pkgName: PKG, soft: true });
|
|
let synthEntry = false;
|
|
if (!entry) {
|
|
if (!srcRoot) {
|
|
console.error(`[NO_DIST] ${PKG} has no built entry and no src/ to synthesize from — run its build.`);
|
|
process.exit(1);
|
|
}
|
|
// Next route files (app/**/layout.tsx, page.tsx) that call `next/font`
|
|
// loaders (Manrope(), IBM_Plex_Mono()) at module top-level crash the browser
|
|
// IIFE: esbuild can't resolve the Next-only loader → stubs it to `undefined`
|
|
// → `undefined()` aborts the whole bundle → window.<global> stays empty and
|
|
// every component vanishes. These files are never DS components anyway, so
|
|
// drop any `next/font`-importing module from the synth-entry set.
|
|
const comps = srcFiles.filter(
|
|
(p) =>
|
|
SRC_IMPL_RX.test(p) &&
|
|
!NON_IMPL_RX.test(p) &&
|
|
!/from\s+['"]next\/font/.test(readFileSync(p, 'utf8')),
|
|
);
|
|
// Next.js app code reads process.env.NEXT_PUBLIC_* at module top-level; in
|
|
// the browser IIFE `process` is undefined → every component throws. Emit a
|
|
// shim module and import it FIRST (ESM evaluates the first import's body
|
|
// before the re-exported component modules), so process exists before any
|
|
// component body runs.
|
|
const shim = resolve(OUT, '.pkg-shim.mjs');
|
|
writeFileSync(
|
|
shim,
|
|
'globalThis.process ??= { env: {} };\n' +
|
|
'globalThis.process.env ??= {};\n' +
|
|
'const __e = globalThis.process.env;\n' +
|
|
"__e.NODE_ENV ??= 'development';\n" +
|
|
"__e.NEXT_PUBLIC_ENABLE_PREVIEW ??= '1';\n" +
|
|
"__e.NEXT_PUBLIC_BASE_PATH ??= '';\n" +
|
|
"__e.NEXT_PUBLIC_API_BASE_URL ??= '';\n" +
|
|
"__e.NEXT_PUBLIC_TRADEIN_CONTACT_EMAIL ??= 'trade-in@example.com';\n",
|
|
);
|
|
entry = join(OUT, '.pkg-entry.mjs');
|
|
// `export *` does NOT re-export a module's default. Components authored as
|
|
// `export default function <Name>` (the v2 views/nav/overlay/panel) would be
|
|
// absent from window.<global> → [BUNDLE_EXPORT] "not a component". Re-export
|
|
// each named default under its declared name so default-exported components
|
|
// reach the global alongside the named ones.
|
|
const defaultReexports = comps
|
|
.map((p) => {
|
|
const m = readFileSync(p, 'utf8').match(
|
|
/export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9]*)/,
|
|
);
|
|
return m ? `export { default as ${m[1]} } from ${JSON.stringify(p)};` : null;
|
|
})
|
|
.filter(Boolean);
|
|
writeFileSync(
|
|
entry,
|
|
`import ${JSON.stringify(slash(shim))};\n` +
|
|
comps.map((p) => `export * from ${JSON.stringify(p)};`).join('\n') +
|
|
'\n' +
|
|
defaultReexports.join('\n') +
|
|
'\n',
|
|
);
|
|
synthEntry = true;
|
|
console.error(
|
|
`[NO_DIST] no built entry — synthesizing from ${comps.length} src files (run the package's build for best results)`,
|
|
);
|
|
}
|
|
|
|
// ── 3. component list: from shipped .d.ts (authoritative when dist exists).
|
|
// ASSUMPTION: components = PascalCase value exports in the .d.ts tree.
|
|
// Override: cfg.componentSrcMap (non-null adds/pins, null excludes).
|
|
const exported = exportedNames(PKG_DIR, pkgJson);
|
|
const names = new Set([...exported].filter(isComponentName));
|
|
for (const [k, v] of Object.entries(srcMap)) {
|
|
if (v === null) { names.delete(k); continue; }
|
|
// Names reach `<script>` blocks in the emitted HTML — reject anything
|
|
// that isn't a plain PascalCase identifier.
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(k)) {
|
|
console.error(`[CONFIG] componentSrcMap: "${k}" is not a valid component name (PascalCase identifiers only)`);
|
|
continue;
|
|
}
|
|
names.add(k);
|
|
}
|
|
let components = [...names].sort().map((name) => ({ name, group: 'general' }));
|
|
if (synthEntry) {
|
|
// Synth mode has no shipped .d.ts → `names` holds only componentSrcMap
|
|
// pins. A non-null pin must AUGMENT src discovery (it exists to pin
|
|
// enrichment to a specific file), not REPLACE it — with the old
|
|
// `!components.length` guard a single pin (ResultPanel, 2026-07-02)
|
|
// collapsed the whole synth bundle to just the pinned component.
|
|
const derived = deriveComponentsFromSrc(srcFiles).filter((c) => srcMap[c.name] !== null);
|
|
const have = new Set(components.map((c) => c.name));
|
|
components = components
|
|
.concat(derived.filter((c) => !have.has(c.name)))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
if (!components.length) {
|
|
if (cfg.cssEntry || existsSync(join(PKG_DIR, 'styles.css'))) {
|
|
console.error('[ZERO_MATCH] no component exports — treating as tokens-only DS');
|
|
return { shape: 'package', entry, components: [], tokensOnly: true };
|
|
}
|
|
console.error(`[ZERO_MATCH] no PascalCase exports in ${PKG} and no styles — nothing to sync`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 4. src/ enrichment per component. Every miss degrades to plain-dist.
|
|
if (srcRoot) {
|
|
for (const c of components) {
|
|
// Pinned via config → skip fuzzy-find entirely.
|
|
let hit = typeof srcMap[c.name] === 'string' ? slash(resolve(PKG_DIR, srcMap[c.name])) : null;
|
|
if (!hit) {
|
|
// ASSUMPTION: <Name>.tsx | <name>/<name>.tsx | <Name>/index.tsx |
|
|
// <kebab-name>.tsx, case-insensitive; dir-match ranks above
|
|
// bare-file match, then prefer one that actually exports `c.name`.
|
|
// Override: cfg.componentSrcMap.
|
|
const kebab = c.name.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
|
|
const nameRx = new RegExp(
|
|
`(?:^|/)(?:${c.name}/(?:index|${c.name})\\.(tsx|jsx)|(?:${c.name}|${kebab})\\.(tsx|jsx))$`,
|
|
'i',
|
|
);
|
|
const hits = srcFiles
|
|
.filter((p) => nameRx.test(p) && !NON_IMPL_RX.test(p))
|
|
.sort(
|
|
(a, b) =>
|
|
(b.toLowerCase().includes(`/${c.name.toLowerCase()}/`) ? 1 : 0) -
|
|
(a.toLowerCase().includes(`/${c.name.toLowerCase()}/`) ? 1 : 0),
|
|
);
|
|
const exportRx = new RegExp(`export\\s+(?:default\\s+)?(?:const|let|var|function|class)\\s+${c.name}\\b`);
|
|
hit = hits.find((p) => exportRx.test(readText(p))) ?? hits[0];
|
|
}
|
|
if (!hit || !existsSync(hit)) continue;
|
|
c.srcPath = hit;
|
|
c.doc = leadingJsdoc(readText(hit), c.name) || undefined;
|
|
// group = last src/ path segment that isn't the component's own dir or
|
|
// a generic container name — else JSDoc @category — else 'general'.
|
|
c.group = slug(
|
|
slash(relative(srcRoot, dirname(hit)))
|
|
.split('/')
|
|
.filter((s) => s && s.toLowerCase() !== c.name.toLowerCase() && !GENERIC_DIR.has(s.toLowerCase()))
|
|
.at(-1)
|
|
|| (c.doc && /@category\s+(\S+)/.exec(c.doc)?.[1])
|
|
|| 'general',
|
|
);
|
|
}
|
|
}
|
|
|
|
console.error(
|
|
` package: ${components.length} components` +
|
|
(srcRoot ? ` (${components.filter((c) => c.srcPath).length} src-matched)` : ' (no src/ — dist-only)'),
|
|
);
|
|
return { shape: 'package', entry, components, synthEntry, exported };
|
|
}
|