diff --git a/.gitattributes b/.gitattributes index 6f704c8db..821ff70f2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # (`/home/codex/repos`). Existing files on ext4 are already LF, so this normalizes # new commits only — no mass renormalization is triggered. *.md text eol=lf -*.ts text eol=lf +*.ts text eol=lf diff *.tsx text eol=lf *.js text eol=lf *.jsonc text eol=lf diff --git a/tools/design-sync/mod.ts b/tools/design-sync/mod.ts index 5e1b0fcb5..d22024bed 100644 Binary files a/tools/design-sync/mod.ts and b/tools/design-sync/mod.ts differ diff --git a/tools/design-sync/src/convert.ts b/tools/design-sync/src/convert.ts index dcaffca1d..a2f3f4116 100644 --- a/tools/design-sync/src/convert.ts +++ b/tools/design-sync/src/convert.ts @@ -2,9 +2,11 @@ * Converter: fresh-ui (Preact/Fresh) registry sources → a synthetic React * package the Claude Design canvas can execute. * - * The eis-chat recipe applies: Preact appears in registry components as - * type-only imports, so compiling the same source under a classic React JSX - * transform yields genuine React components. The converter therefore only: + * The eis-chat recipe still applies to the registry's component surface, + * which is mostly type-only Preact imports, but islands and interactive + * subpaths also use a finite value surface. Compiling the same source under + * a classic React JSX transform therefore needs React-backed equivalents for + * those values as well. The converter therefore only: * * 1. rewrites the three Preact specifiers to local shims * (`preact` → types + Fragment compat, `preact/hooks` → React hooks, @@ -26,27 +28,29 @@ import type { SyncConfig, } from './types.ts'; -const HOOK_NAMES = [ - 'useState', - 'useEffect', - 'useLayoutEffect', - 'useMemo', - 'useCallback', - 'useRef', - 'useContext', - 'useReducer', - 'useId', - 'useImperativeHandle', -]; - /** Value exports the preact compat shim knows how to map onto React. */ const PREACT_VALUE_COMPAT: Record = { Fragment: 'export const Fragment = React.Fragment;', + h: 'export const h = React.createElement;', createContext: 'export const createContext = React.createContext;', cloneElement: 'export const cloneElement = React.cloneElement;', toChildArray: 'export const toChildArray = React.Children.toArray;', }; +/** Preact hook value exports with direct React equivalents. */ +const HOOK_VALUE_COMPAT: Record = { + useCallback: 'export const useCallback = React.useCallback;', + useContext: 'export const useContext = React.useContext;', + useEffect: 'export const useEffect = React.useEffect;', + useId: 'export const useId = React.useId;', + useImperativeHandle: 'export const useImperativeHandle = React.useImperativeHandle;', + useLayoutEffect: 'export const useLayoutEffect = React.useLayoutEffect;', + useMemo: 'export const useMemo = React.useMemo;', + useReducer: 'export const useReducer = React.useReducer;', + useRef: 'export const useRef = React.useRef;', + useState: 'export const useState = React.useState;', +}; + const SIGNAL_IMPL: Record = { useSignal: `export function useSignal(initial: T) { const [value, set] = React.useState(initial); @@ -123,10 +127,26 @@ export interface ConvertOutput { jsxMembers: Set; typeNames: Set; valueNames: Set; + hookNames: Set; signalNames: Set; }; } +/** Raised when registry source cannot be represented by the React compatibility layer. */ +export class ConversionError extends Error { + constructor(readonly diagnostics: readonly string[]) { + super(`conversion errors:\n${diagnostics.map((error) => ` ! ${error}`).join('\n')}`); + this.name = 'ConversionError'; + } +} + +function importBinding(binding: string): { imported: string; typeOnly: boolean } { + const trimmed = binding.trim(); + const typeOnly = trimmed.startsWith('type '); + const withoutType = trimmed.replace(/^type\s+/, ''); + return { imported: withoutType.split(/\s+as\s+/)[0], typeOnly }; +} + function pascal(unitName: string): string { return unitName.split(/[-_]/).map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(''); } @@ -180,26 +200,40 @@ export function convertSource( const bindings = names.split(',').map((n) => n.trim()).filter(Boolean); if (spec === 'preact') { for (const b of bindings) { - const name = b.replace(/^type\s+/, ''); - if (typeOnly || b.startsWith('type ')) scan.typeNames.add(name); + const binding = importBinding(b); + if (typeOnly || binding.typeOnly) scan.typeNames.add(binding.imported); else { - if (!PREACT_VALUE_COMPAT[name]) { - errors.push(`unmapped preact value import "${name}" in ${pkgPath}`); + if (!PREACT_VALUE_COMPAT[binding.imported]) { + errors.push(`unmapped preact value import "${binding.imported}" in ${pkgPath}`); } - scan.valueNames.add(name); + scan.valueNames.add(binding.imported); } } return `import ${typeOnly ?? ''}{ ${names.trim()} } from '${ds}/preact-compat.ts';`; } if (spec === 'preact/hooks') { + for (const b of bindings) { + const binding = importBinding(b); + if (typeOnly || binding.typeOnly) continue; + if (!HOOK_VALUE_COMPAT[binding.imported]) { + errors.push( + `unmapped preact/hooks value import "${binding.imported}" in ${pkgPath}`, + ); + } + scan.hookNames.add(binding.imported); + } return `import { ${names.trim()} } from '${ds}/hooks.ts';`; } for (const b of bindings) { - const name = b.replace(/^type\s+/, ''); - if (!SIGNAL_IMPL[name] && !typeOnly) { - errors.push(`unmapped signal import "${name}" in ${pkgPath}`); + const binding = importBinding(b); + if (!typeOnly && !binding.typeOnly) { + if (!SIGNAL_IMPL[binding.imported]) { + errors.push( + `unmapped @preact/signals value import "${binding.imported}" in ${pkgPath}`, + ); + } } - scan.signalNames.add(name); + scan.signalNames.add(binding.imported); } return `import { ${names.trim()} } from '${ds}/signals.ts';`; }, @@ -232,6 +266,7 @@ export function convertUnits( jsxMembers: new Set(), typeNames: new Set(), valueNames: new Set(), + hookNames: new Set(), signalNames: new Set(), }; @@ -309,6 +344,11 @@ export function convertUnits( conversions.push(result); } + const diagnostics = conversions.flatMap((conversion) => + conversion.errors.map((error) => `${conversion.unit}: ${error}`) + ); + if (diagnostics.length) throw new ConversionError(diagnostics); + emitShims(pkgFiles, shims); return { conversions, pkgFiles, shims }; } @@ -341,10 +381,12 @@ function emitShims(pkgFiles: Map, shims: ConvertOutput['shims']) (valueLines.length ? `\n${valueLines.join('\n')}\n` : ''), ); + const hookLines = [...shims.hookNames].sort() + .map((name) => HOOK_VALUE_COMPAT[name]) + .filter((line): line is string => Boolean(line)); pkgFiles.set( '__ds/hooks.ts', - `import { React } from './react-scope.ts';\n\n` + - HOOK_NAMES.map((h) => `export const ${h} = React.${h};`).join('\n') + '\n', + `import { React } from './react-scope.ts';\n\n${hookLines.join('\n')}\n`, ); const signalBodies = [...shims.signalNames].sort() diff --git a/tools/design-sync/src/convert_test.ts b/tools/design-sync/src/convert_test.ts new file mode 100644 index 000000000..f678a490f --- /dev/null +++ b/tools/design-sync/src/convert_test.ts @@ -0,0 +1,118 @@ +import { assert, assertEquals, assertMatch, assertThrows } from '@std/assert'; +import { buildBundleJs } from './bundle.ts'; +import { ConversionError, convertUnits } from './convert.ts'; +import type { RegistryUnit, SyncConfig } from './types.ts'; + +const CONFIG: SyncConfig = { + projectId: 'test-project', + projectName: 'Test project', + pkg: '@netscript/test-design-sync', + globalName: 'TestDesignSync', + shape: 'package', + registry: { root: 'packages/fresh-ui', manifest: 'registry.manifest.ts' }, + scratchDir: '.llm/tmp/design-sync-test', + fontImport: '', + exclude: [], + subpaths: {}, + groups: { island: 'islands' }, + react: { version: '19.2.0', domVersion: '19.2.0' }, + repoRoot: '/tmp/design-sync-test', + configPath: '/tmp/design-sync-test/config.json', +}; + +function registryUnit(name: string, pkgPath: string, content: string): RegistryUnit { + return { + item: { + name, + kind: 'island', + description: 'Converter test fixture.', + copyOwnership: 'app-owned-after-copy', + tags: ['test'], + files: [{ source: `registry/${pkgPath}`, target: `@${pkgPath}` }], + }, + sources: [{ registryPath: `registry/${pkgPath}`, pkgPath, content }], + }; +} + +Deno.test({ + name: 'converted Preact values resolve against the generated React compatibility shims', + permissions: { read: true, run: true, write: true }, +}, async () => { + const unit = registryUnit( + 'value-island', + 'islands/ValueIsland.tsx', + `import { createContext, h } from 'preact'; +import { useCallback, useContext, useEffect, useId, useRef, useState } from 'preact/hooks'; +import { useSignal } from '@preact/signals'; + +const Context = createContext('ready'); + +export function ValueIsland() { + const signal = useSignal(0); + const context = useContext(Context); + const id = useId(); + const ref = useRef(null); + const [state] = useState(context); + const callback = useCallback(() => signal.value, [signal]); + useEffect(callback, [callback]); + return h('div', { id, ref }, state); +} +`, + ); + + const output = convertUnits(CONFIG, [unit]); + const converted = output.pkgFiles.get('islands/ValueIsland.tsx'); + const preactCompat = output.pkgFiles.get('__ds/preact-compat.ts'); + const hooksCompat = output.pkgFiles.get('__ds/hooks.ts'); + const signalsCompat = output.pkgFiles.get('__ds/signals.ts'); + + assert(converted); + assert(preactCompat); + assert(hooksCompat); + assert(signalsCompat); + assertMatch(converted, /from '\.\.\/__ds\/preact-compat\.ts'/); + assertMatch(converted, /from '\.\.\/__ds\/hooks\.ts'/); + assertMatch(converted, /from '\.\.\/__ds\/signals\.ts'/); + assertMatch(preactCompat, /export const h = React\.createElement;/); + assertMatch(preactCompat, /export const createContext = React\.createContext;/); + for ( + const hook of ['useCallback', 'useContext', 'useEffect', 'useId', 'useRef', 'useState'] + ) { + assertMatch(hooksCompat, new RegExp(`export const ${hook} = React\\.${hook};`)); + } + assertMatch(signalsCompat, /export function useSignal/); + assertEquals(output.conversions[0].errors, []); + + const bundleConfig: SyncConfig = { + ...CONFIG, + repoRoot: Deno.cwd(), + scratchDir: `.llm/tmp/design-sync-convert-test-${crypto.randomUUID()}`, + }; + const scratchRoot = `${bundleConfig.repoRoot}/${bundleConfig.scratchDir}`; + try { + const bundle = await buildBundleJs(bundleConfig, output.pkgFiles, output.conversions); + assert(bundle.bundleJs.length > 0); + } finally { + await Deno.remove(scratchRoot, { recursive: true }).catch(() => undefined); + } +}); + +Deno.test('unmapped Preact values fail conversion with unit, file, and symbol', () => { + const unit = registryUnit( + 'future-island', + 'islands/FutureIsland.tsx', + `import { futureValue } from 'preact'; + +export function FutureIsland() { + return futureValue('div'); +} +`, + ); + + const error = assertThrows(() => convertUnits(CONFIG, [unit]), ConversionError); + assertMatch(error.message, /^conversion errors:/); + assertMatch( + error.message, + /future-island: unmapped preact value import "futureValue" in islands\/FutureIsland\.tsx/, + ); +});