basalt-ui 1.22.0 → 1.23.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.
- package/AGENTS.md +9 -1
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +125 -12
- package/README.md +45 -11
- package/agent/rules/basalt-app.md +26 -2
- package/agent/rules/basalt-charts.md +40 -5
- package/agent/rules/basalt-tokens.md +17 -1
- package/agent/skills/basalt-charts/SKILL.md +23 -8
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +49 -8
- package/configs/oxlint-plugin.test.ts +88 -31
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +113 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +218 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +93 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +315 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +47 -8
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +32 -11
- package/dist/vite.d.ts +42 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +67 -30
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +200 -0
- package/src/charts/kinds/BandStrip.tsx +364 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +193 -0
- package/src/charts/kinds/MirroredBars.tsx +455 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/index.ts +125 -18
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/toolchain-wiring.test.ts +171 -4
- package/src/cli/waiver-audit.test.ts +7 -3
- package/src/guard/check-source.test.ts +138 -42
- package/src/guard/index.ts +83 -17
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -104,11 +104,15 @@ describe('check-theme --audit-allows — theme-allow annotations', () => {
|
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
it('names the scope it audited, so "0 dead" is never read as "0 dead anywhere"', () => {
|
|
107
|
-
// The audit reads exactly what check-theme reads
|
|
108
|
-
//
|
|
107
|
+
// The audit reads exactly what check-theme reads, and that is WIDER than `basalt.roots` — two
|
|
108
|
+
// consumers reported a live `public/site.webmanifest` waiver under a scope line claiming
|
|
109
|
+
// files outside roots are not audited. The line has to name every class the scan reaches, or
|
|
110
|
+
// it is a second false statement in the sentence that exists to prevent one.
|
|
109
111
|
fixture("export const c = '#ff0000' // theme-allow raw-hex — brand literal\n")
|
|
110
112
|
const { log } = audit()
|
|
111
|
-
expect(log).toContain('Scope: the 1 file(s) check-theme scans under basalt.roots
|
|
113
|
+
expect(log).toContain('Scope: the 1 file(s) check-theme scans — everything under basalt.roots')
|
|
114
|
+
expect(log).toContain('sibling index.html and public/ tree')
|
|
115
|
+
expect(log).toContain('basalt.include')
|
|
112
116
|
})
|
|
113
117
|
|
|
114
118
|
it('marks an unaccountable waiver while still reporting what it covers', () => {
|
|
@@ -2738,59 +2738,134 @@ describe('inline-spacing — the style-object test', () => {
|
|
|
2738
2738
|
})
|
|
2739
2739
|
})
|
|
2740
2740
|
|
|
2741
|
-
// ── theme-allow
|
|
2741
|
+
// ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
|
|
2742
2742
|
|
|
2743
2743
|
/**
|
|
2744
|
-
* The
|
|
2744
|
+
* The whole shape space of an annotation, pinned cell by cell.
|
|
2745
2745
|
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
*
|
|
2750
|
-
*
|
|
2746
|
+
* FIVE holes have been found in this one contract in four rounds — two false positives, one false
|
|
2747
|
+
* negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
|
|
2748
|
+
* was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
|
|
2749
|
+
* thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
|
|
2750
|
+
* because a list of observed shapes cannot cover the ones not yet observed.
|
|
2751
|
+
*
|
|
2752
|
+
* So this is a GRID over the four things that actually vary, not a list of anecdotes:
|
|
2753
|
+
*
|
|
2754
|
+
* | axis | values |
|
|
2755
|
+
* |-|-|
|
|
2756
|
+
* | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
|
|
2757
|
+
* | token position | opener line · gutter line · trailing after code |
|
|
2758
|
+
* | where the closer falls | the token's line · its own line · N lines below · n/a |
|
|
2759
|
+
* | what follows | code · blank then code · comment then code · code on the closer's line |
|
|
2760
|
+
*
|
|
2761
|
+
* `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
|
|
2762
|
+
* plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
|
|
2763
|
+
* shared cell; add a row to one and it goes in the other in the same commit.
|
|
2764
|
+
*
|
|
2765
|
+
* The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
|
|
2766
|
+
* consumer, and the whole point of the grid is to make them different things.
|
|
2751
2767
|
*/
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
const
|
|
2755
|
-
|
|
2768
|
+
/** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
|
|
2769
|
+
const jsxShape = (body: string): string =>
|
|
2770
|
+
`export const C = () => (\n <div>\n${body}\n </div>\n)\n`
|
|
2771
|
+
|
|
2772
|
+
/** Does the file waive its `raw-hex`? — the one question every grid row asks. */
|
|
2773
|
+
const shapeWaives = (relPath: string, src: string): boolean =>
|
|
2774
|
+
checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
|
|
2756
2775
|
|
|
2776
|
+
describe('theme-allow shape grid', () => {
|
|
2777
|
+
const REASON = 'deliberate legacy value'
|
|
2778
|
+
const A = `theme-allow raw-hex — ${REASON}`
|
|
2779
|
+
const AF = `theme-allow-file raw-hex — ${REASON}`
|
|
2780
|
+
/** The statement the guard reports `raw-hex` on. */
|
|
2781
|
+
const T = `const a = '#f00'`
|
|
2782
|
+
/** The JSX child the guard reports `raw-hex` on. */
|
|
2783
|
+
const JT = `<span style={{ color: '#f00' }} />`
|
|
2784
|
+
const J = jsxShape
|
|
2785
|
+
const waives = shapeWaives
|
|
2786
|
+
|
|
2787
|
+
// ── SUPPORTED — every cell of the grid that waives ────────────────────────
|
|
2757
2788
|
it.each([
|
|
2758
|
-
|
|
2789
|
+
// `//` — no closer to place.
|
|
2790
|
+
['// own line', PATH, `// ${A}\n${T}\n`],
|
|
2791
|
+
['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
|
|
2792
|
+
['// no space after the marker', PATH, `//${A}\n${T}\n`],
|
|
2793
|
+
['// trailing', PATH, `${T} // ${A}\n`],
|
|
2794
|
+
['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
|
|
2795
|
+
// `/* */` and `/** */` at statement level, closer in each of its three places.
|
|
2796
|
+
['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
|
|
2797
|
+
['/* */ trailing', PATH, `${T} /* ${A} */\n`],
|
|
2798
|
+
['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
|
|
2799
|
+
['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
|
|
2800
|
+
['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
|
|
2801
|
+
['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
|
|
2802
|
+
['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
|
|
2803
|
+
['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
|
|
2804
|
+
// Prose between the token and the closer — any amount of it.
|
|
2805
|
+
['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
|
|
2806
|
+
['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
|
|
2759
2807
|
[
|
|
2760
|
-
'
|
|
2808
|
+
'/** prose first, token on a later gutter line',
|
|
2761
2809
|
PATH,
|
|
2762
|
-
|
|
2810
|
+
`/**\n * some prose\n * ${A}\n */\n${T}\n`,
|
|
2763
2811
|
],
|
|
2764
|
-
|
|
2765
|
-
['
|
|
2812
|
+
// JSX expression containers — the family every disagreement so far has come from.
|
|
2813
|
+
['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
|
|
2814
|
+
['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
|
|
2815
|
+
['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
|
|
2816
|
+
['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
|
|
2766
2817
|
[
|
|
2767
|
-
'
|
|
2818
|
+
'{/* gutter token, closer on its own line',
|
|
2768
2819
|
PATH,
|
|
2769
|
-
`
|
|
2820
|
+
J(` {/*\n ${A}\n */}\n ${JT}`),
|
|
2770
2821
|
],
|
|
2822
|
+
['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
|
|
2771
2823
|
[
|
|
2772
|
-
'
|
|
2824
|
+
'{/** star gutter, closer on its own line',
|
|
2773
2825
|
PATH,
|
|
2774
|
-
`
|
|
2826
|
+
J(` {/**\n * ${A}\n */}\n ${JT}`),
|
|
2775
2827
|
],
|
|
2776
|
-
['block gutter *', PATH, `/**\n * theme-allow raw-hex — ${REASON}\n */\nconst a = '#f00'\n`],
|
|
2777
|
-
['docblock opener /**', PATH, `/** theme-allow raw-hex — ${REASON} */\nconst a = '#f00'\n`],
|
|
2778
|
-
['css trailing', 'src/a.css', `a { color: #f00; /* theme-allow raw-hex — ${REASON} */ }\n`],
|
|
2779
2828
|
[
|
|
2780
|
-
'
|
|
2781
|
-
|
|
2782
|
-
`
|
|
2829
|
+
'{/** token then 6 prose lines',
|
|
2830
|
+
PATH,
|
|
2831
|
+
J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
|
|
2783
2832
|
],
|
|
2833
|
+
['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
|
|
2834
|
+
['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
|
|
2784
2835
|
[
|
|
2785
|
-
'
|
|
2786
|
-
|
|
2787
|
-
|
|
2836
|
+
'{/* closer alone, space between */ and }',
|
|
2837
|
+
PATH,
|
|
2838
|
+
J(` {/*\n ${A}\n */ }\n ${JT}`),
|
|
2788
2839
|
],
|
|
2789
2840
|
[
|
|
2790
|
-
'
|
|
2791
|
-
|
|
2792
|
-
|
|
2841
|
+
'{/* closer alone, two annotations in one block',
|
|
2842
|
+
PATH,
|
|
2843
|
+
J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
|
|
2844
|
+
],
|
|
2845
|
+
// A further comment between the annotation and the code is walked through; a blank line is not.
|
|
2846
|
+
['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
|
|
2847
|
+
[
|
|
2848
|
+
'{/* */} then an unrelated {/* */}',
|
|
2849
|
+
PATH,
|
|
2850
|
+
J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
|
|
2851
|
+
],
|
|
2852
|
+
// The file-declaration form, which is position-independent — pinned in two placements anyway,
|
|
2853
|
+
// because "position-independent" is a claim the parser has to keep making.
|
|
2854
|
+
[
|
|
2855
|
+
'theme-allow-file, {/* closer on its own line',
|
|
2856
|
+
PATH,
|
|
2857
|
+
J(` {/*\n ${AF}\n */}\n ${JT}`),
|
|
2858
|
+
],
|
|
2859
|
+
['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
|
|
2860
|
+
// Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
|
|
2861
|
+
['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
|
|
2862
|
+
[
|
|
2863
|
+
'css reflowed onto continuation lines',
|
|
2864
|
+
'src/a.css',
|
|
2865
|
+
`a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
|
|
2793
2866
|
],
|
|
2867
|
+
['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
|
|
2868
|
+
['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
|
|
2794
2869
|
[
|
|
2795
2870
|
'JSON member form',
|
|
2796
2871
|
'public/site.webmanifest',
|
|
@@ -2800,25 +2875,46 @@ describe('theme-allow comment shapes', () => {
|
|
|
2800
2875
|
expect(waives(relPath as string, src as string)).toBe(true)
|
|
2801
2876
|
})
|
|
2802
2877
|
|
|
2803
|
-
//
|
|
2878
|
+
// ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
|
|
2804
2879
|
it.each([
|
|
2880
|
+
// A blank line is the separation people use for "this comment is not about the next statement".
|
|
2881
|
+
['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
|
|
2882
|
+
['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
|
|
2805
2883
|
[
|
|
2806
|
-
'
|
|
2807
|
-
|
|
2884
|
+
'blank line after a {/* closer on its own line',
|
|
2885
|
+
PATH,
|
|
2886
|
+
J(` {/*\n ${A}\n */}\n\n ${JT}`),
|
|
2887
|
+
],
|
|
2888
|
+
// Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
|
|
2889
|
+
// the reason the token has to START its comment.
|
|
2890
|
+
['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
|
|
2891
|
+
[
|
|
2892
|
+
'mid-sentence in a docblock gutter',
|
|
2893
|
+
PATH,
|
|
2894
|
+
`/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
|
|
2808
2895
|
],
|
|
2809
2896
|
[
|
|
2810
|
-
'mid-sentence in a
|
|
2811
|
-
|
|
2897
|
+
'mid-sentence in a JSX expression comment',
|
|
2898
|
+
PATH,
|
|
2899
|
+
J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
|
|
2900
|
+
],
|
|
2901
|
+
['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
|
|
2902
|
+
// An annotation reaches the first line below its comment — not an arbitrary line further down.
|
|
2903
|
+
// A multi-line opening tag therefore needs the annotation beside the attribute, not above the
|
|
2904
|
+
// tag. Both halves agree, and both report rather than silently waiving the wrong node.
|
|
2905
|
+
[
|
|
2906
|
+
'above a multi-line opening tag',
|
|
2907
|
+
PATH,
|
|
2908
|
+
J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
|
|
2812
2909
|
],
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
expect(waives(PATH, src as string)).toBe(false)
|
|
2910
|
+
])('%s does NOT waive', (_name, relPath, src) => {
|
|
2911
|
+
expect(waives(relPath as string, src as string)).toBe(false)
|
|
2816
2912
|
})
|
|
2817
2913
|
|
|
2818
2914
|
// The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
|
|
2819
2915
|
// classification, so the annotation does not reach the statement below it.
|
|
2820
2916
|
it('a trailing annotation on a real closing brace stays on its own line', () => {
|
|
2821
|
-
const src = `function f() {\n const a = 1\n} //
|
|
2917
|
+
const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
|
|
2822
2918
|
expect(
|
|
2823
2919
|
checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
|
|
2824
2920
|
.filter((f) => f.kind === 'raw-hex')
|
|
@@ -2829,7 +2925,7 @@ describe('theme-allow comment shapes', () => {
|
|
|
2829
2925
|
// A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
|
|
2830
2926
|
// would silently waive the statement below it too.
|
|
2831
2927
|
it('a trailing annotation does not reach the line below', () => {
|
|
2832
|
-
const src = `const a = '#f00' //
|
|
2928
|
+
const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
|
|
2833
2929
|
expect(
|
|
2834
2930
|
checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
|
|
2835
2931
|
).toHaveLength(1)
|
package/src/guard/index.ts
CHANGED
|
@@ -400,7 +400,7 @@ const RAW_CHART_LEGEND_ARRAY = /<ChartLegend\b[^>]*?\bitems\s*=\s*\{\s*\[/g
|
|
|
400
400
|
// alternative so their `>` never terminates the tag early. A bare `>` comparison inside a prop
|
|
401
401
|
// expression still ends the match (accepted limitation of the bounded scan).
|
|
402
402
|
const CHART_ENTRY_POINT_TAG =
|
|
403
|
-
/<(?:MultiLine|Bars|Donut|DualPanel|Heatmap|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
|
|
403
|
+
/<(?:MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
|
|
404
404
|
const HAS_ARIA_LABEL_PROP = /\bariaLabel\s*=/
|
|
405
405
|
|
|
406
406
|
// Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
|
|
@@ -771,8 +771,45 @@ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation
|
|
|
771
771
|
/** How far back a trailing CSS annotation reaches for the declaration it terminates. */
|
|
772
772
|
const MAX_CSS_CONTINUATION_LINES = 8
|
|
773
773
|
|
|
774
|
-
/**
|
|
775
|
-
|
|
774
|
+
/**
|
|
775
|
+
* The 0-based line the annotation's OWN comment closes on.
|
|
776
|
+
*
|
|
777
|
+
* A comment-only annotation waives the first line below its comment that is not itself comment, so
|
|
778
|
+
* the walk needs to know how far the annotation's own comment reaches. It used to substitute a
|
|
779
|
+
* budget — walk forward at most eight lines — which was wrong in two ways nothing could see:
|
|
780
|
+
*
|
|
781
|
+
* 1. **A comment block longer than the budget.** A docblock with ~12 lines of reason between the
|
|
782
|
+
* token and its `*\/` ran the walk out before it reached the code, while the oxlint plugin
|
|
783
|
+
* (which reads the comment NODE, so length is free) waived it. Nothing about the shape said so;
|
|
784
|
+
* the annotation just stopped working past a line count nobody could count.
|
|
785
|
+
* 2. **A blank line inside the block.** Outside a comment a blank line SEPARATES — it is how people
|
|
786
|
+
* say "this comment is not about the next statement" — but a blank gutter line in the middle of
|
|
787
|
+
* a docblock is just prose, and ending the walk there dropped the waiver.
|
|
788
|
+
*
|
|
789
|
+
* Knowing where the comment ends settles both: inside it, keep going; past it, the ordinary
|
|
790
|
+
* comment-run rules apply (see the walk in {@link collectAllowAnnotations}, and
|
|
791
|
+
* {@link isCommentOnly} for the third hole this pass closed).
|
|
792
|
+
*
|
|
793
|
+
* An unterminated block comment reports its own line — the conservative answer for a file that does
|
|
794
|
+
* not parse anyway, and it keeps the walk from running to EOF.
|
|
795
|
+
*/
|
|
796
|
+
function commentBlockEnd(
|
|
797
|
+
lines: readonly string[],
|
|
798
|
+
index: number,
|
|
799
|
+
tokenAt: number,
|
|
800
|
+
syntax: GuardSyntax,
|
|
801
|
+
): number {
|
|
802
|
+
const line = lines[index] ?? ''
|
|
803
|
+
// A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
|
|
804
|
+
// a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
|
|
805
|
+
if (syntax === 'ts' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
|
|
806
|
+
const closer = syntax === 'markup' ? '-->' : '*/'
|
|
807
|
+
if (line.slice(tokenAt).includes(closer)) return index
|
|
808
|
+
for (let ahead = index + 1; ahead < lines.length; ahead++) {
|
|
809
|
+
if ((lines[ahead] ?? '').includes(closer)) return ahead
|
|
810
|
+
}
|
|
811
|
+
return index
|
|
812
|
+
}
|
|
776
813
|
|
|
777
814
|
/**
|
|
778
815
|
* A comment opener sitting immediately before the annotation token.
|
|
@@ -805,6 +842,22 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
805
842
|
*/
|
|
806
843
|
const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
|
|
807
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Is this line NOTHING but comment? — one definition, used both to classify the annotation's own
|
|
847
|
+
* line (own-line vs trailing) and to walk the lines under it.
|
|
848
|
+
*
|
|
849
|
+
* The two used to be written out separately, and the walk's copy was the one missing the
|
|
850
|
+
* {@link JSX_COMMENT_CLOSE} clause: a `*\/}` alone on its line read as the code `}`, so the walk
|
|
851
|
+
* stopped ON the closer and the annotation waived a brace. Two consumers isolated that
|
|
852
|
+
* independently — `{/* … *\/}` and `{/** … *\/}` — each with a probe pair differing by one newline,
|
|
853
|
+
* while the oxlint plugin (whose test is comment-node-based and never sees the brace) waived both.
|
|
854
|
+
* A shared predicate is what stops the two placements from drifting apart again.
|
|
855
|
+
*/
|
|
856
|
+
function isCommentOnly(raw: string, stripped: string): boolean {
|
|
857
|
+
const code = stripped.trim()
|
|
858
|
+
return code === '' || COMMENT_ONLY_LINE.test(raw) || (code === '}' && JSX_COMMENT_CLOSE.test(raw))
|
|
859
|
+
}
|
|
860
|
+
|
|
808
861
|
/**
|
|
809
862
|
* Everything allowed between the comment opener (or the start of the line) and the annotation
|
|
810
863
|
* token, for the token to count as an ANNOTATION rather than prose that mentions one.
|
|
@@ -933,7 +986,12 @@ function collectAllowAnnotations(
|
|
|
933
986
|
}
|
|
934
987
|
|
|
935
988
|
/** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
|
|
936
|
-
const record = (
|
|
989
|
+
const record = (
|
|
990
|
+
index: number,
|
|
991
|
+
annotation: AllowAnnotation,
|
|
992
|
+
trailing: boolean,
|
|
993
|
+
tokenAt: number,
|
|
994
|
+
): void => {
|
|
937
995
|
declared.push({ line: index + 1, annotation })
|
|
938
996
|
if (annotation.scope === 'file') {
|
|
939
997
|
fileScoped.push(annotation)
|
|
@@ -941,16 +999,20 @@ function collectAllowAnnotations(
|
|
|
941
999
|
}
|
|
942
1000
|
add(index, annotation)
|
|
943
1001
|
if (!trailing) {
|
|
944
|
-
// The first line below
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// the separation people use to mean "this comment is not about the next
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1002
|
+
// The first CODE line below the comment. A docblock's `*/` (or the rest of a wrapped reason)
|
|
1003
|
+
// sits between the annotation and the code it is about, so `index + 1` alone meant the
|
|
1004
|
+
// natural multi-line shape silently waived nothing — argo hit it three times in one upgrade,
|
|
1005
|
+
// each looking like a correct annotation. A BLANK line outside the comment still ends the
|
|
1006
|
+
// block: that is the separation people use to mean "this comment is not about the next
|
|
1007
|
+
// statement". Inside it, a blank gutter line is just prose.
|
|
1008
|
+
const closesAt = commentBlockEnd(lines, index, tokenAt, syntax)
|
|
1009
|
+
for (let ahead = 1; index + ahead < lines.length; ahead++) {
|
|
1010
|
+
const at = index + ahead
|
|
1011
|
+
add(at, annotation)
|
|
1012
|
+
if (at <= closesAt) continue
|
|
1013
|
+
const raw = lines[at] ?? ''
|
|
953
1014
|
if (raw.trim() === '') break
|
|
1015
|
+
if (!isCommentOnly(raw, codeLines[at] ?? '')) break
|
|
954
1016
|
}
|
|
955
1017
|
return
|
|
956
1018
|
}
|
|
@@ -968,14 +1030,17 @@ function collectAllowAnnotations(
|
|
|
968
1030
|
for (let i = 0; i < lines.length; i++) {
|
|
969
1031
|
const line = lines[i] ?? ''
|
|
970
1032
|
const stripped = codeLines[i] ?? ''
|
|
971
|
-
const
|
|
972
|
-
const isCommentOnlyLine =
|
|
973
|
-
code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
|
|
1033
|
+
const isCommentOnlyLine = isCommentOnly(line, stripped)
|
|
974
1034
|
|
|
975
1035
|
// The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
|
|
976
1036
|
if (syntax === 'markup') {
|
|
977
1037
|
for (const m of line.matchAll(jsonKey)) {
|
|
978
|
-
record(
|
|
1038
|
+
record(
|
|
1039
|
+
i,
|
|
1040
|
+
parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'),
|
|
1041
|
+
false,
|
|
1042
|
+
m.index,
|
|
1043
|
+
)
|
|
979
1044
|
}
|
|
980
1045
|
}
|
|
981
1046
|
|
|
@@ -993,6 +1058,7 @@ function collectAllowAnnotations(
|
|
|
993
1058
|
i,
|
|
994
1059
|
parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
|
|
995
1060
|
!isCommentOnlyLine,
|
|
1061
|
+
at,
|
|
996
1062
|
)
|
|
997
1063
|
}
|
|
998
1064
|
}
|
package/src/vite.test.ts
CHANGED
|
@@ -234,6 +234,85 @@ describe('basaltAppPlugin — base handling', () => {
|
|
|
234
234
|
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
235
235
|
expect(manifest.icons[0].src).toBe('/brand/web-app-manifest-192x192.png')
|
|
236
236
|
})
|
|
237
|
+
|
|
238
|
+
// ── the consumer-named array form ─────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// `{ dir }` can move basalt's six filenames but not rename one, so an app whose only icon is
|
|
241
|
+
// `favicon.svg` could pick between a manifest naming two PNGs it does not ship and a manifest
|
|
242
|
+
// with no icons at all. Both are wrong for an installable app, and the second is why rb still
|
|
243
|
+
// kept a hand-written manifest one release after `icons: false` was fixed.
|
|
244
|
+
|
|
245
|
+
// rb's REAL icon set, copied from apps/web/public/manifest.webmanifest — the manifest this
|
|
246
|
+
// option exists to let the plugin generate.
|
|
247
|
+
const RB_ICONS = [
|
|
248
|
+
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
249
|
+
] as const
|
|
250
|
+
|
|
251
|
+
test("generates rb's hand-written manifest icons member verbatim", () => {
|
|
252
|
+
const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: RB_ICONS })
|
|
253
|
+
const middleware = getDevMiddleware(plugin, '/')
|
|
254
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
255
|
+
|
|
256
|
+
expect(manifest.icons).toEqual([
|
|
257
|
+
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
|
|
258
|
+
])
|
|
259
|
+
expect(JSON.stringify(manifest)).not.toContain('.png')
|
|
260
|
+
expect(manifest.name).toBe('rb')
|
|
261
|
+
expect(manifest.display).toBe('standalone')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('a named icon reaches the head only when it asks for a rel', () => {
|
|
265
|
+
const iconLinks = (icons: NonNullable<BasaltAppOptions['icons']>): HtmlTagDescriptor[] => {
|
|
266
|
+
const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons })
|
|
267
|
+
resolveConfig(plugin, '/')
|
|
268
|
+
return transformHtml(plugin, HTML_NO_VIEWPORT).filter(
|
|
269
|
+
(tag) => tag.tag === 'link' && String(tag.attrs?.['rel']).includes('icon'),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// rb's index.html already links favicon.svg — the manifest gets fixed, the head is left alone.
|
|
274
|
+
expect(iconLinks(RB_ICONS)).toHaveLength(0)
|
|
275
|
+
|
|
276
|
+
const withRel = iconLinks([{ src: 'favicon.svg', type: 'image/svg+xml', rel: 'icon' }])
|
|
277
|
+
expect(withRel).toHaveLength(1)
|
|
278
|
+
expect(withRel[0]?.attrs).toEqual({
|
|
279
|
+
rel: 'icon',
|
|
280
|
+
type: 'image/svg+xml',
|
|
281
|
+
href: '/favicon.svg',
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('a named icon src is base-prefixed, with or without its leading slash', () => {
|
|
286
|
+
const plugin = getPlugin({
|
|
287
|
+
name: 'rb',
|
|
288
|
+
themeColor: THEME_COLOR,
|
|
289
|
+
icons: [
|
|
290
|
+
{ src: 'favicon.svg', sizes: 'any' },
|
|
291
|
+
{ src: '/icon-512.png', sizes: '512x512' },
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
const middleware = getDevMiddleware(plugin, '/myapp/')
|
|
295
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/myapp/site.webmanifest') ?? '{}')
|
|
296
|
+
|
|
297
|
+
expect(manifest.icons.map((icon: { src: string }) => icon.src)).toEqual([
|
|
298
|
+
'/myapp/favicon.svg',
|
|
299
|
+
'/myapp/icon-512.png',
|
|
300
|
+
])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('an empty array reads as icons: false — no member, not an empty one', () => {
|
|
304
|
+
const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: [] })
|
|
305
|
+
const middleware = getDevMiddleware(plugin, '/')
|
|
306
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
307
|
+
expect(manifest.icons).toBeUndefined()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('the array form omits keys the consumer did not write', () => {
|
|
311
|
+
const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: [{ src: '/a.svg' }] })
|
|
312
|
+
const middleware = getDevMiddleware(plugin, '/')
|
|
313
|
+
const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
|
|
314
|
+
expect(manifest.icons).toEqual([{ src: '/a.svg' }])
|
|
315
|
+
})
|
|
237
316
|
})
|
|
238
317
|
|
|
239
318
|
describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {
|