saasaloy 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +439 -135
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/schemas/manifest.schema.json +2 -1
- package/schemas/registry-item.schema.json +63 -2
- package/templates/base/apps/web/astro.config.mjs +21 -0
- package/templates/base/apps/web/package.json +11 -10
- package/templates/base/apps/web/wrangler.jsonc +7 -0
- package/templates/base/package.json +9 -9
- package/templates/base/packages/ui/package.json +4 -4
- package/templates/base/packages/ui/src/lib/theme.ts +55 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "saasaloy",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Composable SaaS accelerator kit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"saas",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsup",
|
|
42
|
+
"clean": "rimraf dist coverage",
|
|
42
43
|
"prepack": "pnpm -w run readme:cli && pnpm run build",
|
|
43
44
|
"dev": "tsup --watch",
|
|
44
45
|
"start": "node ./dist/index.js",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
"@types/node": "26.4.1",
|
|
55
56
|
"@vitest/coverage-v8": "5.0.0",
|
|
56
57
|
"release-it": "21.0.2",
|
|
58
|
+
"rimraf": "6.1.3",
|
|
57
59
|
"tsup": "8.5.1",
|
|
58
60
|
"vitest": "5.0.0"
|
|
59
61
|
},
|
|
@@ -98,7 +98,8 @@
|
|
|
98
98
|
"plugin-array",
|
|
99
99
|
"const-array",
|
|
100
100
|
"chained-route",
|
|
101
|
-
"drizzle-column"
|
|
101
|
+
"drizzle-column",
|
|
102
|
+
"nav-entry"
|
|
102
103
|
],
|
|
103
104
|
"description": "Which codemod applied this patch. Must list exactly the kinds registry-item.schema.json accepts — the applier records here whatever a descriptor authored, so a kind missing from this enum makes the next `loadManifest` refuse a manifest the tool itself wrote (#98). `schema.test.ts` asserts the two enums and the `Patch` union agree."
|
|
104
105
|
}
|
|
@@ -159,9 +159,10 @@
|
|
|
159
159
|
"plugin-array",
|
|
160
160
|
"const-array",
|
|
161
161
|
"chained-route",
|
|
162
|
-
"drizzle-column"
|
|
162
|
+
"drizzle-column",
|
|
163
|
+
"nav-entry"
|
|
163
164
|
],
|
|
164
|
-
"description": "Which codemod applies this patch. `const-array`, `plugin-array`, `chained-route`, `wrangler-binding` and `
|
|
165
|
+
"description": "Which codemod applies this patch. `const-array`, `plugin-array`, `chained-route`, `wrangler-binding`, `drizzle-column` and `nav-entry` have removal inverses."
|
|
165
166
|
}
|
|
166
167
|
},
|
|
167
168
|
"allOf": [
|
|
@@ -191,6 +192,66 @@
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
},
|
|
195
|
+
{
|
|
196
|
+
"if": {
|
|
197
|
+
"properties": { "kind": { "const": "nav-entry" } },
|
|
198
|
+
"required": ["kind"]
|
|
199
|
+
},
|
|
200
|
+
"then": {
|
|
201
|
+
"required": ["constName", "area", "group", "entry", "icon"],
|
|
202
|
+
"properties": {
|
|
203
|
+
"constName": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"minLength": 1,
|
|
206
|
+
"description": "Name of the module-scope const holding the nav areas (NAV_AREAS)."
|
|
207
|
+
},
|
|
208
|
+
"area": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"minLength": 1,
|
|
211
|
+
"description": "Which area to add to, named by its `to` value."
|
|
212
|
+
},
|
|
213
|
+
"group": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"minLength": 1,
|
|
216
|
+
"description": "Which of that area's groups to add to, named by its `label`."
|
|
217
|
+
},
|
|
218
|
+
"entry": {
|
|
219
|
+
"type": "object",
|
|
220
|
+
"required": ["to", "label"],
|
|
221
|
+
"properties": {
|
|
222
|
+
"to": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"minLength": 1,
|
|
225
|
+
"description": "Route the row links to. Identifies the row for add and remove."
|
|
226
|
+
},
|
|
227
|
+
"label": {
|
|
228
|
+
"type": "string",
|
|
229
|
+
"minLength": 1,
|
|
230
|
+
"description": "Text the row shows."
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
"description": "The nav row, minus its icon. Written as a JSON object literal."
|
|
234
|
+
},
|
|
235
|
+
"icon": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"required": ["name", "from"],
|
|
238
|
+
"properties": {
|
|
239
|
+
"name": {
|
|
240
|
+
"type": "string",
|
|
241
|
+
"minLength": 1,
|
|
242
|
+
"description": "Component rendered as the row's `icon`, e.g. UsersRoundIcon."
|
|
243
|
+
},
|
|
244
|
+
"from": {
|
|
245
|
+
"type": "string",
|
|
246
|
+
"minLength": 1,
|
|
247
|
+
"description": "Module the icon is imported from, e.g. lucide-react."
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
"description": "The icon component. Rendered as an identifier, not a string, and its named import is added on apply and dropped on remove."
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
},
|
|
194
255
|
{
|
|
195
256
|
"if": {
|
|
196
257
|
"properties": { "kind": { "const": "package-json-script" } },
|
|
@@ -36,6 +36,13 @@ export default defineConfig({
|
|
|
36
36
|
// `imageService: "compile"` optimises images during the build and serves the results
|
|
37
37
|
// as plain assets. The adapter's default would reach for Cloudflare's IMAGES binding
|
|
38
38
|
// at runtime, which needs a Worker this site does not have.
|
|
39
|
+
//
|
|
40
|
+
// package.json pins @cloudflare/vite-plugin, which this file never imports. The
|
|
41
|
+
// adapter takes it through a caret range, and each release of the plugin raises the
|
|
42
|
+
// wrangler version it demands at startup, so an unpinned install pairs a new plugin
|
|
43
|
+
// with our pinned wrangler and `astro dev` exits before it is ready. The direct pin
|
|
44
|
+
// makes pnpm install the same plugin for the adapter. Move it and `wrangler` together:
|
|
45
|
+
// they release as a pair, and apps/api (once added) pins the same two versions.
|
|
39
46
|
adapter: cloudflare({ imageService: "compile" }),
|
|
40
47
|
integrations: [react()],
|
|
41
48
|
// Fixed dev port. Every cross-origin consumer in this repo — the api Worker's CORS
|
|
@@ -50,6 +57,20 @@ export default defineConfig({
|
|
|
50
57
|
// packages/ui/src/styles/globals.css, which Layout.astro imports via @repo/ui.
|
|
51
58
|
plugins: [tailwindcss()],
|
|
52
59
|
server: { strictPort: true },
|
|
60
|
+
// Dev only: pre-bundle every dependency the React components import before the
|
|
61
|
+
// first request. In dev the adapter renders inside workerd, and Vite's scan starts
|
|
62
|
+
// from the pages, so it never walks into @repo/ui (a linked workspace, not an npm
|
|
63
|
+
// package) or the islands a module drops in src/. Without these entries Vite finds
|
|
64
|
+
// lucide-react, @base-ui/react and the rest mid-request, re-bundles, and reloads the
|
|
65
|
+
// worker. The re-run components then import a fresh copy of react while
|
|
66
|
+
// react-dom/server holds the old one, and every hook throws "Invalid hook call".
|
|
67
|
+
// A glob, not an include list, so a component a module adds is covered with no edit
|
|
68
|
+
// here.
|
|
69
|
+
ssr: {
|
|
70
|
+
optimizeDeps: {
|
|
71
|
+
entries: ["src/**/*.tsx", "../../packages/ui/src/**/*.tsx"],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
53
74
|
resolve: {
|
|
54
75
|
// `@web` mirrors saasaloy.json's alias of the same name (apps/web/src) — that
|
|
55
76
|
// alias only drives the CLI's file-placement when a module's files[] target
|
|
@@ -4,27 +4,28 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "astro dev",
|
|
7
|
+
"dev": "astro dev --host",
|
|
8
8
|
"build": "astro build",
|
|
9
9
|
"clean": "rimraf dist .astro .wrangler",
|
|
10
10
|
"preview": "astro preview",
|
|
11
11
|
"deploy": "wrangler deploy"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"astro": "7.3.
|
|
15
|
-
"@astrojs/cloudflare": "14.3.
|
|
16
|
-
"@astrojs/react": "
|
|
17
|
-
"react": "19.
|
|
18
|
-
"react-dom": "19.
|
|
14
|
+
"astro": "7.3.5",
|
|
15
|
+
"@astrojs/cloudflare": "14.3.3",
|
|
16
|
+
"@astrojs/react": "7.0.0",
|
|
17
|
+
"react": "19.3.0",
|
|
18
|
+
"react-dom": "19.3.0",
|
|
19
19
|
"@repo/ui": "workspace:*"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
+
"@cloudflare/vite-plugin": "1.62.3",
|
|
22
23
|
"@tailwindcss/vite": "4.3.3",
|
|
23
|
-
"@types/node": "26.
|
|
24
|
-
"@types/react": "19.
|
|
25
|
-
"@types/react-dom": "19.
|
|
24
|
+
"@types/node": "26.6.3",
|
|
25
|
+
"@types/react": "19.3.0",
|
|
26
|
+
"@types/react-dom": "19.3.0",
|
|
26
27
|
"rimraf": "6.1.3",
|
|
27
28
|
"tailwindcss": "4.3.3",
|
|
28
|
-
"wrangler": "4.
|
|
29
|
+
"wrangler": "4.145.0"
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
3
|
"name": "{{PROJECT_NAME}}-web",
|
|
4
4
|
"compatibility_date": "2026-07-22",
|
|
5
|
+
// `astro dev` renders inside workerd, and @astrojs/react's server renderer keeps a
|
|
6
|
+
// dynamic `import("node:stream")` for the Node streaming path. That path never runs
|
|
7
|
+
// here (workerd resolves react-dom/server to the edge build), but without this flag
|
|
8
|
+
// @cloudflare/vite-plugin warns "Unexpected Node.js imports" on every dev start. The
|
|
9
|
+
// adapter only adds `nodejs_als` on its own. It costs nothing while the site is
|
|
10
|
+
// assets-only, and the first `prerender = false` page gets Node APIs with no edit.
|
|
11
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
5
12
|
// Static assets, and still no Worker code — but the layout moved. Registering
|
|
6
13
|
// @astrojs/cloudflare in astro.config.mjs (for src/pages/500.astro) makes Astro build
|
|
7
14
|
// to dist/client + dist/server instead of a flat dist/. While every page is
|
|
@@ -23,22 +23,22 @@
|
|
|
23
23
|
"typecheck": "turbo run typecheck"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@commitlint/cli": "21.2.
|
|
27
|
-
"@commitlint/config-conventional": "21.2.
|
|
26
|
+
"@commitlint/cli": "21.2.3",
|
|
27
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
28
28
|
"husky": "9.1.7",
|
|
29
|
-
"lint-staged": "17.
|
|
30
|
-
"oxlint": "1.
|
|
31
|
-
"oxlint-tsgolint": "7.0.
|
|
32
|
-
"prettier": "3.9.
|
|
33
|
-
"prettier-plugin-astro": "
|
|
29
|
+
"lint-staged": "17.6.0",
|
|
30
|
+
"oxlint": "1.86.0",
|
|
31
|
+
"oxlint-tsgolint": "7.0.2003",
|
|
32
|
+
"prettier": "3.9.9",
|
|
33
|
+
"prettier-plugin-astro": "1.1.0",
|
|
34
34
|
"prettier-plugin-tailwindcss": "0.8.1",
|
|
35
35
|
"rimraf": "6.1.3",
|
|
36
36
|
"stylelint": "17.15.0",
|
|
37
37
|
"stylelint-config-idiomatic-order": "10.0.0",
|
|
38
38
|
"stylelint-config-standard": "40.0.0",
|
|
39
39
|
"stylelint-prettier": "5.0.3",
|
|
40
|
-
"turbo": "2.
|
|
40
|
+
"turbo": "2.11.6",
|
|
41
41
|
"typescript": "7.0.2",
|
|
42
|
-
"ultracite": "7.
|
|
42
|
+
"ultracite": "7.12.2"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"@base-ui/react": "1.8.0",
|
|
21
21
|
"class-variance-authority": "0.7.1",
|
|
22
22
|
"clsx": "2.1.1",
|
|
23
|
-
"lucide-react": "1.
|
|
23
|
+
"lucide-react": "1.49.0",
|
|
24
24
|
"shadcn": "4.21.0",
|
|
25
|
-
"tailwind-merge": "3.
|
|
25
|
+
"tailwind-merge": "3.7.0",
|
|
26
26
|
"tailwindcss": "4.3.3",
|
|
27
27
|
"tw-animate-css": "1.4.0"
|
|
28
28
|
},
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@repo/tsconfig": "workspace:*",
|
|
34
|
-
"@types/react": "19.
|
|
35
|
-
"react": "19.
|
|
34
|
+
"@types/react": "19.3.0",
|
|
35
|
+
"react": "19.3.0",
|
|
36
36
|
"rimraf": "6.1.3",
|
|
37
37
|
"typescript": "7.0.2"
|
|
38
38
|
}
|
|
@@ -48,6 +48,18 @@ export const THEME_LABELS: Record<Theme, string> = {
|
|
|
48
48
|
|
|
49
49
|
const OS_DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Whether a raw string is one of the three themes.
|
|
53
|
+
*
|
|
54
|
+
* `data-theme` is author-writable and `localStorage` holds whatever was last put there, so
|
|
55
|
+
* every value this module reads back out of the page arrives as `string | null`. Narrowing
|
|
56
|
+
* it here rather than asserting `as Theme` at each site is what keeps an unrecognised value
|
|
57
|
+
* on the fallback path instead of into `THEME_LABELS[...]`, where it reads `undefined`.
|
|
58
|
+
*/
|
|
59
|
+
export function isTheme(value: string | null): value is Theme {
|
|
60
|
+
return value === "light" || value === "dark" || value === "system";
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** The stored choice, or `system` when unset, unreadable or not a valid theme. */
|
|
52
64
|
export function getStoredTheme(): Theme {
|
|
53
65
|
try {
|
|
@@ -105,16 +117,55 @@ export function setTheme(theme: Theme): void {
|
|
|
105
117
|
export function relabelThemeToggles(theme?: Theme): void {
|
|
106
118
|
const root = document.documentElement;
|
|
107
119
|
const painted = theme ?? root.getAttribute(THEME_ATTRIBUTE);
|
|
108
|
-
const named =
|
|
109
|
-
painted !== null && THEME_ORDER.includes(painted as Theme)
|
|
110
|
-
? (painted as Theme)
|
|
111
|
-
: getStoredTheme();
|
|
120
|
+
const named = isTheme(painted) ? painted : getStoredTheme();
|
|
112
121
|
|
|
113
122
|
for (const trigger of root.querySelectorAll(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
|
|
114
123
|
trigger.setAttribute("aria-label", THEME_LABELS[named]);
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
// The toggle's click handler, installed by `installThemeToggle` below. It lives at module
|
|
128
|
+
// scope because it reads nothing from that function, and one identity per page means a
|
|
129
|
+
// second install adds no second listener.
|
|
130
|
+
function onClick(event: MouseEvent): void {
|
|
131
|
+
const { target } = event;
|
|
132
|
+
if (!(target instanceof Element)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!target.closest(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const painted = document.documentElement.getAttribute(THEME_ATTRIBUTE);
|
|
140
|
+
// An unrecognised `data-theme` gives index -1 and starts the cycle at the head of
|
|
141
|
+
// THEME_ORDER.
|
|
142
|
+
const index = isTheme(painted) ? THEME_ORDER.indexOf(painted) : -1;
|
|
143
|
+
const next = THEME_ORDER[(index + 1) % THEME_ORDER.length] ?? "light";
|
|
144
|
+
|
|
145
|
+
setTheme(next);
|
|
146
|
+
|
|
147
|
+
// After `setTheme`, and unconditionally: a `system` press CLEARS THEME_STORAGE_KEY, so
|
|
148
|
+
// this key is the only record that the visitor pressed anything at all.
|
|
149
|
+
try {
|
|
150
|
+
localStorage.setItem(THEME_CHOICE_KEY, "1");
|
|
151
|
+
} catch {
|
|
152
|
+
// Unwritable storage costs persistence, not the current page.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The OS half of the same job, and the reason this is not click-only. THEME_INIT_SCRIPT
|
|
157
|
+
// registers its own matchMedia listener; a host that calls `installThemeToggle` instead
|
|
158
|
+
// does not have that script, so without this listener a visitor on `system` keeps the
|
|
159
|
+
// palette resolved at load until the next reload.
|
|
160
|
+
//
|
|
161
|
+
// It repaints only while the state is `system`. A visitor who picked light or dark said
|
|
162
|
+
// so deliberately, and the OS must not overrule that.
|
|
163
|
+
function onOsChange(): void {
|
|
164
|
+
if (getStoredTheme() === "system") {
|
|
165
|
+
setTheme("system");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
118
169
|
/**
|
|
119
170
|
* Install the toggle's click behaviour on `document`, and answer with a function that
|
|
120
171
|
* removes it again.
|
|
@@ -130,47 +181,8 @@ export function relabelThemeToggles(theme?: Theme): void {
|
|
|
130
181
|
* the same value forever and every press would land on the same theme.
|
|
131
182
|
*/
|
|
132
183
|
export function installThemeToggle(): () => void {
|
|
133
|
-
function onClick(event: MouseEvent): void {
|
|
134
|
-
const { target } = event;
|
|
135
|
-
if (!(target instanceof Element)) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
if (!target.closest(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const painted = document.documentElement.getAttribute(THEME_ATTRIBUTE);
|
|
143
|
-
// An unrecognised `data-theme` gives index -1 and starts the cycle at the head of
|
|
144
|
-
// THEME_ORDER.
|
|
145
|
-
const index = painted === null ? -1 : THEME_ORDER.indexOf(painted as Theme);
|
|
146
|
-
const next = THEME_ORDER[(index + 1) % THEME_ORDER.length] ?? "light";
|
|
147
|
-
|
|
148
|
-
setTheme(next);
|
|
149
|
-
|
|
150
|
-
// After `setTheme`, and unconditionally: a `system` press CLEARS THEME_STORAGE_KEY, so
|
|
151
|
-
// this key is the only record that the visitor pressed anything at all.
|
|
152
|
-
try {
|
|
153
|
-
localStorage.setItem(THEME_CHOICE_KEY, "1");
|
|
154
|
-
} catch {
|
|
155
|
-
// Unwritable storage costs persistence, not the current page.
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// The OS half of the same job, and the reason this is not click-only. THEME_INIT_SCRIPT
|
|
160
|
-
// registers its own matchMedia listener; a host that calls this function instead does
|
|
161
|
-
// not have that script, so without this listener a visitor on `system` keeps the palette
|
|
162
|
-
// resolved at load until the next reload.
|
|
163
|
-
//
|
|
164
|
-
// It repaints only while the state is `system`. A visitor who picked light or dark said
|
|
165
|
-
// so deliberately, and the OS must not overrule that.
|
|
166
184
|
const media = window.matchMedia(OS_DARK_QUERY);
|
|
167
185
|
|
|
168
|
-
function onOsChange(): void {
|
|
169
|
-
if (getStoredTheme() === "system") {
|
|
170
|
-
setTheme("system");
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
186
|
document.addEventListener("click", onClick);
|
|
175
187
|
media.addEventListener("change", onOsChange);
|
|
176
188
|
|