bestax-migrate 2.25.6 → 2.26.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.
@@ -0,0 +1,948 @@
1
+ /**
2
+ * A Bulma HTML page → a React module, written beside it.
3
+ *
4
+ * The page is parsed the way a browser builds it (`_shared/html/`), its body
5
+ * becomes the JSX a component returns, laid out with Bulma's displays so the
6
+ * whitespace beside a block, flex or grid box goes, and then the bulma-classes
7
+ * transform runs on that module unchanged, so the HTML converts exactly as
8
+ * the same markup written as JSX would. A number attribute on a plain element
9
+ * is written as a number last, since the planner reads attributes as strings.
10
+ *
11
+ * What a component can't carry becomes a `TODO(bestax-migrate)` line and a
12
+ * report entry under the `html-*` rules (`html-rules.ts`): the head, the
13
+ * `<html>` and `<body>` attributes, scripts (dropped, since a component runs
14
+ * none; their code stays in the page), and what the converter couldn't write
15
+ * as it was. Document-level ones sit above the component, the rest above its
16
+ * `return`, where the planner's own go. A report entry names the page and
17
+ * its line, never the module's.
18
+ *
19
+ * Nothing is ever overwritten: a module already beside the page stops it
20
+ * (`html-file:exists`), so a second run changes nothing, and the page itself
21
+ * stays as it was. A page with no Bulma component class writes nothing (a
22
+ * Vite `index.html` is a page like any other), and a server template
23
+ * (`{{`, `{%`, `<?php`, `<%`) is refused rather than read as HTML.
24
+ */
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { makeApi, runTransform } from '../../runner.js';
28
+ import { isNumberProp, numberLiteral } from '../_shared/html/attributes.js';
29
+ import { emitJsx } from '../_shared/html/emit.js';
30
+ import { childrenOf, isElement, isText, parseHtml, } from '../_shared/html/parse.js';
31
+ import { boxOfDisplay, uaDisplay, validInlineDisplay, } from '../_shared/html/whitespace.js';
32
+ import { flaggableRoot } from './class-map.js';
33
+ import { bulmaDisplay, bulmaOutOfFlow, HELPER_DISPLAY } from './display.js';
34
+ import { htmlRuleId, NAVBAR_FIXED_CLASSES, THEME_CLASSES, } from './html-rules.js';
35
+ import { writesTypeScript } from './project.js';
36
+ import transform from './transform.js';
37
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
38
+ function attributeOf(element, name) {
39
+ return element.attrs.find(attr => attr.name === name && !attr.prefix)?.value;
40
+ }
41
+ function classesOf(element) {
42
+ return (attributeOf(element, 'class') ?? '')
43
+ .split(/[ \t\n\f\r]+/)
44
+ .filter(Boolean);
45
+ }
46
+ function textContent(node) {
47
+ return childrenOf(node)
48
+ .map(child => isText(child) ? child.value : isElement(child) ? textContent(child) : '')
49
+ .join('');
50
+ }
51
+ function quote(text, limit = 60) {
52
+ const flat = text.replace(/\s+/g, ' ').trim();
53
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
54
+ }
55
+ // ---- Display --------------------------------------------------------------
56
+ const INLINE_FORM = {
57
+ inline: 'inline',
58
+ block: 'inline-block',
59
+ boxes: 'inline-flex',
60
+ };
61
+ const BLOCK_FORM = {
62
+ inline: 'block',
63
+ block: 'block',
64
+ boxes: 'flex',
65
+ };
66
+ /**
67
+ * One display that keeps every run of whitespace any of `displays` would.
68
+ * An inline box keeps the runs beside it, `none` (or a box out of the flow)
69
+ * leaves them to what is past it, and a block drops them, so the result sits
70
+ * inline if any of them does, and is no box if any is, before it is a block.
71
+ * Inside, flowing on the line keeps the most and laying children out as boxes
72
+ * the least.
73
+ */
74
+ export function keepingMost(displays) {
75
+ if (displays.size === 1)
76
+ return [...displays][0];
77
+ const boxes = [...displays].map(boxOfDisplay);
78
+ const shown = boxes.filter(box => box.outer !== 'none');
79
+ const inner = shown.some(box => box.inner === 'inline')
80
+ ? 'inline'
81
+ : shown.some(box => box.inner === 'block')
82
+ ? 'block'
83
+ : 'boxes';
84
+ if (shown.some(box => box.outer === 'inline'))
85
+ return INLINE_FORM[inner];
86
+ if (shown.length < boxes.length)
87
+ return 'none';
88
+ return BLOCK_FORM[inner];
89
+ }
90
+ /**
91
+ * An element's display on a Bulma page: its inline `style`'s when CSS reads
92
+ * it, else what Bulma's classes give it at each viewport (its tag's own where
93
+ * no rule reaches), else its tag's own. Bulma's display helpers are
94
+ * `!important`, so beside one an inline display is one case among theirs.
95
+ * A box Bulma takes out of the flow is beside nothing, so `none` counts too.
96
+ * Where the cases differ, the one display kept is the one that keeps every
97
+ * run any of them would (`keepingMost`): whitespace beside an element goes
98
+ * only when every case drops it.
99
+ *
100
+ * The page's own stylesheets are out of reach, as they are for the
101
+ * converter: a class of the page's that changes a display is the limit.
102
+ */
103
+ export const pageDisplayOf = element => {
104
+ const classes = classesOf(element);
105
+ const tagDisplay = uaDisplay(element) ?? 'inline';
106
+ const bulma = [...(bulmaDisplay(element.tagName, classes) ?? [])].map(display => display ?? tagDisplay);
107
+ const inline = validInlineDisplay(element);
108
+ const displays = new Set();
109
+ if (inline !== undefined) {
110
+ displays.add(inline);
111
+ if (classes.some(token => `.${token}` in HELPER_DISPLAY)) {
112
+ for (const display of bulma)
113
+ displays.add(display);
114
+ }
115
+ }
116
+ else if (bulma.length > 0) {
117
+ for (const display of bulma)
118
+ displays.add(display);
119
+ }
120
+ else {
121
+ displays.add(tagDisplay);
122
+ }
123
+ if (bulmaOutOfFlow(classes))
124
+ displays.add('none');
125
+ return keepingMost(displays);
126
+ };
127
+ // ---- Naming and placing the module -----------------------------------------
128
+ /**
129
+ * The component a page becomes: its file's name in PascalCase (its folder's
130
+ * for an `index.html`) and `Page`, so `hero.html` can't shadow bestax's
131
+ * `Hero`. A name starting with a digit starts with `Page` instead
132
+ * (`404.html` is `Page404`), and one with nothing to go by is `Page`.
133
+ */
134
+ export function componentName(file) {
135
+ const stem = path.basename(file, path.extname(file));
136
+ const named = /^index$/i.test(stem)
137
+ ? path.basename(path.dirname(path.resolve(file)))
138
+ : stem;
139
+ const words = named.split(/[^\p{L}\p{Nd}]+/u).filter(Boolean);
140
+ const pascal = words
141
+ .map(word => {
142
+ const [first, ...rest] = [...word];
143
+ return first.toUpperCase() + rest.join('');
144
+ })
145
+ .join('');
146
+ let name;
147
+ if (/^\p{Nd}/u.test(pascal))
148
+ name = `Page${pascal}`;
149
+ else if (pascal.endsWith('Page'))
150
+ name = pascal;
151
+ else
152
+ name = `${pascal}Page`;
153
+ return /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name) ? name : 'Page';
154
+ }
155
+ /**
156
+ * Where a page's module goes: beside it, `.tsx` when the package around it is
157
+ * TypeScript (a tsconfig, or a dependency on `typescript`), `.jsx` otherwise.
158
+ */
159
+ export function outputPathFor(file, options = {}) {
160
+ const extension = writesTypeScript(file, options.packageLanguages)
161
+ ? '.tsx'
162
+ : '.jsx';
163
+ return path.join(path.dirname(file), `${path.basename(file, path.extname(file))}${extension}`);
164
+ }
165
+ // ---- What the page is ------------------------------------------------------
166
+ /** The first element in the content with a class bestax has a component for. */
167
+ function firstBulmaElement(root) {
168
+ for (const child of childrenOf(root)) {
169
+ if (!isElement(child))
170
+ continue;
171
+ if (classesOf(child).some(token => flaggableRoot(token)))
172
+ return child;
173
+ const inner = firstBulmaElement(child);
174
+ if (inner)
175
+ return inner;
176
+ }
177
+ return undefined;
178
+ }
179
+ /** Server-template syntax: Handlebars, Jinja, Liquid, PHP, ERB and EJS. */
180
+ const TEMPLATE_SYNTAX = /\{\{|\{%|<\?(?:php\b|=)|<%/i;
181
+ function lineAt(source, index) {
182
+ return source.slice(0, index).split('\n').length;
183
+ }
184
+ /**
185
+ * For a page a folder walk found but left out: whether to say so. `-e`
186
+ * matches an extension exactly, as it does for every other file, so the
187
+ * advice names the page's own (`-e html` takes no `.htm` page).
188
+ */
189
+ export function excludedHtml(file, source) {
190
+ const element = firstBulmaElement(parseHtml(source).root);
191
+ if (!element)
192
+ return null;
193
+ const extension = path.extname(file).replace(/^\./, '');
194
+ return {
195
+ file,
196
+ line: element.sourceCodeLocation?.startLine ?? null,
197
+ rule: htmlRuleId('html-file', 'not-included'),
198
+ message: `this page has Bulma classes the bulma-classes source converts, but a folder walk takes a page only when \`--extensions\` lists its extension, and it doesn't list \`${extension}\`: name the page, or add \`${extension}\` to \`--extensions\` (\`-e ${extension}\` for the pages alone)`,
199
+ };
200
+ }
201
+ const JAVASCRIPT_TYPES = /^(?:|module|(?:text|application)\/(?:x-)?(?:javascript|ecmascript)|text\/(?:jscript|livescript)|text\/javascript1\.[0-5])$/i;
202
+ /** The patterns an inline script's code is looked over for, in order. */
203
+ const SCRIPT_PATTERNS = [
204
+ [
205
+ 'burger',
206
+ /navbar-burger/,
207
+ "toggles the navbar burger: bestax's `Navbar.Burger` takes `active` and `onClick`, and `Navbar.Menu` the same `active`, so keep the open state in `useState` and pass it to both",
208
+ ],
209
+ [
210
+ 'modal',
211
+ /modal/,
212
+ "opens or closes a modal: bestax's `Modal` shows while `active` is true and calls `onClose` from its background, its close button and Escape, so keep the open state in `useState`",
213
+ ],
214
+ [
215
+ 'dismiss',
216
+ /['"`][^'"`\n]*\.delete\b/,
217
+ "removes an element when its `.delete` is clicked: bestax's `Notification` takes `hasDelete` and `onDelete`, `Tag` takes `onDelete` and `Message` takes `onClose`, so keep whether it shows in state",
218
+ ],
219
+ [
220
+ 'carousel',
221
+ /carousel/i,
222
+ "drives a carousel: bestax's `Carousel` takes its slides as `CarouselItem` children and renders its own arrows and indicators (`autoplay` for a timed one); its styles ship in bestax's `extras.css` and `bestax.css`, not in Bulma's stylesheet",
223
+ ],
224
+ [
225
+ 'theme',
226
+ /data-theme|dataset\.theme|theme-(?:dark|light)|prefers-color-scheme/,
227
+ "switches Bulma's light and dark schemes: bestax's `Theme` writes `data-theme` on `<html>` from its `colorMode` (`light`, `dark` or `system`), so keep the choice in state and pass it",
228
+ ],
229
+ ];
230
+ const SCRIPT_KEPT = "it was dropped, since a component doesn't run a page's scripts, and its code is still in the page";
231
+ function scriptNotes(script) {
232
+ const location = script.sourceCodeLocation;
233
+ const line = location?.startLine ?? null;
234
+ const endLine = location?.endLine;
235
+ const src = attributeOf(script, 'src');
236
+ if (src !== undefined) {
237
+ return [
238
+ {
239
+ rule: htmlRuleId('html-asset'),
240
+ message: `the page loads the script \`${quote(src)}\`, and a component runs no page scripts: load it in the app if the app still needs it, or replace what it does with React code`,
241
+ line,
242
+ },
243
+ ];
244
+ }
245
+ const type = (attributeOf(script, 'type') ?? '').trim().toLowerCase();
246
+ if (type === 'application/ld+json') {
247
+ return [
248
+ {
249
+ rule: htmlRuleId('html-script', 'json-ld'),
250
+ message: `structured data for search engines (JSON-LD) was dropped with the page's scripts: render it where the app sets each page's head, as \`<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />\`; the JSON is still in the page`,
251
+ line,
252
+ endLine,
253
+ },
254
+ ];
255
+ }
256
+ if (!JAVASCRIPT_TYPES.test(type)) {
257
+ return [
258
+ {
259
+ rule: htmlRuleId('html-script'),
260
+ message: `a \`<script type="${quote(type, 40)}">\` was dropped with the page's scripts: if the page's code reads it (a template, data), move what it holds into the component; it is still in the page`,
261
+ line,
262
+ endLine,
263
+ },
264
+ ];
265
+ }
266
+ const code = textContent(script);
267
+ const found = SCRIPT_PATTERNS.filter(([, pattern]) => pattern.test(code));
268
+ if (found.length === 0) {
269
+ return [
270
+ {
271
+ rule: htmlRuleId('html-script'),
272
+ message: `an inline script was dropped, since a component doesn't run a page's scripts: port what it does into the component (state, effects, event handlers); its code is still in the page`,
273
+ line,
274
+ endLine,
275
+ },
276
+ ];
277
+ }
278
+ return found.map(([token, , recipe]) => ({
279
+ rule: htmlRuleId('html-script', token),
280
+ message: `an inline script ${recipe}; ${SCRIPT_KEPT}`,
281
+ line,
282
+ endLine,
283
+ }));
284
+ }
285
+ /** Remove every `<script>` from the content, with a note for each. */
286
+ function dropScripts(parent, notes) {
287
+ const nodes = childrenOf(parent);
288
+ for (let i = 0; i < nodes.length; i++) {
289
+ const node = nodes[i];
290
+ if (!isElement(node))
291
+ continue;
292
+ if (node.tagName === 'script' && node.namespaceURI === HTML_NS) {
293
+ notes.push(...scriptNotes(node));
294
+ nodes.splice(i, 1);
295
+ i--;
296
+ }
297
+ else if (node.tagName !== 'template') {
298
+ dropScripts(node, notes);
299
+ }
300
+ }
301
+ }
302
+ /** Bulma's stylesheet, by its file name. */
303
+ const BULMA_STYLESHEET = /(?:^|[/\\])bulma(?:\.min)?\.css(?:$|[?#])/i;
304
+ function stylesheetNote(href, line, css) {
305
+ const rule = htmlRuleId('html-asset');
306
+ if (!BULMA_STYLESHEET.test(href)) {
307
+ return {
308
+ rule,
309
+ message: `the page loads the stylesheet \`${quote(href)}\`, and a component doesn't: load it once for the app, from its entry or a \`<link>\` in its index.html`,
310
+ line,
311
+ };
312
+ }
313
+ const load = {
314
+ keep: "load Bulma once for the app, from its entry (`import 'bulma/css/bulma.min.css'`, with `bulma` installed) or a `<link>` in its index.html",
315
+ bestax: "under `--css bestax` the app loads bestax's bundle instead, once, from its entry: `import '@allxsmith/bestax-bulma/bestax.css'`. That bundle builds Bulma with bestax blue as its primary, so `is-primary` turns from Bulma's turquoise to blue; to keep the page's color, set the `--bulma-primary-*` variables, `--bulma-primary-invert-l` among them (the bestax-migrate skill's `references/css-migration.md` says which), or use `--css bulma`",
316
+ bulma: "under `--css bulma` the app loads Bulma and bestax's extras once, from its entry: `import 'bulma/css/bulma.min.css'` and `import '@allxsmith/bestax-bulma/extras.css'`",
317
+ }[css];
318
+ return {
319
+ rule,
320
+ message: `the page loads Bulma's stylesheet from \`${quote(href)}\`, and a component doesn't: ${load}`,
321
+ line,
322
+ };
323
+ }
324
+ /** What a `<meta>` is for: its `name`, `property`, `http-equiv` or charset. */
325
+ function metaKind(meta) {
326
+ if (attributeOf(meta, 'charset') !== undefined)
327
+ return 'charset';
328
+ return (attributeOf(meta, 'name') ??
329
+ attributeOf(meta, 'property') ??
330
+ attributeOf(meta, 'http-equiv') ??
331
+ attributeOf(meta, 'itemprop') ??
332
+ 'meta').toLowerCase();
333
+ }
334
+ function listed(values) {
335
+ const unique = [...new Set(values.map(value => quote(value, 30)))];
336
+ const shown = unique.slice(0, 6).join(', ');
337
+ return unique.length > 6 ? `${shown}, …` : shown;
338
+ }
339
+ function headNotes(head, css, notes) {
340
+ const metas = [];
341
+ const links = [];
342
+ for (const node of head) {
343
+ if (!isElement(node))
344
+ continue;
345
+ const line = node.sourceCodeLocation?.startLine ?? null;
346
+ const endLine = node.sourceCodeLocation?.endLine;
347
+ switch (node.tagName) {
348
+ case 'title':
349
+ notes.push({
350
+ rule: htmlRuleId('html-head', 'title'),
351
+ message: `the page's \`<title>\` ("${quote(textContent(node))}") isn't part of a component: set it where the app sets each page's title (under React 19 a component can render \`<title>\` itself, and React moves it into the head)`,
352
+ line,
353
+ });
354
+ break;
355
+ case 'meta':
356
+ metas.push(node);
357
+ break;
358
+ case 'link': {
359
+ const rel = (attributeOf(node, 'rel') ?? '').toLowerCase().split(/\s+/);
360
+ const href = attributeOf(node, 'href');
361
+ if (rel.includes('stylesheet') && href !== undefined) {
362
+ notes.push(stylesheetNote(href, line, css));
363
+ }
364
+ else {
365
+ links.push(node);
366
+ }
367
+ break;
368
+ }
369
+ case 'style':
370
+ notes.push({
371
+ rule: htmlRuleId('html-head', 'style'),
372
+ message: "the page's `<head>` has a `<style>` block, which was dropped: move its rules into the app's stylesheet; they are still in the page",
373
+ line,
374
+ endLine,
375
+ });
376
+ break;
377
+ case 'script':
378
+ notes.push(...scriptNotes(node));
379
+ break;
380
+ case 'base':
381
+ notes.push({
382
+ rule: htmlRuleId('html-head', 'base'),
383
+ message: `the page sets \`<base href="${quote(attributeOf(node, 'href') ?? '')}">\`, which changes how every relative URL on it resolves, and a component can't carry it: check the page's relative URLs resolve without it`,
384
+ line,
385
+ });
386
+ break;
387
+ default:
388
+ notes.push({
389
+ rule: htmlRuleId('html-head'),
390
+ message: `the page's \`<head>\` has a \`<${node.tagName}>\`, which a component can't carry: move it into the app's index.html if the app needs it`,
391
+ line,
392
+ });
393
+ }
394
+ }
395
+ if (metas.length > 0) {
396
+ notes.push({
397
+ rule: htmlRuleId('html-head', 'meta'),
398
+ message: `the page's \`<head>\` has \`<meta>\` tags (${listed(metas.map(metaKind))}) that a component can't carry: the app's index.html holds charset and viewport once, and a page's own (a description, social cards) go where the app sets each page's head`,
399
+ line: metas[0].sourceCodeLocation?.startLine ?? null,
400
+ });
401
+ }
402
+ if (links.length > 0) {
403
+ notes.push({
404
+ rule: htmlRuleId('html-head', 'link'),
405
+ message: `the page's \`<head>\` has \`<link>\` tags (${listed(links.map(link => attributeOf(link, 'rel') ?? 'link'))}) that a component can't carry: keep the app-wide ones (icons, preconnects, preloads) in the app's index.html, and a page's own (canonical) where the app sets each page's head`,
406
+ line: links[0].sourceCodeLocation?.startLine ?? null,
407
+ });
408
+ }
409
+ }
410
+ /** Bulma's light and dark schemes, by `data-theme` value. */
411
+ const SCHEMES = new Set(['light', 'dark']);
412
+ function documentAttributeNotes(where, element, notes) {
413
+ if (!element)
414
+ return;
415
+ const location = element.sourceCodeLocation;
416
+ const lineOf = (name) => location?.attrs?.[name]?.startLine ?? location?.startLine ?? null;
417
+ const other = [];
418
+ for (const attr of element.attrs) {
419
+ const name = attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name;
420
+ const value = attr.value;
421
+ const line = lineOf(name);
422
+ if (/^on./i.test(name)) {
423
+ notes.push({
424
+ rule: htmlRuleId('html-event', name.toLowerCase()),
425
+ message: `the page's \`<${where} ${name}="${quote(value)}">\` runs code a component can't take as a string: port it into the component (an effect, for \`onload\`)`,
426
+ line,
427
+ });
428
+ continue;
429
+ }
430
+ if (name === 'class') {
431
+ for (const token of value.split(/[ \t\n\f\r]+/).filter(Boolean)) {
432
+ if (NAVBAR_FIXED_CLASSES.includes(token)) {
433
+ notes.push({
434
+ rule: htmlRuleId('html-document', token),
435
+ message: `the page's \`<${where}>\` has \`${token}\`, which Bulma reads to pad the page for a fixed navbar: put it on the \`<html>\` in the app's index.html, since bestax's \`Navbar\` adds no class there, whatever its \`fixed\` says`,
436
+ line,
437
+ });
438
+ }
439
+ else if (THEME_CLASSES.includes(token)) {
440
+ notes.push({
441
+ rule: htmlRuleId('html-document', token),
442
+ message: `the page's \`<${where}>\` has \`${token}\`, which sets Bulma's ${token.slice(6)} scheme: put it on the \`<html>\` in the app's index.html, or render bestax's \`<Theme colorMode="${token.slice(6)}">\`, which writes \`data-theme\` on \`<html>\``,
443
+ line,
444
+ });
445
+ }
446
+ else {
447
+ other.push(token);
448
+ }
449
+ }
450
+ if (other.length > 0) {
451
+ notes.push({
452
+ rule: htmlRuleId('html-document', 'class'),
453
+ message: `the page's \`<${where}>\` has classes a component can't carry (\`${quote(other.join(' '))}\`): put them on the app's own \`<${where}>\`, or on an element around this page's content`,
454
+ line,
455
+ });
456
+ }
457
+ continue;
458
+ }
459
+ if (name === 'data-theme') {
460
+ const scheme = value.trim().toLowerCase();
461
+ notes.push({
462
+ rule: htmlRuleId('html-document', name),
463
+ message: `the page's \`<${where}>\` has \`data-theme="${quote(value)}"\`, which sets Bulma's color scheme: put it on the \`<html>\` in the app's index.html${SCHEMES.has(scheme)
464
+ ? `, or render bestax's \`<Theme colorMode="${scheme}">\`, which writes it on \`<html>\``
465
+ : ''}`,
466
+ line,
467
+ });
468
+ continue;
469
+ }
470
+ if (where === 'html' && (name === 'lang' || name === 'dir')) {
471
+ notes.push({
472
+ rule: htmlRuleId('html-document', name),
473
+ message: `the page's \`<html ${name}="${quote(value)}">\` belongs to the whole document, which a component can't reach: set it on the \`<html>\` in the app's index.html`,
474
+ line,
475
+ });
476
+ continue;
477
+ }
478
+ notes.push({
479
+ rule: htmlRuleId('html-document', name),
480
+ message: `the page's \`<${where}>\` has \`${name}="${quote(value)}"\`, which a component can't carry: move it to the app's own \`<${where}>\`, or onto an element around this page's content`,
481
+ line,
482
+ });
483
+ }
484
+ }
485
+ /**
486
+ * React's own props, which no HTML attribute is spelled like. `classname`
487
+ * and `htmlfor` aren't here: the attribute table reads them as `className`
488
+ * and `htmlFor` already.
489
+ */
490
+ const REACT_PROPS = [
491
+ 'defaultValue',
492
+ 'defaultChecked',
493
+ 'dangerouslySetInnerHTML',
494
+ 'suppressContentEditableWarning',
495
+ 'suppressHydrationWarning',
496
+ ];
497
+ /** HTML's old presentational attributes, whose work CSS does now. */
498
+ const PRESENTATIONAL = new Set([
499
+ 'alink',
500
+ 'bgcolor',
501
+ 'bordercolor',
502
+ 'bottommargin',
503
+ 'charoff',
504
+ 'compact',
505
+ 'hspace',
506
+ 'leftmargin',
507
+ 'noshade',
508
+ 'nowrap',
509
+ 'rightmargin',
510
+ 'topmargin',
511
+ 'valign',
512
+ 'vlink',
513
+ 'vspace',
514
+ ]);
515
+ /**
516
+ * What to do about an attribute React has no prop for by that name: rename
517
+ * one that is a React prop in the wrong case (`defaultvalue`, which HTML
518
+ * never read), move a presentational one into CSS, and otherwise drop it or
519
+ * say what reads it. Whether TypeScript takes it depends on the element
520
+ * (React's types list `valign` on a `<td>` and not on a `<th>`), which a
521
+ * report can't see, so no message claims either way.
522
+ */
523
+ function unknownAttribute(token) {
524
+ const prop = REACT_PROPS.find(name => name.toLowerCase() === token.toLowerCase());
525
+ if (prop) {
526
+ return {
527
+ message: `\`${token}\` isn't an HTML attribute, so the page never read it, and it reads as React's \`${prop}\` in the wrong case: rename it to \`${prop}\`, or drop it if it isn't meant`,
528
+ };
529
+ }
530
+ if (PRESENTATIONAL.has(token.toLowerCase())) {
531
+ return {
532
+ message: `\`${token}\` is an obsolete presentational attribute, which CSS replaced: write it as CSS or a class`,
533
+ };
534
+ }
535
+ return {
536
+ message: `\`${token}\` is written as it was, which React renders under that name: drop it if nothing reads it, make it a \`data-\` attribute if a script of the page's does, and if React's types don't list it on this element, declare it for a TypeScript build`,
537
+ };
538
+ }
539
+ /** What the converter reports, as notes. */
540
+ function convertedNote(issue) {
541
+ const line = issue.line;
542
+ const token = issue.token;
543
+ switch (issue.kind) {
544
+ case 'event-handler':
545
+ return {
546
+ rule: htmlRuleId('html-event', token.toLowerCase()),
547
+ message: `\`${token}="${quote(issue.detail ?? '')}"\` was dropped, since React takes a function rather than code in a string: port it into a handler passed as \`${issue.prop}\``,
548
+ line,
549
+ };
550
+ case 'attribute-name':
551
+ return {
552
+ rule: htmlRuleId('html-attr', 'name'),
553
+ message: `the attribute \`${token}\` was left out, since JSX can't write that name (a framework's directive, such as \`@click\` or \`:class\`, is code to port into the component), or the element already has it under another spelling`,
554
+ line,
555
+ };
556
+ case 'unknown-attribute':
557
+ return {
558
+ rule: htmlRuleId('html-attr', 'unknown'),
559
+ ...unknownAttribute(token),
560
+ line,
561
+ };
562
+ case 'javascript-url':
563
+ return {
564
+ rule: htmlRuleId('html-url', 'javascript'),
565
+ message: `a \`javascript:\` URL in \`${token}\` won't run: React 19 replaces one with a URL that throws, and React 18 warns; move the code into an \`onClick\` handler`,
566
+ line,
567
+ };
568
+ case 'style':
569
+ return {
570
+ rule: htmlRuleId('html-attr', 'style'),
571
+ message: `the declaration \`${quote(issue.detail ?? '')}\` in a \`style\` isn't CSS a browser reads, so it was left out, as a browser leaves it out: delete it, or rewrite it if it meant something`,
572
+ line,
573
+ };
574
+ case 'style-important':
575
+ return {
576
+ rule: htmlRuleId('html-attr', 'important'),
577
+ message: `\`${quote(issue.detail ?? token)}\` is written without its \`!important\`, since React sets a style without a priority: if a stylesheet rule beats it now, move it into a class in the app's stylesheet`,
578
+ line,
579
+ };
580
+ case 'custom-element':
581
+ return {
582
+ rule: htmlRuleId('html-element', 'custom'),
583
+ message: `\`<${token}>\` is a custom element: React renders it as written, but the script that defines it isn't loaded by this module, and a TypeScript build needs it declared in \`JSX.IntrinsicElements\``,
584
+ line,
585
+ };
586
+ case 'obsolete-element':
587
+ return {
588
+ rule: htmlRuleId('html-element', 'obsolete'),
589
+ message: `\`<${token}>\` is obsolete HTML: browsers still render it, but React's types list few of these, so a TypeScript build may reject it; replace it with current markup (a helper class such as \`has-text-centered\`, or CSS)`,
590
+ line,
591
+ };
592
+ case 'unknown-element':
593
+ return {
594
+ rule: htmlRuleId('html-element', 'unknown'),
595
+ message: `\`<${token}>\` isn't an HTML element: it renders as an unknown inline element, and a TypeScript build rejects it; replace it with the element it stands for`,
596
+ line,
597
+ };
598
+ case 'template':
599
+ return {
600
+ rule: htmlRuleId('html-element', 'template'),
601
+ message: '`<template>` holds markup for a script to clone, which React has no use for: render that markup from the component, where the script would have used it',
602
+ line,
603
+ };
604
+ case 'element-name':
605
+ return {
606
+ rule: htmlRuleId('html-element', 'name'),
607
+ message: `\`<${token}>\` has a name JSX can't write, so its children were written in its place: put back the element it stood for, if it mattered`,
608
+ line,
609
+ };
610
+ case 'conditional-comment':
611
+ return {
612
+ rule: htmlRuleId('html-element', 'conditional-comment'),
613
+ message: 'an Internet Explorer conditional comment was dropped, since no browser React supports reads one: nothing to do unless the markup inside it matters',
614
+ line,
615
+ };
616
+ }
617
+ }
618
+ /** Attributes holding a URL a browser resolves against the page's address. */
619
+ const URL_ATTRIBUTES = new Set([
620
+ 'href',
621
+ 'src',
622
+ 'poster',
623
+ 'action',
624
+ 'formaction',
625
+ 'xlink:href',
626
+ 'data',
627
+ ]);
628
+ /** A URL with no scheme, not from the site root, and not just a fragment. */
629
+ function isRelative(url) {
630
+ const trimmed = url.trim();
631
+ return (trimmed !== '' &&
632
+ !/^[a-z][a-z\d+.-]*:/i.test(trimmed) &&
633
+ !/^[/\\#?]/.test(trimmed));
634
+ }
635
+ /** A relative URL without its query or fragment, decoded where it can be. */
636
+ function urlPath(url) {
637
+ const bare = url.trim().replace(/[?#].*$/, '');
638
+ try {
639
+ return decodeURIComponent(bare);
640
+ }
641
+ catch {
642
+ return bare;
643
+ }
644
+ }
645
+ /**
646
+ * A relative URL as a note: what to do about it depends on what it points
647
+ * at, read beside the page the way the browser resolved it. A file there
648
+ * moves to the app's public folder, another page becomes a route, and a
649
+ * target that isn't there is a dead link already.
650
+ */
651
+ function relativeUrlNote(name, url, dir, line, candidate) {
652
+ const target = urlPath(url);
653
+ // A srcset holds several URLs, so one is quoted as itself, in it.
654
+ const written = candidate
655
+ ? `\`${quote(url)}\` in \`${name}\``
656
+ : `\`${name}="${quote(url)}"\``;
657
+ if (!fs.existsSync(path.resolve(dir, target))) {
658
+ return {
659
+ rule: htmlRuleId('html-url', 'missing'),
660
+ message: `${written} points at nothing beside the page (no \`${quote(target)}\` in its folder): point it somewhere real, or drop it`,
661
+ line,
662
+ url,
663
+ };
664
+ }
665
+ const resolves = 'in the app it resolves against the address the component renders at';
666
+ if (/\.html?$/i.test(target)) {
667
+ return {
668
+ rule: htmlRuleId('html-url', 'relative'),
669
+ message: `${written} links to another page beside this one, and ${resolves}: point it at the route that page becomes`,
670
+ line,
671
+ url,
672
+ };
673
+ }
674
+ const fromRoot = `/${target.replace(/^(?:\.\.?\/)+/, '')}`;
675
+ return {
676
+ rule: htmlRuleId('html-url', 'relative'),
677
+ message: `${written} is relative to the page's folder, and ${resolves}: copy the file into the app's \`public/\` folder and point at it from the site root (\`${quote(fromRoot)}\`), or import it`,
678
+ line,
679
+ url,
680
+ };
681
+ }
682
+ function relativeUrlNotes(root, dir, notes) {
683
+ for (const child of childrenOf(root)) {
684
+ if (!isElement(child))
685
+ continue;
686
+ const location = child.sourceCodeLocation;
687
+ for (const attr of child.attrs) {
688
+ const name = attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name;
689
+ if (name === 'data' && child.tagName !== 'object')
690
+ continue;
691
+ const candidates = name === 'srcset' || name === 'imagesrcset';
692
+ const urls = candidates
693
+ ? attr.value
694
+ .split(',')
695
+ .map(candidate => candidate.trim().split(/\s+/)[0] ?? '')
696
+ : URL_ATTRIBUTES.has(name)
697
+ ? [attr.value]
698
+ : [];
699
+ const line = location?.attrs?.[name]?.startLine ?? location?.startLine ?? null;
700
+ for (const url of new Set(urls.filter(isRelative))) {
701
+ notes.push(relativeUrlNote(name, url, dir, line, candidates));
702
+ }
703
+ }
704
+ relativeUrlNotes(child, dir, notes);
705
+ }
706
+ }
707
+ function parseNotes(parsed, notes) {
708
+ const byCode = new Map();
709
+ for (const error of parsed.errors) {
710
+ const seen = byCode.get(error.code);
711
+ if (seen)
712
+ seen.count++;
713
+ else
714
+ byCode.set(error.code, { line: error.line, count: 1 });
715
+ }
716
+ for (const [code, { line, count }] of byCode) {
717
+ const more = count > 1 ? `, and ${count - 1} more like it` : '';
718
+ notes.push({
719
+ rule: htmlRuleId('html-parse', code),
720
+ message: `the HTML has a parse error here (\`${code}\`)${more}: the component follows the DOM a browser builds from it, so check that part of the page renders as meant`,
721
+ line,
722
+ });
723
+ }
724
+ }
725
+ // ---- Writing the module ----------------------------------------------------
726
+ /**
727
+ * Comments outside the body (a credit, a licence) as line comments, split on
728
+ * every line terminator JavaScript knows: a U+2028 or U+2029 ends a `//`
729
+ * line too, and what follows it would be code.
730
+ */
731
+ function outsideCommentLines(comments) {
732
+ return comments.flatMap(comment => {
733
+ const lines = comment.data
734
+ .split(/\r\n|[\r\n\u2028\u2029]/)
735
+ .map(line => line.trimEnd());
736
+ // Text on the `<!--` line is set off from it, not indented, so it
737
+ // takes no part in the margin the lines below it share.
738
+ const opening = lines[0].trim() === '' ? undefined : lines.shift().trim();
739
+ while (opening === undefined &&
740
+ lines.length > 0 &&
741
+ lines[0].trim() === '') {
742
+ lines.shift();
743
+ }
744
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
745
+ lines.pop();
746
+ }
747
+ const margin = Math.min(...lines
748
+ .filter(line => line.trim() !== '')
749
+ .map(line => line.match(/^[ \t]*/)[0].length));
750
+ return [
751
+ ...(opening === undefined ? [] : [`// ${opening}`]),
752
+ ...lines.map(line => line.trim() === '' ? '//' : `// ${line.slice(margin)}`),
753
+ ];
754
+ });
755
+ }
756
+ function todoLine(note, page) {
757
+ const message = note.message.replace(/[\r\n\u2028\u2029]+/g, ' ').trim();
758
+ const at = note.line === null
759
+ ? ''
760
+ : ` (${page}:${note.line}${note.endLine !== undefined && note.endLine > note.line
761
+ ? `-${note.endLine}`
762
+ : ''})`;
763
+ return `// TODO(bestax-migrate): ${message}${at}`;
764
+ }
765
+ /** What a shared line says about several relative URLs, by rule. */
766
+ const SHARED_URL_ADVICE = {
767
+ [htmlRuleId('html-url', 'relative')]: "resolve against the page's folder, and in the app against the address the component renders at: copy each file into the app's `public/` folder and point at it from the site root, or import it, and point a link to another page at the route it becomes",
768
+ [htmlRuleId('html-url', 'missing')]: 'point at nothing beside the page: point each somewhere real, or drop it',
769
+ };
770
+ /**
771
+ * The TODO lines above the `return`. A page can hold many relative URLs, all
772
+ * with the one recipe, so where a rule has several they share a line, which
773
+ * lists them and their lines; each is still its own report entry.
774
+ */
775
+ function contentLines(notes, page) {
776
+ const groups = new Map();
777
+ for (const note of notes) {
778
+ if (note.url === undefined)
779
+ continue;
780
+ groups.set(note.rule, [...(groups.get(note.rule) ?? []), note]);
781
+ }
782
+ return notes.flatMap(note => {
783
+ const group = note.url === undefined ? undefined : groups.get(note.rule);
784
+ if (!group || group.length < 2)
785
+ return [todoLine(note, page)];
786
+ if (note !== group[0])
787
+ return [];
788
+ const lines = [
789
+ ...new Set(group.flatMap(each => (each.line === null ? [] : [each.line]))),
790
+ ];
791
+ return [
792
+ `// TODO(bestax-migrate): relative URLs (${listed(group.map(each => `\`${each.url}\``))}) ${SHARED_URL_ADVICE[note.rule]} (${page}:${lines.join(', ')})`,
793
+ ];
794
+ });
795
+ }
796
+ /* eslint-disable @typescript-eslint/no-explicit-any */
797
+ /**
798
+ * The SVG or HTML table a JSX element's attributes are read from: an
799
+ * `<svg>`'s and a `<foreignObject>`'s own are SVG's, and what is inside a
800
+ * `<foreignObject>` is HTML again.
801
+ */
802
+ function spaceOf(elementPath) {
803
+ const own = elementPath.node.openingElement.name.name;
804
+ if (own === 'svg' || own === 'foreignObject')
805
+ return 'svg';
806
+ for (let up = elementPath.parent; up?.node; up = up.parent) {
807
+ if (up.node.type !== 'JSXElement')
808
+ continue;
809
+ const name = up.node.openingElement.name;
810
+ if (name.type !== 'JSXIdentifier')
811
+ continue;
812
+ if (name.name === 'foreignObject')
813
+ return 'html';
814
+ if (name.name === 'svg')
815
+ return 'svg';
816
+ }
817
+ return 'html';
818
+ }
819
+ /**
820
+ * The module as written, after the planner: a number attribute on a plain
821
+ * element as a number (`tabIndex="-1"` as `tabIndex={-1}`), which React's
822
+ * types want and React renders the same, when it is spelled the way it
823
+ * renders; a component's are the planner's. And a blank line after the
824
+ * imports, which the transform puts straight above the component.
825
+ */
826
+ export function finishModule(code) {
827
+ const j = makeApi().jscodeshift;
828
+ const root = j(code);
829
+ const edits = [];
830
+ root.find(j.JSXAttribute).forEach((attrPath) => {
831
+ const { name, value } = attrPath.node;
832
+ if (name.type !== 'JSXIdentifier' || value?.type !== 'StringLiteral') {
833
+ return;
834
+ }
835
+ const tag = attrPath.parent.node.name;
836
+ if (tag.type !== 'JSXIdentifier' || !/^[a-z][A-Za-z\d]*$/.test(tag.name)) {
837
+ return;
838
+ }
839
+ const literal = numberLiteral(value.value);
840
+ if (literal === null)
841
+ return;
842
+ if (!isNumberProp(name.name, spaceOf(attrPath.parent.parent)))
843
+ return;
844
+ edits.push({ start: value.start, end: value.end, text: `{${literal}}` });
845
+ });
846
+ const imports = root.find(j.ImportDeclaration).nodes();
847
+ const last = imports[imports.length - 1];
848
+ if (last && /^\n(?!\n)/.test(code.slice(last.end))) {
849
+ edits.push({ start: last.end, end: last.end, text: '\n' });
850
+ }
851
+ let out = code;
852
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
853
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
854
+ }
855
+ return out;
856
+ }
857
+ function refuse(collector, entry, outputPath) {
858
+ collector?.add(entry);
859
+ return { outputPath, output: null, intermediate: null };
860
+ }
861
+ export function convertPage(file, source, collector, options = {}) {
862
+ const outputPath = outputPathFor(file, options);
863
+ const parsed = parseHtml(source);
864
+ const first = firstBulmaElement(parsed.root);
865
+ if (!first)
866
+ return { outputPath, output: null, intermediate: null };
867
+ if (fs.existsSync(outputPath)) {
868
+ return refuse(collector, {
869
+ file,
870
+ line: null,
871
+ rule: htmlRuleId('html-file', 'exists'),
872
+ message: `\`${path.basename(outputPath)}\` is already beside the page, and the codemod never overwrites a file: move or delete it to convert the page again`,
873
+ }, outputPath);
874
+ }
875
+ const template = TEMPLATE_SYNTAX.exec(source);
876
+ if (template) {
877
+ return refuse(collector, {
878
+ file,
879
+ line: lineAt(source, template.index),
880
+ rule: htmlRuleId('html-file', 'template-syntax'),
881
+ message: `the page has server-template syntax (\`${template[0]}\`), which isn't HTML a browser reads as written: convert the HTML the template renders, or the template by hand`,
882
+ }, outputPath);
883
+ }
884
+ const notes = { document: [], content: [] };
885
+ parseNotes(parsed, notes.document);
886
+ headNotes(parsed.head, options.cssMode ?? 'keep', notes.document);
887
+ const body = parsed.kind === 'document' ? parsed.root : null;
888
+ documentAttributeNotes('html', body?.parentNode && isElement(body.parentNode)
889
+ ? body.parentNode
890
+ : undefined, notes.document);
891
+ documentAttributeNotes('body', body ?? undefined, notes.document);
892
+ dropScripts(parsed.root, notes.document);
893
+ const emitted = emitJsx(parsed.root, {
894
+ displayOf: pageDisplayOf,
895
+ indent: 2,
896
+ });
897
+ notes.content.push(...emitted.reported.map(convertedNote));
898
+ relativeUrlNotes(parsed.root, path.dirname(file), notes.content);
899
+ // In the page's order, which is how a reader works through them.
900
+ const byLine = (a, b) => (a.line ?? Infinity) - (b.line ?? Infinity);
901
+ notes.document.sort(byLine);
902
+ notes.content.sort(byLine);
903
+ const page = path.basename(file);
904
+ const header = outsideCommentLines(parsed.outsideComments);
905
+ const lines = [
906
+ ...header,
907
+ ...(header.length > 0 ? [''] : []),
908
+ ...notes.document.map(note => todoLine(note, page)),
909
+ `export default function ${componentName(file)}() {`,
910
+ ...contentLines(notes.content, page).map(line => ` ${line}`),
911
+ ' return (',
912
+ ];
913
+ const jsxStart = lines.length + 1;
914
+ const intermediate = [...lines, emitted.code, ' );', '}', ''].join('\n');
915
+ for (const note of [...notes.document, ...notes.content]) {
916
+ collector?.add({
917
+ file,
918
+ line: note.line,
919
+ rule: note.rule,
920
+ message: note.message,
921
+ });
922
+ }
923
+ // The transform reads the module's path, so the gates for a server
924
+ // component or another JSX runtime apply to it; its report names the page.
925
+ const onPage = {
926
+ add: entry => collector?.add({
927
+ ...entry,
928
+ file,
929
+ line: entry.line === null || entry.line < jsxStart
930
+ ? null
931
+ : emitted.htmlLineOf(entry.line - jsxStart + 1),
932
+ }),
933
+ };
934
+ const { output } = runTransform(transform, outputPath, intermediate, onPage, {
935
+ ...options,
936
+ collector: onPage,
937
+ });
938
+ return {
939
+ outputPath,
940
+ output: finishModule(output ?? intermediate),
941
+ intermediate,
942
+ };
943
+ }
944
+ /** The `transformHtml` the CLI calls. */
945
+ export function transformHtml(file, source, collector, options = {}) {
946
+ const { outputPath, output } = convertPage(file, source, collector, options);
947
+ return { outputPath, output };
948
+ }