saasaloy 0.1.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/dist/index.js +8455 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/schemas/manifest.schema.json +107 -0
- package/schemas/registry-item.schema.json +336 -0
- package/schemas/saasaloy-lock.schema.json +86 -0
- package/schemas/saasaloy.schema.json +42 -0
- package/templates/base/AGENTS.md +450 -0
- package/templates/base/CLAUDE.md +1 -0
- package/templates/base/DESIGN.md +209 -0
- package/templates/base/README.md +71 -0
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +198 -0
- package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +395 -0
- package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +273 -0
- package/templates/base/_gitignore +32 -0
- package/templates/base/_husky/commit-msg +1 -0
- package/templates/base/_husky/pre-commit +1 -0
- package/templates/base/_prettierignore +31 -0
- package/templates/base/_saasaloy-base.json +9 -0
- package/templates/base/apps/web/astro.config.mjs +61 -0
- package/templates/base/apps/web/package.json +30 -0
- package/templates/base/apps/web/public/favicon.svg +4 -0
- package/templates/base/apps/web/src/layouts/Layout.astro +52 -0
- package/templates/base/apps/web/src/pages/404.astro +24 -0
- package/templates/base/apps/web/src/pages/500.astro +33 -0
- package/templates/base/apps/web/src/pages/index.astro +61 -0
- package/templates/base/apps/web/src/pages/privacy.astro +15 -0
- package/templates/base/apps/web/src/pages/terms.astro +14 -0
- package/templates/base/apps/web/tsconfig.json +11 -0
- package/templates/base/apps/web/wrangler.jsonc +27 -0
- package/templates/base/commitlint.config.js +9 -0
- package/templates/base/lint-staged.config.js +17 -0
- package/templates/base/oxlint.config.mjs +155 -0
- package/templates/base/package.json +44 -0
- package/templates/base/packages/tsconfig/base.json +17 -0
- package/templates/base/packages/tsconfig/package.json +18 -0
- package/templates/base/packages/ui/components.json +19 -0
- package/templates/base/packages/ui/package.json +39 -0
- package/templates/base/packages/ui/src/blocks/cta.tsx +82 -0
- package/templates/base/packages/ui/src/blocks/error-state.tsx +144 -0
- package/templates/base/packages/ui/src/blocks/faq.tsx +64 -0
- package/templates/base/packages/ui/src/blocks/feature-grid.tsx +185 -0
- package/templates/base/packages/ui/src/blocks/footer.tsx +99 -0
- package/templates/base/packages/ui/src/blocks/hero.tsx +84 -0
- package/templates/base/packages/ui/src/blocks/navbar.tsx +159 -0
- package/templates/base/packages/ui/src/blocks/pricing-table.tsx +175 -0
- package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +51 -0
- package/templates/base/packages/ui/src/components/accordion.tsx +78 -0
- package/templates/base/packages/ui/src/components/badge.tsx +53 -0
- package/templates/base/packages/ui/src/components/button.tsx +59 -0
- package/templates/base/packages/ui/src/components/card.tsx +103 -0
- package/templates/base/packages/ui/src/components/input.tsx +20 -0
- package/templates/base/packages/ui/src/components/label.tsx +18 -0
- package/templates/base/packages/ui/src/components/separator.tsx +23 -0
- package/templates/base/packages/ui/src/containers/README.md +11 -0
- package/templates/base/packages/ui/src/content/errors.ts +58 -0
- package/templates/base/packages/ui/src/content/landing.ts +304 -0
- package/templates/base/packages/ui/src/index.ts +8 -0
- package/templates/base/packages/ui/src/lib/interpolate.ts +31 -0
- package/templates/base/packages/ui/src/lib/sentinel.ts +11 -0
- package/templates/base/packages/ui/src/lib/theme.ts +167 -0
- package/templates/base/packages/ui/src/lib/utils.ts +11 -0
- package/templates/base/packages/ui/src/styles/globals.css +164 -0
- package/templates/base/packages/ui/tsconfig.json +7 -0
- package/templates/base/pnpm-workspace.yaml +23 -0
- package/templates/base/prettier.config.js +10 -0
- package/templates/base/saasaloy.json +8 -0
- package/templates/base/stylelint.config.js +46 -0
- package/templates/base/turbo.json +20 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Every word the landing page shows, in one file.
|
|
2
|
+
//
|
|
3
|
+
// The blocks in ../blocks/*.tsx import this directly — NOT through props from
|
|
4
|
+
// index.astro. Astro serializes island props, so feeding a content object into
|
|
5
|
+
// `<PricingTable client:visible />` would write every string into the HTML payload *and*
|
|
6
|
+
// still ship the defaults inside the island's bundle. A direct import keeps the static
|
|
7
|
+
// blocks at zero JavaScript.
|
|
8
|
+
//
|
|
9
|
+
// TWO NAMESPACES, and the split is the point:
|
|
10
|
+
//
|
|
11
|
+
// landing.* Marketing copy — what your product is, who it is for, what it costs.
|
|
12
|
+
// This is the surface a copywriting agent rewrites. Own it, edit it freely.
|
|
13
|
+
// ui.* Chrome and accessibility labels — "Monthly", "Most popular", "Close menu",
|
|
14
|
+
// "Billing period". Nothing here says anything about your product, so a copy
|
|
15
|
+
// pass never *rewrites* it. It does get *translated*, key for key, when
|
|
16
|
+
// landing.* is written in some language other than English — otherwise the
|
|
17
|
+
// page ships Bangla marketing copy under an English "Most popular" badge.
|
|
18
|
+
//
|
|
19
|
+
// SHAPE RULES. These are not style preferences; each one keeps this file mechanically
|
|
20
|
+
// translatable, because a translation layer reads a keyed record and nothing else:
|
|
21
|
+
//
|
|
22
|
+
// 1. Max three levels below a namespace (`landing.features.title`). Compiler-based
|
|
23
|
+
// i18n libraries emit one flat identifier per message and cannot see deeper nesting.
|
|
24
|
+
// 2. Position is never the key. Lists are arrays whose items carry a stable `id`, so
|
|
25
|
+
// reordering the feature grid cannot silently reattach the wrong translation. One
|
|
26
|
+
// list is exempt: a tier's `features` bullets (`landing.pricing.tiers[].features`)
|
|
27
|
+
// stay a plain `string[]`. They are the only strings here that nothing else reads —
|
|
28
|
+
// a `features.items[]` id and an `faq.items[]` id are both stable anchors for an item
|
|
29
|
+
// that outlives its wording, while a tier bullet is read once, as part of one tier,
|
|
30
|
+
// and is rewritten with that tier whenever the plan changes. An id
|
|
31
|
+
// per bullet would have to be invented by whoever writes the bullet, for no reader.
|
|
32
|
+
// The cost is real and accepted: reorder a tier's bullets *without* editing them and
|
|
33
|
+
// a positional catalog follows the slot rather than the sentence.
|
|
34
|
+
// 3. Placeholders are single-brace `{token}`, never a template literal. A catalog is
|
|
35
|
+
// data; a function is not serializable and no extraction tool can read it. Render
|
|
36
|
+
// them with `interpolate()` from ../lib/interpolate.ts.
|
|
37
|
+
// 4. No runtime concatenation. `/month` + `", billed annually"` is two whole messages
|
|
38
|
+
// (`ui.pricing.perMonth`, `ui.pricing.perMonthAnnual`) because word order does not
|
|
39
|
+
// survive the seam in every language.
|
|
40
|
+
// 5. Only user-visible strings live here. Section `id`s and the same-page anchors that
|
|
41
|
+
// point at them are structure and stay in the block. Three things break that rule,
|
|
42
|
+
// all for the same reason — a thing rewritten *with* the copy belongs *near* the
|
|
43
|
+
// copy, or a rewrite leaves it stranded:
|
|
44
|
+
// a. Pricing tiers. The whole list, prices and `ctaHref`s included, so a plan
|
|
45
|
+
// change is one file rather than a file plus a block.
|
|
46
|
+
// b. A feature's `icon`, held as a registry *name* (`"zap"`) and never a component
|
|
47
|
+
// — a component cannot cross the .astro island boundary. Rewrite what a feature
|
|
48
|
+
// is about and its glyph has to be able to follow, or a page about IELTS
|
|
49
|
+
// listening practice renders a terminal prompt. The names ../blocks/feature-grid.tsx
|
|
50
|
+
// accepts are listed in that file.
|
|
51
|
+
// c. The two outbound calls to action — `landing.navbar.ctaHref` and
|
|
52
|
+
// `landing.cta.primaryActionHref`/`.secondaryActionHref`. These are where "sign
|
|
53
|
+
// up" actually goes. They leave the page, so unlike `#features` they cannot
|
|
54
|
+
// break a section link, and the person being interviewed about the product is
|
|
55
|
+
// the only one who knows the URL.
|
|
56
|
+
// A translation layer reads `id`, `icon` and every `*Href` as non-message data, the
|
|
57
|
+
// same way it already has to for a tier's `id`.
|
|
58
|
+
//
|
|
59
|
+
// One chrome string set deliberately lives elsewhere: the theme toggle's labels, in
|
|
60
|
+
// ../lib/theme.ts. That file is inlined verbatim into a pre-paint <script> and is
|
|
61
|
+
// declared import-free on purpose, so it keeps its own constants.
|
|
62
|
+
|
|
63
|
+
/** Marketing copy. The whole of what a landing-copy pass may rewrite. */
|
|
64
|
+
export const landing = {
|
|
65
|
+
// The browser tab and the meta description. `{siteName}` comes from ../index.ts.
|
|
66
|
+
meta: {
|
|
67
|
+
title: "{siteName} — ship your SaaS, not your scaffolding",
|
|
68
|
+
description: "{siteName}, a Cloudflare-native SaaS.",
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Header. The nav links' hrefs are same-page anchors owned by navbar.tsx; only their
|
|
72
|
+
// words are here. An empty label hides that link, which is how a removed section loses
|
|
73
|
+
// its nav entry.
|
|
74
|
+
//
|
|
75
|
+
// `ctaHref` is the exception (shape rule 5c): the header button is the page's most
|
|
76
|
+
// clicked control, and where it goes — a signup form, a waitlist, an app — is a fact
|
|
77
|
+
// about your product, not about the layout. It ships pointing at `#cta`, the closing
|
|
78
|
+
// section, which is honest for a page with nowhere else to send anyone yet.
|
|
79
|
+
navbar: {
|
|
80
|
+
linkFeatures: "Features",
|
|
81
|
+
linkPricing: "Pricing",
|
|
82
|
+
linkFaq: "FAQ",
|
|
83
|
+
ctaLabel: "Get started",
|
|
84
|
+
ctaHref: "#cta",
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
hero: {
|
|
88
|
+
eyebrow: "Now in early access",
|
|
89
|
+
title: "The SaaS you meant to build, already scaffolded.",
|
|
90
|
+
description:
|
|
91
|
+
"{siteName} gives your product a real front door on day one — a landing page, a design system, and room for every feature you add next.",
|
|
92
|
+
primaryActionLabel: "Get started",
|
|
93
|
+
secondaryActionLabel: "See pricing",
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
features: {
|
|
97
|
+
title: "Everything the first release needs",
|
|
98
|
+
description:
|
|
99
|
+
"The parts every SaaS ends up building anyway, ready before you write a line of product code.",
|
|
100
|
+
// `id` is the stable translation key and never the array position. `icon` names a
|
|
101
|
+
// glyph from the registry at the top of ../blocks/feature-grid.tsx — rewrite what a
|
|
102
|
+
// feature is about and change its icon in the same edit. Every item needs one: a name
|
|
103
|
+
// the registry doesn't know renders the fallback glyph, but leaving the field off
|
|
104
|
+
// altogether is a type error, which is the louder and more useful failure.
|
|
105
|
+
items: [
|
|
106
|
+
{
|
|
107
|
+
id: "fast",
|
|
108
|
+
icon: "zap",
|
|
109
|
+
title: "Fast by default",
|
|
110
|
+
description:
|
|
111
|
+
"Static HTML at the edge, with JavaScript sent only for the parts of the page that actually need it.",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "modules",
|
|
115
|
+
icon: "layers",
|
|
116
|
+
title: "Composable modules",
|
|
117
|
+
description:
|
|
118
|
+
"Add an API, a database, auth or billing when you need them — never before, and never all at once.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "source",
|
|
122
|
+
icon: "terminal",
|
|
123
|
+
title: "Source you own",
|
|
124
|
+
description:
|
|
125
|
+
"Every component lands in your repo as plain, editable source. No black box, no framework to fight.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: "secure",
|
|
129
|
+
icon: "shield-check",
|
|
130
|
+
title: "Secure foundations",
|
|
131
|
+
description:
|
|
132
|
+
"Sensible defaults for sessions, cookies and origins, so the boring security work is already done.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "cloudflare",
|
|
136
|
+
icon: "cloud",
|
|
137
|
+
title: "Cloudflare-native",
|
|
138
|
+
description:
|
|
139
|
+
"Ships to Workers with static assets out of the box — one deploy command, no servers to babysit.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "current",
|
|
143
|
+
icon: "gauge",
|
|
144
|
+
title: "Built to stay current",
|
|
145
|
+
description:
|
|
146
|
+
"Dependencies are exact-pinned and updated deliberately, so upgrades are a decision, not a surprise.",
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
pricing: {
|
|
152
|
+
title: "Pricing that stays out of the way",
|
|
153
|
+
description:
|
|
154
|
+
"Start free, upgrade when the product earns it. Every plan includes the full framework.",
|
|
155
|
+
/** Shown beside the annual option. Empty string hides it. */
|
|
156
|
+
annualNote: "Save 20%",
|
|
157
|
+
currencySymbol: "$",
|
|
158
|
+
// The exception to rule 5: the whole tier list lives here, prices and ctaHrefs
|
|
159
|
+
// included, so pricing is rewritten in one place. `monthlyPrice`/`annualPrice` are
|
|
160
|
+
// whole currency units; `null` renders ui.pricing.customPrice. Set `featured` on at
|
|
161
|
+
// most one tier. Each tier carries an `id`; its `features` bullets deliberately do not
|
|
162
|
+
// (rule 2's stated exemption) — they are rewritten with the tier, never alone.
|
|
163
|
+
tiers: [
|
|
164
|
+
{
|
|
165
|
+
id: "free",
|
|
166
|
+
name: "Free",
|
|
167
|
+
description: "For side projects and the first hundred users.",
|
|
168
|
+
monthlyPrice: 0,
|
|
169
|
+
annualPrice: 0,
|
|
170
|
+
features: ["Up to 3 projects", "Community support", "1 GB storage"],
|
|
171
|
+
ctaLabel: "Start for free",
|
|
172
|
+
ctaHref: "#cta",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "pro",
|
|
176
|
+
name: "Pro",
|
|
177
|
+
description: "For teams shipping to paying customers.",
|
|
178
|
+
monthlyPrice: 29,
|
|
179
|
+
annualPrice: 23,
|
|
180
|
+
features: [
|
|
181
|
+
"Unlimited projects",
|
|
182
|
+
"Email support",
|
|
183
|
+
"100 GB storage",
|
|
184
|
+
"Usage analytics",
|
|
185
|
+
"Custom domains",
|
|
186
|
+
],
|
|
187
|
+
ctaLabel: "Start free trial",
|
|
188
|
+
ctaHref: "#cta",
|
|
189
|
+
featured: true,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "enterprise",
|
|
193
|
+
name: "Enterprise",
|
|
194
|
+
description:
|
|
195
|
+
"For organisations with procurement and a security review.",
|
|
196
|
+
monthlyPrice: null,
|
|
197
|
+
annualPrice: null,
|
|
198
|
+
features: [
|
|
199
|
+
"SSO and SCIM",
|
|
200
|
+
"Priority support",
|
|
201
|
+
"Audit logs",
|
|
202
|
+
"Custom contracts",
|
|
203
|
+
],
|
|
204
|
+
ctaLabel: "Talk to sales",
|
|
205
|
+
ctaHref: "#cta",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
faq: {
|
|
211
|
+
title: "Questions, answered",
|
|
212
|
+
description:
|
|
213
|
+
"The things people ask before they commit a weekend to a new stack.",
|
|
214
|
+
items: [
|
|
215
|
+
{
|
|
216
|
+
id: "what-you-get",
|
|
217
|
+
question: "What do I actually get?",
|
|
218
|
+
answer:
|
|
219
|
+
"A working monorepo: a static marketing site, a shared design system, and a command that adds the rest — API, database, auth, billing — one capability at a time.",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "sync",
|
|
223
|
+
question: "Is this a boilerplate I have to keep in sync?",
|
|
224
|
+
answer:
|
|
225
|
+
"No. Everything is copied into your repository as source you own and edit. There is no framework release to chase and nothing that overwrites your changes behind your back.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "design-system",
|
|
229
|
+
question: "Can I use my own design system?",
|
|
230
|
+
answer:
|
|
231
|
+
"Yes. The theme is a single stylesheet of design tokens and the components are plain files in your repo, so restyling is editing, not forking.",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: "deploy",
|
|
235
|
+
question: "Where does it deploy?",
|
|
236
|
+
answer:
|
|
237
|
+
"Cloudflare, by default — static assets on Workers, with the same account covering the database, queues and storage a feature module needs later.",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: "outgrow",
|
|
241
|
+
question: "What if I outgrow it?",
|
|
242
|
+
answer:
|
|
243
|
+
"Then you keep the code. Nothing here is a runtime dependency on us: the generated project is an ordinary pnpm workspace that builds without any of our tooling installed.",
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// The closing ask. Both hrefs are shape rule 5c: outbound destinations, not anchors.
|
|
249
|
+
// They ship pointing at the homepage because a freshly scaffolded project has nowhere
|
|
250
|
+
// else to send anyone — replace them with the real signup, waitlist or docs URL, and
|
|
251
|
+
// write labels the destination can honestly satisfy until you do.
|
|
252
|
+
cta: {
|
|
253
|
+
title: "Start building today",
|
|
254
|
+
description:
|
|
255
|
+
"Set up {siteName} in a couple of minutes. No credit card, no sales call, no lock-in.",
|
|
256
|
+
primaryActionLabel: "Get started",
|
|
257
|
+
primaryActionHref: "/",
|
|
258
|
+
secondaryActionLabel: "Read the docs",
|
|
259
|
+
secondaryActionHref: "/",
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
// Footer navigation, like the navbar: hrefs live in footer.tsx, words live here, and an
|
|
263
|
+
// empty label drops that link (an empty heading drops the whole group).
|
|
264
|
+
footer: {
|
|
265
|
+
tagline: "A Cloudflare-native SaaS, scaffolded with Saasaloy.",
|
|
266
|
+
groupProduct: "Product",
|
|
267
|
+
groupLegal: "Legal",
|
|
268
|
+
linkFeatures: "Features",
|
|
269
|
+
linkPricing: "Pricing",
|
|
270
|
+
linkFaq: "FAQ",
|
|
271
|
+
linkTerms: "Terms",
|
|
272
|
+
linkPrivacy: "Privacy",
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Chrome and accessibility labels. Says nothing about your product; translate, don't rewrite. */
|
|
277
|
+
export const ui = {
|
|
278
|
+
navbar: {
|
|
279
|
+
/** Accessible name for the desktop nav landmark. */
|
|
280
|
+
mainNavLabel: "Main",
|
|
281
|
+
/** Accessible name for the nav landmark inside the mobile panel. */
|
|
282
|
+
mobileNavLabel: "Mobile",
|
|
283
|
+
openMenu: "Open menu",
|
|
284
|
+
closeMenu: "Close menu",
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
pricing: {
|
|
288
|
+
billingPeriodLabel: "Billing period",
|
|
289
|
+
monthly: "Monthly",
|
|
290
|
+
annual: "Annual",
|
|
291
|
+
featuredBadge: "Most popular",
|
|
292
|
+
/** Rendered instead of a number when a tier's price is `null`. */
|
|
293
|
+
customPrice: "Custom",
|
|
294
|
+
/** Symbol and amount as one message — placement varies by locale. */
|
|
295
|
+
price: "{currencySymbol}{price}",
|
|
296
|
+
// Two whole messages rather than "/month" plus a suffix (shape rule 4).
|
|
297
|
+
perMonth: "/month",
|
|
298
|
+
perMonthAnnual: "/month, billed annually",
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
footer: {
|
|
302
|
+
copyright: "© {year} {siteName}. All rights reserved.",
|
|
303
|
+
},
|
|
304
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Shared UI package (`@repo/ui`). This root entrypoint is for project-wide constants
|
|
2
|
+
// only — the theme, primitives and blocks are reached through their own subpath exports
|
|
3
|
+
// (`@repo/ui/globals.css`, `@repo/ui/components/*`, `@repo/ui/blocks/*`) and are
|
|
4
|
+
// deliberately NOT re-exported here, so importing one never drags in the rest.
|
|
5
|
+
//
|
|
6
|
+
// It also proves the monorepo's JIT internal-package wiring — apps/web imports
|
|
7
|
+
// `siteName` from here with no build step (workspace:* + Vite transpiles the TS directly).
|
|
8
|
+
export const siteName = "{{PROJECT_NAME}}";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Fill the `{token}` placeholders in a content string (see ../content/landing.ts).
|
|
2
|
+
//
|
|
3
|
+
// This exists so copy can be data. A template literal —
|
|
4
|
+
// `` `${siteName} gives your product…` `` — is a function, which means it cannot be
|
|
5
|
+
// serialized, cannot be split per locale, and cannot be read by any extraction tool.
|
|
6
|
+
// A string with `{siteName}` in it can be all three.
|
|
7
|
+
//
|
|
8
|
+
// Single brace rather than `{{double}}`: widening `{x}` to `{{x}}` later is one regex,
|
|
9
|
+
// while narrowing risks eating literal braces someone meant to keep.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Replace every `{token}` in `message` with `values[token]`.
|
|
13
|
+
*
|
|
14
|
+
* An unknown token is left exactly as written — a visible `{plan}` in the page is a bug
|
|
15
|
+
* you can see and fix, where an empty gap is a bug you ship.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* interpolate("Set up {siteName} in a minute.", { siteName: "Acme" });
|
|
19
|
+
* // → "Set up Acme in a minute."
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function interpolate(
|
|
23
|
+
message: string,
|
|
24
|
+
values: Record<string, string | number>
|
|
25
|
+
): string {
|
|
26
|
+
// `Object.hasOwn`, not `key in values`: `in` walks the prototype chain, so `{constructor}`
|
|
27
|
+
// or `{toString}` would render a function body into the page instead of passing through.
|
|
28
|
+
return message.replaceAll(/\{(\w+)\}/g, (token, key: string) =>
|
|
29
|
+
Object.hasOwn(values, key) ? String(values[key]) : token
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Canary for the tool repo's `scripts/verify-css` smoke test. Tailwind's class
|
|
2
|
+
// detection is text-based — it scans source files as plain text and never imports them —
|
|
3
|
+
// so this constant needs no consumer to make the utility below reach the built CSS. It
|
|
4
|
+
// reaches it only if globals.css's `@source "../**/*.{ts,tsx}"` glob is actually
|
|
5
|
+
// matching packages/ui, which is the exact failure the smoke test exists to catch.
|
|
6
|
+
//
|
|
7
|
+
// The utility is an arbitrary property (`[--saasaloy-css-probe:1]`) so it compiles to a
|
|
8
|
+
// custom property nothing reads and nothing renders. The name is deliberately unique:
|
|
9
|
+
// verify-css also asserts it appears nowhere in apps/web, so a passing test can only
|
|
10
|
+
// mean the packages/ui glob worked. Deleting this file breaks that test, not the build.
|
|
11
|
+
export const CSS_PROBE_CLASS = "[--saasaloy-css-probe:1]";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The theme state machine — light / dark / system — shared by every host that renders
|
|
2
|
+
// @repo/ui. Framework-free on purpose: no React, no Astro, no imports at all.
|
|
3
|
+
//
|
|
4
|
+
// THIS FILE IS IMPORTED IN NODE. Both hosts pull it in at *build* time (Astro
|
|
5
|
+
// frontmatter, and a Vite config for a React SPA), so there must be no `window`,
|
|
6
|
+
// `document` or `localStorage` access at module scope. Every browser API below lives
|
|
7
|
+
// inside a function body or inside the THEME_INIT_SCRIPT string. Breaking that rule
|
|
8
|
+
// breaks the build, not the page, and the error is a long way from the cause.
|
|
9
|
+
|
|
10
|
+
/** localStorage key holding the visitor's explicit choice. `system` clears it. */
|
|
11
|
+
export const THEME_STORAGE_KEY = "theme";
|
|
12
|
+
|
|
13
|
+
/** Attribute on `<html>` carrying the *chosen* state — also the JS-present marker. */
|
|
14
|
+
export const THEME_ATTRIBUTE = "data-theme";
|
|
15
|
+
|
|
16
|
+
/** Attribute marking a toggle trigger. The delegated click listener matches on it. */
|
|
17
|
+
export const THEME_TOGGLE_ATTRIBUTE = "data-theme-toggle";
|
|
18
|
+
|
|
19
|
+
export type Theme = "light" | "dark" | "system";
|
|
20
|
+
|
|
21
|
+
/** What `system` collapses to once the OS preference is read. */
|
|
22
|
+
export type ResolvedTheme = "light" | "dark";
|
|
23
|
+
|
|
24
|
+
/** Cycle order for the toggle: light → dark → system → light. */
|
|
25
|
+
export const THEME_ORDER: readonly Theme[] = ["light", "dark", "system"];
|
|
26
|
+
|
|
27
|
+
// The accessible name for each state, naming both where you are and where the next
|
|
28
|
+
// press takes you — the icon carries the state visually, and a button's accessible name
|
|
29
|
+
// has to carry the action. THEME_INIT_SCRIPT keeps this current as the state cycles, so
|
|
30
|
+
// the block's static aria-label and this map must not drift; both read it from here.
|
|
31
|
+
export const THEME_LABELS: Record<Theme, string> = {
|
|
32
|
+
dark: "Theme: dark. Switch to system.",
|
|
33
|
+
light: "Theme: light. Switch to dark.",
|
|
34
|
+
system: "Theme: system. Switch to light.",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const OS_DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
38
|
+
|
|
39
|
+
/** The stored choice, or `system` when unset, unreadable or not a valid theme. */
|
|
40
|
+
export function getStoredTheme(): Theme {
|
|
41
|
+
try {
|
|
42
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
43
|
+
return stored === "light" || stored === "dark" ? stored : "system";
|
|
44
|
+
} catch {
|
|
45
|
+
// A throwing localStorage (Safari private mode, blocked storage) must degrade to
|
|
46
|
+
// following the OS, never to an unstyled page.
|
|
47
|
+
return "system";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Collapse a choice to the palette to paint. `system` asks the OS. */
|
|
52
|
+
export function resolveTheme(theme: Theme): ResolvedTheme {
|
|
53
|
+
if (theme === "light" || theme === "dark") {
|
|
54
|
+
return theme;
|
|
55
|
+
}
|
|
56
|
+
return window.matchMedia(OS_DARK_QUERY).matches ? "dark" : "light";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Persist a choice and apply it: storage, `data-theme`, and the `.dark` class. */
|
|
60
|
+
export function setTheme(theme: Theme): void {
|
|
61
|
+
try {
|
|
62
|
+
if (theme === "system") {
|
|
63
|
+
localStorage.removeItem(THEME_STORAGE_KEY);
|
|
64
|
+
} else {
|
|
65
|
+
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Unwritable storage costs persistence, not the current page.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const root = document.documentElement;
|
|
72
|
+
root.setAttribute(THEME_ATTRIBUTE, theme);
|
|
73
|
+
root.classList.toggle("dark", resolveTheme(theme) === "dark");
|
|
74
|
+
|
|
75
|
+
for (const trigger of root.querySelectorAll(`[${THEME_TOGGLE_ATTRIBUTE}]`)) {
|
|
76
|
+
trigger.setAttribute("aria-label", THEME_LABELS[theme]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The pre-paint resolver, and the only thing that ever *changes* the theme at runtime.
|
|
81
|
+
//
|
|
82
|
+
// It is a string rather than a function because it has to be inlined into each host
|
|
83
|
+
// document verbatim:
|
|
84
|
+
//
|
|
85
|
+
// - Astro `<script is:inline set:html={THEME_INIT_SCRIPT} />` in the <head>
|
|
86
|
+
// (see apps/web/src/layouts/Layout.astro).
|
|
87
|
+
// - Vite a `transformIndexHtml` plugin injecting it at `head-prepend`. Vite's
|
|
88
|
+
// index.html only substitutes `%VITE_*%` env values, so it cannot reach a
|
|
89
|
+
// TypeScript constant any other way.
|
|
90
|
+
//
|
|
91
|
+
// A module import cannot do this job in *either* host: `<script type="module">` is
|
|
92
|
+
// deferred by specification and always runs after first paint, which is exactly the
|
|
93
|
+
// flash this script exists to prevent. And it is a plain string, not a stringified
|
|
94
|
+
// function, because a minifier or renamer would otherwise be free to change its meaning
|
|
95
|
+
// on the way into the bundle.
|
|
96
|
+
//
|
|
97
|
+
// It also installs the toggle's behaviour, which is why the block that renders the
|
|
98
|
+
// button needs no JavaScript of its own: a delegated click listener on `document`
|
|
99
|
+
// (registered while <head> parses — `document` exists, the button need not) plus a
|
|
100
|
+
// matchMedia listener that re-resolves only while the state is `system`.
|
|
101
|
+
//
|
|
102
|
+
// Written as ES5-era syntax with no optional chaining: it ships unminified and
|
|
103
|
+
// untranspiled to every visitor, including the ones the rest of the bundle drops.
|
|
104
|
+
export const THEME_INIT_SCRIPT = `(function () {
|
|
105
|
+
var STORAGE_KEY = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
106
|
+
var ATTRIBUTE = ${JSON.stringify(THEME_ATTRIBUTE)};
|
|
107
|
+
var TRIGGER = "[" + ${JSON.stringify(THEME_TOGGLE_ATTRIBUTE)} + "]";
|
|
108
|
+
var ORDER = ${JSON.stringify(THEME_ORDER)};
|
|
109
|
+
var LABELS = ${JSON.stringify(THEME_LABELS)};
|
|
110
|
+
var root = document.documentElement;
|
|
111
|
+
var media = window.matchMedia(${JSON.stringify(OS_DARK_QUERY)});
|
|
112
|
+
|
|
113
|
+
function read() {
|
|
114
|
+
try {
|
|
115
|
+
var stored = localStorage.getItem(STORAGE_KEY);
|
|
116
|
+
return stored === "light" || stored === "dark" ? stored : "system";
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return "system";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function write(theme) {
|
|
123
|
+
try {
|
|
124
|
+
if (theme === "system") localStorage.removeItem(STORAGE_KEY);
|
|
125
|
+
else localStorage.setItem(STORAGE_KEY, theme);
|
|
126
|
+
} catch (error) {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function paint(theme) {
|
|
130
|
+
root.setAttribute(ATTRIBUTE, theme);
|
|
131
|
+
root.classList.toggle("dark", theme === "dark" || (theme === "system" && media.matches));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function relabel(theme) {
|
|
135
|
+
var triggers = root.querySelectorAll(TRIGGER);
|
|
136
|
+
for (var i = 0; i < triggers.length; i++) {
|
|
137
|
+
triggers[i].setAttribute("aria-label", LABELS[theme]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
paint(read());
|
|
142
|
+
|
|
143
|
+
document.addEventListener("click", function (event) {
|
|
144
|
+
var node = event.target;
|
|
145
|
+
if (!node || typeof node.closest !== "function" || !node.closest(TRIGGER)) return;
|
|
146
|
+
// Cycle from what is painted, not from what is stored. Where storage is unwritable
|
|
147
|
+
// the write() above is a no-op, so read() would answer "system" forever and every
|
|
148
|
+
// press would land on light. The attribute is set by paint() on every transition and
|
|
149
|
+
// is the one state that survives a dead localStorage.
|
|
150
|
+
var current = root.getAttribute(ATTRIBUTE) || read();
|
|
151
|
+
var next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
|
|
152
|
+
write(next);
|
|
153
|
+
paint(next);
|
|
154
|
+
relabel(next);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
media.addEventListener("change", function () {
|
|
158
|
+
if (root.getAttribute(ATTRIBUTE) === "system") paint("system");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
162
|
+
// The attribute is author-writable, so anything could be sitting there. LABELS has no
|
|
163
|
+
// entry for a value outside ORDER, and aria-label="undefined" is worse than a stale one.
|
|
164
|
+
var painted = root.getAttribute(ATTRIBUTE);
|
|
165
|
+
relabel(ORDER.indexOf(painted) < 0 ? read() : painted);
|
|
166
|
+
});
|
|
167
|
+
})();`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import type { ClassValue } from "clsx";
|
|
3
|
+
import { twMerge } from "tailwind-merge";
|
|
4
|
+
|
|
5
|
+
// The canonical shadcn `cn()`: clsx resolves conditionals/arrays into a class string,
|
|
6
|
+
// then tailwind-merge drops earlier utilities that a later one overrides (so a caller's
|
|
7
|
+
// `className="p-8"` actually beats a component's default `p-4` instead of racing it in
|
|
8
|
+
// the cascade). Every component and block in this package composes classes through it.
|
|
9
|
+
export function cn(...inputs: ClassValue[]) {
|
|
10
|
+
return twMerge(clsx(inputs));
|
|
11
|
+
}
|