@weft-js/cli 0.1.1 → 0.1.3

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 (171) hide show
  1. package/README.md +534 -54
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -13,15 +13,15 @@ const FLUID = /(-?[\d.]+)(vw|vi|cqi|cqw|vmin|vmax)\b/
13
13
  const MAX_RATIO = 2.5
14
14
 
15
15
  /** Comments blanked out, so offsets stay those of the file. */
16
- const stripComments = css => css.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
16
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
17
17
 
18
18
  /** `clamp(rem, rem + cqi, rem)` for a viewport/container-only size, from its px at 375 and 1280 (ratio ≤ 2.5). */
19
19
  export function fluidFor(n) {
20
- const at = w => (n * w) / 100
20
+ const at = (w) => (n * w) / 100
21
21
  const min = at(375)
22
22
  const max = Math.min(at(1280), min * MAX_RATIO)
23
23
  const slope = (max - min) / (1280 - 375)
24
- const r = x => Math.round(x * 1000) / 1000
24
+ const r = (x) => Math.round(x * 1000) / 1000
25
25
  return `clamp(${r(min / 16)}rem, calc(${r((min - slope * 375) / 16)}rem + ${r(slope * 100)}cqi), ${r(max / 16)}rem)`
26
26
  }
27
27
 
@@ -31,22 +31,46 @@ function fontSize(value, at, report) {
31
31
  const fluid = FLUID.exec(value)
32
32
  if (fluid && !/rem\b/.test(value)) {
33
33
  const only = new RegExp(`^\\s*${fluid[0].replace(/[.]/g, '\\.')}\\s*$`).test(value)
34
- report('X005', at, at + value.length, `\`font-size: ${value.trim()}\` scales with the ${fluid[2].startsWith('c') ? 'container' : 'viewport'} only: browser zoom and the user's font size don't change it (WCAG 1.4.4).`,
35
- only ? { description: 'Use a fluid clamp with rem ends', safe: false, text: fluidFor(Number(fluid[1])) } : undefined)
34
+ report(
35
+ 'X005',
36
+ at,
37
+ at + value.length,
38
+ `\`font-size: ${value.trim()}\` scales with the ${fluid[2].startsWith('c') ? 'container' : 'viewport'} only: browser zoom and the user's font size don't change it (WCAG 1.4.4).`,
39
+ only
40
+ ? {
41
+ description: 'Use a fluid clamp with rem ends',
42
+ safe: false,
43
+ text: fluidFor(Number(fluid[1])),
44
+ }
45
+ : undefined,
46
+ )
36
47
  }
37
48
  const clamp = /^\s*clamp\(\s*([\d.]+)(rem|px)\s*,[\s\S]*,\s*([\d.]+)(rem|px)\s*\)\s*$/.exec(value)
38
49
  if (clamp && clamp[2] === clamp[4] && Number(clamp[1]) > 0) {
39
50
  const ratio = Number(clamp[3]) / Number(clamp[1])
40
- if (ratio > MAX_RATIO) report('X006', at, at + value.length, `This fluid size grows ${ratio.toFixed(2)}× from its minimum to its maximum; above 2.5×, 200% zoom no longer doubles the text (WCAG 1.4.4).`)
51
+ if (ratio > MAX_RATIO)
52
+ report(
53
+ 'X006',
54
+ at,
55
+ at + value.length,
56
+ `This fluid size grows ${ratio.toFixed(2)}× from its minimum to its maximum; above 2.5×, 200% zoom no longer doubles the text (WCAG 1.4.4).`,
57
+ )
41
58
  }
42
59
  }
43
60
 
44
- const faceFamily = body => /font-family\s*:\s*(['"]?)([^;'"]+)\1/.exec(body)?.[2].trim()
61
+ const faceFamily = (body) => /font-family\s*:\s*(['"]?)([^;'"]+)\1/.exec(body)?.[2].trim()
45
62
 
46
63
  /** Every `@font-face` in a CSS text: family, body and offsets. */
47
64
  function faces(css) {
48
65
  const out = []
49
- for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g)) out.push({ family: faceFamily(m[1]), body: m[1], start: m.index, bodyStart: m.index + m[0].indexOf('{') + 1, end: m.index + m[0].length })
66
+ for (const m of css.matchAll(/@font-face\s*\{([^}]*)\}/g))
67
+ out.push({
68
+ family: faceFamily(m[1]),
69
+ body: m[1],
70
+ start: m.index,
71
+ bodyStart: m.index + m[0].indexOf('{') + 1,
72
+ end: m.index + m[0].length,
73
+ })
50
74
  return out
51
75
  }
52
76
 
@@ -56,73 +80,167 @@ function faces(css) {
56
80
  */
57
81
  export function textChecks(root, { files, exclude = [], fontBuild = false }) {
58
82
  const out = []
59
- const sheets = discover(root, { include: ['**/*.css'], exclude }).map(rel => ({ rel, text: readFileSync(join(root, rel), 'utf8') }))
60
- const pages = discover(root, { include: ['**/*.html'], exclude }).map(rel => ({ rel, text: readFileSync(join(root, rel), 'utf8') }))
83
+ const sheets = discover(root, { include: ['**/*.css'], exclude }).map((rel) => ({
84
+ rel,
85
+ text: readFileSync(join(root, rel), 'utf8'),
86
+ }))
87
+ const pages = discover(root, { include: ['**/*.html'], exclude }).map((rel) => ({
88
+ rel,
89
+ text: readFileSync(join(root, rel), 'utf8'),
90
+ }))
61
91
  const fontsJson = join(root, '.weft', 'fonts.json')
62
92
  const built = existsSync(fontsJson) ? JSON.parse(readFileSync(fontsJson, 'utf8')) : null
63
93
 
64
- const reporter = file => {
94
+ const reporter = makeReporter(out)
95
+
96
+ const faceUrls = checkSheets({ sheets, built, fontBuild, reporter, out })
97
+
98
+ // -- Markup: viewport meta, font preloads, media in boundaries, pending timers --
99
+ const viewport = (file, start, end, content, valueStart) => {
100
+ const parts = content
101
+ .split(',')
102
+ .map((s) => s.trim())
103
+ .filter(Boolean)
104
+ const bad = parts.filter(
105
+ (p) =>
106
+ /^user-scalable\s*=\s*(no|0)$/i.test(p) ||
107
+ (/^maximum-scale\s*=/i.test(p) && parseFloat(p.split('=')[1]) < 2),
108
+ )
109
+ if (!bad.length) return
110
+ const keep = parts.filter((p) => !bad.includes(p)).join(', ')
111
+ reporter(file)(
112
+ 'X008',
113
+ start,
114
+ end,
115
+ `The viewport meta blocks zoom (\`${bad.join('`, `')}\`): people who need larger text can't pinch to get it (WCAG 1.4.4).`,
116
+ {
117
+ description: 'Remove the zoom limits',
118
+ safe: true,
119
+ text: keep,
120
+ at: [valueStart, valueStart + content.length],
121
+ },
122
+ )
123
+ }
124
+ checkPages(pages, viewport, preload)
125
+
126
+ /** X023: a font preload no `@font-face` uses (the font build's own preloads live under its asset URLs). */
127
+ function preload(file, href, start, end) {
128
+ if (
129
+ href &&
130
+ !faceUrls.has(basename(href.split('?')[0])) &&
131
+ !href.includes('@weft-assets') &&
132
+ !href.startsWith('/assets/')
133
+ ) {
134
+ reporter(file)(
135
+ 'X023',
136
+ start,
137
+ end,
138
+ `This preloads \`${href}\`, which no \`@font-face\` uses: the bytes compete with the page's own critical requests.`,
139
+ )
140
+ }
141
+ }
142
+
143
+ checkJsx(files, reporter, viewport, preload)
144
+ return out
145
+ }
146
+
147
+ function makeReporter(out) {
148
+ const reporter = (file) => {
65
149
  const pos = positions(file.text)
66
150
  /** `fix.at`: the replaced range when it isn't the flagged one. */
67
151
  return (code, start, end, why, fix) => {
68
152
  const [a, b] = fix?.at ?? [start, end]
69
- out.push(diagnostic({ code, file: file.rel, span: pos.span(start, end), offset: [start, end], why, fix: fix && { description: fix.description, safe: fix.safe, edits: [{ file: file.rel, span: pos.span(a, b), offset: [a, b], text: fix.text }] } }))
153
+ out.push(
154
+ diagnostic({
155
+ code,
156
+ file: file.rel,
157
+ span: pos.span(start, end),
158
+ offset: [start, end],
159
+ why,
160
+ fix: fix && {
161
+ description: fix.description,
162
+ safe: fix.safe,
163
+ edits: [{ file: file.rel, span: pos.span(a, b), offset: [a, b], text: fix.text }],
164
+ },
165
+ }),
166
+ )
70
167
  }
71
168
  }
169
+ return reporter
170
+ }
72
171
 
172
+ function checkSheets({ sheets, built, fontBuild, reporter, out }) {
73
173
  // -- CSS: font sizes and font faces --
74
- const allFaces = sheets.flatMap(s => faces(stripComments(s.text)).map(f => ({ ...f, sheet: s })))
75
- const families = new Set(allFaces.map(f => f.family).filter(Boolean))
76
- const faceUrls = new Set(allFaces.flatMap(f => [...f.body.matchAll(/url\(\s*['"]?([^'")]+)/g)].map(m => basename(m[1].split('?')[0]))))
174
+ const allFaces = sheets.flatMap((s) =>
175
+ faces(stripComments(s.text)).map((f) => ({ ...f, sheet: s })),
176
+ )
177
+ const families = new Set(allFaces.map((f) => f.family).filter(Boolean))
178
+ const faceUrls = new Set(
179
+ allFaces.flatMap((f) =>
180
+ [...f.body.matchAll(/url\(\s*['"]?([^'")]+)/g)].map((m) => basename(m[1].split('?')[0])),
181
+ ),
182
+ )
77
183
  for (const s of sheets) {
78
184
  const css = stripComments(s.text)
79
185
  const report = reporter(s)
80
- for (const m of css.matchAll(/(?<![\w-])font-size\s*:\s*([^;}]+)/g)) fontSize(m[1], m.index + m[0].indexOf(m[1], m[0].indexOf(':')), report)
186
+ for (const m of css.matchAll(/(?<![\w-])font-size\s*:\s*([^;}]+)/g))
187
+ fontSize(m[1], m.index + m[0].indexOf(m[1], m[0].indexOf(':')), report)
81
188
  for (const f of faces(css)) {
82
189
  const display = /font-display\s*:\s*(block|auto)\b/.exec(f.body)
83
190
  if (display) {
84
191
  const at = f.bodyStart + display.index + display[0].lastIndexOf(display[1])
85
- report('X021', at, at + display[1].length, `\`font-display: ${display[1]}\` hides text for up to 3 s while the font loads; with a matched fallback face, \`swap\` shifts nothing.`, { description: 'Use swap', safe: true, text: 'swap' })
192
+ report(
193
+ 'X021',
194
+ at,
195
+ at + display[1].length,
196
+ `\`font-display: ${display[1]}\` hides text for up to 3 s while the font loads; with a matched fallback face, \`swap\` shifts nothing.`,
197
+ { description: 'Use swap', safe: true, text: 'swap' },
198
+ )
86
199
  }
87
200
  const local = /url\(\s*['"]?(?!https?:|data:)/.test(f.body)
88
201
  if (!local || !f.family || f.family.endsWith('Fallback')) continue
89
202
  if (!families.has(`${f.family} Fallback`)) {
90
- const how = fontBuild ? 'declare it in `text({ fonts })`, which generates the fallback' : 'add `@weft-js/text` and declare it in `text({ fonts })`'
91
- report('X020', f.start, f.start + '@font-face'.length, `\`@font-face\` for "${f.family}" has no "${f.family} Fallback" face: the swap from the system font shifts layout. Fix: ${how}.`)
203
+ const how = fontBuild
204
+ ? 'declare it in `text({ fonts })`, which generates the fallback'
205
+ : 'add `@weft-js/text` and declare it in `text({ fonts })`'
206
+ report(
207
+ 'X020',
208
+ f.start,
209
+ f.start + '@font-face'.length,
210
+ `\`@font-face\` for "${f.family}" has no "${f.family} Fallback" face: the swap from the system font shifts layout. Fix: ${how}.`,
211
+ )
92
212
  }
93
- if (!/unicode-range\s*:/.test(f.body)) report('X022', f.start, f.start + '@font-face'.length, `"${f.family}" ships the whole font file: no \`unicode-range\`, so it isn't subset.`)
213
+ if (!/unicode-range\s*:/.test(f.body))
214
+ report(
215
+ 'X022',
216
+ f.start,
217
+ f.start + '@font-face'.length,
218
+ `"${f.family}" ships the whole font file: no \`unicode-range\`, so it isn't subset.`,
219
+ )
94
220
  }
95
221
  }
96
222
  for (const [code, why] of built?.report?.overBudget ?? []) {
97
223
  if (code === 'X022') out.push(diagnostic({ code: 'X022', file: '.weft/fonts.json', why }))
98
224
  }
99
225
 
100
- // -- Markup: viewport meta, font preloads, media in boundaries, pending timers --
101
- const viewport = (file, start, end, content, valueStart) => {
102
- const parts = content.split(',').map(s => s.trim()).filter(Boolean)
103
- const bad = parts.filter(p => /^user-scalable\s*=\s*(no|0)$/i.test(p) || (/^maximum-scale\s*=/i.test(p) && parseFloat(p.split('=')[1]) < 2))
104
- if (!bad.length) return
105
- const keep = parts.filter(p => !bad.includes(p)).join(', ')
106
- reporter(file)('X008', start, end, `The viewport meta blocks zoom (\`${bad.join('`, `')}\`): people who need larger text can't pinch to get it (WCAG 1.4.4).`,
107
- { description: 'Remove the zoom limits', safe: true, text: keep, at: [valueStart, valueStart + content.length] })
108
- }
226
+ return faceUrls
227
+ }
228
+
229
+ function checkPages(pages, viewport, preload) {
109
230
  for (const p of pages) {
110
231
  for (const m of p.text.matchAll(/<meta\b[^>]*name\s*=\s*["']viewport["'][^>]*>/gi)) {
111
232
  const c = /content\s*=\s*(["'])([^"']*)\1/i.exec(m[0])
112
- if (c) viewport(p, m.index, m.index + m[0].length, c[2], m.index + c.index + c[0].indexOf(c[2]))
233
+ if (c)
234
+ viewport(p, m.index, m.index + m[0].length, c[2], m.index + c.index + c[0].indexOf(c[2]))
113
235
  }
114
236
  for (const m of p.text.matchAll(/<link\b[^>]*rel\s*=\s*["']preload["'][^>]*>/gi)) {
115
- if (/\bas\s*=\s*["']font["']/i.test(m[0])) preload(p, /href\s*=\s*["']([^"']+)/i.exec(m[0])?.[1], m.index, m.index + m[0].length)
116
- }
117
- }
118
-
119
- /** X023: a font preload no `@font-face` uses (the font build's own preloads live under its asset URLs). */
120
- function preload(file, href, start, end) {
121
- if (href && !faceUrls.has(basename(href.split('?')[0])) && !href.includes('@weft-assets') && !href.startsWith('/assets/')) {
122
- reporter(file)('X023', start, end, `This preloads \`${href}\`, which no \`@font-face\` uses: the bytes compete with the page's own critical requests.`)
237
+ if (/\bas\s*=\s*["']font["']/i.test(m[0]))
238
+ preload(p, /href\s*=\s*["']([^"']+)/i.exec(m[0])?.[1], m.index, m.index + m[0].length)
123
239
  }
124
240
  }
241
+ }
125
242
 
243
+ function checkJsx(files, reporter, viewport, preload) {
126
244
  for (const f of files) {
127
245
  if (!/\.[jt]sx$/.test(f.rel)) continue
128
246
  let scan
@@ -133,40 +251,82 @@ export function textChecks(root, { files, exclude = [], fontBuild = false }) {
133
251
  }
134
252
  const report = reporter(f)
135
253
  // X033: `x.pending ? <Skeleton /> : …`, with no `x.value` test beside it.
136
- for (const m of f.text.matchAll(/\b([\w$]+)\.pending\s*(?:\?|&&)\s*\(?\s*<\s*(Shape|Skeleton|Spinner|Progress)\b/g)) {
254
+ for (const m of f.text.matchAll(
255
+ /\b([\w$]+)\.pending\s*(?:\?|&&)\s*\(?\s*<\s*(Shape|Skeleton|Spinner|Progress)\b/g,
256
+ )) {
137
257
  const line = f.text.slice(f.text.lastIndexOf('\n', m.index) + 1, m.index)
138
- if (!line.includes(`${m[1]}.value`)) report('X033', m.index, m.index + m[0].length, `\`${m[1]}.pending\` is true on every refetch too, so this <${m[2]}> flashes over a value that's already on screen.`)
258
+ if (!line.includes(`${m[1]}.value`))
259
+ report(
260
+ 'X033',
261
+ m.index,
262
+ m.index + m[0].length,
263
+ `\`${m[1]}.pending\` is true on every refetch too, so this <${m[2]}> flashes over a value that's already on screen.`,
264
+ )
139
265
  }
140
266
  for (const el of scan.elements) {
141
- const style = el.attr('style')
142
- if (style) {
143
- if (style.kind === 'string') for (const m of style.value.matchAll(/font-size\s*:\s*([^;]+)/g)) fontSize(m[1], style.valueStart + 1 + m.index + m[0].indexOf(m[1]), report)
144
- else for (const m of (style.expr ?? '').matchAll(/fontSize\s*:\s*(['"`])([^'"`]+)\1/g)) fontSize(m[2], style.valueStart + m.index + m[0].indexOf(m[2]), report)
145
- }
146
- if (el.name === 'meta' && el.attr('name')?.value === 'viewport' && el.attr('content')?.kind === 'string') {
147
- const c = el.attr('content')
148
- viewport(f, el.start, el.end, c.value, c.valueStart + 1)
149
- }
150
- if (el.name === 'link' && el.attr('rel')?.value === 'preload' && el.attr('as')?.value === 'font') {
151
- preload(f, el.attr('href')?.value, el.start, el.end)
152
- }
153
- if (['img', 'video', 'iframe'].includes(el.name) && el.ancestors.includes('Boundary')) {
154
- const sized = (el.attr('width') && el.attr('height')) || /aspect-?ratio/i.test(style?.value ?? style?.expr ?? '')
155
- if (!sized) report('X030', el.start, el.tagEnd, `<${el.name}> inside a <Boundary> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall, then pushes the content down when it loads.`)
156
- }
157
- if (/overflow(-[xy]|Y|X)?\s*:\s*['"]?(auto|scroll)/.test(style?.value ?? style?.expr ?? '')) {
158
- const live = /\{\s*(\w+)\.value\b/.exec(el.children)?.[1]
159
- if (live && new RegExp(`\\b${live}\\s*=\\s*[^\\n]*\\bstream\\b`).test(f.text)) report('X035', el.start, el.tagEnd, `\`${live}\` is streamed straight into a scroll container: as its tail reflows, the lines above it move.`)
160
- }
161
- const pending = el.name === 'Boundary' && el.attr('pending')
162
- if (pending?.kind === 'expr') {
163
- const tag = /^<\s*([A-Z][\w.]*)/.exec(pending.expr)?.[1]
164
- const timers = /\b(setTimeout|setInterval)\s*\(/
165
- if (timers.test(pending.expr) || (tag && timers.test(scan.functions.get(tag) ?? ''))) {
166
- report('X034', pending.start, pending.end, `This \`pending\` fallback${tag ? ` (<${tag}>)` : ''} runs its own timers: <Boundary> already waits 150 ms before showing it and holds it 400 ms, and a second clock makes it flash.`)
167
- }
168
- }
267
+ checkElement({ el, f, scan, report, viewport, preload })
268
+ }
269
+ }
270
+ }
271
+
272
+ function checkElement({ el, f, scan, report, viewport, preload }) {
273
+ const style = el.attr('style')
274
+ if (style) {
275
+ if (style.kind === 'string')
276
+ for (const m of style.value.matchAll(/font-size\s*:\s*([^;]+)/g))
277
+ fontSize(m[1], style.valueStart + 1 + m.index + m[0].indexOf(m[1]), report)
278
+ else
279
+ for (const m of (style.expr ?? '').matchAll(/fontSize\s*:\s*(['"`])([^'"`]+)\1/g))
280
+ fontSize(m[2], style.valueStart + m.index + m[0].indexOf(m[2]), report)
281
+ }
282
+ if (
283
+ el.name === 'meta' &&
284
+ el.attr('name')?.value === 'viewport' &&
285
+ el.attr('content')?.kind === 'string'
286
+ ) {
287
+ const c = el.attr('content')
288
+ viewport(f, el.start, el.end, c.value, c.valueStart + 1)
289
+ }
290
+ if (
291
+ el.name === 'link' &&
292
+ el.attr('rel')?.value === 'preload' &&
293
+ el.attr('as')?.value === 'font'
294
+ ) {
295
+ preload(f, el.attr('href')?.value, el.start, el.end)
296
+ }
297
+ if (['img', 'video', 'iframe'].includes(el.name) && el.ancestors.includes('Boundary')) {
298
+ const sized =
299
+ (el.attr('width') && el.attr('height')) ||
300
+ /aspect-?ratio/i.test(style?.value ?? style?.expr ?? '')
301
+ if (!sized)
302
+ report(
303
+ 'X030',
304
+ el.start,
305
+ el.tagEnd,
306
+ `<${el.name}> inside a <Boundary> has no \`width\`/\`height\` or \`aspect-ratio\`: it renders 0 px tall, then pushes the content down when it loads.`,
307
+ )
308
+ }
309
+ if (/overflow(-[xy]|Y|X)?\s*:\s*['"]?(auto|scroll)/.test(style?.value ?? style?.expr ?? '')) {
310
+ const live = /\{\s*(\w+)\.value\b/.exec(el.children)?.[1]
311
+ if (live && new RegExp(`\\b${live}\\s*=\\s*[^\\n]*\\bstream\\b`).test(f.text))
312
+ report(
313
+ 'X035',
314
+ el.start,
315
+ el.tagEnd,
316
+ `\`${live}\` is streamed straight into a scroll container: as its tail reflows, the lines above it move.`,
317
+ )
318
+ }
319
+ const pending = el.name === 'Boundary' && el.attr('pending')
320
+ if (pending?.kind === 'expr') {
321
+ const tag = /^<\s*([A-Z][\w.]*)/.exec(pending.expr)?.[1]
322
+ const timers = /\b(setTimeout|setInterval)\s*\(/
323
+ if (timers.test(pending.expr) || (tag && timers.test(scan.functions.get(tag) ?? ''))) {
324
+ report(
325
+ 'X034',
326
+ pending.start,
327
+ pending.end,
328
+ `This \`pending\` fallback${tag ? ` (<${tag}>)` : ''} runs its own timers: <Boundary> already waits 150 ms before showing it and holds it 400 ms, and a second clock makes it flash.`,
329
+ )
169
330
  }
170
331
  }
171
- return out
172
332
  }
package/src/tokens.mjs CHANGED
@@ -11,14 +11,22 @@ export async function writeTokens(root) {
11
11
  root = resolve(root)
12
12
  let plugin
13
13
  try {
14
- plugin = await import(pathToFileURL(createRequire(join(root, 'package.json')).resolve('@weft-js/warp/plugin')).href)
14
+ plugin = await import(
15
+ pathToFileURL(createRequire(join(root, 'package.json')).resolve('@weft-js/warp/plugin')).href
16
+ )
15
17
  } catch {
16
- throw Object.assign(new Error('`weft tokens` exports warp\'s tokens, and this app has no @weft-js/warp'), { code: 'E_NO_WARP' })
18
+ throw Object.assign(
19
+ new Error("`weft tokens` exports warp's tokens, and this app has no @weft-js/warp"),
20
+ { code: 'E_NO_WARP' },
21
+ )
17
22
  }
18
- const css = (await loadConfig(root)).contributions.find(c => c.name === '@weft-js/warp')?.manifest.tokens?.css ?? ''
23
+ const css =
24
+ (await loadConfig(root)).contributions.find((c) => c.name === '@weft-js/warp')?.manifest.tokens
25
+ ?.css ?? ''
19
26
  const dir = join(root, '.weft', 'tokens')
20
27
  mkdirSync(dir, { recursive: true })
21
28
  const files = plugin.dtcg(css)
22
- for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), `${JSON.stringify(content, null, 2)}\n`)
29
+ for (const [name, content] of Object.entries(files))
30
+ writeFileSync(join(dir, name), `${JSON.stringify(content, null, 2)}\n`)
23
31
  return { ok: true, dir: '.weft/tokens', files: Object.keys(files) }
24
32
  }
@@ -0,0 +1,95 @@
1
+ import { builtinModules } from 'node:module'
2
+ import ts from 'typescript'
3
+
4
+ const builtins = new Set(builtinModules)
5
+ const NEEDS = {
6
+ fs: 'fs',
7
+ 'fs/promises': 'fs',
8
+ child_process: 'spawn',
9
+ cluster: 'spawn',
10
+ net: 'net',
11
+ tls: 'net',
12
+ dgram: 'net',
13
+ dns: 'net',
14
+ 'dns/promises': 'net',
15
+ http: 'net',
16
+ https: 'net',
17
+ http2: 'net',
18
+ }
19
+ const NET_GLOBALS = new Set(['fetch', 'WebSocket', 'EventSource'])
20
+ /** Bundlers inline it, and it says nothing about the machine. */
21
+ const AMBIENT_ENV = new Set(['NODE_ENV'])
22
+
23
+ /** What one file does directly, and what it imports. */
24
+ export function scanFile(text, file) {
25
+ const sf = ts.createSourceFile(
26
+ file,
27
+ text,
28
+ ts.ScriptTarget.Latest,
29
+ true,
30
+ /\.[cm]?tsx?$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX,
31
+ )
32
+ const uses = []
33
+ const imports = []
34
+ const use = (need, node) =>
35
+ uses.push({ need, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1 })
36
+ const specifier = (name, node) => {
37
+ const bare = name.replace(/^node:/, '')
38
+ if (name.endsWith('.node')) use('native', node)
39
+ else if (builtins.has(bare)) NEEDS[bare] && use(NEEDS[bare], node)
40
+ else imports.push(name)
41
+ }
42
+ const isProcess = (node) => ts.isIdentifier(node) && node.text === 'process'
43
+ const visit = (node) => {
44
+ if (
45
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
46
+ node.moduleSpecifier &&
47
+ ts.isStringLiteralLike(node.moduleSpecifier)
48
+ ) {
49
+ if (!(node.importClause?.isTypeOnly || node.isTypeOnly))
50
+ specifier(node.moduleSpecifier.text, node)
51
+ } else if (ts.isCallExpression(node)) {
52
+ const callee = node.expression
53
+ const [first] = node.arguments
54
+ if (
55
+ (callee.kind === ts.SyntaxKind.ImportKeyword ||
56
+ (ts.isIdentifier(callee) && callee.text === 'require')) &&
57
+ first &&
58
+ ts.isStringLiteralLike(first)
59
+ )
60
+ specifier(first.text, node)
61
+ else if (ts.isIdentifier(callee) && NET_GLOBALS.has(callee.text)) use('net', node)
62
+ else if (
63
+ ts.isPropertyAccessExpression(callee) &&
64
+ isProcess(callee.expression) &&
65
+ callee.name.text === 'dlopen'
66
+ )
67
+ use('native', node)
68
+ } else if (
69
+ ts.isNewExpression(node) &&
70
+ ts.isIdentifier(node.expression) &&
71
+ NET_GLOBALS.has(node.expression.text)
72
+ )
73
+ use('net', node)
74
+ else if (
75
+ ts.isPropertyAccessExpression(node) &&
76
+ isProcess(node.expression) &&
77
+ node.name.text === 'env'
78
+ ) {
79
+ const parent = node.parent
80
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node)
81
+ AMBIENT_ENV.has(parent.name.text) || use(`env:${parent.name.text}`, node)
82
+ else if (
83
+ ts.isElementAccessExpression(parent) &&
84
+ parent.expression === node &&
85
+ ts.isStringLiteralLike(parent.argumentExpression)
86
+ )
87
+ AMBIENT_ENV.has(parent.argumentExpression.text) ||
88
+ use(`env:${parent.argumentExpression.text}`, node)
89
+ else use('env:*', node)
90
+ }
91
+ ts.forEachChild(node, visit)
92
+ }
93
+ visit(sf)
94
+ return { uses, imports }
95
+ }