@kodaicoder/shadcn-svelte-daisyui-adapter 0.0.0-stage → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ The CLI no longer creates `daisyui-adapter.overrides.css` or writes its `@import` line. Overrides are opt-in: the user adds the file and the import, and the README's Overrides section describes how. An existing file and import are left untouched, so a project that installed 0.1.0 keeps both.
6
+
7
+ A re-run applies the change to a 0.1.0 project, and the header comment updates to 0.2.0.
8
+
9
+ ## 0.1.0
10
+
11
+ First release. The CLI writes the header comment, the `@plugin "@kodaicoder/shadcn-svelte-daisyui-adapter";` line, the `daisyui-adapter.overrides.css` file and its import, and the 32 bindings per block; the plugin emits `--daisy-color-*` and `--daisy-radius` from `src/daisyui-theme.css` at build time.
12
+
13
+ The first install is the only required run. A later `shadcn-svelte init` keeps the header comment and the override import, wipes the bindings, and the next `init` restores them.
14
+
15
+ A change to the mapping table or to the shape the plugin emits is a minor bump, and it requires a re-run. The header comment names the version that wrote the stylesheet, so a project can tell which one it holds.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nutchapon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @kodaicoder/shadcn-svelte-daisyui-adapter
2
+
3
+ Generate a shadcn-svelte token set from a daisyUI v5 theme, at build time, with no daisyUI runtime dependency.
4
+
5
+ The CLI reads your stylesheet and your `src/daisyui-theme.css`, then rewrites each `:root` and `.dark` block so every shadcn token reads a daisyUI value, with the value that was already in the file as the fallback. It also writes the `@plugin` line and the chart ramp. The plugin then emits `--daisy-color-*` and `--daisy-radius` from the theme while Tailwind builds, so a theme change takes one rebuild and no re-run.
6
+
7
+ This package is not affiliated with shadcn-svelte or daisyUI. Both names are used only to say what it reads and what it writes.
8
+
9
+ ## Prerequisites
10
+
11
+ - A SvelteKit project with `shadcn-svelte` set up through step 4 of its guide.
12
+ - `components.json` at the project root, with `tailwind.css` pointing at the project's global stylesheet (`src/routes/layout.css` on SvelteKit 3, `src/app.css` on older projects). The CLI reads that path on every run and never hardcodes it.
13
+ - That stylesheet imported by the root layout.
14
+ - `src/daisyui-theme.css` holding one light and one dark block from the [daisyUI theme generator](https://daisyui.com/theme-generator/). Copy the blocks as they are, including the `@plugin "daisyui/theme"` prelude.
15
+
16
+ ## Usage
17
+
18
+ ```bash
19
+ # 1. paste one light and one dark block from the theme generator
20
+ # into src/daisyui-theme.css
21
+
22
+ # 2. shadcn-svelte, as its guide says
23
+ pnpm dlx shadcn-svelte@latest init
24
+
25
+ # 3. the adapter, at an exact version
26
+ pnpm dlx @kodaicoder/shadcn-svelte-daisyui-adapter@0.2.0 init
27
+
28
+ # 4. the gate
29
+ pnpm build
30
+ ```
31
+
32
+ The CLI adds the package as a devDependency at its own version, because the `@plugin` specifier resolves through `node_modules` at build time. Run it from the project root.
33
+
34
+ ## What the CLI prints
35
+
36
+ ```
37
+ [daisyui-adapter] theme ok: light + dark
38
+ [daisyui-adapter] wrote src/routes/layout.css
39
+ [daisyui-adapter] 32 light, 32 dark
40
+ [daisyui-adapter] installed @kodaicoder/shadcn-svelte-daisyui-adapter@0.2.0
41
+ ```
42
+
43
+ The declaration count comes back from the file it wrote, so a stylesheet missing a row the adapter expects shows a number below 32. A second run prints `already installed` instead of installing again. A run on a stylesheet from 0.1.0 prints `upgrading 0.1.0 -> 0.2.0` before the rest.
44
+
45
+ A missing theme file is a warning, not a failure. The CLI prints `E1`, writes the bindings with your own values as fallbacks, and the build renders the project's palette. A malformed theme file exits non-zero with `E2` or `E3`, because a wrong pair would render wrong colours silently.
46
+
47
+ ## The header comment
48
+
49
+ The first line of the stylesheet is the version marker:
50
+
51
+ ```
52
+ /* @kodaicoder/shadcn-svelte-daisyui-adapter 0.2.0 | re-run `pnpm dlx @kodaicoder/shadcn-svelte-daisyui-adapter@0.2.0 init` after `shadcn-svelte init` */
53
+ ```
54
+
55
+ `shadcn-svelte init` keeps the header comment, wipes the bindings, and the next adapter run restores them. If the project has an override import, that import is kept too, but the CLI does not create it. Re-run when:
56
+
57
+ - `shadcn-svelte init` or a large `add` changed the stylesheet;
58
+ - you moved to a newer CLI and want the header to name it;
59
+ - the changelog says the mapping table or the emitted shape changed.
60
+
61
+ The CLI refuses to run when the header names a newer version than the CLI. It prints `E4`, exits non-zero and writes nothing, because an older CLI would undo bindings a newer one wrote. Run the version the header names, or upgrade the CLI.
62
+
63
+ ## Overrides (optional)
64
+
65
+ The CLI does not create `daisyui-adapter.overrides.css` and does not write its import. Overrides are opt-in: you add both yourself.
66
+
67
+ To use overrides, create `daisyui-adapter.overrides.css` beside the global stylesheet, then add this line after the other `@import` lines and before the generated `:root` and `.dark` blocks:
68
+
69
+ ```css
70
+ @import "./daisyui-adapter.overrides.css";
71
+ ```
72
+
73
+ The CLI never writes or deletes the file. If the project installed 0.1.0, the file and the import already exist. Keep them if you use them, or delete both if you do not.
74
+
75
+ The import comes before the generated blocks, so a plain `:root` there loses on source order. Raise the specificity and keep the light block out of dark mode. Use `html:not(.dark)` for light and `html.dark` for dark:
76
+
77
+ ```css
78
+ html:not(.dark) {
79
+ --daisy-derived-chart-1: oklch(0.62 0.08 250);
80
+ }
81
+
82
+ html.dark {
83
+ --daisy-derived-chart-1: oklch(0.72 0.08 250);
84
+ }
85
+ ```
86
+
87
+ Both selectors beat the generated `:root` and `.dark`, and the light one does not leak into dark. Write the pair when you need both schemes.
88
+
89
+ ## Version policy
90
+
91
+ The header records one exact version, and that number is what a project holds. A patch release fixes the CLI or the parser without moving a key or changing a shape, and does not need a re-run. A change to the mapping table, to a token's shape or to what the plugin emits is a minor bump, and it requires a re-run. The changelog names each one.
92
+
93
+ ## Checking the result
94
+
95
+ `pnpm build` is the gate. The plugin reads `src/daisyui-theme.css` while Tailwind builds, so a malformed theme block fails there. With the theme removed, the build still passes and the page renders the pre-adapter palette.
96
+
97
+ ## Testing a tarball before publish
98
+
99
+ `pnpm pack` writes `kodaicoder-shadcn-svelte-daisyui-adapter-0.2.0.tgz`. Set `DAISYUI_ADAPTER_INSTALL_SPEC` to that file and the CLI installs it instead of the registry copy. Pack from the package directory, then run the CLI from the app's root:
100
+
101
+ ```bash
102
+ # in packages/daisyui-adapter, writing into the app's root
103
+ pnpm pack --pack-destination <app>
104
+
105
+ # in the app's root
106
+ DAISYUI_ADAPTER_INSTALL_SPEC=./kodaicoder-shadcn-svelte-daisyui-adapter-0.2.0.tgz \
107
+ node <repo>/packages/daisyui-adapter/cli/init.mjs
108
+ ```
109
+
110
+ The CLI prints `installed ./kodaicoder-shadcn-svelte-daisyui-adapter-0.2.0.tgz`. Without the variable it installs `@kodaicoder/shadcn-svelte-daisyui-adapter@0.2.0` from the registry.
package/cli/init.mjs ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+ // The adapter CLI. It reads components.json and the stylesheet, validates the theme, then writes the
3
+ // header comment, the plugin line, the bindings for both blocks, and the five chart values. Re-running
4
+ // it is byte-identical. The override file and its import are the project's to add; this CLI never
5
+ // creates, deletes or rewrites either one.
6
+ //
7
+ // It never writes a raw `--color-*` name (ticket 14: that forms a cycle with `@theme inline`, and
8
+ // the fallback is then silently ignored), and it writes the chart ramp itself rather than leaving it
9
+ // to the plugin, because `addBase` lands in `@layer base` and the project's unlayered `:root` wins
10
+ // (ticket 13).
11
+ //
12
+ // Name, version and the bin come from package.json, so the header, the plugin specifier and the
13
+ // self-install spec cannot drift from each other. The header is replaced, not prepended: a project
14
+ // that ran an older CLI must end up with exactly one line naming its version.
15
+ import { execSync } from 'node:child_process';
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { resolve } from 'node:path';
18
+
19
+ import { BINDING_KEYS, SINGLE, mixValue } from '../dist/map.mjs';
20
+ import { chartTokens, solveScheme } from '../dist/ramp.mjs';
21
+ import { parseTheme, themePath } from '../dist/index.mjs';
22
+
23
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
24
+ const PKG = manifest.name;
25
+ const VERSION = manifest.version;
26
+ const BIN = Object.keys(manifest.bin)[0];
27
+ const HEADER = `/* ${PKG} ${VERSION} | re-run \`pnpm dlx ${PKG}@${VERSION} init\` after \`shadcn-svelte init\` */`;
28
+ const HEADER_PATTERN = /^\/\* (\S+) (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?) \| re-run [^\n]*\*\/\r?\n?/;
29
+ const OVERRIDE = 'daisyui-adapter.overrides.css';
30
+ const BORDER_ENTRY = '--default-border-width: var(--daisy-border, 1px);';
31
+ const PLUGIN = `@plugin "${PKG}";`;
32
+ const PNPM = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
33
+
34
+ const colorVar = (source) => `--daisy-color-${source}`;
35
+ const targetVar = (key) => (SINGLE[key].scalar ? '--daisy-radius' : colorVar(SINGLE[key].source));
36
+
37
+ const compareVersions = (a, b) => {
38
+ const split = (value) => {
39
+ const [core, pre = ''] = value.split('-');
40
+ return { nums: core.split('.').map(Number), pre: pre ? pre.split('.') : [] };
41
+ };
42
+ const left = split(a);
43
+ const right = split(b);
44
+ for (let i = 0; i < 3; i++) {
45
+ if (left.nums[i] !== right.nums[i]) return Math.sign(left.nums[i] - right.nums[i]);
46
+ }
47
+ if (left.pre.length === 0 || right.pre.length === 0) {
48
+ return Math.sign(right.pre.length - left.pre.length);
49
+ }
50
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
51
+ if (left.pre[i] === right.pre[i]) continue;
52
+ if (left.pre[i] === undefined) return -1;
53
+ if (right.pre[i] === undefined) return 1;
54
+ const [x, y] = [Number(left.pre[i]), Number(right.pre[i])];
55
+ if (Number.isNaN(x) || Number.isNaN(y)) return left.pre[i] < right.pre[i] ? -1 : 1;
56
+ return Math.sign(x - y);
57
+ }
58
+ return 0;
59
+ };
60
+
61
+ // A value this CLI wrote on an earlier run keeps its fallback in the second var() argument. Reading
62
+ // it back out is what makes a re-run byte-identical instead of nesting var() inside var().
63
+ const unwrapFallback = (value) => {
64
+ const match = /^var\(--[\w-]+,\s*([\s\S]*)\)$/.exec(value.trim());
65
+ return match ? match[1].trim() : value.trim();
66
+ };
67
+
68
+ const blockBounds = (source, selector) => {
69
+ const pattern = new RegExp(`^${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{`, 'm');
70
+ const match = pattern.exec(source);
71
+ if (!match) return null;
72
+ const open = source.indexOf('{', match.index);
73
+ let depth = 0;
74
+ for (let i = open; i < source.length; i++) {
75
+ if (source[i] === '{') depth++;
76
+ else if (source[i] === '}') {
77
+ depth--;
78
+ if (depth === 0) return { open, close: i };
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+
84
+ const declarations = (body) =>
85
+ [...body.matchAll(/^\s*(--[\w-]+)\s*:\s*([\s\S]*?);\s*$/gm)].map((m) => ({
86
+ prop: m[1],
87
+ value: m[2].trim()
88
+ }));
89
+
90
+ const readBlock = (source, selector, where) => {
91
+ const bounds = blockBounds(source, selector);
92
+ if (!bounds) throw new Error(`[daisyui-adapter] ${where} has no ${selector} block`);
93
+ return declarations(source.slice(bounds.open + 1, bounds.close));
94
+ };
95
+
96
+ // Foreign declarations stay where they are and in their order. Every key the map owns is rewritten
97
+ // from its fallback, so a key the plugin never emits still resolves. Derived keys are dropped and
98
+ // re-emitted rather than accumulated. Without a theme the chart rows are left as the project has
99
+ // them, because a ramp cannot be solved from a file that is not there.
100
+ //
101
+ // A chart row takes a mix key's shape: the project's own row in the fallback slot, the ramp behind a
102
+ // helper. A row that already holds a ramp is not a project value, and copying one into its own slot
103
+ // is the one thing that must never happen. That row is left alone, because the value it replaced is
104
+ // only in the project's history, and a second solve would hide that it was lost.
105
+ const isRamp = (value) => value.includes('oklch(from var(--daisy-color-primary)');
106
+
107
+ let rampWarned = false;
108
+ const warnBareRamp = (key) => {
109
+ if (rampWarned) return;
110
+ rampWarned = true;
111
+ console.warn(
112
+ `[daisyui-adapter] --${key} holds a ramp written by an older CLI with no fallback. Its original ` +
113
+ `value is gone; restore the stylesheet from version control, or set --daisyui-derived-${key} ` +
114
+ `in the optional ${OVERRIDE} file, which the README's Overrides section describes.`
115
+ );
116
+ };
117
+
118
+ const rebuild = (current, fallback, scheme) => {
119
+ const lines = [];
120
+ for (const { prop, value } of current) {
121
+ if (prop.startsWith('--daisy-derived-')) continue;
122
+ const key = prop.slice(2);
123
+ if (BINDING_KEYS.includes(key) || key.startsWith('chart-')) continue;
124
+ lines.push(`\t${prop}: ${value};`);
125
+ }
126
+ for (const key of BINDING_KEYS) {
127
+ const old = unwrapFallback(
128
+ current.find((d) => d.prop === `--${key}`)?.value ?? fallback[`--${key}`] ?? ''
129
+ );
130
+ if (SINGLE[key]) {
131
+ lines.push(`\t--${key}: var(${targetVar(key)}, ${old});`);
132
+ } else {
133
+ lines.push(`\t--${key}: var(--daisy-derived-${key}, ${old});`);
134
+ lines.push(`\t--daisy-derived-${key}: ${mixValue(key, colorVar)};`);
135
+ }
136
+ }
137
+ if (scheme) {
138
+ for (const [i, value] of chartTokens(scheme).entries()) {
139
+ const key = `chart-${i + 1}`;
140
+ const old = unwrapFallback(current.find((d) => d.prop === `--${key}`)?.value ?? '');
141
+ if (old && !isRamp(old)) {
142
+ lines.push(`\t--${key}: var(--daisy-derived-${key}, ${old});`);
143
+ lines.push(`\t--daisy-derived-${key}: ${value};`);
144
+ } else if (old) {
145
+ warnBareRamp(key);
146
+ lines.push(`\t--${key}: ${old};`);
147
+ } else {
148
+ lines.push(`\t--${key}: ${value};`);
149
+ }
150
+ }
151
+ } else {
152
+ for (const { prop, value } of current)
153
+ if (prop.startsWith('--chart-')) lines.push(`\t${prop}: ${value};`);
154
+ }
155
+ return lines.join('\n');
156
+ };
157
+
158
+ const replaceBlock = (source, selector, body) => {
159
+ const bounds = blockBounds(source, selector);
160
+ if (!bounds) return null;
161
+ return `${source.slice(0, bounds.open + 1)}\n${body}\n${source.slice(bounds.close)}`;
162
+ };
163
+
164
+ const insertAfterLastImport = (source, line) => {
165
+ if (source.includes(line)) return source;
166
+ const lines = source.split('\n');
167
+ const last = lines.reduce((found, text, i) => (text.startsWith('@import ') ? i : found), -1);
168
+ if (last === -1) throw new Error('[daisyui-adapter] the stylesheet has no @import line');
169
+ lines.splice(last + 1, 0, line);
170
+ return lines.join('\n');
171
+ };
172
+
173
+ // The one entry the bindings cannot carry. Tailwind reads a bare border width from
174
+ // `--default-border-width`, and `--daisy-border` is emitted by the plugin from the theme's --border,
175
+ // so the 1px here is only the fallback for a project with no theme (ticket 11).
176
+ const insertThemeEntries = (source) => {
177
+ if (source.includes('--default-border-width')) return source;
178
+ const bounds = blockBounds(source, '@theme inline');
179
+ if (!bounds) {
180
+ console.warn('[daisyui-adapter] no @theme inline block, so --border will not reach border');
181
+ return source;
182
+ }
183
+ return `${source.slice(0, bounds.close)}\t${BORDER_ENTRY}\n${source.slice(bounds.close)}`;
184
+ };
185
+
186
+ const installLine = (spec) => {
187
+ const local = resolve('node_modules', ...PKG.split('/'), 'package.json');
188
+ if (existsSync(local)) {
189
+ try {
190
+ if (JSON.parse(readFileSync(local, 'utf8')).version === VERSION) {
191
+ return `already installed ${PKG} ${VERSION}`;
192
+ }
193
+ } catch {}
194
+ }
195
+ try {
196
+ // One command string: an argument array with a shell makes Windows Node warn on every run
197
+ // (DEP0190), and `pnpm.cmd` cannot be executed without a shell there.
198
+ execSync(`${PNPM} add -D "${spec}"`, { stdio: 'inherit' });
199
+ } catch {
200
+ throw new Error(
201
+ `[daisyui-adapter] could not install ${spec}. Run \`pnpm add -D ${spec}\` yourself, then \`pnpm exec ${BIN} init\`.`
202
+ );
203
+ }
204
+ return `installed ${spec}`;
205
+ };
206
+
207
+ const main = () => {
208
+ const componentsPath = resolve('components.json');
209
+ if (!existsSync(componentsPath)) {
210
+ throw new Error('[daisyui-adapter] no components.json. Run `shadcn-svelte init` first.');
211
+ }
212
+ const components = JSON.parse(readFileSync(componentsPath, 'utf8'));
213
+ if (!components.tailwind?.css) {
214
+ throw new Error('[daisyui-adapter] components.json has no tailwind.css path');
215
+ }
216
+ const cssPath = resolve(components.tailwind.css);
217
+ const where = components.tailwind.css;
218
+ let css = readFileSync(cssPath, 'utf8');
219
+
220
+ // An older CLI cannot reproduce a newer version's bindings, so it would quietly undo them. The
221
+ // stylesheet still names the version that wrote it, which is the only record a project keeps.
222
+ const header = HEADER_PATTERN.exec(css);
223
+ const headerVersion = header?.[2] ?? null;
224
+ if (headerVersion && compareVersions(headerVersion, VERSION) > 0) {
225
+ throw new Error(
226
+ `[daisyui-adapter] E4 ${where} was written by ${PKG} ${headerVersion}, but this CLI is ${VERSION}. ` +
227
+ `Run \`pnpm dlx ${PKG}@${headerVersion} init\` or upgrade the CLI.`
228
+ );
229
+ }
230
+ if (headerVersion) css = css.replace(HEADER_PATTERN, '');
231
+ if (headerVersion && compareVersions(headerVersion, VERSION) < 0) {
232
+ console.log(`[daisyui-adapter] upgrading ${headerVersion} -> ${VERSION}`);
233
+ }
234
+
235
+ // E1: absence warns at init and continues, because a later paste fixes it. E2 and E3 throw,
236
+ // because a malformed pair would render wrong colours silently.
237
+ const path = themePath();
238
+ let themes = null;
239
+ if (!existsSync(path)) {
240
+ console.warn(
241
+ `[daisyui-adapter] E1 Cannot read the daisyUI theme file at ${path}. ` +
242
+ `Put one light and one dark theme block from https://daisyui.com/theme-generator/ ` +
243
+ `in that file, then re-run the build. The CLI-written fallbacks render until then.`
244
+ );
245
+ } else {
246
+ themes = parseTheme(readFileSync(path, 'utf8'), path);
247
+ }
248
+
249
+ const stockLight = Object.fromEntries(
250
+ readBlock(css, ':root', where).map((d) => [d.prop, d.value])
251
+ );
252
+ const chart = themes
253
+ ? { light: solveScheme(themes.light), dark: solveScheme(themes.dark) }
254
+ : null;
255
+
256
+ css = replaceBlock(
257
+ css,
258
+ '.dark',
259
+ rebuild(readBlock(css, '.dark', where), stockLight, chart?.dark ?? null)
260
+ );
261
+ if (!css) throw new Error(`[daisyui-adapter] ${where} has no .dark block`);
262
+ css = replaceBlock(
263
+ css,
264
+ ':root',
265
+ rebuild(readBlock(css, ':root', where), stockLight, chart?.light ?? null)
266
+ );
267
+ if (!css) throw new Error(`[daisyui-adapter] ${where} has no :root block`);
268
+
269
+ if (themes) {
270
+ css = insertAfterLastImport(css, PLUGIN);
271
+ css = insertThemeEntries(css);
272
+ }
273
+ css = `${HEADER}\n${css}`;
274
+
275
+ writeFileSync(cssPath, css);
276
+
277
+ // Read back what was written: a block the CLI matched but could not fill has to show a count
278
+ // below 32 rather than a number from the data it meant to write.
279
+ const written = readFileSync(cssPath, 'utf8');
280
+ const count = (selector) =>
281
+ readBlock(written, selector, where).filter((d) => !d.prop.startsWith('--daisy-derived-'))
282
+ .length;
283
+ const lightCount = count(':root');
284
+ const darkCount = count('.dark');
285
+
286
+ if (themes) console.log('[daisyui-adapter] theme ok: light + dark');
287
+ console.log(`[daisyui-adapter] wrote ${where}`);
288
+ console.log(`[daisyui-adapter] ${lightCount} light, ${darkCount} dark`);
289
+
290
+ // The install is last, after the lines that describe what was written, so a failed install
291
+ // still tells the user the stylesheet is in place.
292
+ const spec = process.env.DAISYUI_ADAPTER_INSTALL_SPEC ?? `${PKG}@${VERSION}`;
293
+ console.log(`[daisyui-adapter] ${installLine(spec)}`);
294
+
295
+ if (chart) {
296
+ for (const scheme of ['light', 'dark']) {
297
+ const solved = chart[scheme];
298
+ console.log(
299
+ `[daisyui-adapter] ${scheme} chart L ${solved.lightness.map((l) => l.toFixed(4)).join(' ')} ` +
300
+ `min adjacent ${solved.minAdjacent.toFixed(3)}`
301
+ );
302
+ }
303
+ }
304
+ };
305
+
306
+ try {
307
+ main();
308
+ } catch (error) {
309
+ console.error(String(error.message ?? error));
310
+ process.exitCode = 1;
311
+ }
package/dist/index.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // The build-time half. It reads the user's theme and emits one namespace: `--daisy-color-<source>`
2
+ // for the 20 daisyUI colours and `--daisy-radius` for the box rung. It never writes a shadcn token
3
+ // name, so the CLI's bindings stay the only place a token is named.
4
+ //
5
+ // Two reasons it emits bindings rather than tokens. The plugin's `addBase` lands in `@layer base`,
6
+ // and the project's unlayered `:root` beats any layered rule regardless of specificity (ticket 13).
7
+ // And a `--primary: var(--color-primary, ...)` binding would form a cycle with `@theme inline`'s
8
+ // `--color-primary: var(--primary)`, leaving the fallback silently ignored (ticket 14).
9
+ //
10
+ // Error text is ticket 02's contract: E1 is absent, E2 is a malformed pair, E3 is a malformed block.
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+
14
+ import { COLOR_SOURCES } from './map.mjs';
15
+
16
+ export const THEME_FILE = 'src/daisyui-theme.css';
17
+ const GENERATOR = 'https://daisyui.com/theme-generator/';
18
+
19
+ const blockPattern = /@plugin\s+["']daisyui\/theme["']\s*\{([\s\S]*?)\}/g;
20
+
21
+ const E2 = (path, problem) =>
22
+ `[daisyui-adapter] ${path}: ${problem}. The file must hold exactly two v5 theme blocks, one ` +
23
+ `light and one dark. Generate both at ${GENERATOR}, paste them into that file, then re-run the build.`;
24
+
25
+ const E3 = (path, label, problem) =>
26
+ `[daisyui-adapter] ${path}: theme "${label}" ${problem}. Replace that block with a complete ` +
27
+ `v5 block from ${GENERATOR}, then re-run the build.`;
28
+
29
+ // Reads exactly one light block and one dark block. Order does not matter, `default` and
30
+ // `prefersdark` are advisory, and `color-scheme` is the only light/dark marker.
31
+ export const parseTheme = (text, path) => {
32
+ const blocks = [...text.matchAll(blockPattern)];
33
+ if (blocks.length !== 2) {
34
+ throw new Error(E2(path, `holds ${blocks.length} theme block(s)`));
35
+ }
36
+ const themes = {};
37
+ for (const block of blocks) {
38
+ const body = block[1];
39
+ const label = /name\s*:\s*["']([^"']+)["']/.exec(body)?.[1] ?? 'unnamed';
40
+ const scheme = /color-scheme\s*:\s*["']?(light|dark)["']?\s*;/.exec(body)?.[1];
41
+ if (!scheme) {
42
+ throw new Error(E3(path, label, 'has no color-scheme'));
43
+ }
44
+ if (/--[pr]-[a-z]/.test(body)) {
45
+ throw new Error(E3(path, label, 'uses daisyUI v4 variable names'));
46
+ }
47
+ if (themes[scheme]) {
48
+ throw new Error(E2(path, `holds two ${scheme} blocks`));
49
+ }
50
+ const vars = Object.fromEntries(
51
+ [...body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map((match) => [match[1], match[2].trim()])
52
+ );
53
+ for (const source of COLOR_SOURCES) {
54
+ if (!vars[`--color-${source}`]) {
55
+ throw new Error(E3(path, label, `is missing --color-${source}`));
56
+ }
57
+ }
58
+ if (!vars['--radius-box']) {
59
+ throw new Error(E3(path, label, 'is missing --radius-box'));
60
+ }
61
+ if (!vars['--border']) {
62
+ throw new Error(E3(path, label, 'is missing --border'));
63
+ }
64
+ themes[scheme] = vars;
65
+ }
66
+ return themes;
67
+ };
68
+
69
+ export const themePath = (cwd = process.cwd()) => resolve(cwd, THEME_FILE);
70
+
71
+ // Absent is a state a later paste fixes, so the plugin emits nothing and the CLI-written fallbacks
72
+ // decide what renders. Present but malformed is a defect that would render wrong colours silently.
73
+ export const readTheme = (cwd = process.cwd()) => {
74
+ const path = themePath(cwd);
75
+ if (!existsSync(path)) return null;
76
+ return parseTheme(readFileSync(path, 'utf8'), path);
77
+ };
78
+
79
+ export const bindingsFor = (vars) => {
80
+ const out = {};
81
+ for (const source of COLOR_SOURCES) out[`--daisy-color-${source}`] = vars[`--color-${source}`];
82
+ out['--daisy-radius'] = vars['--radius-box'];
83
+ out['--daisy-border'] = vars['--border'];
84
+ return out;
85
+ };
86
+
87
+ export default {
88
+ handler({ addBase }) {
89
+ const themes = readTheme();
90
+ if (!themes) {
91
+ console.log(`[daisyui-adapter] no ${THEME_FILE}, emitting nothing; the CLI fallbacks render`);
92
+ return;
93
+ }
94
+ addBase({
95
+ ':root': bindingsFor(themes.light),
96
+ '.dark': bindingsFor(themes.dark)
97
+ });
98
+ }
99
+ };
package/dist/map.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // The one list that drives both the plugin and the CLI, so a renamed key cannot become an invalid
2
+ // token. Ticket 09 (surfaces), 10 (sidebar rungs) and 11 (radius) settled the rows; ticket 12
3
+ // settled that the chart ramp is computed, not assigned, so chart-1..5 are absent here on purpose
4
+ // and are written by the CLI from ramp.mjs.
5
+
6
+ export const COLOR_SOURCES = [
7
+ 'base-100',
8
+ 'base-200',
9
+ 'base-300',
10
+ 'base-content',
11
+ 'primary',
12
+ 'primary-content',
13
+ 'secondary',
14
+ 'secondary-content',
15
+ 'accent',
16
+ 'accent-content',
17
+ 'neutral',
18
+ 'neutral-content',
19
+ 'info',
20
+ 'info-content',
21
+ 'success',
22
+ 'success-content',
23
+ 'warning',
24
+ 'warning-content',
25
+ 'error',
26
+ 'error-content'
27
+ ];
28
+
29
+ // A single row is a direct read of one daisyUI source. `radius` reads the CLI's scalar binding.
30
+ export const SINGLE = {
31
+ background: { source: 'base-100' },
32
+ foreground: { source: 'base-content' },
33
+ card: { source: 'base-100' },
34
+ 'card-foreground': { source: 'base-content' },
35
+ popover: { source: 'base-100' },
36
+ 'popover-foreground': { source: 'base-content' },
37
+ primary: { source: 'primary' },
38
+ 'primary-foreground': { source: 'primary-content' },
39
+ secondary: { source: 'base-200' },
40
+ 'secondary-foreground': { source: 'base-content' },
41
+ muted: { source: 'base-200' },
42
+ 'accent-foreground': { source: 'base-content' },
43
+ destructive: { source: 'error' },
44
+ ring: { source: 'primary' },
45
+ sidebar: { source: 'base-200' },
46
+ 'sidebar-foreground': { source: 'base-content' },
47
+ 'sidebar-primary': { source: 'primary' },
48
+ 'sidebar-primary-foreground': { source: 'primary-content' },
49
+ 'sidebar-accent': { source: 'base-300' },
50
+ 'sidebar-accent-foreground': { source: 'base-content' },
51
+ 'sidebar-ring': { source: 'primary' },
52
+ radius: { scalar: 'radius' }
53
+ };
54
+
55
+ // A mix row is a derivation over two sources. Values stay opaque: Tailwind's opacity modifiers
56
+ // multiply alpha, so any alpha baked in here would be wrong at every use site.
57
+ const MIX = {
58
+ 'muted-foreground': ['base-content', 62, 'base-100'],
59
+ accent: ['base-200', 85, 'base-300'],
60
+ border: ['base-content', 10, 'base-100'],
61
+ input: ['base-content', 15, 'base-100'],
62
+ 'sidebar-border': ['base-content', 10, 'base-100']
63
+ };
64
+
65
+ export const MIX_KEYS = Object.keys(MIX);
66
+
67
+ // The colour source a mix row reads, so the plugin can emit exactly the bindings the map needs.
68
+ export const mixSources = (key) => {
69
+ const row = MIX[key];
70
+ return row ? [row[0], row[2]] : [];
71
+ };
72
+
73
+ export const mixValue = (key, colorVar) => {
74
+ const row = MIX[key];
75
+ if (!row) throw new Error(`[daisyui-adapter] no mix row for ${key}`);
76
+ return `color-mix(in oklch, var(${colorVar(row[0])}) ${row[1]}%, var(${colorVar(row[2])}))`;
77
+ };
78
+
79
+ export const isSingle = (key) => key in SINGLE;
80
+ export const isMix = (key) => key in MIX;
81
+
82
+ // Every binding key the CLI writes into each block, in a stable order.
83
+ export const BINDING_KEYS = [...Object.keys(SINGLE), ...MIX_KEYS];
package/dist/ramp.mjs ADDED
@@ -0,0 +1,188 @@
1
+ // The chart ramp rule settled in ticket 12, in one place so the plugin and the writer cannot drift.
2
+ //
3
+ // One hue from the theme's `primary`, five lightness steps, chroma capped. Lightness is not shared
4
+ // between schemes: the adapter solves one anchor for 3.2:1 against that scheme's own base-100 and
5
+ // lays four more steps at a fixed offset. A shared band fails, because the light band puts chart-5
6
+ // at 1.18:1 on daisyUI's dark page.
7
+ //
8
+ // Chroma is capped per step, not once for the ramp. sRGB cannot carry chroma 0.08 at every
9
+ // lightness, so a single cap clips, and clipping moves the hue: on the daisyUI dark pair the top
10
+ // step drifted 4.5 degrees. Fitting each step to the gamut keeps the hue constant and costs no
11
+ // separation (worst adjacent dE 0.090 instead of 0.074).
12
+
13
+ export const CAP = 0.08;
14
+ export const STEP = 0.09;
15
+ export const ANCHOR_RATIO = 3.2;
16
+ export const STEPS = 5;
17
+ export const MIN_ADJACENT_DELTA_E = 0.06;
18
+ export const MIN_COMPLIANT_DELTA_E = 0.05;
19
+
20
+ export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
21
+
22
+ // Unclamped, so a caller can tell in-gamut from clipped.
23
+ function rawLinear(L, C, h) {
24
+ const a = C * Math.cos((h * Math.PI) / 180);
25
+ const b = C * Math.sin((h * Math.PI) / 180);
26
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
27
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
28
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
29
+ return [
30
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
31
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
32
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
33
+ ];
34
+ }
35
+
36
+ export const toLinear = (L, C, h) => rawLinear(L, C, h).map((v) => clamp(v, 0, 1));
37
+
38
+ const inGamut = (L, C, h) => rawLinear(L, C, h).every((v) => v >= -0.0005 && v <= 1.0005);
39
+
40
+ // The largest chroma sRGB can hold at this lightness and hue, to 4 decimal places.
41
+ export function gamutChroma(L, h) {
42
+ let lo = 0;
43
+ let hi = 0.4;
44
+ for (let i = 0; i < 60; i++) {
45
+ const mid = (lo + hi) / 2;
46
+ if (inGamut(L, mid, h)) lo = mid;
47
+ else hi = mid;
48
+ }
49
+ return Math.floor(lo * 10000) / 10000;
50
+ }
51
+
52
+ export const luminance = (lin) => 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
53
+
54
+ export const contrastLum = (a, b) => {
55
+ const [hi, lo] = [a, b].sort((x, y) => y - x);
56
+ return (hi + 0.05) / (lo + 0.05);
57
+ };
58
+
59
+ export const toOklab = (lin) => {
60
+ const [r, g, b] = lin;
61
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
62
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
63
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
64
+ return [
65
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
66
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
67
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
68
+ ];
69
+ };
70
+
71
+ export const deltaE = (a, b) => {
72
+ const A = toOklab(a);
73
+ const B = toOklab(b);
74
+ return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]);
75
+ };
76
+
77
+ export const hueOf = (lin) => {
78
+ const [, a, b] = toOklab(lin);
79
+ return ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360;
80
+ };
81
+
82
+ // The widest hue difference inside a set, measured the short way around the wheel.
83
+ export function hueSpread(values) {
84
+ const hs = values.map(hueOf);
85
+ let worst = 0;
86
+ for (let i = 0; i < hs.length; i++) {
87
+ for (let j = i + 1; j < hs.length; j++) {
88
+ const d = Math.abs(hs[i] - hs[j]) % 360;
89
+ worst = Math.max(worst, Math.min(d, 360 - d));
90
+ }
91
+ }
92
+ return worst;
93
+ }
94
+
95
+ export function parseOklch(value) {
96
+ const match = /oklch\(\s*([\d.]+)(%)?\s+([\d.]+)\s+([\d.]+)/.exec(value ?? '');
97
+ if (!match) return null;
98
+ return { L: match[2] ? Number(match[1]) / 100 : Number(match[1]), C: Number(match[3]), h: Number(match[4]) };
99
+ }
100
+
101
+ // Reads one daisyUI theme block into its `--color-*` and radius keys.
102
+ export function readThemeBlock(body) {
103
+ return Object.fromEntries(
104
+ [...body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()])
105
+ );
106
+ }
107
+
108
+ // Solves the five lightness values and the five chroma caps for one scheme.
109
+ export function solveScheme(vars) {
110
+ const primary = parseOklch(vars['--color-primary']);
111
+ const surface = parseOklch(vars['--color-base-100']);
112
+ if (!primary || !surface) {
113
+ throw new Error('[chart-ramp] the theme needs a numeric --color-primary and --color-base-100');
114
+ }
115
+ const cappedBrand = Math.min(primary.C, CAP);
116
+ const surfaceLum = luminance(toLinear(surface.L, surface.C, surface.h));
117
+ const upward = 1.05 / (surfaceLum + 0.05) >= (surfaceLum + 0.05) / 0.05;
118
+ const direction = upward ? 1 : -1;
119
+
120
+ let lo = upward ? surface.L : 0;
121
+ let hi = upward ? 1 : surface.L;
122
+ let anchor = upward ? 0.97 : 0.03;
123
+ for (let i = 0; i < 90; i++) {
124
+ const mid = (lo + hi) / 2;
125
+ if (contrastLum(luminance(toLinear(mid, cappedBrand, primary.h)), surfaceLum) >= ANCHOR_RATIO) {
126
+ anchor = mid;
127
+ if (upward) hi = mid;
128
+ else lo = mid;
129
+ } else {
130
+ if (upward) lo = mid;
131
+ else hi = mid;
132
+ }
133
+ }
134
+
135
+ const lightness = [0, 1, 2, 3, 4].map((i) => anchor + direction * STEP * i);
136
+ const outside = lightness.filter((l) => l < 0.02 || l > 0.98);
137
+ if (outside.length > 0) {
138
+ throw new Error(
139
+ `[chart-ramp] base-100 at L ${surface.L.toFixed(3)} leaves no room for five steps ` +
140
+ `(${outside.length} of 5 outside L 0.02 to 0.98). A mid-tone surface cannot carry a one-hue ramp; ` +
141
+ `pick a base-100 closer to white or black, or supply chart values in the overrides file.`
142
+ );
143
+ }
144
+
145
+ const chroma = lightness.map((l) => Math.min(cappedBrand, gamutChroma(l, primary.h)));
146
+ const rgb = lightness.map((l, i) => toLinear(l, chroma[i], primary.h));
147
+ const contrast = rgb.map((c) => contrastLum(luminance(c), surfaceLum));
148
+ const adjacent = rgb.slice(1).map((c, i) => deltaE(c, rgb[i]));
149
+ const minAdjacent = Math.min(...adjacent);
150
+
151
+ if (minAdjacent < MIN_ADJACENT_DELTA_E) {
152
+ throw new Error(
153
+ `[chart-ramp] adjacent separation ${minAdjacent.toFixed(3)} is below ${MIN_ADJACENT_DELTA_E}`
154
+ );
155
+ }
156
+ if (contrast.some((c) => c < 3)) {
157
+ throw new Error(
158
+ `[chart-ramp] a step is below 3:1 against base-100: ${contrast.map((c) => c.toFixed(2)).join(', ')}`
159
+ );
160
+ }
161
+
162
+ const drift = hueSpread(rgb);
163
+ if (drift > 2) {
164
+ throw new Error(`[chart-ramp] the ramp drifts ${drift.toFixed(2)} degrees in hue, so it is not one hue`);
165
+ }
166
+
167
+ return {
168
+ anchor,
169
+ direction,
170
+ lightness,
171
+ chroma,
172
+ primary,
173
+ surface,
174
+ surfaceLum,
175
+ contrast,
176
+ adjacent,
177
+ minAdjacent,
178
+ hueSpread: drift
179
+ };
180
+ }
181
+
182
+ // The five token values. Hue and brand chroma stay live, so a later `primary` change flows through;
183
+ // only the per-step chroma ceiling is baked, because sRGB needs a different ceiling per step.
184
+ export function chartTokens(scheme) {
185
+ return scheme.lightness.map(
186
+ (l, i) => `oklch(from var(--daisy-color-primary) ${l.toFixed(4)} min(c, ${scheme.chroma[i]}) h)`
187
+ );
188
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
- {
2
- "name": "@kodaicoder/shadcn-svelte-daisyui-adapter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@kodaicoder/shadcn-svelte-daisyui-adapter",
3
+ "version": "0.2.0",
4
+ "description": "Generate a shadcn-svelte token set from a daisyUI v5 theme, at build time, with no daisyUI runtime dependency.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "daisyui-adapter": "cli/init.mjs"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "./map": "./dist/map.mjs",
15
+ "./ramp": "./dist/ramp.mjs"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "cli",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "keywords": [
23
+ "daisyui",
24
+ "shadcn-svelte",
25
+ "sveltekit",
26
+ "tailwindcss",
27
+ "theme"
28
+ ],
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/kodaicoder/shadcn-svelte-daisyui-adapter.git",
32
+ "directory": "packages/daisyui-adapter"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }