@weft-js/cli 0.1.1 → 0.1.2
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.
- package/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/text-checks.mjs
CHANGED
|
@@ -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(
|
|
35
|
-
|
|
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)
|
|
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))
|
|
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 => ({
|
|
60
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
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))
|
|
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(
|
|
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
|
|
91
|
-
|
|
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))
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
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]))
|
|
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(
|
|
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`))
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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))
|
|
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
|
+
}
|