@hirobius/design-system 0.19.0 → 0.20.0

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.
Files changed (144) hide show
  1. package/CONSUMING.md +9 -6
  2. package/DESIGN.md +7 -5
  3. package/README.md +14 -11
  4. package/codemods/hds-prefix.mjs +231 -0
  5. package/codemods/jsx-fold.mjs +515 -0
  6. package/codemods/not-found-pattern.mjs +71 -0
  7. package/codemods/patterns-subpath.mjs +242 -81
  8. package/codemods/patterns-subpath.names.json +41 -15
  9. package/codemods/removed-0.20.json +194 -0
  10. package/codemods/tile-grid.mjs +283 -0
  11. package/codemods/unrewritable.mjs +291 -0
  12. package/dist/chunks/DemoStateContext-Cz5TmrYu.js +32 -0
  13. package/dist/chunks/empty-state-BBqhCx0S.js +1217 -0
  14. package/dist/chunks/{form-BvNNnJ0t.js → form-COP3vK9_.js} +29 -27
  15. package/dist/contexts.js +157 -78
  16. package/dist/form.js +1 -1
  17. package/dist/hirobius-ui.js +1826 -5503
  18. package/dist/manifest.js +12 -13
  19. package/dist/patterns.js +820 -561
  20. package/dist/styles.css +1 -1
  21. package/dist/tokens.css +1 -1
  22. package/dist/tokens.js +29 -8
  23. package/dist/types/src/app/components/avatar-group.d.ts +1 -2
  24. package/dist/types/src/app/components/badge.d.ts +8 -3
  25. package/dist/types/src/app/components/blockquote.d.ts +1 -2
  26. package/dist/types/src/app/components/box-sx.d.ts +110 -13
  27. package/dist/types/src/app/components/box.d.ts +6 -3
  28. package/dist/types/src/app/components/breadcrumb.d.ts +0 -5
  29. package/dist/types/src/app/components/button.d.ts +33 -4
  30. package/dist/types/src/app/components/card.d.ts +7 -8
  31. package/dist/types/src/app/components/combobox.d.ts +47 -11
  32. package/dist/types/src/app/components/container.d.ts +2 -2
  33. package/dist/types/src/app/components/data-table-section.d.ts +1 -1
  34. package/dist/types/src/app/components/dialog.d.ts +7 -1
  35. package/dist/types/src/app/components/disclosure.d.ts +1 -5
  36. package/dist/types/src/app/components/field.d.ts +1 -2
  37. package/dist/types/src/app/components/grid.d.ts +10 -6
  38. package/dist/types/src/app/components/hds-tooltip.d.ts +12 -2
  39. package/dist/types/src/app/components/inline-code.d.ts +1 -2
  40. package/dist/types/src/app/components/input.d.ts +8 -5
  41. package/dist/types/src/app/components/kbd.d.ts +1 -2
  42. package/dist/types/src/app/components/metadata-list.d.ts +1 -2
  43. package/dist/types/src/app/components/page-header.d.ts +0 -2
  44. package/dist/types/src/app/components/popover.d.ts +8 -0
  45. package/dist/types/src/app/components/progress.d.ts +33 -6
  46. package/dist/types/src/app/components/segmented-control.d.ts +2 -26
  47. package/dist/types/src/app/components/select.d.ts +10 -1
  48. package/dist/types/src/app/components/skeleton.d.ts +2 -3
  49. package/dist/types/src/app/components/spinner.d.ts +1 -2
  50. package/dist/types/src/app/components/stack.d.ts +4 -1
  51. package/dist/types/src/app/components/stat.d.ts +1 -2
  52. package/dist/types/src/app/components/status-dot.d.ts +18 -4
  53. package/dist/types/src/app/components/status-list-item.d.ts +1 -2
  54. package/dist/types/src/app/components/surface.d.ts +2 -3
  55. package/dist/types/src/app/components/switcher.d.ts +1 -1
  56. package/dist/types/src/app/components/table.d.ts +1 -15
  57. package/dist/types/src/app/components/tag.d.ts +0 -6
  58. package/dist/types/src/app/components/text.d.ts +1 -2
  59. package/dist/types/src/app/components/textarea.d.ts +1 -2
  60. package/dist/types/src/app/components/toast.d.ts +1 -2
  61. package/dist/types/src/app/components/tooltip.d.ts +1 -1
  62. package/dist/types/src/app/context/DemoStateContext.d.ts +1 -1
  63. package/dist/types/src/app/context/RouterContext.d.ts +5 -5
  64. package/dist/types/src/app/data/manifest-types.d.ts +6 -5
  65. package/dist/types/src/app/design-system/generated-token-refs.d.ts +8 -0
  66. package/dist/types/src/app/design-system/generated-token-values.d.ts +1 -0
  67. package/dist/types/src/app/design-system/tokens.d.ts +22 -3
  68. package/dist/types/src/icons.d.ts +1 -1
  69. package/dist/types/src/index.d.ts +33 -124
  70. package/dist/types/src/patterns.d.ts +20 -25
  71. package/dist/variables.css +6 -0
  72. package/docs/CONSUMING.md +21 -13
  73. package/hirobius.tokens.json +48 -15
  74. package/llms.txt +19 -20
  75. package/package.json +18 -22
  76. package/public/hds-manifest.json +2400 -6488
  77. package/public/llms/components.txt +41 -423
  78. package/public/llms/layout.txt +4 -4
  79. package/public/llms-full.txt +67 -448
  80. package/public/llms.txt +19 -20
  81. package/src/app/data/component-api.json +1379 -3271
  82. package/dist/chunks/LanguageContext-CH_bwzCq.js +0 -83
  83. package/dist/chunks/TokenDisplayContext-evZi_214.js +0 -18
  84. package/dist/chunks/activity-feed-DIMeUJpW.js +0 -21950
  85. package/dist/types/src/app/components/activity-feed.d.ts +0 -73
  86. package/dist/types/src/app/components/app-shell.d.ts +0 -40
  87. package/dist/types/src/app/components/aspect-ratio.d.ts +0 -25
  88. package/dist/types/src/app/components/bleed.d.ts +0 -39
  89. package/dist/types/src/app/components/button-group.d.ts +0 -22
  90. package/dist/types/src/app/components/calendar.d.ts +0 -22
  91. package/dist/types/src/app/components/carousel.d.ts +0 -26
  92. package/dist/types/src/app/components/center.d.ts +0 -36
  93. package/dist/types/src/app/components/cinematic-link.d.ts +0 -18
  94. package/dist/types/src/app/components/circular-progress.d.ts +0 -28
  95. package/dist/types/src/app/components/cluster.d.ts +0 -39
  96. package/dist/types/src/app/components/command-palette.d.ts +0 -46
  97. package/dist/types/src/app/components/component-instance-matrix.d.ts +0 -27
  98. package/dist/types/src/app/components/context-menu.d.ts +0 -78
  99. package/dist/types/src/app/components/cover.d.ts +0 -43
  100. package/dist/types/src/app/components/date-input.d.ts +0 -31
  101. package/dist/types/src/app/components/date-range-input.d.ts +0 -34
  102. package/dist/types/src/app/components/date-time-input.d.ts +0 -30
  103. package/dist/types/src/app/components/doc-link-card.d.ts +0 -32
  104. package/dist/types/src/app/components/error-boundary.d.ts +0 -25
  105. package/dist/types/src/app/components/file-input.d.ts +0 -30
  106. package/dist/types/src/app/components/foundation-swatch.d.ts +0 -45
  107. package/dist/types/src/app/components/frame.d.ts +0 -38
  108. package/dist/types/src/app/components/heading-stack.d.ts +0 -52
  109. package/dist/types/src/app/components/history-card.d.ts +0 -12
  110. package/dist/types/src/app/components/hover-card.d.ts +0 -46
  111. package/dist/types/src/app/components/icon-button.d.ts +0 -22
  112. package/dist/types/src/app/components/image-lightbox.d.ts +0 -14
  113. package/dist/types/src/app/components/input-group.d.ts +0 -23
  114. package/dist/types/src/app/components/multi-selector.d.ts +0 -40
  115. package/dist/types/src/app/components/nav-group.d.ts +0 -29
  116. package/dist/types/src/app/components/nav-item.d.ts +0 -33
  117. package/dist/types/src/app/components/not-found-pattern.d.ts +0 -1
  118. package/dist/types/src/app/components/overflow-list.d.ts +0 -21
  119. package/dist/types/src/app/components/selectable-card.d.ts +0 -29
  120. package/dist/types/src/app/components/side-nav.d.ts +0 -23
  121. package/dist/types/src/app/components/sketch.d.ts +0 -19
  122. package/dist/types/src/app/components/stacked-card-rail.d.ts +0 -32
  123. package/dist/types/src/app/components/stepper-field.d.ts +0 -12
  124. package/dist/types/src/app/components/stepper.d.ts +0 -33
  125. package/dist/types/src/app/components/text-lockup.d.ts +0 -38
  126. package/dist/types/src/app/components/tile-grid.d.ts +0 -17
  127. package/dist/types/src/app/components/time-input.d.ts +0 -24
  128. package/dist/types/src/app/components/toggle-button.d.ts +0 -32
  129. package/dist/types/src/app/components/token.d.ts +0 -90
  130. package/dist/types/src/app/components/tokenUtils.d.ts +0 -38
  131. package/dist/types/src/app/components/tokenizer.d.ts +0 -24
  132. package/dist/types/src/app/components/toolbar.d.ts +0 -36
  133. package/dist/types/src/app/components/top-nav.d.ts +0 -23
  134. package/dist/types/src/app/components/tree-list.d.ts +0 -30
  135. package/dist/types/src/app/data/nav-model.d.ts +0 -39
  136. package/dist/types/src/app/hooks/useFocusVisible.d.ts +0 -9
  137. package/dist/types/src/app/layouts/CaseStudyLayout.d.ts +0 -20
  138. package/dist/types/src/app/layouts/DocLayout.d.ts +0 -14
  139. package/dist/types/src/app/layouts/EmbeddedDocLayoutContext.d.ts +0 -6
  140. package/dist/types/src/app/layouts/HdsDocsShell.d.ts +0 -72
  141. package/dist/types/src/app/layouts/HdsSystemDocLayout.d.ts +0 -14
  142. package/dist/types/src/app/lib/hds-search.d.ts +0 -45
  143. package/dist/types/src/app/lib/nav-active.d.ts +0 -16
  144. package/dist/types/src/app/lib/navLevels.d.ts +0 -4
@@ -0,0 +1,515 @@
1
+ /**
2
+ * The engine behind the codemods that fold a removed component into the
3
+ * survivor that renders it (hds#395, hds#389's 2026-10-01 decision update):
4
+ * hds-not-found-pattern (NotFoundPattern -> ErrorPattern) and hds-tile-grid
5
+ * (TileGrid -> Grid). It ships with them as a package bin dependency, so it has
6
+ * no dependencies; codemods/unrewritable.mjs masks the source and lists what a
7
+ * codemod cannot see.
8
+ *
9
+ * A fold rewrites one named import of the package root and every JSX element of
10
+ * the name it binds:
11
+ * - the import names the survivor: `TileGrid` becomes `Grid`, from the root or
12
+ * from `/patterns` (the rule says which). An alias stays the file's name
13
+ * (`TileGrid as Tiles` becomes `Grid as Tiles`), and so does the old name
14
+ * when the file already binds the survivor's name to something else. A file
15
+ * that already imports the survivor reuses that import;
16
+ * - each `<Name …>` gets the attributes the rule writes, and its closing tag
17
+ * the new name. Every other attribute, comment and line break stays.
18
+ * A file is left as written, and listed for a manual edit, when any use of the
19
+ * name is not a JSX tag (`typeof`, a value, a member tag), when a tag has
20
+ * attributes the rule cannot map (a spread, say), when its tags do not pair up,
21
+ * or when it cannot be read to its end. A re-export, a type-only import, a
22
+ * removed props type, and a namespace or dynamic import that reads the name are
23
+ * listed too. Running a fold twice changes nothing.
24
+ */
25
+ import { readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
26
+ import { join, relative, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { findUnrewritable, maskSource } from './unrewritable.mjs';
29
+
30
+ export const ROOT_PKG = '@hirobius/design-system';
31
+ export const PATTERNS = `${ROOT_PKG}/patterns`;
32
+
33
+ const EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
34
+ const SKIP_DIRS = new Set([
35
+ 'node_modules',
36
+ 'dist',
37
+ 'build',
38
+ '.git',
39
+ '.next',
40
+ '.vercel',
41
+ 'coverage',
42
+ 'storybook-static',
43
+ ]);
44
+ const ID = String.raw`[A-Za-z_$][\w$]*`;
45
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
+
47
+ /**
48
+ * @typedef {{ start: number, end: number, comma: number, name: string, local: string,
49
+ * aliased: boolean, typeOnly: boolean }} Spec
50
+ * `start`/`end` bound the specifier without comments; `comma` is the comma after
51
+ * it, or -1.
52
+ * @typedef {{ from: number, to: number, kw: string, typeKw: boolean, def?: string,
53
+ * quote: string, semi: string, pkg: string, bodyFrom: number, bodyTo: number,
54
+ * specs: Spec[] }} Statement
55
+ * @typedef {{ name: string, start: number, end: number, kind: 'string' | 'expr' | 'bare' | 'spread',
56
+ * quote?: string, value?: string, valueStart?: number, valueEnd?: number }} Attr
57
+ * @typedef {{ at: number, nameEnd: number, end: number, selfClosing: boolean,
58
+ * attrs: Attr[], line: number }} Tag
59
+ * @typedef {{ start: number, end: number, text: string }} Edit
60
+ * @typedef {{
61
+ * name: string,
62
+ * survivor: string,
63
+ * survivorFrom: string,
64
+ * types?: Record<string, string>,
65
+ * rewrite: (tag: Tag, source: string) => { edits: Edit[] } | { manual: string },
66
+ * }} FoldRule
67
+ * `rewrite` maps one opening tag's attributes; `types` names each removed props
68
+ * type and what replaces it, for the manual report.
69
+ */
70
+
71
+ /** Each named import or re-export of `pkg`, read on the masked `code`. */
72
+ function statements(source, code, pkg) {
73
+ const re = new RegExp(
74
+ String.raw`(?:^|(?<=;))([ \t]*)(import|export)(\s+type)?\s*(?:(${ID})\s*,\s*)?\{([^}]*)\}\s*from\s*(['"]) {${pkg.length}}\6(;?)`,
75
+ 'gm',
76
+ );
77
+ const out = [];
78
+ for (const m of code.matchAll(re)) {
79
+ const to = m.index + m[0].length;
80
+ if (!source.startsWith(pkg, to - m[7].length - pkg.length - 1)) continue;
81
+ const bodyFrom = m.index + m[0].indexOf('{') + 1;
82
+ const bodyTo = bodyFrom + m[5].length;
83
+ out.push({
84
+ from: m.index + m[1].length,
85
+ to,
86
+ kw: m[2],
87
+ typeKw: !!m[3],
88
+ def: m[4],
89
+ quote: m[6],
90
+ semi: m[7],
91
+ pkg,
92
+ bodyFrom,
93
+ bodyTo,
94
+ specs: specsOf(code, bodyFrom, bodyTo),
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ const SPEC = new RegExp(String.raw`^(type\s+)?(${ID})(?:\s+as\s+(${ID}))?$`);
101
+
102
+ function specsOf(code, from, to) {
103
+ const specs = [];
104
+ let seg = from;
105
+ for (let k = from; k <= to; k++) {
106
+ if (k < to && code[k] !== ',') continue;
107
+ const text = code.slice(seg, k);
108
+ const a = text.search(/\S/);
109
+ if (a >= 0) {
110
+ const start = seg + a;
111
+ const end = seg + text.trimEnd().length;
112
+ const m = SPEC.exec(code.slice(start, end).replace(/\s+/g, ' '));
113
+ if (m)
114
+ specs.push({
115
+ start,
116
+ end,
117
+ comma: k < to ? k : -1,
118
+ typeOnly: !!m[1],
119
+ name: m[2],
120
+ local: m[3] ?? m[2],
121
+ aliased: !!m[3],
122
+ });
123
+ }
124
+ seg = k + 1;
125
+ }
126
+ return specs;
127
+ }
128
+
129
+ const lineOf = (source, at) => source.slice(0, at).split('\n').length;
130
+ const lineStart = (source, at) => source.lastIndexOf('\n', at - 1) + 1;
131
+ const lineEnd = (source, at) => {
132
+ const nl = source.indexOf('\n', at);
133
+ return nl < 0 ? source.length : nl;
134
+ };
135
+ const indentOf = (source, at) =>
136
+ /^[\t ]*/.exec(source.slice(lineStart(source, at), at).replace(/^/, ''))[0];
137
+
138
+ /** Every use of the identifier `name` in the masked code; a member access (`a.Name`) is not one. */
139
+ function uses(code, name) {
140
+ return [
141
+ ...code.matchAll(new RegExp(String.raw`(?<![\w$])(?<!(?<!\.)\.)${esc(name)}(?![\w$])`, 'g')),
142
+ ].map((m) => m.index);
143
+ }
144
+
145
+ /** The `}` that closes the `{` at `at` in the masked code, or -1. */
146
+ function closeBrace(code, at) {
147
+ let depth = 0;
148
+ for (let i = at; i < code.length; i++) {
149
+ if (code[i] === '{') depth++;
150
+ else if (code[i] === '}' && --depth === 0) return i;
151
+ }
152
+ return -1;
153
+ }
154
+
155
+ /** The attributes of the opening tag whose name ends at `from`, up to its `>` or `/>`; null when it does not parse. */
156
+ function parseTag(source, code, from) {
157
+ const attrs = [];
158
+ let i = from;
159
+ const skip = () => {
160
+ while (i < code.length && /\s/.test(code[i])) i++;
161
+ };
162
+ for (;;) {
163
+ skip();
164
+ if (i >= code.length) return null;
165
+ if (code[i] === '>') return { attrs, end: i + 1, selfClosing: false };
166
+ if (code.startsWith('/>', i)) return { attrs, end: i + 2, selfClosing: true };
167
+ if (code[i] === '{') {
168
+ const close = closeBrace(code, i);
169
+ if (close < 0) return null;
170
+ attrs.push({ name: '', kind: 'spread', start: i, end: close + 1 });
171
+ i = close + 1;
172
+ continue;
173
+ }
174
+ const m = /^[A-Za-z_$][\w$-]*(?::[\w$-]+)?/.exec(code.slice(i, i + 200));
175
+ if (!m) return null;
176
+ const attr = { name: m[0], start: i, kind: 'bare' };
177
+ i += m[0].length;
178
+ let j = i;
179
+ while (j < code.length && /\s/.test(code[j])) j++;
180
+ if (code[j] === '=') {
181
+ j++;
182
+ while (j < code.length && /\s/.test(code[j])) j++;
183
+ const q = code[j];
184
+ // String text is blank in the masked code, so the next quote closes it.
185
+ const close =
186
+ q === '"' || q === "'" ? code.indexOf(q, j + 1) : q === '{' ? closeBrace(code, j) : -1;
187
+ if (close < 0) return null;
188
+ Object.assign(attr, {
189
+ kind: q === '{' ? 'expr' : 'string',
190
+ quote: q === '{' ? undefined : q,
191
+ value: source.slice(j + 1, close),
192
+ valueStart: j,
193
+ valueEnd: close + 1,
194
+ });
195
+ i = close + 1;
196
+ }
197
+ attr.end = i;
198
+ attrs.push(attr);
199
+ }
200
+ }
201
+
202
+ /** Deletes a whole statement, with its line when nothing else is on it. */
203
+ function dropStatement(source, st) {
204
+ const ls = lineStart(source, st.from);
205
+ let end = st.to;
206
+ while (source[end] === ' ' || source[end] === '\t') end++;
207
+ if (
208
+ /^[\t ]*$/.test(source.slice(ls, st.from)) &&
209
+ (source[end] === '\n' || end === source.length)
210
+ )
211
+ return {
212
+ start: ls === 0 && source.startsWith('') ? 1 : ls,
213
+ end: Math.min(end + 1, source.length),
214
+ text: '',
215
+ };
216
+ return { start: st.from, end, text: '' };
217
+ }
218
+
219
+ /** Deletes one specifier and its comma; a line left blank goes too, and so does a statement left empty. */
220
+ function dropSpec(source, st, spec) {
221
+ if (st.specs.length === 1) {
222
+ if (!st.def) return dropStatement(source, st);
223
+ return {
224
+ start: st.from,
225
+ end: st.to,
226
+ text: `${st.kw} ${st.def} from ${st.quote}${st.pkg}${st.quote}${st.semi}`,
227
+ };
228
+ }
229
+ let start = spec.start;
230
+ let end = spec.end;
231
+ if (spec.comma >= 0) {
232
+ end = spec.comma + 1;
233
+ while (source[end] === ' ' || source[end] === '\t') end++;
234
+ } else start = st.specs[st.specs.indexOf(spec) - 1].comma;
235
+ const ls = lineStart(source, start);
236
+ const le = lineEnd(source, end);
237
+ if (/^[\t ]*$/.test(source.slice(ls, start)) && /^[\t ]*$/.test(source.slice(end, le)))
238
+ return { start: ls, end: Math.min(le + 1, source.length), text: '' };
239
+ return { start, end, text: '' };
240
+ }
241
+
242
+ /** Adds a specifier to the end of a statement's list, on a line of its own when the list has one per line. */
243
+ function addSpec(source, st, text) {
244
+ const last = st.specs[st.specs.length - 1];
245
+ if (!last) return { start: st.bodyFrom, end: st.bodyTo, text: ` ${text} ` };
246
+ if (last.comma < 0) return { start: last.end, end: last.end, text: `, ${text}` };
247
+ const le = lineEnd(source, last.comma);
248
+ if (st.bodyTo > le)
249
+ return { start: le, end: le, text: `\n${indentOf(source, last.start)}${text},` };
250
+ return { start: last.comma + 1, end: last.comma + 1, text: ` ${text},` };
251
+ }
252
+
253
+ /**
254
+ * The lines the edits change, as hunks for `--dry-run`: `line` is 1-based in
255
+ * `source`. Built from the edits, so the cost is linear in the file whatever
256
+ * the distance between the first and the last change. Edits on one line share a
257
+ * hunk; a whole line an edit deletes (its newline included) shows as removed.
258
+ * @param {string} source
259
+ * @param {Edit[]} edits
260
+ */
261
+ function editHunks(source, edits) {
262
+ const clusters = [];
263
+ for (const e of [...edits].sort((a, b) => a.start - b.start || a.end - b.end)) {
264
+ const from = lineStart(source, e.start);
265
+ const to = e.end > e.start && source[e.end - 1] === '\n' ? e.end - 1 : lineEnd(source, e.end);
266
+ const last = clusters[clusters.length - 1];
267
+ if (last && from <= last.to) {
268
+ last.to = Math.max(last.to, to);
269
+ last.edits.push(e);
270
+ } else clusters.push({ from, to, edits: [e] });
271
+ }
272
+ return clusters.map(({ from, to, edits: group }) => {
273
+ let text = source.slice(from, to);
274
+ for (const e of group.sort((a, b) => b.start - a.start || b.end - a.end))
275
+ text = text.slice(0, e.start - from) + e.text + text.slice(Math.min(e.end, to) - from);
276
+ const wholeLines = group.every((e) => e.end > to);
277
+ return {
278
+ line: lineOf(source, from),
279
+ before: source.slice(from, to).split('\n'),
280
+ after: wholeLines && text === '' ? [] : text.split('\n'),
281
+ };
282
+ });
283
+ }
284
+
285
+ const unchanged = (source, manual) => ({ source, changed: false, sites: 0, manual, edits: [] });
286
+
287
+ /**
288
+ * Pure transform of one file's source. Returns the source unchanged, with the
289
+ * reasons in `manual`, when the file holds a use the rule cannot rewrite.
290
+ * @param {string} source
291
+ * @param {FoldRule} rule
292
+ * @returns {{ source: string, changed: boolean, sites: number, manual: string[],
293
+ * edits: { line: number, before: string[], after: string[] }[] }}
294
+ */
295
+ export function foldComponent(source, rule) {
296
+ const { code, unterminated } = maskSource(source);
297
+ const types = rule.types ?? {};
298
+ const manual = findUnrewritable(source, ROOT_PKG, [rule.name, ...Object.keys(types)]);
299
+ const roots = statements(source, code, ROOT_PKG);
300
+ const hits = [];
301
+ for (const st of roots)
302
+ for (const s of st.specs) {
303
+ if (Object.hasOwn(types, s.name))
304
+ manual.push(`${s.name} from '${ROOT_PKG}' (removed in 0.20.0, use ${types[s.name]})`);
305
+ if (s.name !== rule.name) continue;
306
+ if (st.kw === 'export') {
307
+ const where = rule.survivorFrom === ROOT_PKG ? '' : ` from '${rule.survivorFrom}'`;
308
+ manual.push(
309
+ `export { ${s.name} } from '${ROOT_PKG}' (removed in 0.20.0; re-export ${rule.survivor}${where} by hand)`,
310
+ );
311
+ } else if (st.typeKw || s.typeOnly)
312
+ manual.push(
313
+ `import type { ${s.name} } from '${ROOT_PKG}' at line ${lineOf(source, st.from)} (removed in 0.20.0; use typeof ${rule.survivor} by hand)`,
314
+ );
315
+ else hits.push({ st, s });
316
+ }
317
+ if (hits.length === 0 || unterminated) return unchanged(source, manual);
318
+ if (hits.length > 1) {
319
+ manual.push(`${rule.name} is imported ${hits.length} times; rewrite it by hand`);
320
+ return unchanged(source, manual);
321
+ }
322
+ const [{ st, s }] = hits;
323
+
324
+ // Every use of the local name must be the import itself or a JSX tag.
325
+ const opens = [];
326
+ let closes = 0;
327
+ const local = s.local;
328
+ const problems = [];
329
+ for (const at of uses(code, local)) {
330
+ if (at >= st.bodyFrom && at < st.bodyTo) continue;
331
+ const nameEnd = at + local.length;
332
+ if (code[at - 1] === '<' && /[\s/>]/.test(code[nameEnd] ?? '')) {
333
+ const tag = parseTag(source, code, nameEnd);
334
+ if (!tag) problems.push(`<${local}> at line ${lineOf(source, at)} could not be read`);
335
+ else opens.push({ at, nameEnd, line: lineOf(source, at), ...tag });
336
+ } else if (
337
+ /<\/\s*$/.test(code.slice(Math.max(0, at - 8), at)) &&
338
+ /^\s*>/.test(code.slice(nameEnd))
339
+ )
340
+ closes++;
341
+ else
342
+ problems.push(`${local} at line ${lineOf(source, at)} is not a JSX tag; rewrite it by hand`);
343
+ }
344
+ if (opens.filter((t) => !t.selfClosing).length !== closes)
345
+ problems.push(`cannot pair every <${local}> with its closing tag; rewrite it by hand`);
346
+
347
+ const edits = [];
348
+ for (const tag of opens) {
349
+ const r = rule.rewrite(tag, source);
350
+ if ('manual' in r) problems.push(`<${rule.name}> at line ${tag.line}: ${r.manual}`);
351
+ else edits.push(...r.edits);
352
+ }
353
+ if (problems.length > 0) return unchanged(source, [...manual, ...problems]);
354
+
355
+ // The binding: reuse a survivor import, keep the file's name, or take the survivor's.
356
+ const sources = rule.survivorFrom === ROOT_PKG ? [ROOT_PKG] : [ROOT_PKG, rule.survivorFrom];
357
+ const imported = sources
358
+ .flatMap((pkg) => (pkg === ROOT_PKG ? roots : statements(source, code, pkg)))
359
+ .some(
360
+ (x) =>
361
+ x.kw === 'import' &&
362
+ !x.typeKw &&
363
+ x.specs.some((y) => y.name === rule.survivor && !y.aliased && !y.typeOnly),
364
+ );
365
+ const reuse = !s.aliased && imported;
366
+ const keepName = s.aliased || (!reuse && uses(code, rule.survivor).length > 0);
367
+ const name = keepName ? local : rule.survivor;
368
+ const specText = keepName ? `${rule.survivor} as ${local}` : rule.survivor;
369
+
370
+ if (reuse) edits.push(dropSpec(source, st, s));
371
+ else if (rule.survivorFrom === ROOT_PKG)
372
+ edits.push({ start: s.start, end: s.end, text: specText });
373
+ else {
374
+ const target = statements(source, code, rule.survivorFrom).find(
375
+ (x) => x.kw === 'import' && !x.typeKw && !x.def,
376
+ );
377
+ const line = `import { ${specText} } from ${st.quote}${rule.survivorFrom}${st.quote}${st.semi}`;
378
+ if (target) edits.push(addSpec(source, target, specText), dropSpec(source, st, s));
379
+ else if (st.specs.length === 1 && !st.def)
380
+ edits.push({ start: st.from, end: st.to, text: line });
381
+ else
382
+ edits.push(dropSpec(source, st, s), {
383
+ start: st.to,
384
+ end: st.to,
385
+ text: `\n${indentOf(source, st.from)}${line}`,
386
+ });
387
+ }
388
+ if (name !== local) {
389
+ for (const tag of opens) edits.push({ start: tag.at, end: tag.nameEnd, text: name });
390
+ for (const m of code.matchAll(new RegExp(String.raw`<\/\s*(${esc(local)})\s*>`, 'g'))) {
391
+ const at = m.index + m[0].indexOf(local, 2);
392
+ edits.push({ start: at, end: at + local.length, text: name });
393
+ }
394
+ }
395
+
396
+ let out = source;
397
+ for (const e of edits.sort((x, y) => y.start - x.start || y.end - x.end))
398
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
399
+ return {
400
+ source: out,
401
+ changed: out !== source,
402
+ sites: opens.length,
403
+ manual,
404
+ edits: editHunks(source, edits),
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Where to insert attributes in front of `attr`, or after the tag name when
410
+ * `attr` is null: the separator copies the tag's layout (a new line and indent
411
+ * when its attributes sit one per line).
412
+ * @param {string} source
413
+ * @param {Tag} tag
414
+ * @param {Attr | null} attr
415
+ */
416
+ export function insertionPoint(source, tag, attr) {
417
+ const first = attr ?? tag.attrs[0];
418
+ if (!first) return { at: tag.nameEnd, sep: ' ' };
419
+ const gap = /\s*$/.exec(source.slice(tag.nameEnd, first.start))[0];
420
+ const sep = gap.includes('\n') ? gap : ' ';
421
+ return attr ? { at: attr.start, sep } : { at: tag.nameEnd, sep };
422
+ }
423
+
424
+ function* walk(dir) {
425
+ for (const entry of readdirSync(dir)) {
426
+ if (SKIP_DIRS.has(entry)) continue;
427
+ const full = join(dir, entry);
428
+ const st = statSync(full);
429
+ if (st.isDirectory()) yield* walk(full);
430
+ else if (EXTS.has(full.slice(full.lastIndexOf('.')))) yield full;
431
+ }
432
+ }
433
+
434
+ /** Scan a directory with one rule. Writes only when `write` is true. */
435
+ export function runFold({ root, write = false, rule }) {
436
+ const files = [];
437
+ const manual = [];
438
+ let sites = 0;
439
+ for (const file of walk(root)) {
440
+ const src = readFileSync(file, 'utf8');
441
+ if (!src.includes(ROOT_PKG)) continue;
442
+ const r = foldComponent(src, rule);
443
+ for (const stmt of r.manual) manual.push({ file: relative(root, file), stmt });
444
+ if (!r.changed) continue;
445
+ files.push({ file: relative(root, file), sites: r.sites, edits: r.edits });
446
+ sites += r.sites;
447
+ if (write) writeFileSync(file, r.source);
448
+ }
449
+ return { files, sites, manual };
450
+ }
451
+
452
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
453
+
454
+ /**
455
+ * The CLI every fold codemod shares:
456
+ * <bin> [--root <dir>] [--check] [--dry-run]
457
+ * `--check` writes nothing and exits 1 while a rewrite or a manual edit remains;
458
+ * `--dry-run` writes nothing and prints each changed line before (-) and after (+).
459
+ */
460
+ export function main(argv, { bin, rule }) {
461
+ const args = { root: process.cwd(), check: false, dryRun: false };
462
+ for (let i = 0; i < argv.length; i++) {
463
+ if (argv[i] === '--root') args.root = resolve(argv[++i] ?? '');
464
+ else if (argv[i] === '--check') args.check = true;
465
+ else if (argv[i] === '--dry-run') args.dryRun = true;
466
+ else {
467
+ console.error(
468
+ `unknown argument: ${argv[i]}\nusage: ${bin} [--root <dir>] [--check] [--dry-run]`,
469
+ );
470
+ return 2;
471
+ }
472
+ }
473
+ const write = !args.check && !args.dryRun;
474
+ const res = runFold({ root: args.root, write, rule });
475
+ const summary = `${plural(res.files.length, 'file')}, ${plural(res.sites, 'site')} (${rule.name} -> ${rule.survivor})`;
476
+ const manualLines = res.manual.map((m) => ` ${m.file}: ${m.stmt}`);
477
+ if (args.check) {
478
+ if (res.files.length > 0 || res.manual.length > 0) {
479
+ console.error(`${bin}: rewrite needed: ${summary}`);
480
+ for (const f of res.files) console.error(` ${f.file}: ${plural(f.sites, 'site')}`);
481
+ for (const l of manualLines) console.error(l);
482
+ return 1;
483
+ }
484
+ console.log(`${bin}: nothing to rewrite`);
485
+ return 0;
486
+ }
487
+ console.log(`${bin}: ${write ? 'rewrote' : 'would rewrite'} ${summary}`);
488
+ for (const f of res.files) {
489
+ console.log(` ${f.file}: ${plural(f.sites, 'site')}`);
490
+ if (args.dryRun)
491
+ for (const h of f.edits) {
492
+ console.log(`@@ line ${h.line}`);
493
+ for (const l of h.before) console.log(`- ${l}`);
494
+ for (const l of h.after) console.log(`+ ${l}`);
495
+ }
496
+ }
497
+ if (res.manual.length > 0) {
498
+ console.warn(`${bin}: cannot rewrite these, do them by hand:`);
499
+ for (const l of manualLines) console.warn(l);
500
+ }
501
+ return 0;
502
+ }
503
+
504
+ /**
505
+ * Whether the module at `url` is the script node was started with. Compares real
506
+ * paths: npm/yarn link the bin and pnpm links the package directory, so argv[1]
507
+ * is a symlink while import.meta.url is already resolved.
508
+ */
509
+ export function isEntry(url) {
510
+ try {
511
+ return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(url));
512
+ } catch {
513
+ return false;
514
+ }
515
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * not-found-pattern codemod (hds#395, hds#389's 2026-10-01 decision update)
4
+ *
5
+ * 0.20.0 removed NotFoundPattern from the package root. It took no props and
6
+ * rendered `<ErrorPattern displayText="404" message="Page not found" />`, so
7
+ * this writes that element in its place, with ErrorPattern imported from
8
+ * `@hirobius/design-system/patterns`:
9
+ *
10
+ * import { NotFoundPattern } from '@hirobius/design-system';
11
+ * <NotFoundPattern />
12
+ *
13
+ * becomes
14
+ *
15
+ * import { ErrorPattern } from '@hirobius/design-system/patterns';
16
+ * <ErrorPattern displayText="404" message="Page not found" />
17
+ *
18
+ * Other root names stay on the root; ErrorPattern joins an existing `/patterns`
19
+ * import, and a file that already imports ErrorPattern reuses it. An alias stays
20
+ * the file's name (`NotFoundPattern as Missing` becomes `ErrorPattern as
21
+ * Missing`), and so does `NotFoundPattern` when the file binds `ErrorPattern` to
22
+ * something else. Attributes the element already has (`key`) are kept, and so
23
+ * are comments and layout. A tag with a spread, or with a `displayText` or
24
+ * `message` NotFoundPattern ignored, is left for a manual edit, as is any use
25
+ * that is not a JSX tag (codemods/jsx-fold.mjs). Running it twice changes nothing.
26
+ *
27
+ * node codemods/not-found-pattern.mjs [--root <dir>] [--check] [--dry-run]
28
+ *
29
+ * --root <dir> directory to scan (default: current directory)
30
+ * --check write nothing; exit 1 while a rewrite is needed or a site needs
31
+ * a manual edit (a namespace or dynamic import that reads the
32
+ * name, or a file that cannot be read to its end, included)
33
+ * --dry-run write nothing; print each changed line before (-) and after (+), exit 0
34
+ */
35
+ import { PATTERNS, foldComponent, insertionPoint, isEntry, main, runFold } from './jsx-fold.mjs';
36
+
37
+ /** @type {import('./jsx-fold.mjs').FoldRule} */
38
+ export const RULE = Object.freeze({
39
+ name: 'NotFoundPattern',
40
+ survivor: 'ErrorPattern',
41
+ survivorFrom: PATTERNS,
42
+ rewrite(tag, source) {
43
+ if (tag.attrs.some((a) => a.kind === 'spread'))
44
+ return { manual: 'a spread could carry displayText or message; rewrite it by hand' };
45
+ const set = tag.attrs.find((a) => a.name === 'displayText' || a.name === 'message');
46
+ if (set)
47
+ return {
48
+ manual: `NotFoundPattern ignored ${set.name}, ErrorPattern would render it; rewrite it by hand`,
49
+ };
50
+ const { at, sep } = insertionPoint(source, tag, null);
51
+ return {
52
+ edits: [
53
+ { start: at, end: at, text: `${sep}displayText="404"${sep}message="Page not found"` },
54
+ ],
55
+ };
56
+ },
57
+ });
58
+
59
+ /** Pure transform of one file's source (codemods/jsx-fold.mjs `foldComponent`). */
60
+ export function transformSource(source) {
61
+ return foldComponent(source, RULE);
62
+ }
63
+
64
+ /** Scan a directory. Writes only when `write` is true. */
65
+ export function runCodemod({ root, write = false }) {
66
+ return runFold({ root, write, rule: RULE });
67
+ }
68
+
69
+ if (isEntry(import.meta.url)) {
70
+ process.exit(main(process.argv.slice(2), { bin: 'hds-not-found-pattern', rule: RULE }));
71
+ }