tscircuit/ti
ti tscircuit library
- Version
- 1.0.195
- License
- unset
- Stars
- 0
system-block-ui/src/model/generate-tsx.ts
import { SUBCIRCUIT_CATALOG } from "./catalog";
import { resolveDesignConnections } from "./resolve-connection";
import { renderSystemDiagramSvg } from "./system-diagram-svg";
import type {
BlockInstance,
GenerateTsxRequest,
LogicalConnection,
ResolvedConnection,
ResolvedTrace,
SubcircuitDefinition,
} from "./types";
import { ConnectionResolutionError } from "./types";
const sanitizeInstanceName = (value: string): string => {
const sanitized = value
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "_")
.replace(/^[-\d]+/, "");
return sanitized || "block";
};
const quote = (value: string): string => JSON.stringify(value);
const compareStrings = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const sanitizeJsxCommentText = (value: string): string => {
const sanitized = value
.replace(/[\u0000-\u001F\u007F-\u009F]/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/[^A-Za-z0-9._+ -]+/g, "_")
.slice(0, 80)
.trim();
return sanitized || "automatic";
};
const prefixSelector = (blockName: string, selector: string): string => {
const relative = selector.trim().replace(/^>\s*/, "");
// Nested subcircuits use a descendant selector between component levels,
// for example `.U4Sensor .U4 > .SCL`. Adding a child combinator before
// that path makes tscircuit search for a non-existent combined component.
const separator = /^\.[^>\s]+\s+\./.test(relative) ? " " : " > ";
return `.${blockName}${separator}${relative}`;
};
const renderImport = (
packageName: string,
componentNames: readonly string[],
): readonly string[] => {
if (componentNames.length === 1) {
return [`import { ${componentNames[0]} } from ${quote(packageName)}`];
}
return [
"import {",
...componentNames.map((name) => ` ${name},`),
`} from ${quote(packageName)}`,
];
};
const renderTrace = (
trace: ResolvedTrace,
instanceNameByBlockId: ReadonlyMap<string, string>,
): readonly string[] => {
const fromName = instanceNameByBlockId.get(trace.fromBlockId);
const toName = instanceNameByBlockId.get(trace.toBlockId);
if (!fromName || !toName) {
throw new ConnectionResolutionError(
"UNKNOWN_BLOCK",
"A resolved trace references a missing block instance.",
{ fromBlockId: trace.fromBlockId, toBlockId: trace.toBlockId },
);
}
return [
" <trace",
` from=${quote(prefixSelector(fromName, trace.fromSelector))}`,
` to=${quote(prefixSelector(toName, trace.toSelector))}`,
" />",
];
};
interface PreparedBlock {
block: BlockInstance;
definition: SubcircuitDefinition;
instanceName: string;
sheetName: string;
}
const prepareBlocks = (
blocks: readonly BlockInstance[],
catalog: readonly SubcircuitDefinition[],
): readonly PreparedBlock[] => {
const definitionById = new Map(catalog.map((item) => [item.id, item]));
const prepared = blocks.map((block) => {
const definition = definitionById.get(block.definitionId);
if (!definition) {
throw new ConnectionResolutionError(
"UNKNOWN_SUBCIRCUIT",
`Block ${block.id} references unknown subcircuit ${block.definitionId}.`,
{ blockId: block.id, definitionId: block.definitionId },
);
}
if (definition.canInstantiate === false) {
throw new Error(
`${definition.title} cannot be safely instantiated by generated TSX. ${definition.warning ?? ""}`.trim(),
);
}
for (const [coordinate, value] of [
["schX", block.schX],
["schY", block.schY],
] as const) {
if (value !== undefined && !Number.isFinite(value)) {
throw new Error(
`Block ${block.id} has an invalid ${coordinate} coordinate.`,
);
}
}
const instanceName = sanitizeInstanceName(block.name ?? block.id);
return {
block,
definition,
instanceName,
sheetName: sanitizeInstanceName(block.schSheetName ?? instanceName),
};
});
const names = new Set<string>();
const sheetNames = new Set<string>();
for (const item of prepared) {
if (names.has(item.instanceName)) {
throw new Error(`Duplicate generated block name: ${item.instanceName}`);
}
if (sheetNames.has(item.sheetName)) {
throw new Error(
`Duplicate generated schematic sheet name: ${item.sheetName}`,
);
}
names.add(item.instanceName);
sheetNames.add(item.sheetName);
}
return prepared.sort(
(a, b) =>
compareStrings(a.instanceName, b.instanceName) ||
compareStrings(a.block.id, b.block.id),
);
};
export const SYSTEM_DIAGRAM_DISPLAY_NAME = "System Diagram";
export const SYSTEM_DIAGRAM_SHEET_NAME_BASE = "system_diagram";
export const SYSTEM_DIAGRAM_MODULE_BASENAME = "GeneratedSystem.system-diagram";
export const SYSTEM_DIAGRAM_MODULE_FILENAME = `${SYSTEM_DIAGRAM_MODULE_BASENAME}.ts`;
export const SYSTEM_DIAGRAM_MODULE_IMPORT_PATH = `./${SYSTEM_DIAGRAM_MODULE_BASENAME}`;
export interface GeneratedSystemDesignArtifacts {
/** Canonical source shown, exported, and evaluated by the preview. */
tsx: string;
/** Deterministic virtual/download path for the generated SVG module. */
systemDiagramModuleFileName: string;
/** Companion module imported by `tsx` and supplied during evaluation. */
systemDiagramModuleSource: string;
systemDiagramSvg: string;
systemDiagramSheetName: string;
}
const getSystemDiagramSheetName = (
prepared: readonly PreparedBlock[],
): string => {
const detailSheetNames = new Set(prepared.map(({ sheetName }) => sheetName));
let sheetName = SYSTEM_DIAGRAM_SHEET_NAME_BASE;
let suffix = 2;
while (detailSheetNames.has(sheetName)) {
sheetName = `${SYSTEM_DIAGRAM_SHEET_NAME_BASE}_${suffix}`;
suffix += 1;
}
return sheetName;
};
const orderLogicalConnections = (
connections: readonly LogicalConnection[],
): readonly LogicalConnection[] =>
[...connections].sort(
(a, b) =>
compareStrings(a.id, b.id) ||
compareStrings(a.fromBlockId, b.fromBlockId) ||
compareStrings(a.toBlockId, b.toBlockId) ||
compareStrings(a.kind, b.kind) ||
compareStrings(a.protocol ?? "", b.protocol ?? ""),
);
const renderSystemDiagramModule = (systemDiagramSvg: string): string =>
[
"export const SYSTEM_DIAGRAM_SVG = [",
...systemDiagramSvg.split("\n").map((line) => ` ${quote(line)},`),
'].join("\\n")',
"",
].join("\n");
const renderSystemDiagramImport = (): string =>
`import { SYSTEM_DIAGRAM_SVG } from ${quote(SYSTEM_DIAGRAM_MODULE_IMPORT_PATH)}`;
interface RenderGeneratedSourceRequest {
request: GenerateTsxRequest;
prepared: readonly PreparedBlock[];
resolvedConnections: readonly ResolvedConnection[];
systemDiagramSheetName: string;
}
const renderGeneratedSource = ({
request,
prepared,
resolvedConnections,
systemDiagramSheetName,
}: RenderGeneratedSourceRequest): string => {
const instanceNameByBlockId = new Map(
prepared.map((item) => [item.block.id, item.instanceName]),
);
const packageByComponent = new Map<string, Set<string>>();
for (const item of prepared) {
const packageName = request.packageName ?? item.definition.importPath;
const names = packageByComponent.get(packageName) ?? new Set<string>();
names.add(item.definition.componentName);
packageByComponent.set(packageName, names);
}
const lines: string[] = [];
for (const [packageName, components] of [...packageByComponent].sort(
([a], [b]) => compareStrings(a, b),
)) {
lines.push(
...renderImport(packageName, [...components].sort(compareStrings)),
);
}
lines.push(
renderSystemDiagramImport(),
'import "tscircuit"',
"",
"export default () => (",
);
lines.push(
request.boardName
? ` <board name=${quote(sanitizeInstanceName(request.boardName))} routingDisabled>`
: " <board routingDisabled>",
);
lines.push(
" <schematicsheet",
` name=${quote(systemDiagramSheetName)}`,
` displayName=${quote(SYSTEM_DIAGRAM_DISPLAY_NAME)}`,
" sheetIndex={0}",
" >",
" <schematicgraphic svgContent={SYSTEM_DIAGRAM_SVG} />",
" </schematicsheet>",
);
prepared.forEach((item, sheetIndex) => {
lines.push(
" <schematicsheet",
` name=${quote(item.sheetName)}`,
` displayName=${quote(item.definition.title)}`,
` sheetIndex={${sheetIndex + 1}}`,
" />",
);
});
lines.push("");
for (const item of prepared) {
lines.push(
` <${item.definition.componentName}`,
` name=${quote(item.instanceName)}`,
` schSheetName=${quote(item.sheetName)}`,
...(item.block.schX === undefined
? []
: [` schX={${item.block.schX}}`]),
...(item.block.schY === undefined
? []
: [` schY={${item.block.schY}}`]),
" />",
);
}
if (resolvedConnections.length > 0) lines.push("");
for (const connection of resolvedConnections) {
const protocolComment = sanitizeJsxCommentText(
connection.protocol ?? "automatic",
);
lines.push(
` {/* ${connection.kind === "power" ? "Power" : "Data"}: ${protocolComment} */}`,
);
for (const trace of connection.traces) {
lines.push(...renderTrace(trace, instanceNameByBlockId));
}
}
lines.push(" </board>", ")", "");
return lines.join("\n");
};
/** Generate canonical TSX and its companion system-diagram module. */
export const generateSystemDesignArtifacts = (
request: GenerateTsxRequest,
): GeneratedSystemDesignArtifacts => {
const catalog = request.catalog ?? SUBCIRCUIT_CATALOG;
const prepared = prepareBlocks(request.blocks, catalog);
const orderedConnections = orderLogicalConnections(request.connections);
const resolvedConnections = resolveDesignConnections(
request.blocks,
orderedConnections,
catalog,
);
const systemDiagramSheetName = getSystemDiagramSheetName(prepared);
const systemDiagramSvg = renderSystemDiagramSvg({
blocks: request.blocks,
resolvedConnections,
catalog,
title: SYSTEM_DIAGRAM_DISPLAY_NAME,
});
const sourceRequest = {
request,
prepared,
resolvedConnections,
systemDiagramSheetName,
};
return {
tsx: renderGeneratedSource(sourceRequest),
systemDiagramModuleFileName: SYSTEM_DIAGRAM_MODULE_FILENAME,
systemDiagramModuleSource: renderSystemDiagramModule(systemDiagramSvg),
systemDiagramSvg,
systemDiagramSheetName,
};
};
/** Generate canonical tscircuit TSX from system blocks. */
export const generateTsx = (request: GenerateTsxRequest): string =>
generateSystemDesignArtifacts(request).tsx;
export const generateSystemTsx = generateTsx;