toiljs 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +315 -1
  2. package/assets/logo.svg +37 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/configure.js +10 -4
  5. package/build/cli/create.js +60 -32
  6. package/build/cli/diagnostics.d.ts +55 -0
  7. package/build/cli/diagnostics.js +333 -0
  8. package/build/cli/doctor.d.ts +6 -0
  9. package/build/cli/doctor.js +249 -0
  10. package/build/cli/index.js +26 -0
  11. package/build/cli/proc.d.ts +5 -0
  12. package/build/cli/proc.js +20 -0
  13. package/build/cli/ui.d.ts +1 -0
  14. package/build/cli/ui.js +1 -0
  15. package/build/cli/update.d.ts +7 -0
  16. package/build/cli/update.js +117 -0
  17. package/build/cli/updates.d.ts +10 -0
  18. package/build/cli/updates.js +45 -0
  19. package/build/client/.tsbuildinfo +1 -1
  20. package/build/client/dev/error-overlay.js +1 -1
  21. package/build/client/head/metadata.js +3 -1
  22. package/build/client/index.d.ts +5 -1
  23. package/build/client/index.js +2 -0
  24. package/build/client/navigation/navigation.js +1 -1
  25. package/build/client/routing/Router.js +2 -2
  26. package/build/client/search/search.d.ts +26 -0
  27. package/build/client/search/search.js +101 -0
  28. package/build/client/search/use-page-search.d.ts +8 -0
  29. package/build/client/search/use-page-search.js +21 -0
  30. package/build/compiler/.tsbuildinfo +1 -1
  31. package/build/compiler/generate.js +35 -26
  32. package/build/compiler/index.d.ts +2 -0
  33. package/build/compiler/index.js +1 -0
  34. package/build/compiler/pages.d.ts +8 -0
  35. package/build/compiler/pages.js +37 -0
  36. package/build/compiler/plugin.js +3 -1
  37. package/build/compiler/prerender.d.ts +1 -0
  38. package/build/compiler/prerender.js +11 -5
  39. package/build/compiler/seo.js +10 -3
  40. package/build/compiler/vite.js +7 -0
  41. package/build/io/.tsbuildinfo +1 -1
  42. package/examples/basic/client/components/Header.tsx +43 -38
  43. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  44. package/examples/basic/client/layout.tsx +4 -1
  45. package/examples/basic/client/public/index.html +18 -16
  46. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -0
  47. package/examples/basic/client/routes/(legal)/terms.tsx +15 -0
  48. package/examples/basic/client/routes/about.tsx +21 -19
  49. package/examples/basic/client/routes/blog/[id].tsx +26 -12
  50. package/examples/basic/client/routes/features/actions.tsx +67 -0
  51. package/examples/basic/client/routes/features/error/error.tsx +16 -0
  52. package/examples/basic/client/routes/features/error/index.tsx +27 -0
  53. package/examples/basic/client/routes/features/head.tsx +38 -0
  54. package/examples/basic/client/routes/features/index.tsx +83 -0
  55. package/examples/basic/client/routes/features/realtime.tsx +34 -0
  56. package/examples/basic/client/routes/features/script.tsx +31 -0
  57. package/examples/basic/client/routes/features/seo.tsx +39 -0
  58. package/examples/basic/client/routes/features/template/b.tsx +14 -0
  59. package/examples/basic/client/routes/features/template/index.tsx +20 -0
  60. package/examples/basic/client/routes/features/template/template.tsx +16 -0
  61. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
  62. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
  63. package/examples/basic/client/routes/gallery/index.tsx +42 -0
  64. package/examples/basic/client/routes/gallery/layout.tsx +13 -0
  65. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
  66. package/examples/basic/client/routes/get-started.tsx +157 -84
  67. package/examples/basic/client/routes/index.tsx +137 -87
  68. package/examples/basic/client/routes/loader-demo/index.tsx +59 -50
  69. package/examples/basic/client/routes/search.tsx +61 -0
  70. package/examples/basic/client/routes/test.tsx +7 -8
  71. package/examples/basic/client/styles/main.css +624 -552
  72. package/examples/basic/client/toil.tsx +2 -4
  73. package/package.json +3 -2
  74. package/presets/eslint.js +10 -3
  75. package/src/cli/configure.ts +363 -353
  76. package/src/cli/create.ts +563 -530
  77. package/src/cli/diagnostics.ts +421 -0
  78. package/src/cli/doctor.ts +318 -0
  79. package/src/cli/features.ts +166 -160
  80. package/src/cli/index.ts +242 -211
  81. package/src/cli/proc.ts +30 -0
  82. package/src/cli/ui.ts +111 -103
  83. package/src/cli/update.ts +150 -0
  84. package/src/cli/updates.ts +69 -0
  85. package/src/client/components/Image.tsx +91 -89
  86. package/src/client/dev/error-overlay.tsx +193 -197
  87. package/src/client/head/metadata.ts +94 -92
  88. package/src/client/index.ts +79 -64
  89. package/src/client/navigation/Link.tsx +94 -100
  90. package/src/client/navigation/navigation.ts +215 -218
  91. package/src/client/routing/Router.tsx +210 -193
  92. package/src/client/routing/hooks.ts +110 -114
  93. package/src/client/routing/lazy.ts +77 -81
  94. package/src/client/search/search.ts +189 -0
  95. package/src/client/search/use-page-search.ts +73 -0
  96. package/src/compiler/config.ts +173 -171
  97. package/src/compiler/fonts.ts +89 -87
  98. package/src/compiler/generate.ts +378 -364
  99. package/src/compiler/image-report.ts +88 -85
  100. package/src/compiler/index.ts +2 -0
  101. package/src/compiler/pages.ts +70 -0
  102. package/src/compiler/plugin.ts +51 -47
  103. package/src/compiler/prerender.ts +152 -130
  104. package/src/compiler/routes.ts +132 -131
  105. package/src/compiler/seo.ts +381 -356
  106. package/src/compiler/vite.ts +155 -130
  107. package/src/io/FastSet.ts +99 -96
  108. package/test/configure.test.ts +94 -90
  109. package/test/doctor.test.ts +140 -0
  110. package/test/dom/Image.test.tsx +73 -46
  111. package/test/dom/Script.test.tsx +48 -45
  112. package/test/dom/action.test.tsx +146 -129
  113. package/test/dom/error-overlay.test.tsx +44 -44
  114. package/test/dom/loader.test.tsx +2 -2
  115. package/test/dom/revalidate.test.tsx +1 -1
  116. package/test/dom/route-head.test.tsx +35 -2
  117. package/test/dom/slot.test.tsx +131 -109
  118. package/test/dom/view-transitions.test.tsx +53 -51
  119. package/test/features.test.ts +149 -142
  120. package/test/fonts.test.ts +28 -26
  121. package/test/head.test.ts +45 -35
  122. package/test/metadata.test.ts +42 -41
  123. package/test/pages.test.ts +105 -0
  124. package/test/prerender.test.ts +54 -46
  125. package/test/search.test.ts +114 -0
  126. package/test/seo.test.ts +164 -142
  127. package/test/slot-layouts.test.ts +69 -0
  128. package/test/update.test.ts +44 -0
@@ -1,109 +1,131 @@
1
- // @vitest-environment jsdom
2
- import { act, cleanup, render } from '@testing-library/react';
3
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
- import type { ReactNode } from 'react';
5
-
6
- import { Slot } from '../../src/client/components/Slot';
7
- import { navigate } from '../../src/client/navigation/navigation';
8
- import { Router } from '../../src/client/routing/Router';
9
- import { clearLoaderData } from '../../src/client/routing/loader';
10
- import { SlotContext } from '../../src/client/routing/slot-context';
11
- import type { RouteDef } from '../../src/client/types';
12
-
13
- const layoutWithModal = () =>
14
- Promise.resolve({
15
- default: ({ children }: { children?: ReactNode }) => (
16
- <div>
17
- {children}
18
- <Slot name="modal" />
19
- </div>
20
- ),
21
- });
22
-
23
- afterEach(cleanup);
24
- beforeEach(() => {
25
- clearLoaderData();
26
- window.history.replaceState({}, '', '/');
27
- });
28
-
29
- describe('Slot', () => {
30
- it('renders the named slot element from context, or the fallback', () => {
31
- const { getByText, queryByText } = render(
32
- <SlotContext.Provider value={{ modal: <span>MODAL</span> }}>
33
- <Slot name="modal" />
34
- <Slot name="missing" fallback={<span>FB</span>} />
35
- </SlotContext.Provider>,
36
- );
37
- expect(getByText('MODAL')).toBeTruthy();
38
- expect(getByText('FB')).toBeTruthy();
39
- expect(queryByText('missing')).toBeNull();
40
- });
41
- });
42
-
43
- describe('Router parallel slots', () => {
44
- const routes: RouteDef[] = [
45
- { pattern: '/', load: () => Promise.resolve({ default: () => <main>PAGE</main> }) },
46
- ];
47
- // A layout that renders the page plus the "modal" slot.
48
- const layout = () =>
49
- Promise.resolve({
50
- default: ({ children }: { children?: ReactNode }) => (
51
- <div>
52
- {children}
53
- <Slot name="modal" />
54
- </div>
55
- ),
56
- });
57
- const slots: Record<string, RouteDef[]> = {
58
- modal: [{ pattern: '/', load: () => Promise.resolve({ default: () => <aside>SLOT</aside> }) }],
59
- };
60
-
61
- it('renders the main route and a matching slot together', async () => {
62
- const { findByText } = render(<Router routes={routes} layout={layout} slots={slots} />);
63
- // Both the page and the parallel slot render for the same URL.
64
- await findByText('PAGE');
65
- await findByText('SLOT');
66
- });
67
- });
68
-
69
- describe('intercepting routes', () => {
70
- const routes: RouteDef[] = [
71
- { pattern: '/photo/:id', load: () => Promise.resolve({ default: () => <main>PHOTO PAGE</main> }) },
72
- { pattern: '/', load: () => Promise.resolve({ default: () => <main>FEED</main> }) },
73
- ];
74
- const slots: Record<string, RouteDef[]> = {
75
- modal: [
76
- {
77
- pattern: '/photo/:id',
78
- intercept: true,
79
- load: () => Promise.resolve({ default: () => <aside>PHOTO MODAL</aside> }),
80
- },
81
- ],
82
- };
83
-
84
- // This test must run before any navigation so it observes a "hard" load (soft-nav state is false).
85
- it('shows the full page on a hard load (no interception)', async () => {
86
- window.history.replaceState({}, '', '/photo/1');
87
- const { findByText, queryByText } = render(
88
- <Router routes={routes} layout={layoutWithModal} slots={slots} />,
89
- );
90
- await findByText('PHOTO PAGE');
91
- expect(queryByText('PHOTO MODAL')).toBeNull();
92
- });
93
-
94
- it('intercepts on soft navigation: modal overlays, previous page stays', async () => {
95
- window.history.replaceState({}, '', '/');
96
- const { findByText, queryByText } = render(
97
- <Router routes={routes} layout={layoutWithModal} slots={slots} />,
98
- );
99
- await findByText('FEED');
100
- act(() => {
101
- navigate('/photo/1');
102
- });
103
- // The intercepting slot route shows the modal…
104
- await findByText('PHOTO MODAL');
105
- // …while the main view keeps the previous page (the backdrop), not the full photo page.
106
- expect(queryByText('FEED')).not.toBeNull();
107
- expect(queryByText('PHOTO PAGE')).toBeNull();
108
- });
109
- });
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import type { ReactNode } from 'react';
5
+
6
+ import { Slot } from '../../src/client/components/Slot';
7
+ import { navigate } from '../../src/client/navigation/navigation';
8
+ import { Router } from '../../src/client/routing/Router';
9
+ import { clearLoaderData } from '../../src/client/routing/loader';
10
+ import { SlotContext } from '../../src/client/routing/slot-context';
11
+ import type { RouteDef } from '../../src/client/types';
12
+
13
+ const layoutWithModal = () =>
14
+ Promise.resolve({
15
+ default: ({ children }: { children?: ReactNode }) => (
16
+ <div>
17
+ {children}
18
+ <Slot name="modal" />
19
+ </div>
20
+ ),
21
+ });
22
+
23
+ afterEach(cleanup);
24
+ beforeEach(() => {
25
+ clearLoaderData();
26
+ window.history.replaceState({}, '', '/');
27
+ });
28
+
29
+ describe('Slot', () => {
30
+ it('renders the named slot element from context, or the fallback', () => {
31
+ const { getByText, queryByText } = render(
32
+ <SlotContext.Provider value={{ modal: <span>MODAL</span> }}>
33
+ <Slot name="modal" />
34
+ <Slot
35
+ name="missing"
36
+ fallback={<span>FB</span>}
37
+ />
38
+ </SlotContext.Provider>,
39
+ );
40
+ expect(getByText('MODAL')).toBeTruthy();
41
+ expect(getByText('FB')).toBeTruthy();
42
+ expect(queryByText('missing')).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe('Router parallel slots', () => {
47
+ const routes: RouteDef[] = [
48
+ { pattern: '/', load: () => Promise.resolve({ default: () => <main>PAGE</main> }) },
49
+ ];
50
+ // A layout that renders the page plus the "modal" slot.
51
+ const layout = () =>
52
+ Promise.resolve({
53
+ default: ({ children }: { children?: ReactNode }) => (
54
+ <div>
55
+ {children}
56
+ <Slot name="modal" />
57
+ </div>
58
+ ),
59
+ });
60
+ const slots: Record<string, RouteDef[]> = {
61
+ modal: [
62
+ { pattern: '/', load: () => Promise.resolve({ default: () => <aside>SLOT</aside> }) },
63
+ ],
64
+ };
65
+
66
+ it('renders the main route and a matching slot together', async () => {
67
+ const { findByText } = render(
68
+ <Router
69
+ routes={routes}
70
+ layout={layout}
71
+ slots={slots}
72
+ />,
73
+ );
74
+ // Both the page and the parallel slot render for the same URL.
75
+ await findByText('PAGE');
76
+ await findByText('SLOT');
77
+ });
78
+ });
79
+
80
+ describe('intercepting routes', () => {
81
+ const routes: RouteDef[] = [
82
+ {
83
+ pattern: '/photo/:id',
84
+ load: () => Promise.resolve({ default: () => <main>PHOTO PAGE</main> }),
85
+ },
86
+ { pattern: '/', load: () => Promise.resolve({ default: () => <main>FEED</main> }) },
87
+ ];
88
+ const slots: Record<string, RouteDef[]> = {
89
+ modal: [
90
+ {
91
+ pattern: '/photo/:id',
92
+ intercept: true,
93
+ load: () => Promise.resolve({ default: () => <aside>PHOTO MODAL</aside> }),
94
+ },
95
+ ],
96
+ };
97
+
98
+ // This test must run before any navigation so it observes a "hard" load (soft-nav state is false).
99
+ it('shows the full page on a hard load (no interception)', async () => {
100
+ window.history.replaceState({}, '', '/photo/1');
101
+ const { findByText, queryByText } = render(
102
+ <Router
103
+ routes={routes}
104
+ layout={layoutWithModal}
105
+ slots={slots}
106
+ />,
107
+ );
108
+ await findByText('PHOTO PAGE');
109
+ expect(queryByText('PHOTO MODAL')).toBeNull();
110
+ });
111
+
112
+ it('intercepts on soft navigation: modal overlays, previous page stays', async () => {
113
+ window.history.replaceState({}, '', '/');
114
+ const { findByText, queryByText } = render(
115
+ <Router
116
+ routes={routes}
117
+ layout={layoutWithModal}
118
+ slots={slots}
119
+ />,
120
+ );
121
+ await findByText('FEED');
122
+ act(() => {
123
+ navigate('/photo/1');
124
+ });
125
+ // The intercepting slot route shows the modal…
126
+ await findByText('PHOTO MODAL');
127
+ // …while the main view keeps the previous page (the backdrop), not the full photo page.
128
+ expect(queryByText('FEED')).not.toBeNull();
129
+ expect(queryByText('PHOTO PAGE')).toBeNull();
130
+ });
131
+ });
@@ -1,51 +1,53 @@
1
- // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
-
4
- import { navigate, setViewTransitions } from '../../src/client/navigation/navigation';
5
-
6
- interface VTDoc {
7
- startViewTransition?: (cb: () => void) => unknown;
8
- }
9
- const doc = document as Document & VTDoc;
10
-
11
- afterEach(() => {
12
- setViewTransitions(false);
13
- delete doc.startViewTransition;
14
- vi.restoreAllMocks();
15
- window.history.replaceState({}, '', '/');
16
- });
17
-
18
- describe('view transitions', () => {
19
- function stubReducedMotion(matches: boolean): void {
20
- window.matchMedia = vi.fn().mockReturnValue({ matches }) as unknown as typeof window.matchMedia;
21
- }
22
-
23
- it('wraps navigation in startViewTransition when enabled and supported', () => {
24
- const vt = vi.fn((cb: () => void) => {
25
- cb();
26
- });
27
- doc.startViewTransition = vt;
28
- stubReducedMotion(false);
29
- setViewTransitions(true);
30
- navigate('/a');
31
- expect(vt).toHaveBeenCalledOnce();
32
- });
33
-
34
- it('skips the view transition under prefers-reduced-motion', () => {
35
- const vt = vi.fn();
36
- doc.startViewTransition = vt;
37
- stubReducedMotion(true);
38
- setViewTransitions(true);
39
- navigate('/b');
40
- expect(vt).not.toHaveBeenCalled();
41
- });
42
-
43
- it('does not use view transitions when disabled', () => {
44
- const vt = vi.fn();
45
- doc.startViewTransition = vt;
46
- stubReducedMotion(false);
47
- setViewTransitions(false);
48
- navigate('/c');
49
- expect(vt).not.toHaveBeenCalled();
50
- });
51
- });
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { navigate, setViewTransitions } from '../../src/client/navigation/navigation';
5
+
6
+ interface VTDoc {
7
+ startViewTransition?: (cb: () => void) => unknown;
8
+ }
9
+ const doc = document as Document & VTDoc;
10
+
11
+ afterEach(() => {
12
+ setViewTransitions(false);
13
+ delete doc.startViewTransition;
14
+ vi.restoreAllMocks();
15
+ window.history.replaceState({}, '', '/');
16
+ });
17
+
18
+ describe('view transitions', () => {
19
+ function stubReducedMotion(matches: boolean): void {
20
+ window.matchMedia = vi
21
+ .fn()
22
+ .mockReturnValue({ matches }) as unknown as typeof window.matchMedia;
23
+ }
24
+
25
+ it('wraps navigation in startViewTransition when enabled and supported', () => {
26
+ const vt = vi.fn((cb: () => void) => {
27
+ cb();
28
+ });
29
+ doc.startViewTransition = vt;
30
+ stubReducedMotion(false);
31
+ setViewTransitions(true);
32
+ navigate('/a');
33
+ expect(vt).toHaveBeenCalledOnce();
34
+ });
35
+
36
+ it('skips the view transition under prefers-reduced-motion', () => {
37
+ const vt = vi.fn();
38
+ doc.startViewTransition = vt;
39
+ stubReducedMotion(true);
40
+ setViewTransitions(true);
41
+ navigate('/b');
42
+ expect(vt).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('does not use view transitions when disabled', () => {
46
+ const vt = vi.fn();
47
+ doc.startViewTransition = vt;
48
+ stubReducedMotion(false);
49
+ setViewTransitions(false);
50
+ navigate('/c');
51
+ expect(vt).not.toHaveBeenCalled();
52
+ });
53
+ });
@@ -1,142 +1,149 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import {
4
- defaultConfigSource,
5
- detectPreprocessor,
6
- detectTailwind,
7
- packageDiff,
8
- preprocessorForExt,
9
- requiredPackages,
10
- setConfigImages,
11
- setStyleImports,
12
- styleEntry,
13
- styleImportLines,
14
- type StyleFeatures,
15
- } from '../src/cli/features';
16
-
17
- const CSS: StyleFeatures = { preprocessor: 'css', tailwind: false };
18
- const SASS_TW: StyleFeatures = { preprocessor: 'sass', tailwind: true };
19
-
20
- describe('styleEntry / preprocessorForExt', () => {
21
- it('maps preprocessors to stylesheet paths', () => {
22
- expect(styleEntry('css')).toBe('styles/main.css');
23
- expect(styleEntry('sass')).toBe('styles/main.scss');
24
- expect(styleEntry('less')).toBe('styles/main.less');
25
- expect(styleEntry('stylus')).toBe('styles/main.styl');
26
- });
27
-
28
- it('reverses extensions back to preprocessors', () => {
29
- expect(preprocessorForExt('scss')).toBe('sass');
30
- expect(preprocessorForExt('.sass')).toBe('sass');
31
- expect(preprocessorForExt('less')).toBe('less');
32
- expect(preprocessorForExt('styl')).toBe('stylus');
33
- expect(preprocessorForExt('css')).toBe('css');
34
- expect(preprocessorForExt('txt')).toBeNull();
35
- });
36
- });
37
-
38
- describe('requiredPackages / packageDiff', () => {
39
- it('lists packages for a feature set', () => {
40
- expect(requiredPackages(CSS)).toEqual([]);
41
- expect(requiredPackages({ preprocessor: 'sass', tailwind: false })).toEqual(['sass']);
42
- expect(requiredPackages({ preprocessor: 'css', tailwind: true })).toEqual([
43
- 'tailwindcss',
44
- '@tailwindcss/vite',
45
- ]);
46
- });
47
-
48
- it('diffs add/remove between two setups', () => {
49
- expect(packageDiff(CSS, SASS_TW)).toEqual({
50
- add: ['sass', 'tailwindcss', '@tailwindcss/vite'],
51
- remove: [],
52
- });
53
- expect(packageDiff(SASS_TW, CSS)).toEqual({
54
- add: [],
55
- remove: ['sass', 'tailwindcss', '@tailwindcss/vite'],
56
- });
57
- expect(packageDiff({ preprocessor: 'sass', tailwind: false }, { preprocessor: 'less', tailwind: false })).toEqual(
58
- { add: ['less'], remove: ['sass'] },
59
- );
60
- });
61
- });
62
-
63
- describe('styleImportLines / setStyleImports', () => {
64
- it('orders Tailwind before the main stylesheet', () => {
65
- expect(styleImportLines(CSS)).toEqual(["import './styles/main.css';"]);
66
- expect(styleImportLines(SASS_TW)).toEqual([
67
- "import './styles/tailwind.css';",
68
- "import './styles/main.scss';",
69
- ]);
70
- });
71
-
72
- it('rewrites the app entry imports, preserving the rest', () => {
73
- const src = [
74
- "import { routes, layout, notFound } from 'toiljs/routes';",
75
- '',
76
- "import './styles/main.css';",
77
- '',
78
- 'Toil.mount(routes, layout, notFound);',
79
- '',
80
- ].join('\n');
81
-
82
- const out = setStyleImports(src, SASS_TW);
83
- expect(out).toContain("import './styles/tailwind.css';");
84
- expect(out).toContain("import './styles/main.scss';");
85
- expect(out).not.toContain("import './styles/main.css';");
86
- expect(out).toContain("from 'toiljs/routes'");
87
- expect(out).toContain('Toil.mount(routes, layout, notFound);');
88
- });
89
-
90
- it('round-trips back to plain CSS (drops Tailwind import)', () => {
91
- const src = [
92
- "import { routes, layout, notFound } from 'toiljs/routes';",
93
- "import './styles/tailwind.css';",
94
- "import './styles/main.scss';",
95
- 'Toil.mount(routes, layout, notFound);',
96
- ].join('\n');
97
-
98
- const out = setStyleImports(src, CSS);
99
- expect(out).toContain("import './styles/main.css';");
100
- expect(out).not.toContain('tailwind.css');
101
- expect(out).not.toContain('main.scss');
102
- });
103
- });
104
-
105
- describe('detect from dependencies', () => {
106
- it('finds the active preprocessor and Tailwind', () => {
107
- expect(detectPreprocessor({ sass: '^1' })).toBe('sass');
108
- expect(detectPreprocessor({ less: '^4' })).toBe('less');
109
- expect(detectPreprocessor({})).toBe('css');
110
- expect(detectTailwind({ '@tailwindcss/vite': '^4' })).toBe(true);
111
- expect(detectTailwind({ react: '^19' })).toBe(false);
112
- });
113
- });
114
-
115
- describe('setConfigImages / defaultConfigSource', () => {
116
- it('flips an existing images flag', () => {
117
- const src = 'export default defineConfig({\n client: {\n images: true,\n },\n});\n';
118
- expect(setConfigImages(src, false)).toContain('images: false');
119
- expect(setConfigImages(src, false)).not.toContain('images: true');
120
- });
121
-
122
- it('adds images to an existing client block', () => {
123
- const out = setConfigImages('export default defineConfig({ client: { base: "/" } });', false);
124
- expect(out).toContain('images: false');
125
- expect(out).toContain('base: "/"');
126
- });
127
-
128
- it('adds a client block to a bare config', () => {
129
- const out = setConfigImages('export default defineConfig({});', false);
130
- expect(out).toContain('client: { images: false }');
131
- });
132
-
133
- it('returns null when the shape is unrecognized', () => {
134
- expect(setConfigImages('const x = 1;', false)).toBeNull();
135
- });
136
-
137
- it('round-trips through defaultConfigSource', () => {
138
- const src = defaultConfigSource(false);
139
- expect(src).toContain('images: false');
140
- expect(setConfigImages(src, true)).toContain('images: true');
141
- });
142
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ defaultConfigSource,
5
+ detectPreprocessor,
6
+ detectTailwind,
7
+ packageDiff,
8
+ preprocessorForExt,
9
+ requiredPackages,
10
+ setConfigImages,
11
+ setStyleImports,
12
+ styleEntry,
13
+ type StyleFeatures,
14
+ styleImportLines,
15
+ } from '../src/cli/features';
16
+
17
+ const CSS: StyleFeatures = { preprocessor: 'css', tailwind: false };
18
+ const SASS_TW: StyleFeatures = { preprocessor: 'sass', tailwind: true };
19
+
20
+ describe('styleEntry / preprocessorForExt', () => {
21
+ it('maps preprocessors to stylesheet paths', () => {
22
+ expect(styleEntry('css')).toBe('styles/main.css');
23
+ expect(styleEntry('sass')).toBe('styles/main.scss');
24
+ expect(styleEntry('less')).toBe('styles/main.less');
25
+ expect(styleEntry('stylus')).toBe('styles/main.styl');
26
+ });
27
+
28
+ it('reverses extensions back to preprocessors', () => {
29
+ expect(preprocessorForExt('scss')).toBe('sass');
30
+ expect(preprocessorForExt('.sass')).toBe('sass');
31
+ expect(preprocessorForExt('less')).toBe('less');
32
+ expect(preprocessorForExt('styl')).toBe('stylus');
33
+ expect(preprocessorForExt('css')).toBe('css');
34
+ expect(preprocessorForExt('txt')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('requiredPackages / packageDiff', () => {
39
+ it('lists packages for a feature set', () => {
40
+ expect(requiredPackages(CSS)).toEqual([]);
41
+ expect(requiredPackages({ preprocessor: 'sass', tailwind: false })).toEqual(['sass']);
42
+ expect(requiredPackages({ preprocessor: 'css', tailwind: true })).toEqual([
43
+ 'tailwindcss',
44
+ '@tailwindcss/vite',
45
+ ]);
46
+ });
47
+
48
+ it('diffs add/remove between two setups', () => {
49
+ expect(packageDiff(CSS, SASS_TW)).toEqual({
50
+ add: ['sass', 'tailwindcss', '@tailwindcss/vite'],
51
+ remove: [],
52
+ });
53
+ expect(packageDiff(SASS_TW, CSS)).toEqual({
54
+ add: [],
55
+ remove: ['sass', 'tailwindcss', '@tailwindcss/vite'],
56
+ });
57
+ expect(
58
+ packageDiff(
59
+ { preprocessor: 'sass', tailwind: false },
60
+ { preprocessor: 'less', tailwind: false },
61
+ ),
62
+ ).toEqual({ add: ['less'], remove: ['sass'] });
63
+ });
64
+ });
65
+
66
+ describe('styleImportLines / setStyleImports', () => {
67
+ it('orders Tailwind before the main stylesheet', () => {
68
+ expect(styleImportLines(CSS)).toEqual(["import './styles/main.css';"]);
69
+ expect(styleImportLines(SASS_TW)).toEqual([
70
+ "import './styles/tailwind.css';",
71
+ "import './styles/main.scss';",
72
+ ]);
73
+ });
74
+
75
+ it('rewrites the app entry imports, preserving the rest', () => {
76
+ const src = [
77
+ "import { routes, layout, notFound } from 'toiljs/routes';",
78
+ '',
79
+ "import './styles/main.css';",
80
+ '',
81
+ 'Toil.mount(routes, layout, notFound);',
82
+ '',
83
+ ].join('\n');
84
+
85
+ const out = setStyleImports(src, SASS_TW);
86
+ expect(out).toContain("import './styles/tailwind.css';");
87
+ expect(out).toContain("import './styles/main.scss';");
88
+ expect(out).not.toContain("import './styles/main.css';");
89
+ expect(out).toContain("from 'toiljs/routes'");
90
+ expect(out).toContain('Toil.mount(routes, layout, notFound);');
91
+ });
92
+
93
+ it('round-trips back to plain CSS (drops Tailwind import)', () => {
94
+ const src = [
95
+ "import { routes, layout, notFound } from 'toiljs/routes';",
96
+ "import './styles/tailwind.css';",
97
+ "import './styles/main.scss';",
98
+ 'Toil.mount(routes, layout, notFound);',
99
+ ].join('\n');
100
+
101
+ const out = setStyleImports(src, CSS);
102
+ expect(out).toContain("import './styles/main.css';");
103
+ expect(out).not.toContain('tailwind.css');
104
+ expect(out).not.toContain('main.scss');
105
+ });
106
+ });
107
+
108
+ describe('detect from dependencies', () => {
109
+ it('finds the active preprocessor and Tailwind', () => {
110
+ expect(detectPreprocessor({ sass: '^1' })).toBe('sass');
111
+ expect(detectPreprocessor({ less: '^4' })).toBe('less');
112
+ expect(detectPreprocessor({})).toBe('css');
113
+ expect(detectTailwind({ '@tailwindcss/vite': '^4' })).toBe(true);
114
+ expect(detectTailwind({ react: '^19' })).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe('setConfigImages / defaultConfigSource', () => {
119
+ it('flips an existing images flag', () => {
120
+ const src =
121
+ 'export default defineConfig({\n client: {\n images: true,\n },\n});\n';
122
+ expect(setConfigImages(src, false)).toContain('images: false');
123
+ expect(setConfigImages(src, false)).not.toContain('images: true');
124
+ });
125
+
126
+ it('adds images to an existing client block', () => {
127
+ const out = setConfigImages(
128
+ 'export default defineConfig({ client: { base: "/" } });',
129
+ false,
130
+ );
131
+ expect(out).toContain('images: false');
132
+ expect(out).toContain('base: "/"');
133
+ });
134
+
135
+ it('adds a client block to a bare config', () => {
136
+ const out = setConfigImages('export default defineConfig({});', false);
137
+ expect(out).toContain('client: { images: false }');
138
+ });
139
+
140
+ it('returns null when the shape is unrecognized', () => {
141
+ expect(setConfigImages('const x = 1;', false)).toBeNull();
142
+ });
143
+
144
+ it('round-trips through defaultConfigSource', () => {
145
+ const src = defaultConfigSource(false);
146
+ expect(src).toContain('images: false');
147
+ expect(setConfigImages(src, true)).toContain('images: true');
148
+ });
149
+ });