@usableapp/cardds 0.7.107 → 0.7.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,13 @@
1
- // The parts of a line, and the head, the body and the foot of a card, a sheet, a stack sheet and a modal replace the parts each whole had of its own (docs/parts-plan.md, Lh 2026-10-21): this
1
+ // The parts of a line, and the head, the body and the foot of a card, a sheet, a stack sheet and a modal replace the parts each whole had of its own (Lh 2026-10-21, the inner parts): this
2
2
  // rewrites a file's JSX and its `cardds` imports from the retired names to the new, through the TypeScript AST, never by the text of a name. A consumer runs it
3
- // on its screens after the release; the previews were migrated by it. Later PRs of the program add their rows to RENAMES and RESHAPES.
3
+ // on its screens after the release; the previews were migrated by it. It also moves the `Row` props 0.7.106 retired (`title`, `meta`, `lead`, `leadIcon`,
4
+ // `body`, `end`, `endValue`, `endCaption`) into the line's children, which the 0.7.107 version left; a screen migrated with that version is checked again.
4
5
  // npx cardds codemod-parts <files…> rewrite each file in place; a tag it cannot rewrite is printed and left
5
- // npx cardds codemod-parts --check <files…> exit 1 naming every tag and import that still writes a retired name
6
+ // npx cardds codemod-parts --check <files…> exit 1 naming every tag, prop, import and type that still writes a retired name
6
7
  // (in this repo also `node scripts/codemod-parts.mjs …`; it reads `typescript` from the project that runs it)
8
+ // A part a tag now needs is imported once, on the file's value import (`import type { … }` only has its type names renamed, `CardFootProps` → `FootProps`).
9
+ // A Card's `foot={cond ? x : undefined}` (or `: null`, or `cond && x`) keeps its condition, `{cond ? <Foot>{x}</Foot> : null}`, so no empty foot is drawn;
10
+ // a `foot={value}` is written `<Foot>{value}</Foot>` as it is: the script cannot know whether `value` may be undefined, and the consumer reads that line.
7
11
  import { readFileSync, writeFileSync } from 'node:fs';
8
12
  import { fileURLToPath } from 'node:url';
9
13
  import ts from 'typescript';
@@ -21,15 +25,28 @@ export const RENAMES = {
21
25
  CardFoot: { to: 'Foot' },
22
26
  };
23
27
 
24
- /** a tag whose props became children: the props the tag drew its head with, and what each is now. `Card` keeps its own props, so only the head's go */
28
+ /** a tag whose props became children: the props the tag drew its head with, and what each is now. `Card` keeps its own props, so only the head's go;
29
+ * a `Row` keeps its own too, and the props that drew its line (ROW_PROPS) go */
25
30
  export const RESHAPES = {
26
31
  CardHead: { to: 'Head', words: { heading: 'title', caption: 'meta', level: 'level' }, end: 'end' },
27
32
  SheetHead: { to: 'Head', adds: 'centre', words: { title: 'title', sub: 'meta' }, lead: 'lead', end: 'trail' },
28
33
  Card: { words: { heading: 'title', caption: 'meta', level: 'level' }, end: 'end', head: true },
34
+ Row: { line: true },
29
35
  };
30
36
  /** a Card's foot props, written as its last child, a `Foot`: `foot` its children, `footLink` its `link`, `footRound` its `round` */
31
37
  const FOOT_PROPS = ['foot', 'footLink', 'footRound'];
32
38
  const HEAD_PROPS = { Card: ['heading', 'caption', 'end', 'level'], CardHead: ['heading', 'caption', 'end', 'level'], SheetHead: ['lead', 'title', 'sub', 'trail'] };
39
+ /** a Row's props of 0.7.105, each written as a child in this order (the 0.7.106 table): `lead` and `end` stand bare, the rest become a Lead, a Words, an End */
40
+ const ROW_PROPS = {
41
+ lead: 'its lead as the first child', leadIcon: '<Lead icon /> first', title: '<Words title meta /> after the lead', meta: '<Words title meta /> after the lead',
42
+ body: '<Words title meta>…</Words> after the lead', endValue: '<End value caption /> after the words', endCaption: '<End value caption /> after the words', end: 'its end as the last child',
43
+ };
44
+ /** the new tag a retired tag is written as, or null */
45
+ const retiredTo = (name) => (name in RENAMES ? RENAMES[name].to : RESHAPES[name]?.to ?? null);
46
+ /** a retired tag's props type → the part's (`CardFootProps` → `FootProps`) */
47
+ const TYPE_RENAMES = Object.fromEntries(Object.keys({ ...RENAMES, ...RESHAPES }).filter(retiredTo).map((n) => [`${n}Props`, `${retiredTo(n)}Props`]));
48
+ /** the parts a reshape writes, imported where the file's tags now need them */
49
+ const PARTS = ['Head', 'Words', 'Foot', 'Lead', 'End'];
33
50
  const LINE = 140;
34
51
 
35
52
  const parse = (source, file) => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
@@ -50,6 +67,7 @@ const fits = (rule, el) => {
50
67
  const line = (sf, node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
51
68
  const openingOf = (n) => (ts.isJsxElement(n) ? n.openingElement : n);
52
69
  const headProps = (opening) => (HEAD_PROPS[tagName(opening)] ?? []).filter((p) => findAttr(opening, p));
70
+ const rowProps = (opening) => Object.keys(ROW_PROPS).filter((p) => findAttr(opening, p));
53
71
 
54
72
  /** every retired name the file still writes, as a JSX tag, a `cardds` import or a prop that became a child: `{ line, name, write }`, in file order */
55
73
  export function check(source, file = 'x.tsx') {
@@ -57,12 +75,14 @@ export function check(source, file = 'x.tsx') {
57
75
  const out = [];
58
76
  const walk = (n) => {
59
77
  const name = tagName(n);
60
- if (name && name in RENAMES && !ts.isJsxClosingElement(n)) out.push({ line: line(sf, n), name, write: RENAMES[name].to });
61
- if (name && (name === 'CardHead' || name === 'SheetHead') && !ts.isJsxClosingElement(n)) out.push({ line: line(sf, n), name, write: 'Head' });
78
+ if (name && retiredTo(name) && !ts.isJsxClosingElement(n)) out.push({ line: line(sf, n), name, write: retiredTo(name) });
62
79
  if (name === 'Card' && (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n))) {
63
80
  for (const p of headProps(n)) out.push({ line: line(sf, n), name: `Card ${p}`, write: '<Head><Words title meta /></Head> as its first child' });
64
81
  for (const p of FOOT_PROPS.filter((f) => findAttr(n, f))) out.push({ line: line(sf, n), name: `Card ${p}`, write: '<Foot round link>…</Foot> as its last child' });
65
82
  }
83
+ if (name === 'Row' && (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n))) {
84
+ for (const p of rowProps(n)) out.push({ line: line(sf, n), name: `Row ${p}`, write: ROW_PROPS[p] });
85
+ }
66
86
  ts.forEachChild(n, walk);
67
87
  };
68
88
  walk(sf);
@@ -76,8 +96,8 @@ export function check(source, file = 'x.tsx') {
76
96
  if (imp.moduleSpecifier.text !== 'cardds' || !nb || !ts.isNamedImports(nb)) continue;
77
97
  for (const e of nb.elements) {
78
98
  const name = (e.propertyName ?? e.name).text;
79
- if (name in RENAMES) out.push({ line: line(sf, e), name: e.name.text, write: RENAMES[name].to });
80
- if (name === 'CardHead' || name === 'SheetHead') out.push({ line: line(sf, e), name: e.name.text, write: 'Head' });
99
+ const to = retiredTo(name) ?? TYPE_RENAMES[name];
100
+ if (to) out.push({ line: line(sf, e), name: e.name.text, write: to });
81
101
  }
82
102
  }
83
103
  return out.sort((a, b) => a.line - b.line);
@@ -85,14 +105,18 @@ export function check(source, file = 'x.tsx') {
85
105
 
86
106
  const lineIndent = (source, pos) => /^[ \t]*/.exec(source.slice(source.lastIndexOf('\n', pos - 1) + 1))[0];
87
107
  const valueOf = (attr, sf) => (attr.initializer ? attr.initializer.getText(sf) : '{true}');
88
- /** a prop's value as a child: a JSX element stands as it is, anything else in braces */
89
- function childOf(attr, sf) {
108
+ /** a prop's value, the expression or string it holds, or null when it has none (`<Card foot>`) */
109
+ function exprOf(attr) {
90
110
  const init = attr.initializer;
91
111
  if (!init) return null;
92
- const expr = ts.isJsxExpression(init) ? init.expression : init;
93
- if (!expr) return null;
94
- const jsx = ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr) || ts.isJsxFragment(expr);
95
- return jsx ? expr.getText(sf) : `{${expr.getText(sf)}}`;
112
+ return ts.isJsxExpression(init) ? init.expression ?? null : init;
113
+ }
114
+ /** an expression as a child: a JSX element stands as it is, anything else in braces */
115
+ const asChild = (expr, sf) => (ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr) || ts.isJsxFragment(expr) ? expr.getText(sf) : `{${expr.getText(sf)}}`);
116
+ /** a prop's value as a child, or null */
117
+ function childOf(attr, sf) {
118
+ const expr = exprOf(attr);
119
+ return expr ? asChild(expr, sf) : null;
96
120
  }
97
121
  /** `parts` laid on one line when they fit, else one to a line under `indent` */
98
122
  function lay(open, parts, close, indent) {
@@ -120,6 +144,7 @@ function reshape(source, sf, el) {
120
144
  const opening = openingOf(el), name = tagName(opening);
121
145
  const rule = RESHAPES[name];
122
146
  if (!rule) return null;
147
+ if (rule.line) return rowOf(source, sf, el);
123
148
  const used = headProps(opening);
124
149
  if (name === 'CardHead' && !used.length && ts.isJsxSelfClosingElement(el) === false) {
125
150
  return { rename: true };
@@ -159,12 +184,19 @@ function moved(node, sf, text, to) {
159
184
  return text.replace(/\n( *)/g, (m, sp) => `\n${' '.repeat(Math.max(0, sp.length + by))}`);
160
185
  }
161
186
  /** what a Card's `foot` held, as the Foot's children standing at column `to`: a fragment's children each as it is, anything else as one child */
162
- function footChildren(attr, sf, to) {
163
- const expr = attr.initializer && ts.isJsxExpression(attr.initializer) ? attr.initializer.expression : null;
164
- if (expr && ts.isJsxFragment(expr)) return expr.children.filter((c) => !(ts.isJsxText(c) && !c.getText(sf).trim())).map((c) => moved(c, sf, c.getText(sf).trim(), to));
165
- const child = childOf(attr, sf);
166
- if (!child) return [];
167
- return [expr && (ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)) ? moved(expr, sf, child, to) : child];
187
+ function footChildren(expr, sf, to) {
188
+ if (!expr) return [];
189
+ if (ts.isJsxFragment(expr)) return expr.children.filter((c) => !(ts.isJsxText(c) && !c.getText(sf).trim())).map((c) => moved(c, sf, c.getText(sf).trim(), to));
190
+ return [ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr) ? moved(expr, sf, asChild(expr, sf), to) : asChild(expr, sf)];
191
+ }
192
+ /** a `foot` written under a condition, `cond ? x : undefined`, `cond ? x : null` or `cond && x`: the condition's text and `x`; a plain value has no `when` */
193
+ function footCondition(expr, sf) {
194
+ if (!expr) return { shown: null };
195
+ if (ts.isConditionalExpression(expr) && (expr.whenFalse.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(expr.whenFalse) && expr.whenFalse.text === 'undefined'))) {
196
+ return { when: (foot) => `{${expr.condition.getText(sf)} ? ${foot} : null}`, shown: expr.whenTrue };
197
+ }
198
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) return { when: (foot) => `{${expr.left.getText(sf)} && ${foot}}`, shown: expr.right };
199
+ return { shown: expr };
168
200
  }
169
201
  /** a Card that still writes its foot with props, as an edit that drops them and writes a `<Foot>` as its last child (`footRound` alone drew no foot: it only goes) */
170
202
  function footOf(source, sf, el) {
@@ -178,14 +210,42 @@ function footOf(source, sf, el) {
178
210
  if (!fa && !la) return { pos: opening.getStart(sf), end: opening.end, text: self ? rest.replace(/>$/, ' />') : rest };
179
211
  const round = !ra ? '' : hasAttr(opening, 'footRound') ? ' round' : ` round=${valueOf(ra, sf)}`;
180
212
  const attrs = `${round}${la ? ` link=${valueOf(la, sf)}` : ''}`;
181
- const kids = fa ? footChildren(fa, sf, indent.length + 4) : [];
182
- const foot = kids.length ? lay(`<Foot${attrs}>`, kids, '</Foot>', `${indent} `) : `<Foot${attrs} />`;
213
+ const { when, shown } = footCondition(fa ? exprOf(fa) : null, sf);
214
+ const kids = footChildren(shown, sf, indent.length + 4);
215
+ const drawn = kids.length ? lay(`<Foot${attrs}>`, kids, '</Foot>', `${indent} `) : `<Foot${attrs} />`;
216
+ const foot = when ? when(drawn) : drawn;
183
217
  if (self) return { pos: el.getStart(sf), end: el.end, text: `${rest}\n${indent} ${foot}\n${indent}</Card>` };
184
218
  const inner = source.slice(opening.end, el.closingElement.getStart(sf));
185
219
  if (!inner.includes('\n')) return { pos: el.getStart(sf), end: el.end, text: lay(rest, [inner.trim(), foot].filter(Boolean), '</Card>', indent) };
186
220
  return { edits: [{ pos: opening.getStart(sf), end: opening.end, text: rest }, { pos: el.closingElement.getStart(sf), end: el.closingElement.getStart(sf), text: ` ${foot}\n${indent}` }] };
187
221
  }
188
222
 
223
+ /** a Row that still draws its line with props, as an edit that drops them and writes the line's children in order: lead · words · its own children · end */
224
+ function rowOf(source, sf, el) {
225
+ const opening = openingOf(el);
226
+ const used = rowProps(opening);
227
+ if (!used.length) return null;
228
+ const indent = lineIndent(source, opening.getStart(sf));
229
+ const at = (p) => findAttr(opening, p);
230
+ const attrs = (pairs) => pairs.filter(([from]) => at(from)).map(([from, to]) => ` ${to}=${valueOf(at(from), sf)}`).join('');
231
+ const words = attrs([['title', 'title'], ['meta', 'meta']]), body = at('body') ? childOf(at('body'), sf) : null;
232
+ const ends = attrs([['endValue', 'value'], ['endCaption', 'caption']]);
233
+ const front = [
234
+ at('leadIcon') && `<Lead icon=${valueOf(at('leadIcon'), sf)} />`,
235
+ at('lead') && childOf(at('lead'), sf),
236
+ (words || body) && (body ? `<Words${words}>${body}</Words>` : `<Words${words} />`),
237
+ ].filter(Boolean);
238
+ const back = [ends && `<End${ends} />`, at('end') && childOf(at('end'), sf)].filter(Boolean);
239
+ const rest = without(source, opening, used, sf).replace(/\s*\/>$/, '>').replace(/\s*\n\s*>$/, '>').replace(/^<Row\s+>$/, '<Row>');
240
+ if (ts.isJsxSelfClosingElement(el)) return { pos: el.getStart(sf), end: el.end, text: lay(rest, [...front, ...back], '</Row>', indent) };
241
+ const inner = source.slice(opening.end, el.closingElement.getStart(sf));
242
+ if (!inner.includes('\n')) return { pos: el.getStart(sf), end: el.end, text: lay(rest, [...front, inner.trim(), ...back], '</Row>', indent) };
243
+ return { edits: [
244
+ { pos: opening.getStart(sf), end: opening.end, text: front.length ? `${rest}\n${indent} ${front.join(`\n${indent} `)}` : rest },
245
+ { pos: el.closingElement.getStart(sf), end: el.closingElement.getStart(sf), text: back.map((b) => ` ${b}\n${indent}`).join('') },
246
+ ] };
247
+ }
248
+
189
249
  /** the file rewritten: `source` after, `tags` rewritten, `imports` rewritten, and `left`, the tags no rule fits (`{ line, name, why }`) */
190
250
  export function rewrite(source, file = 'x.tsx') {
191
251
  let tags = 0, imports = 0;
@@ -272,34 +332,53 @@ function importList(names, wrapped) {
272
332
  return `{\n${rows.map((r) => ` ${r},`).join('\n')}\n}`;
273
333
  }
274
334
 
275
- /** the `cardds` import after the tags changed: a retired name replaced by its new one in place, `Head` and `Words` added where a tag now needs them, a name no tag writes any more dropped */
335
+ /** the `cardds` imports after the tags changed: a retired name replaced by its new one in place, a part added to the value import where a tag now needs it,
336
+ * a name no tag writes any more dropped; an `import type` only has its retired type names renamed, and a file with no value import gets one after it */
276
337
  function fixImports(source, file) {
277
338
  const sf = parse(source, file);
278
339
  const used = new Set();
279
340
  const walk = (n) => { const name = tagName(n); if (name && !ts.isJsxClosingElement(n)) used.add(name); ts.forEachChild(n, walk); };
280
341
  walk(sf);
281
342
  const edits = [];
282
- let imports = 0;
343
+ let imports = 0, values = 0, last = null;
344
+ const types = new Map();
345
+ const typeSpec = (e, name) => {
346
+ if (e.propertyName) return e.getText(sf);
347
+ if (TYPE_RENAMES[name]) types.set(name, TYPE_RENAMES[name]);
348
+ return `${e.isTypeOnly ? 'type ' : ''}${TYPE_RENAMES[name] ?? name}`;
349
+ };
283
350
  for (const imp of sf.statements.filter(ts.isImportDeclaration)) {
284
351
  const nb = imp.importClause?.namedBindings;
285
352
  if (imp.moduleSpecifier.text !== 'cardds' || !nb || !ts.isNamedImports(nb)) continue;
353
+ last = imp;
354
+ const typeOnly = !!imp.importClause.isTypeOnly;
286
355
  const names = [];
287
356
  const add = (t) => { if (!names.includes(t)) names.push(t); };
288
357
  for (const e of nb.elements) {
289
358
  const name = (e.propertyName ?? e.name).text;
290
- const retired = !e.propertyName && (name in RENAMES || name === 'CardHead' || name === 'SheetHead') && !used.has(name);
359
+ if (typeOnly || e.isTypeOnly) { add(typeSpec(e, name)); continue; }
360
+ const retired = !e.propertyName && retiredTo(name) && !used.has(name);
291
361
  if (!retired) { add(e.getText(sf)); continue; }
292
- const to = name in RENAMES ? RENAMES[name].to : 'Head';
362
+ const to = retiredTo(name);
293
363
  add(to);
294
364
  if (to === 'Head' && used.has('Words')) add('Words');
295
365
  }
296
- for (const part of ['Head', 'Words', 'Foot']) if (used.has(part) && !names.includes(part)) names.push(part);
366
+ if (!typeOnly) { values++; for (const part of PARTS) if (used.has(part) && !names.includes(part)) names.push(part); }
297
367
  if (names.join(', ') === nb.elements.map((e) => e.getText(sf)).join(', ')) continue;
298
368
  imports++;
299
369
  edits.push({ pos: nb.getStart(sf), end: nb.end, text: importList(names, nb.getText(sf).includes('\n')) });
300
370
  }
371
+ if (types.size) {
372
+ const rename = (n) => { if (ts.isIdentifier(n) && types.has(n.text)) edits.push({ pos: n.getStart(sf), end: n.end, text: types.get(n.text) }); ts.forEachChild(n, rename); };
373
+ for (const st of sf.statements) if (!ts.isImportDeclaration(st)) rename(st);
374
+ }
375
+ const needed = PARTS.filter((part) => used.has(part));
376
+ if (last && !values && needed.length) {
377
+ imports++;
378
+ edits.push({ pos: last.end, end: last.end, text: `\nimport ${importList(needed, false)} from ${last.moduleSpecifier.getText(sf)}${source[last.end - 1] === ';' ? ';' : ''}` });
379
+ }
301
380
  const declared = new Set(sf.statements.flatMap((st) => (ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name ? [st.name.text] : ts.isVariableStatement(st) ? st.declarationList.declarations.flatMap((d) => (ts.isIdentifier(d.name) ? [d.name.text] : [])) : []));
302
- return { source: apply(source, edits), imports, clash: ['Head', 'Words', 'Body', 'Foot'].filter((n) => used.has(n) && declared.has(n)) };
381
+ return { source: apply(source, edits), imports, clash: [...PARTS, 'Body'].filter((n) => used.has(n) && declared.has(n)) };
303
382
  }
304
383
 
305
384
  /** the command: rewrite each file in place, or with `--check` name what still writes a retired name; the exit code */