basalt-ui 1.22.0 → 1.23.1
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 +29 -12
- package/CHANGELOG.md +28 -0
- package/MIGRATING.md +207 -12
- package/README.md +74 -21
- package/agent/rules/basalt-app.md +33 -3
- package/agent/rules/basalt-charts.md +78 -7
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +35 -6
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +27 -10
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- 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 +127 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +271 -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 +95 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +328 -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 +228 -31
- 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 +60 -13
- 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 +282 -0
- package/src/charts/kinds/BandStrip.tsx +457 -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 +232 -0
- package/src/charts/kinds/MirroredBars.tsx +491 -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/help.test.ts +74 -1
- package/src/cli/index.ts +436 -52
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/tokens-css.test.ts +62 -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 +211 -42
- package/src/guard/index.ts +149 -23
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -800,6 +800,79 @@ describe('chart-missing-aria-label', () => {
|
|
|
800
800
|
const f = find(text)
|
|
801
801
|
expect(kinds(f)).not.toContain('chart-missing-aria-label')
|
|
802
802
|
})
|
|
803
|
+
|
|
804
|
+
// ── tag provenance ─────────────────────────────────────────────────────────
|
|
805
|
+
//
|
|
806
|
+
// The rule keys on a tag NAME, so a consumer's OWN component sharing a shipped kind's name
|
|
807
|
+
// collected an unactionable `ariaLabel` demand (reported against 1.23.0: a hand-composed local
|
|
808
|
+
// `MirroredBars`, 235 lines, nothing to do with the kind). The gate is a one-directional
|
|
809
|
+
// narrowing — skip only what this file DEFINES and does not import from basalt-ui.
|
|
810
|
+
|
|
811
|
+
it('does NOT flag a tag this file DEFINES itself (a local component sharing a kind name)', () => {
|
|
812
|
+
const text = [
|
|
813
|
+
'function MirroredBars({ data }: Props) {',
|
|
814
|
+
' return <svg />',
|
|
815
|
+
'}',
|
|
816
|
+
'export function Panel() {',
|
|
817
|
+
' return <MirroredBars data={points} />',
|
|
818
|
+
'}',
|
|
819
|
+
].join('\n')
|
|
820
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('skips a locally defined kind name however it is declared (const/memo, not just function)', () => {
|
|
824
|
+
const text = ['const BandStrip = memo(Inner)', '<BandStrip data={rows} />'].join('\n')
|
|
825
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it('STILL flags when the same file also imports that name from basalt-ui (a real shadow)', () => {
|
|
829
|
+
const text = [
|
|
830
|
+
"import { MirroredBars } from 'basalt-ui/charts'",
|
|
831
|
+
'function Panel() {',
|
|
832
|
+
' return <MirroredBars data={points} />',
|
|
833
|
+
'}',
|
|
834
|
+
].join('\n')
|
|
835
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it('STILL flags a tag imported from a consumer BARREL that re-exports the kind', () => {
|
|
839
|
+
const text = ["import { MultiLine } from '../charts'", '<MultiLine data={points} />'].join('\n')
|
|
840
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('STILL flags a file with no imports and no local definition (coverage is not import-gated)', () => {
|
|
844
|
+
expect(kinds(find('<Donut data={slices} />'))).toContain('chart-missing-aria-label')
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('honours an aliased basalt import, since the alias is the tag the JSX writes', () => {
|
|
848
|
+
const text = [
|
|
849
|
+
"import { Bars as BasaltBars } from 'basalt-ui/charts'",
|
|
850
|
+
'const Bars = () => null',
|
|
851
|
+
'<Bars data={points} />',
|
|
852
|
+
].join('\n')
|
|
853
|
+
// `Bars` is defined locally here and only `BasaltBars` came from basalt — the local one is skipped.
|
|
854
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
describe('unframed-chart — the same provenance gate', () => {
|
|
859
|
+
it('does NOT flag a consumer’s OWN ChartLegend taking an items array', () => {
|
|
860
|
+
const text = [
|
|
861
|
+
'function ChartLegend({ items }: Props) {',
|
|
862
|
+
' return <ul />',
|
|
863
|
+
'}',
|
|
864
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
865
|
+
].join('\n')
|
|
866
|
+
expect(kinds(find(text))).not.toContain('unframed-chart')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('still flags the shipped ChartLegend built from an inline array', () => {
|
|
870
|
+
const text = [
|
|
871
|
+
"import { ChartLegend } from 'basalt-ui/charts'",
|
|
872
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
873
|
+
].join('\n')
|
|
874
|
+
expect(kinds(find(text))).toContain('unframed-chart')
|
|
875
|
+
})
|
|
803
876
|
})
|
|
804
877
|
|
|
805
878
|
// ── 16. card-with-border ─────────────────────────────────────────────────────
|
|
@@ -2738,59 +2811,134 @@ describe('inline-spacing — the style-object test', () => {
|
|
|
2738
2811
|
})
|
|
2739
2812
|
})
|
|
2740
2813
|
|
|
2741
|
-
// ── theme-allow
|
|
2814
|
+
// ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
|
|
2742
2815
|
|
|
2743
2816
|
/**
|
|
2744
|
-
* The
|
|
2817
|
+
* The whole shape space of an annotation, pinned cell by cell.
|
|
2818
|
+
*
|
|
2819
|
+
* FIVE holes have been found in this one contract in four rounds — two false positives, one false
|
|
2820
|
+
* negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
|
|
2821
|
+
* was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
|
|
2822
|
+
* thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
|
|
2823
|
+
* because a list of observed shapes cannot cover the ones not yet observed.
|
|
2824
|
+
*
|
|
2825
|
+
* So this is a GRID over the four things that actually vary, not a list of anecdotes:
|
|
2745
2826
|
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
*
|
|
2750
|
-
*
|
|
2827
|
+
* | axis | values |
|
|
2828
|
+
* |-|-|
|
|
2829
|
+
* | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
|
|
2830
|
+
* | token position | opener line · gutter line · trailing after code |
|
|
2831
|
+
* | where the closer falls | the token's line · its own line · N lines below · n/a |
|
|
2832
|
+
* | what follows | code · blank then code · comment then code · code on the closer's line |
|
|
2833
|
+
*
|
|
2834
|
+
* `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
|
|
2835
|
+
* plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
|
|
2836
|
+
* shared cell; add a row to one and it goes in the other in the same commit.
|
|
2837
|
+
*
|
|
2838
|
+
* The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
|
|
2839
|
+
* consumer, and the whole point of the grid is to make them different things.
|
|
2751
2840
|
*/
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
const
|
|
2755
|
-
|
|
2841
|
+
/** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
|
|
2842
|
+
const jsxShape = (body: string): string =>
|
|
2843
|
+
`export const C = () => (\n <div>\n${body}\n </div>\n)\n`
|
|
2844
|
+
|
|
2845
|
+
/** Does the file waive its `raw-hex`? — the one question every grid row asks. */
|
|
2846
|
+
const shapeWaives = (relPath: string, src: string): boolean =>
|
|
2847
|
+
checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
|
|
2756
2848
|
|
|
2849
|
+
describe('theme-allow shape grid', () => {
|
|
2850
|
+
const REASON = 'deliberate legacy value'
|
|
2851
|
+
const A = `theme-allow raw-hex — ${REASON}`
|
|
2852
|
+
const AF = `theme-allow-file raw-hex — ${REASON}`
|
|
2853
|
+
/** The statement the guard reports `raw-hex` on. */
|
|
2854
|
+
const T = `const a = '#f00'`
|
|
2855
|
+
/** The JSX child the guard reports `raw-hex` on. */
|
|
2856
|
+
const JT = `<span style={{ color: '#f00' }} />`
|
|
2857
|
+
const J = jsxShape
|
|
2858
|
+
const waives = shapeWaives
|
|
2859
|
+
|
|
2860
|
+
// ── SUPPORTED — every cell of the grid that waives ────────────────────────
|
|
2757
2861
|
it.each([
|
|
2758
|
-
|
|
2862
|
+
// `//` — no closer to place.
|
|
2863
|
+
['// own line', PATH, `// ${A}\n${T}\n`],
|
|
2864
|
+
['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
|
|
2865
|
+
['// no space after the marker', PATH, `//${A}\n${T}\n`],
|
|
2866
|
+
['// trailing', PATH, `${T} // ${A}\n`],
|
|
2867
|
+
['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
|
|
2868
|
+
// `/* */` and `/** */` at statement level, closer in each of its three places.
|
|
2869
|
+
['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
|
|
2870
|
+
['/* */ trailing', PATH, `${T} /* ${A} */\n`],
|
|
2871
|
+
['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
|
|
2872
|
+
['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
|
|
2873
|
+
['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
|
|
2874
|
+
['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
|
|
2875
|
+
['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
|
|
2876
|
+
['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
|
|
2877
|
+
// Prose between the token and the closer — any amount of it.
|
|
2878
|
+
['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
|
|
2879
|
+
['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
|
|
2759
2880
|
[
|
|
2760
|
-
'
|
|
2881
|
+
'/** prose first, token on a later gutter line',
|
|
2761
2882
|
PATH,
|
|
2762
|
-
|
|
2883
|
+
`/**\n * some prose\n * ${A}\n */\n${T}\n`,
|
|
2763
2884
|
],
|
|
2764
|
-
|
|
2765
|
-
['
|
|
2885
|
+
// JSX expression containers — the family every disagreement so far has come from.
|
|
2886
|
+
['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
|
|
2887
|
+
['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
|
|
2888
|
+
['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
|
|
2889
|
+
['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
|
|
2766
2890
|
[
|
|
2767
|
-
'
|
|
2891
|
+
'{/* gutter token, closer on its own line',
|
|
2768
2892
|
PATH,
|
|
2769
|
-
`
|
|
2893
|
+
J(` {/*\n ${A}\n */}\n ${JT}`),
|
|
2770
2894
|
],
|
|
2895
|
+
['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
|
|
2771
2896
|
[
|
|
2772
|
-
'
|
|
2897
|
+
'{/** star gutter, closer on its own line',
|
|
2773
2898
|
PATH,
|
|
2774
|
-
`
|
|
2899
|
+
J(` {/**\n * ${A}\n */}\n ${JT}`),
|
|
2775
2900
|
],
|
|
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
2901
|
[
|
|
2780
|
-
'
|
|
2781
|
-
|
|
2782
|
-
`
|
|
2902
|
+
'{/** token then 6 prose lines',
|
|
2903
|
+
PATH,
|
|
2904
|
+
J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
|
|
2783
2905
|
],
|
|
2906
|
+
['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
|
|
2907
|
+
['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
|
|
2784
2908
|
[
|
|
2785
|
-
'
|
|
2786
|
-
|
|
2787
|
-
|
|
2909
|
+
'{/* closer alone, space between */ and }',
|
|
2910
|
+
PATH,
|
|
2911
|
+
J(` {/*\n ${A}\n */ }\n ${JT}`),
|
|
2788
2912
|
],
|
|
2789
2913
|
[
|
|
2790
|
-
'
|
|
2791
|
-
|
|
2792
|
-
|
|
2914
|
+
'{/* closer alone, two annotations in one block',
|
|
2915
|
+
PATH,
|
|
2916
|
+
J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
|
|
2917
|
+
],
|
|
2918
|
+
// A further comment between the annotation and the code is walked through; a blank line is not.
|
|
2919
|
+
['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
|
|
2920
|
+
[
|
|
2921
|
+
'{/* */} then an unrelated {/* */}',
|
|
2922
|
+
PATH,
|
|
2923
|
+
J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
|
|
2793
2924
|
],
|
|
2925
|
+
// The file-declaration form, which is position-independent — pinned in two placements anyway,
|
|
2926
|
+
// because "position-independent" is a claim the parser has to keep making.
|
|
2927
|
+
[
|
|
2928
|
+
'theme-allow-file, {/* closer on its own line',
|
|
2929
|
+
PATH,
|
|
2930
|
+
J(` {/*\n ${AF}\n */}\n ${JT}`),
|
|
2931
|
+
],
|
|
2932
|
+
['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
|
|
2933
|
+
// Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
|
|
2934
|
+
['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
|
|
2935
|
+
[
|
|
2936
|
+
'css reflowed onto continuation lines',
|
|
2937
|
+
'src/a.css',
|
|
2938
|
+
`a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
|
|
2939
|
+
],
|
|
2940
|
+
['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
|
|
2941
|
+
['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
|
|
2794
2942
|
[
|
|
2795
2943
|
'JSON member form',
|
|
2796
2944
|
'public/site.webmanifest',
|
|
@@ -2800,25 +2948,46 @@ describe('theme-allow comment shapes', () => {
|
|
|
2800
2948
|
expect(waives(relPath as string, src as string)).toBe(true)
|
|
2801
2949
|
})
|
|
2802
2950
|
|
|
2803
|
-
//
|
|
2951
|
+
// ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
|
|
2804
2952
|
it.each([
|
|
2953
|
+
// A blank line is the separation people use for "this comment is not about the next statement".
|
|
2954
|
+
['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
|
|
2955
|
+
['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
|
|
2805
2956
|
[
|
|
2806
|
-
'
|
|
2807
|
-
|
|
2957
|
+
'blank line after a {/* closer on its own line',
|
|
2958
|
+
PATH,
|
|
2959
|
+
J(` {/*\n ${A}\n */}\n\n ${JT}`),
|
|
2808
2960
|
],
|
|
2961
|
+
// Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
|
|
2962
|
+
// the reason the token has to START its comment.
|
|
2963
|
+
['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
|
|
2809
2964
|
[
|
|
2810
|
-
'mid-sentence in a docblock',
|
|
2811
|
-
|
|
2965
|
+
'mid-sentence in a docblock gutter',
|
|
2966
|
+
PATH,
|
|
2967
|
+
`/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
|
|
2968
|
+
],
|
|
2969
|
+
[
|
|
2970
|
+
'mid-sentence in a JSX expression comment',
|
|
2971
|
+
PATH,
|
|
2972
|
+
J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
|
|
2973
|
+
],
|
|
2974
|
+
['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
|
|
2975
|
+
// An annotation reaches the first line below its comment — not an arbitrary line further down.
|
|
2976
|
+
// A multi-line opening tag therefore needs the annotation beside the attribute, not above the
|
|
2977
|
+
// tag. Both halves agree, and both report rather than silently waiving the wrong node.
|
|
2978
|
+
[
|
|
2979
|
+
'above a multi-line opening tag',
|
|
2980
|
+
PATH,
|
|
2981
|
+
J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
|
|
2812
2982
|
],
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
expect(waives(PATH, src as string)).toBe(false)
|
|
2983
|
+
])('%s does NOT waive', (_name, relPath, src) => {
|
|
2984
|
+
expect(waives(relPath as string, src as string)).toBe(false)
|
|
2816
2985
|
})
|
|
2817
2986
|
|
|
2818
2987
|
// The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
|
|
2819
2988
|
// classification, so the annotation does not reach the statement below it.
|
|
2820
2989
|
it('a trailing annotation on a real closing brace stays on its own line', () => {
|
|
2821
|
-
const src = `function f() {\n const a = 1\n} //
|
|
2990
|
+
const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
|
|
2822
2991
|
expect(
|
|
2823
2992
|
checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
|
|
2824
2993
|
.filter((f) => f.kind === 'raw-hex')
|
|
@@ -2829,7 +2998,7 @@ describe('theme-allow comment shapes', () => {
|
|
|
2829
2998
|
// A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
|
|
2830
2999
|
// would silently waive the statement below it too.
|
|
2831
3000
|
it('a trailing annotation does not reach the line below', () => {
|
|
2832
|
-
const src = `const a = '#f00' //
|
|
3001
|
+
const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
|
|
2833
3002
|
expect(
|
|
2834
3003
|
checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
|
|
2835
3004
|
).toHaveLength(1)
|
package/src/guard/index.ts
CHANGED
|
@@ -400,9 +400,55 @@ 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
|
-
/<(
|
|
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
|
+
// ── Tag provenance, shared by the two chart tag rules ────────────────────────────────────────────
|
|
407
|
+
//
|
|
408
|
+
// Both `chart-missing-aria-label` and `unframed-chart` key on a JSX tag NAME, which is the whole of
|
|
409
|
+
// their signal — so a consumer's OWN component that merely shares a shipped kind's name collected
|
|
410
|
+
// the finding too. Reported by a consumer on 1.23.0: a hand-composed local `<MirroredBars>` was
|
|
411
|
+
// told to pass an `ariaLabel` prop it does not accept, by a rule that presents as a correctness
|
|
412
|
+
// finding rather than as a naming one. `shadow-basalt-export` / `ai-sdk-major` scope through
|
|
413
|
+
// `isBasaltScopedFile` (package-level); the analogous file-level signal here is where the name in
|
|
414
|
+
// THIS file came from.
|
|
415
|
+
//
|
|
416
|
+
// The gate is deliberately a one-directional NARROWING: a tag is skipped only when the file DEFINES
|
|
417
|
+
// a component of that name and does not also import it from basalt-ui. Everything else still fires
|
|
418
|
+
// — a tag imported from basalt-ui, one imported from a consumer barrel that re-exports it (`import
|
|
419
|
+
// { MultiLine } from '../charts'`, the shape a downstream component library uses), and one the scan
|
|
420
|
+
// cannot attribute at all. Requiring a POSITIVE basalt import instead would have silently switched
|
|
421
|
+
// both rules off for every barrel-wrapping consumer and for every file with no import statements —
|
|
422
|
+
// a far bigger hole than the false positive it closes.
|
|
423
|
+
const LOCAL_COMPONENT_DEF =
|
|
424
|
+
/(?:^|[\n;}])\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z]\w*)/g
|
|
425
|
+
// `from 'basalt-ui'` and every subpath (`basalt-ui/charts`, `basalt-ui/tokens`, …).
|
|
426
|
+
const BASALT_NAMED_IMPORT =
|
|
427
|
+
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]basalt-ui(?:\/[^'"]*)?['"]/g
|
|
428
|
+
|
|
429
|
+
/** Component-shaped names DEFINED in this file — the provable "not the shipped kind" signal. */
|
|
430
|
+
function localComponentNames(codeText: string): ReadonlySet<string> {
|
|
431
|
+
const out = new Set<string>()
|
|
432
|
+
for (const m of codeText.matchAll(LOCAL_COMPONENT_DEF)) out.add(m[1] as string)
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Names bound in this file by a named import from `basalt-ui` — the local binding, so
|
|
437
|
+
* `{ Bars as BasaltBars }` records `BasaltBars`, which is the name the JSX tag will use. */
|
|
438
|
+
function basaltImportedNames(codeText: string): ReadonlySet<string> {
|
|
439
|
+
const out = new Set<string>()
|
|
440
|
+
for (const m of codeText.matchAll(BASALT_NAMED_IMPORT)) {
|
|
441
|
+
for (const raw of (m[1] as string).split(',')) {
|
|
442
|
+
const spec = raw.trim().replace(/^type\s+/, '')
|
|
443
|
+
if (spec.length === 0) continue
|
|
444
|
+
const parts = spec.split(/\s+as\s+/)
|
|
445
|
+
const bound = (parts[parts.length - 1] as string).trim()
|
|
446
|
+
if (bound.length > 0) out.add(bound)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out
|
|
450
|
+
}
|
|
451
|
+
|
|
406
452
|
// Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
|
|
407
453
|
// tag name rejects a same-prefixed custom component (`<inputRef`, `<selectAll`).
|
|
408
454
|
const RAW_FORM_CONTROL = /<(?:input|select|textarea)\b/g
|
|
@@ -771,8 +817,45 @@ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation
|
|
|
771
817
|
/** How far back a trailing CSS annotation reaches for the declaration it terminates. */
|
|
772
818
|
const MAX_CSS_CONTINUATION_LINES = 8
|
|
773
819
|
|
|
774
|
-
/**
|
|
775
|
-
|
|
820
|
+
/**
|
|
821
|
+
* The 0-based line the annotation's OWN comment closes on.
|
|
822
|
+
*
|
|
823
|
+
* A comment-only annotation waives the first line below its comment that is not itself comment, so
|
|
824
|
+
* the walk needs to know how far the annotation's own comment reaches. It used to substitute a
|
|
825
|
+
* budget — walk forward at most eight lines — which was wrong in two ways nothing could see:
|
|
826
|
+
*
|
|
827
|
+
* 1. **A comment block longer than the budget.** A docblock with ~12 lines of reason between the
|
|
828
|
+
* token and its `*\/` ran the walk out before it reached the code, while the oxlint plugin
|
|
829
|
+
* (which reads the comment NODE, so length is free) waived it. Nothing about the shape said so;
|
|
830
|
+
* the annotation just stopped working past a line count nobody could count.
|
|
831
|
+
* 2. **A blank line inside the block.** Outside a comment a blank line SEPARATES — it is how people
|
|
832
|
+
* say "this comment is not about the next statement" — but a blank gutter line in the middle of
|
|
833
|
+
* a docblock is just prose, and ending the walk there dropped the waiver.
|
|
834
|
+
*
|
|
835
|
+
* Knowing where the comment ends settles both: inside it, keep going; past it, the ordinary
|
|
836
|
+
* comment-run rules apply (see the walk in {@link collectAllowAnnotations}, and
|
|
837
|
+
* {@link isCommentOnly} for the third hole this pass closed).
|
|
838
|
+
*
|
|
839
|
+
* An unterminated block comment reports its own line — the conservative answer for a file that does
|
|
840
|
+
* not parse anyway, and it keeps the walk from running to EOF.
|
|
841
|
+
*/
|
|
842
|
+
function commentBlockEnd(
|
|
843
|
+
lines: readonly string[],
|
|
844
|
+
index: number,
|
|
845
|
+
tokenAt: number,
|
|
846
|
+
syntax: GuardSyntax,
|
|
847
|
+
): number {
|
|
848
|
+
const line = lines[index] ?? ''
|
|
849
|
+
// A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
|
|
850
|
+
// a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
|
|
851
|
+
if (syntax === 'ts' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
|
|
852
|
+
const closer = syntax === 'markup' ? '-->' : '*/'
|
|
853
|
+
if (line.slice(tokenAt).includes(closer)) return index
|
|
854
|
+
for (let ahead = index + 1; ahead < lines.length; ahead++) {
|
|
855
|
+
if ((lines[ahead] ?? '').includes(closer)) return ahead
|
|
856
|
+
}
|
|
857
|
+
return index
|
|
858
|
+
}
|
|
776
859
|
|
|
777
860
|
/**
|
|
778
861
|
* A comment opener sitting immediately before the annotation token.
|
|
@@ -805,6 +888,22 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
|
|
|
805
888
|
*/
|
|
806
889
|
const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
|
|
807
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Is this line NOTHING but comment? — one definition, used both to classify the annotation's own
|
|
893
|
+
* line (own-line vs trailing) and to walk the lines under it.
|
|
894
|
+
*
|
|
895
|
+
* The two used to be written out separately, and the walk's copy was the one missing the
|
|
896
|
+
* {@link JSX_COMMENT_CLOSE} clause: a `*\/}` alone on its line read as the code `}`, so the walk
|
|
897
|
+
* stopped ON the closer and the annotation waived a brace. Two consumers isolated that
|
|
898
|
+
* independently — `{/* … *\/}` and `{/** … *\/}` — each with a probe pair differing by one newline,
|
|
899
|
+
* while the oxlint plugin (whose test is comment-node-based and never sees the brace) waived both.
|
|
900
|
+
* A shared predicate is what stops the two placements from drifting apart again.
|
|
901
|
+
*/
|
|
902
|
+
function isCommentOnly(raw: string, stripped: string): boolean {
|
|
903
|
+
const code = stripped.trim()
|
|
904
|
+
return code === '' || COMMENT_ONLY_LINE.test(raw) || (code === '}' && JSX_COMMENT_CLOSE.test(raw))
|
|
905
|
+
}
|
|
906
|
+
|
|
808
907
|
/**
|
|
809
908
|
* Everything allowed between the comment opener (or the start of the line) and the annotation
|
|
810
909
|
* token, for the token to count as an ANNOTATION rather than prose that mentions one.
|
|
@@ -933,7 +1032,12 @@ function collectAllowAnnotations(
|
|
|
933
1032
|
}
|
|
934
1033
|
|
|
935
1034
|
/** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
|
|
936
|
-
const record = (
|
|
1035
|
+
const record = (
|
|
1036
|
+
index: number,
|
|
1037
|
+
annotation: AllowAnnotation,
|
|
1038
|
+
trailing: boolean,
|
|
1039
|
+
tokenAt: number,
|
|
1040
|
+
): void => {
|
|
937
1041
|
declared.push({ line: index + 1, annotation })
|
|
938
1042
|
if (annotation.scope === 'file') {
|
|
939
1043
|
fileScoped.push(annotation)
|
|
@@ -941,16 +1045,20 @@ function collectAllowAnnotations(
|
|
|
941
1045
|
}
|
|
942
1046
|
add(index, annotation)
|
|
943
1047
|
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
|
-
|
|
1048
|
+
// The first CODE line below the comment. A docblock's `*/` (or the rest of a wrapped reason)
|
|
1049
|
+
// sits between the annotation and the code it is about, so `index + 1` alone meant the
|
|
1050
|
+
// natural multi-line shape silently waived nothing — argo hit it three times in one upgrade,
|
|
1051
|
+
// each looking like a correct annotation. A BLANK line outside the comment still ends the
|
|
1052
|
+
// block: that is the separation people use to mean "this comment is not about the next
|
|
1053
|
+
// statement". Inside it, a blank gutter line is just prose.
|
|
1054
|
+
const closesAt = commentBlockEnd(lines, index, tokenAt, syntax)
|
|
1055
|
+
for (let ahead = 1; index + ahead < lines.length; ahead++) {
|
|
1056
|
+
const at = index + ahead
|
|
1057
|
+
add(at, annotation)
|
|
1058
|
+
if (at <= closesAt) continue
|
|
1059
|
+
const raw = lines[at] ?? ''
|
|
953
1060
|
if (raw.trim() === '') break
|
|
1061
|
+
if (!isCommentOnly(raw, codeLines[at] ?? '')) break
|
|
954
1062
|
}
|
|
955
1063
|
return
|
|
956
1064
|
}
|
|
@@ -968,14 +1076,17 @@ function collectAllowAnnotations(
|
|
|
968
1076
|
for (let i = 0; i < lines.length; i++) {
|
|
969
1077
|
const line = lines[i] ?? ''
|
|
970
1078
|
const stripped = codeLines[i] ?? ''
|
|
971
|
-
const
|
|
972
|
-
const isCommentOnlyLine =
|
|
973
|
-
code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
|
|
1079
|
+
const isCommentOnlyLine = isCommentOnly(line, stripped)
|
|
974
1080
|
|
|
975
1081
|
// The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
|
|
976
1082
|
if (syntax === 'markup') {
|
|
977
1083
|
for (const m of line.matchAll(jsonKey)) {
|
|
978
|
-
record(
|
|
1084
|
+
record(
|
|
1085
|
+
i,
|
|
1086
|
+
parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'),
|
|
1087
|
+
false,
|
|
1088
|
+
m.index,
|
|
1089
|
+
)
|
|
979
1090
|
}
|
|
980
1091
|
}
|
|
981
1092
|
|
|
@@ -993,6 +1104,7 @@ function collectAllowAnnotations(
|
|
|
993
1104
|
i,
|
|
994
1105
|
parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
|
|
995
1106
|
!isCommentOnlyLine,
|
|
1107
|
+
at,
|
|
996
1108
|
)
|
|
997
1109
|
}
|
|
998
1110
|
}
|
|
@@ -1981,10 +2093,25 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1981
2093
|
}
|
|
1982
2094
|
}
|
|
1983
2095
|
|
|
2096
|
+
// Both chart tag rules below gate on tag PROVENANCE (see the block above LOCAL_COMPONENT_DEF).
|
|
2097
|
+
// Computed once, and only if one of the two actually runs.
|
|
2098
|
+
const unframedChartRuns =
|
|
2099
|
+
GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)
|
|
2100
|
+
const ariaLabelRuns =
|
|
2101
|
+
GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
|
|
2102
|
+
ruleApplies('chart-missing-aria-label', relPath)
|
|
2103
|
+
const localNames =
|
|
2104
|
+
unframedChartRuns || ariaLabelRuns ? localComponentNames(codeText) : new Set<string>()
|
|
2105
|
+
const basaltNames =
|
|
2106
|
+
unframedChartRuns || ariaLabelRuns ? basaltImportedNames(codeText) : new Set<string>()
|
|
2107
|
+
const isShippedTag = (tag: string): boolean => basaltNames.has(tag) || !localNames.has(tag)
|
|
2108
|
+
|
|
1984
2109
|
// unframed-chart — full-text tag-scoped scan (not per-line, see RAW_CHART_LEGEND_ARRAY comment).
|
|
1985
2110
|
// Scans `codeText` (comment-stripped) so a legend example inside a comment can't match; reports
|
|
1986
|
-
// at the line of the `items={[` token itself.
|
|
1987
|
-
|
|
2111
|
+
// at the line of the `items={[` token itself. Skipped wholesale when `ChartLegend` is this file's
|
|
2112
|
+
// OWN component: a consumer's legend taking an `items` array is not the shipped one that derives
|
|
2113
|
+
// its entries, and the remedy ("pass deriveLegend(series)") is unreachable there.
|
|
2114
|
+
if (unframedChartRuns && isShippedTag('ChartLegend')) {
|
|
1988
2115
|
for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
|
|
1989
2116
|
const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split('\n').length
|
|
1990
2117
|
push('unframed-chart', lineNo, 'items={[')
|
|
@@ -1998,12 +2125,11 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
1998
2125
|
// which is what `Finding.text` would then quote back — the closing bracket rather than the tag
|
|
1999
2126
|
// that is missing the prop. The allow-comment is honored anywhere in the tag's span, so a
|
|
2000
2127
|
// `theme-allow` that used to sit on the closing line keeps working.
|
|
2001
|
-
if (
|
|
2002
|
-
GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
|
|
2003
|
-
ruleApplies('chart-missing-aria-label', relPath)
|
|
2004
|
-
) {
|
|
2128
|
+
if (ariaLabelRuns) {
|
|
2005
2129
|
for (const m of codeText.matchAll(CHART_ENTRY_POINT_TAG)) {
|
|
2006
2130
|
const tagText = m[0]
|
|
2131
|
+
// A tag this file defines itself is not the shipped kind — it does not take `ariaLabel`.
|
|
2132
|
+
if (!isShippedTag(m[1] as string)) continue
|
|
2007
2133
|
if (HAS_ARIA_LABEL_PROP.test(tagText)) continue
|
|
2008
2134
|
const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
|
|
2009
2135
|
const endLine = startLine + (tagText.split('\n').length - 1)
|
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)', () => {
|