@inkdropapp/theme-dev-helpers 0.3.9 → 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/pnpm-workspace.yaml +2 -2
- package/src/dev-server/variables.tsx +1 -1
- package/src/generate-palette.ts +37 -5
- package/src/palette.test.ts +68 -14
- package/src/palette.ts +91 -8
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/pnpm-workspace.yaml
CHANGED
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
|
|
|
@@ -22,10 +25,20 @@ const options = program.opts()
|
|
|
22
25
|
const outputPath = options.output as string
|
|
23
26
|
const appearance = options.appearance as 'light' | 'dark' | undefined
|
|
24
27
|
|
|
28
|
+
const baseStyleSheetSpecifiers = [
|
|
29
|
+
'@inkdropapp/css/reset.css',
|
|
30
|
+
'@inkdropapp/css/tokens.css',
|
|
31
|
+
'@inkdropapp/css/ui.css',
|
|
32
|
+
'@inkdropapp/css/tags.css',
|
|
33
|
+
'@inkdropapp/css/status.css',
|
|
34
|
+
'@inkdropapp/css/task-progress.css',
|
|
35
|
+
'@inkdropapp/base-ui-theme/styles/theme.css'
|
|
36
|
+
]
|
|
37
|
+
|
|
25
38
|
// Function to extract theme CSS variables
|
|
26
39
|
async function extractPalette(outputPath: string) {
|
|
27
40
|
const themePackageJson = await import(path.join(process.cwd(), 'package.json'))
|
|
28
|
-
const themeVariableNames: string[] = (await import(`@inkdropapp/css/
|
|
41
|
+
const themeVariableNames: string[] = (await import(`@inkdropapp/css/variables.json`)).default
|
|
29
42
|
|
|
30
43
|
const browser = await puppeteer.launch()
|
|
31
44
|
const page = await browser.newPage()
|
|
@@ -38,8 +51,18 @@ async function extractPalette(outputPath: string) {
|
|
|
38
51
|
})
|
|
39
52
|
.on('pageerror', (error) => console.error(error instanceof Error ? error.message : error))
|
|
40
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
|
+
|
|
41
58
|
const baseUrl = pathToFileURL(process.cwd()).toString() + '/'
|
|
42
|
-
const
|
|
59
|
+
const baseStyleSheetURLs = baseStyleSheetSpecifiers.map((spec) => import.meta.resolve(spec))
|
|
60
|
+
const content = buildPreviewHTML(
|
|
61
|
+
themePackageJson,
|
|
62
|
+
baseUrl,
|
|
63
|
+
baseStyleSheetURLs,
|
|
64
|
+
resolvedAppearance
|
|
65
|
+
)
|
|
43
66
|
|
|
44
67
|
await page.goto(baseUrl)
|
|
45
68
|
await page.setContent(content)
|
|
@@ -58,9 +81,18 @@ async function extractPalette(outputPath: string) {
|
|
|
58
81
|
|
|
59
82
|
const themeCSSVariables = mapThemeVariables(themeVariableNames, computedCSSVariables)
|
|
60
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
|
+
|
|
61
93
|
// Write to the output file
|
|
62
94
|
const outputFilePath = path.resolve(outputPath)
|
|
63
|
-
await writeFile(outputFilePath, JSON.stringify(
|
|
95
|
+
await writeFile(outputFilePath, JSON.stringify(resolvedVariables, null, 2))
|
|
64
96
|
|
|
65
97
|
await browser.close()
|
|
66
98
|
}
|
package/src/palette.test.ts
CHANGED
|
@@ -1,52 +1,106 @@
|
|
|
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/'
|
|
6
|
+
const baseStyleSheetURLs = [
|
|
7
|
+
'file:///pkgs/@inkdropapp/css/reset.css',
|
|
8
|
+
'file:///pkgs/@inkdropapp/base-ui-theme/styles/theme.css'
|
|
9
|
+
]
|
|
6
10
|
|
|
7
|
-
test('emits the base href so relative links resolve', () => {
|
|
8
|
-
const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
|
|
11
|
+
test('emits the base href so relative theme links resolve', () => {
|
|
12
|
+
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
|
|
9
13
|
expect(html).toContain(`<base href="${baseUrl}" />`)
|
|
10
14
|
})
|
|
11
15
|
|
|
12
|
-
test('
|
|
13
|
-
const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
expect(html).toContain('node_modules/@inkdropapp/base-ui-theme/styles/theme.css')
|
|
16
|
+
test('links each resolved Inkdrop base stylesheet as an absolute URL', () => {
|
|
17
|
+
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, baseStyleSheetURLs)
|
|
18
|
+
for (const href of baseStyleSheetURLs) {
|
|
19
|
+
expect(html).toContain(`<link rel="stylesheet" href="${href}" />`)
|
|
20
|
+
}
|
|
18
21
|
})
|
|
19
22
|
|
|
20
23
|
test('links each declared theme stylesheet under styles/', () => {
|
|
21
24
|
const html = buildPreviewHTML(
|
|
22
25
|
{ name: 'acme', styleSheets: ['base.css', 'syntax.css'] },
|
|
23
|
-
baseUrl
|
|
26
|
+
baseUrl,
|
|
27
|
+
[]
|
|
24
28
|
)
|
|
25
29
|
expect(html).toContain('<link rel="stylesheet" href="styles/base.css" />')
|
|
26
30
|
expect(html).toContain('<link rel="stylesheet" href="styles/syntax.css" />')
|
|
27
31
|
})
|
|
28
32
|
|
|
29
33
|
test('omits theme stylesheet links when none are declared', () => {
|
|
30
|
-
const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
|
|
34
|
+
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
|
|
31
35
|
expect(html).not.toContain('href="styles/')
|
|
32
36
|
})
|
|
33
37
|
|
|
34
38
|
test('sets the body class to the theme name', () => {
|
|
35
|
-
const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl)
|
|
39
|
+
const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl, [])
|
|
36
40
|
expect(html).toContain('<body class="acme-dark">')
|
|
37
41
|
})
|
|
38
42
|
|
|
39
43
|
test('appends an appearance-mode class when an appearance is forced', () => {
|
|
40
|
-
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, 'dark')
|
|
44
|
+
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [], 'dark')
|
|
41
45
|
expect(html).toContain('<body class="acme dark-mode">')
|
|
42
46
|
})
|
|
43
47
|
|
|
44
48
|
test('adds no appearance class when appearance is omitted', () => {
|
|
45
|
-
const html = buildPreviewHTML({ name: 'acme' }, baseUrl)
|
|
49
|
+
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
|
|
46
50
|
expect(html).not.toContain('-mode')
|
|
47
51
|
})
|
|
48
52
|
})
|
|
49
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
|
+
|
|
50
104
|
describe('mapThemeVariables', () => {
|
|
51
105
|
test('maps each declared name to its computed value, ignoring extras', () => {
|
|
52
106
|
const result = mapThemeVariables(['--accent', '--bg'], {
|
package/src/palette.ts
CHANGED
|
@@ -4,25 +4,45 @@
|
|
|
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
|
|
17
32
|
* compute a theme's CSS custom properties.
|
|
18
33
|
*
|
|
19
34
|
* It pulls in Inkdrop's base CSS plus the theme's own stylesheets so that the
|
|
20
|
-
* computed style of `<body>` resolves every themed variable.
|
|
21
|
-
*
|
|
35
|
+
* computed style of `<body>` resolves every themed variable. Inkdrop's base
|
|
36
|
+
* stylesheets are passed in as already-resolved absolute URLs (they live in
|
|
37
|
+
* this package's own dependencies, not the theme's), while the theme's own
|
|
38
|
+
* `styles/` links stay relative and resolve against the `<base href>` — i.e.
|
|
22
39
|
* the theme project root.
|
|
23
40
|
*
|
|
24
41
|
* @param theme - The theme package's metadata (`name`, `styleSheets`).
|
|
25
|
-
* @param baseUrl - File URL used as the document `<base href
|
|
42
|
+
* @param baseUrl - File URL used as the document `<base href>`; the theme's own
|
|
43
|
+
* `styles/` links resolve against it.
|
|
44
|
+
* @param baseStyleSheetURLs - Absolute URLs of Inkdrop's base stylesheets,
|
|
45
|
+
* resolved from this package's dependency graph by the caller.
|
|
26
46
|
* @param appearance - Optional forced appearance; adds a `<appearance>-mode`
|
|
27
47
|
* body class when provided.
|
|
28
48
|
* @returns The full HTML document as a string.
|
|
@@ -30,8 +50,13 @@ export type ThemeAppearance = 'light' | 'dark'
|
|
|
30
50
|
export function buildPreviewHTML(
|
|
31
51
|
theme: ThemePackage,
|
|
32
52
|
baseUrl: string,
|
|
53
|
+
baseStyleSheetURLs: string[],
|
|
33
54
|
appearance?: ThemeAppearance
|
|
34
55
|
): string {
|
|
56
|
+
const baseCSSLinks = baseStyleSheetURLs
|
|
57
|
+
.map((href) => `<link rel="stylesheet" href="${href}" />`)
|
|
58
|
+
.join('\n')
|
|
59
|
+
|
|
35
60
|
const themeCSSLinks = (theme.styleSheets ?? [])
|
|
36
61
|
.map((filePath) => `<link rel="stylesheet" href="styles/${filePath}" />`)
|
|
37
62
|
.join('\n')
|
|
@@ -42,10 +67,7 @@ export function buildPreviewHTML(
|
|
|
42
67
|
<html>
|
|
43
68
|
<head>
|
|
44
69
|
<base href="${baseUrl}" />
|
|
45
|
-
|
|
46
|
-
<link rel="stylesheet" href="node_modules/@inkdropapp/css/tokens.css" />
|
|
47
|
-
<link rel="stylesheet" href="node_modules/@inkdropapp/css/tags.css" />
|
|
48
|
-
<link rel="stylesheet" href="node_modules/@inkdropapp/base-ui-theme/styles/theme.css" />
|
|
70
|
+
${baseCSSLinks}
|
|
49
71
|
${themeCSSLinks}
|
|
50
72
|
</head>
|
|
51
73
|
<body class="${bodyClass}">
|
|
@@ -76,3 +98,64 @@ export function mapThemeVariables(
|
|
|
76
98
|
{} as Record<string, string>
|
|
77
99
|
)
|
|
78
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
|
+
}
|