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