Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified tools/design-sync/mod.ts
Binary file not shown.
96 changes: 69 additions & 27 deletions tools/design-sync/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
useSignal: `export function useSignal<T>(initial: T) {
const [value, set] = React.useState(initial);
Expand Down Expand Up @@ -123,10 +127,26 @@ export interface ConvertOutput {
jsxMembers: Set<string>;
typeNames: Set<string>;
valueNames: Set<string>;
hookNames: Set<string>;
signalNames: Set<string>;
};
}

/** 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('');
}
Expand Down Expand Up @@ -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';`;
},
Expand Down Expand Up @@ -232,6 +266,7 @@ export function convertUnits(
jsxMembers: new Set(),
typeNames: new Set(),
valueNames: new Set(),
hookNames: new Set(),
signalNames: new Set(),
};

Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -341,10 +381,12 @@ function emitShims(pkgFiles: Map<string, string>, 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()
Expand Down
118 changes: 118 additions & 0 deletions tools/design-sync/src/convert_test.ts
Original file line number Diff line number Diff line change
@@ -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<T>/);
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/,
);
});