@inkdropapp/theme-dev-helpers 0.3.10 → 0.3.11
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/package.json +2 -2
- package/src/dev-server/variables.tsx +1 -1
- package/src/generate-palette.ts +27 -5
- package/src/palette.test.ts +51 -1
- package/src/palette.ts +77 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inkdropapp/theme-dev-helpers",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "A helper module for creating themes for Inkdrop",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"inkdrop",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@inkdropapp/base-ui-theme": "^0.7.5",
|
|
28
|
-
"@inkdropapp/css": "^0.6.
|
|
28
|
+
"@inkdropapp/css": "^0.6.8",
|
|
29
29
|
"@vitejs/plugin-react": "^6.0.2",
|
|
30
30
|
"commander": "^15.0.0",
|
|
31
31
|
"puppeteer": "^25.1.0",
|
package/src/generate-palette.ts
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'path'
|
|
|
4
4
|
import { pathToFileURL } from 'url'
|
|
5
5
|
import { Command } from 'commander'
|
|
6
6
|
import packageJson from '../package.json'
|
|
7
|
-
import { buildPreviewHTML, mapThemeVariables } from './palette'
|
|
7
|
+
import { buildPreviewHTML, deriveAppearance, mapThemeVariables, resolveLightDark } from './palette'
|
|
8
8
|
|
|
9
9
|
const program = new Command()
|
|
10
10
|
|
|
@@ -13,7 +13,10 @@ program
|
|
|
13
13
|
.name('generate-palette')
|
|
14
14
|
.description('CLI tool for extracting CSS variables from a theme package for Inkdrop')
|
|
15
15
|
.version(packageJson.version)
|
|
16
|
-
.option(
|
|
16
|
+
.option(
|
|
17
|
+
'-a, --appearance <light/dark>',
|
|
18
|
+
'Force the UI appearance ("light" or "dark"); defaults to the theme\'s declared appearance'
|
|
19
|
+
)
|
|
17
20
|
.option('-o, --output <path>', 'Output file path (default: ./palette.json)', './palette.json')
|
|
18
21
|
.parse(process.argv)
|
|
19
22
|
|
|
@@ -28,13 +31,14 @@ const baseStyleSheetSpecifiers = [
|
|
|
28
31
|
'@inkdropapp/css/ui.css',
|
|
29
32
|
'@inkdropapp/css/tags.css',
|
|
30
33
|
'@inkdropapp/css/status.css',
|
|
34
|
+
'@inkdropapp/css/task-progress.css',
|
|
31
35
|
'@inkdropapp/base-ui-theme/styles/theme.css'
|
|
32
36
|
]
|
|
33
37
|
|
|
34
38
|
// Function to extract theme CSS variables
|
|
35
39
|
async function extractPalette(outputPath: string) {
|
|
36
40
|
const themePackageJson = await import(path.join(process.cwd(), 'package.json'))
|
|
37
|
-
const themeVariableNames: string[] = (await import(`@inkdropapp/css/
|
|
41
|
+
const themeVariableNames: string[] = (await import(`@inkdropapp/css/variables.json`)).default
|
|
38
42
|
|
|
39
43
|
const browser = await puppeteer.launch()
|
|
40
44
|
const page = await browser.newPage()
|
|
@@ -47,9 +51,18 @@ async function extractPalette(outputPath: string) {
|
|
|
47
51
|
})
|
|
48
52
|
.on('pageerror', (error) => console.error(error instanceof Error ? error.message : error))
|
|
49
53
|
|
|
54
|
+
// Fall back to the theme's declared appearance so `light-dark()` resolves to
|
|
55
|
+
// the branch the theme actually ships, even when no --appearance is forced.
|
|
56
|
+
const resolvedAppearance = appearance ?? deriveAppearance(themePackageJson)
|
|
57
|
+
|
|
50
58
|
const baseUrl = pathToFileURL(process.cwd()).toString() + '/'
|
|
51
59
|
const baseStyleSheetURLs = baseStyleSheetSpecifiers.map((spec) => import.meta.resolve(spec))
|
|
52
|
-
const content = buildPreviewHTML(
|
|
60
|
+
const content = buildPreviewHTML(
|
|
61
|
+
themePackageJson,
|
|
62
|
+
baseUrl,
|
|
63
|
+
baseStyleSheetURLs,
|
|
64
|
+
resolvedAppearance
|
|
65
|
+
)
|
|
53
66
|
|
|
54
67
|
await page.goto(baseUrl)
|
|
55
68
|
await page.setContent(content)
|
|
@@ -68,9 +81,18 @@ async function extractPalette(outputPath: string) {
|
|
|
68
81
|
|
|
69
82
|
const themeCSSVariables = mapThemeVariables(themeVariableNames, computedCSSVariables)
|
|
70
83
|
|
|
84
|
+
// Resolve `light-dark()` to raw values so non-CSS consumers (e.g. the mobile
|
|
85
|
+
// app's React Native renderer) don't have to evaluate it themselves.
|
|
86
|
+
const resolvedVariables = Object.fromEntries(
|
|
87
|
+
Object.entries(themeCSSVariables).map(([name, value]) => [
|
|
88
|
+
name,
|
|
89
|
+
typeof value === 'string' ? resolveLightDark(value, resolvedAppearance) : value
|
|
90
|
+
])
|
|
91
|
+
)
|
|
92
|
+
|
|
71
93
|
// Write to the output file
|
|
72
94
|
const outputFilePath = path.resolve(outputPath)
|
|
73
|
-
await writeFile(outputFilePath, JSON.stringify(
|
|
95
|
+
await writeFile(outputFilePath, JSON.stringify(resolvedVariables, null, 2))
|
|
74
96
|
|
|
75
97
|
await browser.close()
|
|
76
98
|
}
|
package/src/palette.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { buildPreviewHTML, mapThemeVariables } from './palette'
|
|
2
|
+
import { buildPreviewHTML, deriveAppearance, mapThemeVariables, resolveLightDark } from './palette'
|
|
3
3
|
|
|
4
4
|
describe('buildPreviewHTML', () => {
|
|
5
5
|
const baseUrl = 'file:///themes/acme/'
|
|
@@ -51,6 +51,56 @@ describe('buildPreviewHTML', () => {
|
|
|
51
51
|
})
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
describe('deriveAppearance', () => {
|
|
55
|
+
test('is dark when the name contains "dark"', () => {
|
|
56
|
+
expect(deriveAppearance({ name: 'solarized-dark-ui' })).toBe('dark')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('is dark when themeAppearance is "dark"', () => {
|
|
60
|
+
expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'dark' })).toBe('dark')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('is light otherwise', () => {
|
|
64
|
+
expect(deriveAppearance({ name: 'acme-light-ui' })).toBe('light')
|
|
65
|
+
expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'light' })).toBe('light')
|
|
66
|
+
expect(deriveAppearance({})).toBe('light')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('resolveLightDark', () => {
|
|
71
|
+
test('keeps the first branch for light, the second for dark', () => {
|
|
72
|
+
expect(resolveLightDark('light-dark(red, blue)', 'light')).toBe('red')
|
|
73
|
+
expect(resolveLightDark('light-dark(red, blue)', 'dark')).toBe('blue')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('leaves values without light-dark untouched', () => {
|
|
77
|
+
expect(resolveLightDark('hsl(192deg 100% 5%)', 'dark')).toBe('hsl(192deg 100% 5%)')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('ignores commas nested inside the branches', () => {
|
|
81
|
+
const value = 'light-dark(hsl(215deg 14% 34%), hsl(218deg 11% 65% / 40%))'
|
|
82
|
+
expect(resolveLightDark(value, 'dark')).toBe('hsl(218deg 11% 65% / 40%)')
|
|
83
|
+
expect(resolveLightDark(value, 'light')).toBe('hsl(215deg 14% 34%)')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('resolves nested light-dark within the chosen branch', () => {
|
|
87
|
+
const value = 'light-dark(light-dark(a, b), c)'
|
|
88
|
+
expect(resolveLightDark(value, 'light')).toBe('a')
|
|
89
|
+
expect(resolveLightDark(value, 'dark')).toBe('c')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('resolves light-dark embedded in a larger value', () => {
|
|
93
|
+
const value = '0px 1px 2px 0 light-dark(rgba(0, 0, 0, 0.1), hsl(0deg 0% 0% / 20%)) inset'
|
|
94
|
+
expect(resolveLightDark(value, 'dark')).toBe('0px 1px 2px 0 hsl(0deg 0% 0% / 20%) inset')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('resolves multiple light-dark occurrences in one value', () => {
|
|
98
|
+
const value = 'light-dark(a, b), light-dark(c, d)'
|
|
99
|
+
expect(resolveLightDark(value, 'light')).toBe('a, c')
|
|
100
|
+
expect(resolveLightDark(value, 'dark')).toBe('b, d')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
54
104
|
describe('mapThemeVariables', () => {
|
|
55
105
|
test('maps each declared name to its computed value, ignoring extras', () => {
|
|
56
106
|
const result = mapThemeVariables(['--accent', '--bg'], {
|
package/src/palette.ts
CHANGED
|
@@ -4,13 +4,28 @@
|
|
|
4
4
|
* browser lives in `generate-palette.ts`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export type ThemeAppearance = 'light' | 'dark'
|
|
8
|
+
|
|
7
9
|
/** Theme metadata read from a theme package's `package.json`. */
|
|
8
10
|
export interface ThemePackage {
|
|
9
11
|
name?: string
|
|
12
|
+
/** Explicit appearance declared by the theme (`"light"` | `"dark"`). */
|
|
13
|
+
themeAppearance?: ThemeAppearance
|
|
10
14
|
styleSheets?: string[]
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Derives a theme's appearance the way Inkdrop's desktop app does (see
|
|
19
|
+
* `ThemePackage#getAppearance`): dark when the package name contains "dark" or
|
|
20
|
+
* `themeAppearance` is "dark", otherwise light. Used to pick which branch of a
|
|
21
|
+
* `light-dark()` value to keep.
|
|
22
|
+
*
|
|
23
|
+
* @param theme - The theme package's metadata.
|
|
24
|
+
* @returns The resolved appearance.
|
|
25
|
+
*/
|
|
26
|
+
export function deriveAppearance(theme: ThemePackage): ThemeAppearance {
|
|
27
|
+
return theme.name?.includes('dark') || theme.themeAppearance === 'dark' ? 'dark' : 'light'
|
|
28
|
+
}
|
|
14
29
|
|
|
15
30
|
/**
|
|
16
31
|
* Builds the standalone HTML document that is loaded into a headless browser to
|
|
@@ -83,3 +98,64 @@ export function mapThemeVariables(
|
|
|
83
98
|
{} as Record<string, string>
|
|
84
99
|
)
|
|
85
100
|
}
|
|
101
|
+
|
|
102
|
+
/** Index of the `)` matching the `(` at `openIndex`, or -1 when unbalanced. */
|
|
103
|
+
function matchingParen(value: string, openIndex: number): number {
|
|
104
|
+
let depth = 0
|
|
105
|
+
for (let i = openIndex; i < value.length; i++) {
|
|
106
|
+
if (value[i] === '(') depth++
|
|
107
|
+
else if (value[i] === ')' && --depth === 0) return i
|
|
108
|
+
}
|
|
109
|
+
return -1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Splits `value` at its first top-level (paren-depth-zero) comma into
|
|
114
|
+
* `[before, after]`. Commas inside nested function calls (e.g. `hsl(…)`) are
|
|
115
|
+
* ignored. Returns `[value, '']` when there is no top-level comma.
|
|
116
|
+
*/
|
|
117
|
+
function splitTopLevelComma(value: string): [string, string] {
|
|
118
|
+
let depth = 0
|
|
119
|
+
for (let i = 0; i < value.length; i++) {
|
|
120
|
+
const ch = value[i]
|
|
121
|
+
if (ch === '(') depth++
|
|
122
|
+
else if (ch === ')') depth--
|
|
123
|
+
else if (ch === ',' && depth === 0) return [value.slice(0, i), value.slice(i + 1)]
|
|
124
|
+
}
|
|
125
|
+
return [value, '']
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Replaces every `light-dark(<light>, <dark>)` in a CSS value with the branch
|
|
130
|
+
* matching `appearance`, recursively and in place.
|
|
131
|
+
*
|
|
132
|
+
* Inkdrop's mobile app (React Native) can't evaluate the CSS `light-dark()`
|
|
133
|
+
* function, so the generated palette must carry already-resolved raw values.
|
|
134
|
+
* The browser leaves `light-dark()` unresolved inside custom properties, so we
|
|
135
|
+
* pick the branch ourselves — mirroring how the desktop app selects light/dark
|
|
136
|
+
* via the body's `color-scheme`. Nested calls (the kept branch may itself
|
|
137
|
+
* contain `light-dark()`) and calls embedded in larger values (shadows,
|
|
138
|
+
* gradients, borders) are handled.
|
|
139
|
+
*
|
|
140
|
+
* @param value - A CSS value that may contain one or more `light-dark()` calls.
|
|
141
|
+
* @param appearance - Which branch to keep (`light` → first, `dark` → second).
|
|
142
|
+
* @returns The value with all `light-dark()` calls resolved.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveLightDark(value: string, appearance: ThemeAppearance): string {
|
|
145
|
+
const marker = 'light-dark('
|
|
146
|
+
const start = value.indexOf(marker)
|
|
147
|
+
if (start === -1) return value
|
|
148
|
+
|
|
149
|
+
const open = start + marker.length - 1
|
|
150
|
+
const close = matchingParen(value, open)
|
|
151
|
+
if (close === -1) return value // unbalanced — leave the value untouched
|
|
152
|
+
|
|
153
|
+
const [light, dark] = splitTopLevelComma(value.slice(open + 1, close))
|
|
154
|
+
const chosen = (appearance === 'dark' ? dark : light).trim()
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
value.slice(0, start) +
|
|
158
|
+
resolveLightDark(chosen, appearance) +
|
|
159
|
+
resolveLightDark(value.slice(close + 1), appearance)
|
|
160
|
+
)
|
|
161
|
+
}
|