alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
520 lines (483 loc) • 16.7 kB
JavaScript
// Migrates the deprecated layout components to their class-first equivalents:
//
// <HStack className="gap-m"> → <View className="flex-row gap-m">
// <VStack className="gap-m"> → <View className="gap-m">
// <Stack> → <View className="flex-row flex-wrap">
// <Surface size="sm" variant="lowered" className="gap-m">
// → <Box className="surface surface-sm lowered gap-m">
// <EditableSurface> → <EditableSection className="surface">
// <FormEditableSurface> → <FormEditableSection className="surface">
//
// Imports from "alouette" are rewritten in place; imports of the library's own
// modules by relative path (inside alouette itself) are pointed at
// ui/primitives/View and ui/containers/Box. The rewrite is text edits at node
// positions, so the file keeps its formatting — run the project's formatter and
// linter afterwards to reorder imports.
import { dirname, relative, resolve, sep } from "node:path";
const COMPONENTS = {
HStack: { group: "stacks", target: "View", classes: ["flex-row"] },
VStack: { group: "stacks", target: "View", classes: [] },
Stack: {
group: "stacks",
target: "View",
classes: ["flex-row", "flex-wrap"],
},
Surface: { group: "surface", target: "Box", classes: ["surface"] },
EditableSurface: {
group: "editable",
target: "EditableSection",
classes: ["surface"],
},
FormEditableSurface: {
group: "editable",
target: "FormEditableSection",
classes: ["surface"],
},
};
const TYPES = {
HStackProps: { group: "stacks", target: "ViewProps" },
VStackProps: { group: "stacks", target: "ViewProps" },
StackProps: { group: "stacks", target: "ViewProps" },
SurfaceProps: { group: "surface", target: "BoxProps" },
EditableSurfaceProps: { group: "editable", target: "EditableSectionProps" },
FormEditableSurfaceProps: {
group: "editable",
target: "FormEditableSectionProps",
},
};
export const GROUPS = ["stacks", "surface", "editable"];
const TARGET_MODULES = {
View: "primitives/View",
ViewProps: "primitives/View",
Box: "containers/Box",
BoxProps: "containers/Box",
EditableSection: "containers/EditableSection",
EditableSectionProps: "containers/EditableSection",
FormEditableSection: "forms/FormEditableSection",
FormEditableSectionProps: "forms/FormEditableSection",
};
// The library's own modules, for imports by relative path inside alouette.
const INTERNAL_MODULES = [
["/ui/stacks/stacks", "stacks"],
["/ui/containers/Surface", "surface"],
["/ui/containers/EditableSurface", "editable"],
["/ui/forms/FormEditableSurface", "editable"],
];
// Surface props, in the order their classes are written.
const SURFACE_PROP_CLASSES = {
size: {
xxs: "surface-xxs",
xs: "surface-xs",
sm: "surface-sm",
md: "surface-md",
lg: "surface-lg",
},
variant: {
surface: "",
highlight: "bg-highlight",
"highlight-accent": "bg-highlight-accent",
lowered: "lowered",
translucent: "bg-translucent",
},
shadow: {
none: "shadow-none",
s: "shadow-s",
m: "shadow-m",
l: "shadow-l",
lowered: "shadow-lowered",
},
};
// View does not merge classes, so a direction the caller already wrote replaces
// the one a stack implied instead of being written next to it.
const SAME_PROPERTY = {
"flex-row": /^(?:flex-row|flex-col|flex-row-reverse|flex-col-reverse)$/,
"flex-wrap":
/^(?:flex-wrap|flex-nowrap|flex-wrap-reverse|flex-wrap-balance)$/,
};
const MAYBE_AFFECTED =
/\b(?:[HV]?Stack|Surface|EditableSurface|FormEditableSurface)(?:Props)?\b/;
function toPosix(path) {
return path.split(sep).join("/");
}
function resolveSource(specifier, filePath) {
if (specifier === "alouette") return { kind: "package" };
if (!specifier.startsWith(".")) return undefined;
const absolute = toPosix(resolve(dirname(filePath), specifier));
for (const [suffix, group] of INTERNAL_MODULES) {
if (!absolute.endsWith(suffix)) continue;
const uiRoot = `${absolute.slice(0, -suffix.length)}/ui`;
return {
kind: "internal",
group,
importPathFor(target) {
const path = toPosix(
relative(dirname(filePath), `${uiRoot}/${TARGET_MODULES[target]}`),
);
return path.startsWith(".") ? path : `./${path}`;
},
};
}
return undefined;
}
function scriptKindFor(ts, filePath) {
if (/\.(?:ts|mts|cts)$/.test(filePath)) return ts.ScriptKind.TS;
if (filePath.endsWith(".tsx")) return ts.ScriptKind.TSX;
return ts.ScriptKind.JSX;
}
/**
* @param {string} text source of the file
* @param {string} filePath absolute or cwd-relative path, used to resolve
* relative imports and to pick the parser
* @param {typeof import("typescript")} ts
* @param {{ groups?: Array<"stacks" | "surface" | "editable"> }} [options]
* @returns {{ output: string, changed: boolean, warnings: Array<{ line: number, message: string }> }}
*/
export function transformSurfaceAndStacks(
text,
filePath,
ts,
{ groups = GROUPS } = {},
) {
const unchanged = { output: text, changed: false, warnings: [] };
if (!MAYBE_AFFECTED.test(text)) return unchanged;
const sourceFile = ts.createSourceFile(
filePath,
text,
ts.ScriptTarget.Latest,
true,
scriptKindFor(ts, filePath),
);
const edits = [];
const warnings = [];
function warn(node, message) {
const { line } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(sourceFile),
);
warnings.push({ line: line + 1, message });
}
function replace(node, replacement) {
edits.push({
start: node.getStart(sourceFile),
end: node.end,
text: replacement,
});
}
// --- bindings -------------------------------------------------------------
const bindings = new Map();
const boundNames = new Set();
const importDeclarations = [];
for (const statement of sourceFile.statements) {
if (
!ts.isImportDeclaration(statement) ||
!ts.isStringLiteral(statement.moduleSpecifier) ||
!statement.importClause
) {
continue;
}
importDeclarations.push(statement);
const clause = statement.importClause;
if (clause.name) boundNames.add(clause.name.text);
const namedBindings = clause.namedBindings;
if (!namedBindings) continue;
if (!ts.isNamedImports(namedBindings)) {
boundNames.add(namedBindings.name.text);
continue;
}
const source = resolveSource(statement.moduleSpecifier.text, filePath);
for (const element of namedBindings.elements) {
boundNames.add(element.name.text);
if (!source) continue;
const imported = (element.propertyName ?? element.name).text;
const migration = COMPONENTS[imported] ?? TYPES[imported];
if (!migration || !groups.includes(migration.group)) continue;
if (source.kind === "internal" && source.group !== migration.group) {
continue;
}
bindings.set(element.name.text, {
imported,
migration,
declaration: statement,
element,
source,
kept: false,
});
}
}
if (bindings.size === 0) return unchanged;
// --- JSX elements ---------------------------------------------------------
const renamedTags = new Set();
const keptTags = new Set();
function staticString(initializer) {
if (!initializer) return undefined;
if (ts.isStringLiteral(initializer)) return initializer.text;
if (
ts.isJsxExpression(initializer) &&
initializer.expression &&
(ts.isStringLiteral(initializer.expression) ||
ts.isNoSubstitutionTemplateLiteral(initializer.expression))
) {
return initializer.expression.text;
}
return undefined;
}
function migrateElement(opening) {
const binding = bindings.get(opening.tagName.text);
if (!COMPONENTS[binding.imported]) return;
const { migration } = binding;
const closing = ts.isJsxOpeningElement(opening)
? opening.parent.closingElement
: undefined;
const tags = closing
? [opening.tagName, closing.tagName]
: [opening.tagName];
let classNameAttribute;
const surfaceClasses = {};
const removedAttributes = [];
for (const attribute of opening.attributes.properties) {
if (ts.isJsxSpreadAttribute(attribute)) {
if (binding.imported === "Surface") {
warn(
attribute,
"props spread onto Surface: a size, variant or shadow inside them is not migrated",
);
}
continue;
}
const name = attribute.name.getText(sourceFile);
if (name === "className") {
classNameAttribute = attribute;
continue;
}
if (binding.imported !== "Surface" || !(name in SURFACE_PROP_CLASSES)) {
continue;
}
const value = staticString(attribute.initializer);
const mapped =
value === undefined ? undefined : SURFACE_PROP_CLASSES[name][value];
if (mapped === undefined) {
warn(
attribute,
`Surface ${name} is not a static, known value: this Surface is left as is`,
);
binding.kept = true;
for (const tag of tags) keptTags.add(tag);
return;
}
surfaceClasses[name] = mapped;
removedAttributes.push(attribute);
}
const classes = [
...migration.classes,
...Object.keys(SURFACE_PROP_CLASSES)
.map((name) => surfaceClasses[name])
.filter(Boolean),
];
for (const tag of tags) {
renamedTags.add(tag);
replace(tag, migration.target);
}
for (const attribute of removedAttributes) {
edits.push({ start: attribute.pos, end: attribute.end, text: "" });
}
if (!classNameAttribute) {
if (classes.length > 0) {
edits.push({
start: opening.attributes.pos,
end: opening.attributes.pos,
text: ` className="${classes.join(" ")}"`,
});
}
return;
}
const { initializer } = classNameAttribute;
const value = staticString(initializer);
if (value !== undefined) {
const written = value.split(/\s+/).filter(Boolean);
const added = classes.filter(
(cls) =>
!SAME_PROPERTY[cls] ||
!written.some((token) => SAME_PROPERTY[cls].test(token)),
);
if (added.length > 0) {
replace(initializer, JSON.stringify([...added, ...written].join(" ")));
}
return;
}
if (
classes.length > 0 &&
initializer &&
ts.isJsxExpression(initializer) &&
initializer.expression
) {
const expression = initializer.expression.getText(sourceFile);
replace(
initializer,
`{\`${classes.join(" ")} \${${expression} ?? ""}\`}`,
);
if (migration.target === "View") {
warn(
classNameAttribute,
`dynamic className on ${binding.imported}: "${classes.join(" ")}" is prepended and View does not merge classes — check the expression sets no flex direction or wrap of its own`,
);
}
}
}
function visitElements(node) {
if (
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
ts.isIdentifier(node.tagName) &&
bindings.has(node.tagName.text)
) {
migrateElement(node);
}
ts.forEachChild(node, visitElements);
}
// --- other references (typeof Stack, HStackProps, component: Surface) -----
function isRenamedReference(node) {
const { parent } = node;
if (renamedTags.has(node) || keptTags.has(node)) return false;
if (
(ts.isPropertyAccessExpression(parent) || ts.isQualifiedName(parent)) &&
parent.name === node
) {
return false;
}
if (ts.isQualifiedName(parent) && parent.right === node) return false;
if (
(ts.isPropertyAssignment(parent) ||
ts.isPropertySignature(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isJsxAttribute(parent) ||
ts.isBindingElement(parent)) &&
(parent.name === node || parent.propertyName === node)
) {
return false;
}
return true;
}
function visitReferences(node) {
if (
ts.isIdentifier(node) &&
bindings.has(node.text) &&
isRenamedReference(node)
) {
const binding = bindings.get(node.text);
if (ts.isExportSpecifier(node.parent)) {
warn(node, `${node.text} is re-exported: migrate the export by hand`);
binding.kept = true;
} else if (ts.isShorthandPropertyAssignment(node.parent)) {
replace(node, `${node.text}: ${binding.migration.target}`);
} else {
replace(node, binding.migration.target);
}
}
ts.forEachChild(node, visitReferences);
}
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) continue;
visitElements(statement);
}
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement)) continue;
visitReferences(statement);
}
// --- imports --------------------------------------------------------------
const declarationChanges = new Map();
function changesOf(declaration) {
if (!declarationChanges.has(declaration)) {
declarationChanges.set(declaration, {
remove: new Set(),
add: new Map(),
newImports: new Map(),
});
}
return declarationChanges.get(declaration);
}
function importResolvingTo(path) {
const absolute = resolve(dirname(filePath), path);
return importDeclarations.find(
(declaration) =>
declaration.moduleSpecifier.text.startsWith(".") &&
resolve(dirname(filePath), declaration.moduleSpecifier.text) ===
absolute,
);
}
for (const binding of bindings.values()) {
const { declaration, element, source, migration } = binding;
const changes = changesOf(declaration);
if (!binding.kept) changes.remove.add(element);
const { target } = migration;
if (boundNames.has(target)) continue;
boundNames.add(target);
const isType =
target.endsWith("Props") ||
element.isTypeOnly ||
declaration.importClause.isTypeOnly;
if (source.kind === "package") {
changes.add.set(target, isType);
continue;
}
const path = source.importPathFor(target);
const existing = importResolvingTo(path);
if (
existing?.importClause.namedBindings &&
ts.isNamedImports(existing.importClause.namedBindings)
) {
changesOf(existing).add.set(target, isType);
continue;
}
if (!changes.newImports.has(path)) changes.newImports.set(path, new Map());
changes.newImports.get(path).set(target, isType);
}
for (const [declaration, changes] of declarationChanges) {
const clause = declaration.importClause;
const namedBindings = clause.namedBindings;
const specifiers = namedBindings.elements
.filter((element) => !changes.remove.has(element))
.map((element) => element.getText(sourceFile));
for (const [name, isType] of changes.add) {
specifiers.push(isType && !clause.isTypeOnly ? `type ${name}` : name);
}
const newImports = [...changes.newImports].map(([path, names]) => {
const allTypes = [...names.values()].every(Boolean);
const list = [...names]
.map(([name, isType]) => (isType && !allTypes ? `type ${name}` : name))
.join(", ");
return `import ${allTypes ? "type " : ""}{ ${list} } from "${path}";`;
});
if (specifiers.length === 0 && !clause.name) {
const end =
text[declaration.end] === "\n" ? declaration.end + 1 : declaration.end;
edits.push({
start: declaration.getStart(sourceFile),
end,
text: newImports.map((line) => `${line}\n`).join(""),
});
continue;
}
if (
specifiers.length !== namedBindings.elements.length ||
changes.add.size > 0 ||
changes.remove.size > 0
) {
replace(namedBindings, `{ ${specifiers.join(", ")} }`);
}
if (newImports.length > 0) {
edits.push({
start: declaration.end,
end: declaration.end,
text: newImports.map((line) => `\n${line}`).join(""),
});
}
}
// Right to left; at one position the removal runs before the insertion, so
// an attribute removed at a tag's end does not swallow a className inserted
// there.
edits.sort((a, b) => b.start - a.start || b.end - a.end);
let output = text;
for (const edit of edits) {
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
}
return { output, changed: output !== text, warnings };
}