@docpensieve/theme 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valentin Chevoleau
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 ADDED
@@ -0,0 +1,56 @@
1
+ # @docpensieve/theme
2
+
3
+ > Composable themes
4
+
5
+ Part of [DocPensieve](https://github.com/Juniors017/docpensieve), a static documentation site generator:
6
+ Markdown and MDX in, static HTML out, one version per orphan branch, JSON-LD
7
+ structured data from the frontmatter.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @docpensieve/theme
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Two themes ship — Tailwind and a dependency-free custom theme — behind the
18
+ same contract. Templates ask the theme for their classes rather than
19
+ hard-coding them, which gives two renderings for a single set of templates.
20
+
21
+ ```js
22
+ import { ThemeEngine, CustomProvider } from '@docpensieve/theme';
23
+
24
+ const engine = new ThemeEngine().register(
25
+ 'custom',
26
+ new CustomProvider({ tokens: { '--dp-accent': '#008060' } }),
27
+ );
28
+ ```
29
+
30
+ Writing your own takes two members:
31
+
32
+ ```ts
33
+ import { BaseThemeProvider } from '@docpensieve/theme';
34
+ import type { ThemeOutput, CompileContext } from '@docpensieve/theme';
35
+
36
+ class MyProvider extends BaseThemeProvider {
37
+ get classes() {
38
+ return { nav: 'my-nav' };
39
+ }
40
+ async compile(context?: CompileContext): Promise<ThemeOutput> {
41
+ return { css: '.my-nav{}', variables: {} };
42
+ }
43
+ }
44
+ ```
45
+
46
+ `tailwindcss` is installed with the package: the default theme needs it, and
47
+ starting a project should not ask for anything more. The custom theme does not
48
+ use it.
49
+
50
+ ## Documentation
51
+
52
+ See the [repository](https://github.com/Juniors017/docpensieve#readme).
53
+
54
+ ## License
55
+
56
+ MIT
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@docpensieve/theme",
3
+ "version": "0.1.0",
4
+ "description": "DocPensieve theme system: composable providers (Tailwind, custom)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.0.0"
9
+ },
10
+ "main": "./src/index.js",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./types/index.d.ts",
14
+ "default": "./src/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "src",
19
+ "styles",
20
+ "types"
21
+ ],
22
+ "dependencies": {
23
+ "@docpensieve/shared": "^0.1.0",
24
+ "tailwindcss": "^4.3.3"
25
+ },
26
+ "keywords": [
27
+ "docpensieve",
28
+ "theme",
29
+ "css",
30
+ "tailwindcss",
31
+ "design-system"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Juniors017/docpensieve.git",
36
+ "directory": "packages/theme"
37
+ },
38
+ "homepage": "https://github.com/Juniors017/docpensieve#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/Juniors017/docpensieve/issues"
41
+ },
42
+ "author": "Valentin Chevoleau (Juniors017)",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "types": "./types/index.d.ts",
47
+ "scripts": {
48
+ "prepack": "tsc -b tsconfig.build.json"
49
+ }
50
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Contract shared by every theme provider.
3
+ * @module @docpensieve/theme/base-provider
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} ThemeOutput
8
+ * @property {string} css CSS to concatenate into the final stylesheet.
9
+ * @property {Record<string, string>} variables Exposed CSS variables (`--dp-*`).
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} CompileContext
14
+ * @property {string[]} [candidates] Class names collected from the rendered
15
+ * pages. A utility provider, Tailwind first and foremost, only emits the
16
+ * matching rules; the others ignore them.
17
+ */
18
+
19
+ /**
20
+ * Base class to extend in order to plug in a CSS framework.
21
+ *
22
+ * A provider generates no HTML: it supplies CSS, variables and a table of
23
+ * class aliases. That is what lets a single template render correctly under
24
+ * Tailwind as well as under the custom theme.
25
+ */
26
+ export class BaseThemeProvider {
27
+ /** Short provider identifier, unique within the engine. */
28
+ static id = 'base';
29
+
30
+ /** @param {Record<string, any>} [options] Options taken from `config.theme`. */
31
+ constructor(options = {}) {
32
+ if (new.target === BaseThemeProvider) {
33
+ throw new TypeError('BaseThemeProvider is abstract: extend it instead of instantiating it.');
34
+ }
35
+ this.options = options;
36
+ }
37
+
38
+ /**
39
+ * Class aliases this provider imposes on the shell slots.
40
+ *
41
+ * Deliberately synchronous and outside `compile()`: templates need these
42
+ * classes to be rendered, and a utility provider needs the rendered pages to
43
+ * compile its CSS. Keeping them apart breaks that circular dependency.
44
+ *
45
+ * @returns {Record<string, string>} Redefined slots, the others falling back
46
+ * to `DEFAULT_THEME_CLASSES`.
47
+ */
48
+ get classes() {
49
+ return {};
50
+ }
51
+
52
+ /**
53
+ * Produces the provider's CSS contribution.
54
+ *
55
+ * @param {CompileContext} [_context]
56
+ * @returns {Promise<ThemeOutput>}
57
+ * @abstract
58
+ */
59
+ async compile(_context) {
60
+ throw new Error(`${this.constructor.name} must implement compile().`);
61
+ }
62
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DocPensieve's custom theme, with no external dependency.
3
+ *
4
+ * @module @docpensieve/theme/custom-provider
5
+ */
6
+
7
+ import { BaseThemeProvider } from './base-provider.js';
8
+ import { joinCss, readStyle } from './styles.js';
9
+
10
+ /**
11
+ * Palette and measures of the light theme.
12
+ *
13
+ * Dark mode does not live here: it fits in two blocks of `custom.css`, since
14
+ * a flat table of variables cannot express a media query.
15
+ */
16
+ export const DEFAULT_TOKENS = Object.freeze({
17
+ '--dp-bg': '#ffffff',
18
+ '--dp-bg-soft': '#f7f8fa',
19
+ '--dp-text': '#1c1e21',
20
+ '--dp-text-soft': '#5f6773',
21
+ '--dp-border': '#e3e6ea',
22
+ '--dp-rule': '#e8ebef',
23
+ '--dp-accent': '#5b57d1',
24
+ '--dp-accent-soft': '#f0effc',
25
+ '--dp-shadow': 'rgba(20, 24, 34, 0.12)',
26
+ '--dp-radius': '6px',
27
+ '--dp-font': 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
28
+ '--dp-font-mono':
29
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
30
+ '--dp-content-width': 'none',
31
+ '--dp-sidebar-width': '15.5rem',
32
+ '--dp-toc-width': '13rem',
33
+ });
34
+
35
+ /** Custom theme: hand-written CSS, no dependency. */
36
+ export class CustomProvider extends BaseThemeProvider {
37
+ static id = 'custom';
38
+
39
+ /**
40
+ * @param {{ tokens?: Record<string, string>, css?: string }} [options]
41
+ * `tokens` overrides the palette and the measures — keys without a
42
+ * leading `--` are prefixed by the engine. `css` is appended after the
43
+ * default stylesheet, so it wins at equal specificity.
44
+ */
45
+ constructor(options = {}) {
46
+ super(options);
47
+ this.tokens = { ...DEFAULT_TOKENS, ...(options.tokens ?? {}) };
48
+ this.extraCss = options.css ?? '';
49
+ }
50
+
51
+ /**
52
+ * @returns {Promise<import('./base-provider.js').ThemeOutput>}
53
+ * @throws {ThemeError} When a stylesheet of the package is missing.
54
+ */
55
+ async compile() {
56
+ // Skeleton first, skin second: the grid and the sticky columns are shared
57
+ // with the other providers, only the visual decisions belong to this theme.
58
+ const [structure, prose, skin] = await Promise.all([
59
+ readStyle('structure.css'),
60
+ readStyle('prose.css'),
61
+ readStyle('custom.css'),
62
+ ]);
63
+
64
+ return {
65
+ css: joinCss(structure, prose, skin, this.extraCss),
66
+ variables: this.tokens,
67
+ };
68
+ }
69
+ }
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @docpensieve/theme — composable theme providers.
3
+ * @module @docpensieve/theme
4
+ */
5
+
6
+ /**
7
+ * Types of the provider contract, re-exported for consumers of the published
8
+ * package: without this they would only be reachable through an internal path.
9
+ *
10
+ * @typedef {import('./base-provider.js').ThemeOutput} ThemeOutput
11
+ * @typedef {import('./base-provider.js').CompileContext} CompileContext
12
+ */
13
+
14
+ export { BaseThemeProvider } from './base-provider.js';
15
+ export { CustomProvider, DEFAULT_TOKENS } from './custom-provider.js';
16
+ export { TailwindProvider } from './tailwind-provider.js';
17
+ export { ThemeEngine } from './theme-engine.js';
package/src/styles.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Reads the stylesheets shipped with the package.
3
+ *
4
+ * @module @docpensieve/theme/styles
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { ThemeError } from '@docpensieve/shared';
12
+
13
+ /** Stylesheet folder, resolved from this module rather than from the cwd. */
14
+ const STYLES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'styles');
15
+
16
+ /**
17
+ * Reads a stylesheet of the package.
18
+ *
19
+ * @param {string} name File name, `'structure.css'` for instance.
20
+ * @returns {Promise<string>} Contents, trimmed.
21
+ * @throws {ThemeError} When the stylesheet is missing.
22
+ */
23
+ export async function readStyle(name) {
24
+ try {
25
+ return (await readFile(path.join(STYLES_DIR, name), 'utf8')).trim();
26
+ } catch (cause) {
27
+ throw new ThemeError(`Theme stylesheet not found: ${name}.`, {
28
+ cause,
29
+ hint: 'The @docpensieve/theme package looks incomplete: reinstall the dependencies.',
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Joins several CSS fragments into one stylesheet.
36
+ *
37
+ * @param {...(string | undefined | null)} parts
38
+ * @returns {string}
39
+ */
40
+ export function joinCss(...parts) {
41
+ return parts
42
+ .map((part) => (part ?? '').trim())
43
+ .filter(Boolean)
44
+ .join('\n\n');
45
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Theme built on Tailwind CSS.
3
+ *
4
+ * @module @docpensieve/theme/tailwind-provider
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+
11
+ import { ThemeError } from '@docpensieve/shared';
12
+
13
+ import { BaseThemeProvider } from './base-provider.js';
14
+ import { joinCss, readStyle } from './styles.js';
15
+
16
+ /**
17
+ * DocPensieve tokens, as literal values of the Tailwind palette.
18
+ *
19
+ * Tailwind only emits a theme variable when a class uses it:
20
+ * `var(--color-slate-700)` would stay empty as long as nobody writes
21
+ * `text-slate-700`. The values are therefore copied, at the cost of a possible
22
+ * drift if Tailwind retouches its palette.
23
+ */
24
+ const TAILWIND_TOKENS = Object.freeze({
25
+ '--dp-bg': '#ffffff',
26
+ '--dp-bg-soft': 'oklch(96.8% 0.007 247.896)',
27
+ '--dp-text': 'oklch(20.8% 0.042 265.755)',
28
+ '--dp-text-soft': 'oklch(55.4% 0.046 257.417)',
29
+ '--dp-border': 'oklch(92.9% 0.013 255.508)',
30
+ '--dp-rule': 'oklch(92.9% 0.013 255.508)',
31
+ '--dp-accent': 'oklch(51.1% 0.262 276.966)',
32
+ '--dp-accent-soft': 'oklch(96.2% 0.018 272.314)',
33
+ '--dp-shadow': 'rgba(15, 23, 42, 0.12)',
34
+ '--dp-radius': '0.375rem',
35
+ '--dp-font': 'var(--font-sans, ui-sans-serif, system-ui, sans-serif)',
36
+ '--dp-font-mono': 'var(--font-mono, ui-monospace, monospace)',
37
+ '--dp-content-width': 'none',
38
+ '--dp-sidebar-width': '15.5rem',
39
+ '--dp-toc-width': '13rem',
40
+ });
41
+
42
+ /**
43
+ * Slots dressed in Tailwind utilities.
44
+ *
45
+ * The `dp-*` classes stay first: they are what the shared skeleton and the
46
+ * bridge target. Everything else is utilities, including the current-page
47
+ * state, expressed through the `aria-[current=page]` variant.
48
+ */
49
+ const TAILWIND_CLASSES = Object.freeze({
50
+ header:
51
+ 'dp-header border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90',
52
+ brand: 'dp-brand font-semibold tracking-tight text-slate-900 no-underline dark:text-slate-100',
53
+ versionsList:
54
+ 'dp-versions-list rounded-md border border-slate-200 bg-white p-1 shadow-lg dark:border-slate-800 dark:bg-slate-900',
55
+ sidebar: 'dp-sidebar text-sm',
56
+ navLink:
57
+ 'dp-nav-link block rounded px-2 py-1 no-underline text-slate-600 hover:bg-slate-100 hover:text-slate-900 aria-[current=page]:bg-indigo-50 aria-[current=page]:font-medium aria-[current=page]:text-indigo-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:aria-[current=page]:bg-slate-800 dark:aria-[current=page]:text-indigo-300',
58
+ navLabel:
59
+ 'dp-nav-label block px-2 pt-3 pb-1 text-xs font-semibold uppercase tracking-wide text-slate-500',
60
+ toc: 'dp-toc text-sm',
61
+ tocTitle: 'dp-toc-title mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500',
62
+ tocList: 'dp-toc-list border-l border-slate-200 dark:border-slate-800',
63
+ footer:
64
+ 'dp-footer border-t border-slate-200 p-6 text-center text-sm text-slate-500 dark:border-slate-800',
65
+ });
66
+
67
+ /** Minimal stylesheet handed to Tailwind, unless the project says otherwise. */
68
+ const DEFAULT_SOURCE = '@import "tailwindcss";';
69
+
70
+ /** Tailwind theme: on-demand compilation of the classes actually used. */
71
+ export class TailwindProvider extends BaseThemeProvider {
72
+ static id = 'tailwind';
73
+
74
+ /**
75
+ * @param {{ tokens?: Record<string, string>, css?: string, source?: string }} [options]
76
+ * `source` replaces the entry stylesheet handed to Tailwind, to put a
77
+ * `@theme` block or additional directives in it.
78
+ */
79
+ constructor(options = {}) {
80
+ super(options);
81
+ this.tokens = { ...TAILWIND_TOKENS, ...(options.tokens ?? {}) };
82
+ this.extraCss = options.css ?? '';
83
+ this.source = options.source ?? DEFAULT_SOURCE;
84
+ }
85
+
86
+ /** @returns {Record<string, string>} Slots dressed in utilities. */
87
+ get classes() {
88
+ return { ...TAILWIND_CLASSES };
89
+ }
90
+
91
+ /**
92
+ * Loads Tailwind.
93
+ *
94
+ * It ships as a dependency of this package, but is only loaded when this
95
+ * provider compiles: a project on the custom theme never pays for it. A
96
+ * failure here means a broken installation.
97
+ *
98
+ * @returns {Promise<{ compile: Function, dir: string }>}
99
+ * @throws {ThemeError} When the `tailwindcss` package cannot be found.
100
+ */
101
+ async #loadTailwind() {
102
+ try {
103
+ const require = createRequire(import.meta.url);
104
+ const dir = path.dirname(require.resolve('tailwindcss/package.json'));
105
+ const { compile } = await import('tailwindcss');
106
+ return { compile, dir };
107
+ } catch (cause) {
108
+ throw new ThemeError('The "tailwindcss" package cannot be found.', {
109
+ cause,
110
+ hint: 'Reinstall the dependencies with "npm install", or choose the custom theme in docpensieve.config.js.',
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {import('./base-provider.js').CompileContext} [context]
117
+ * @returns {Promise<import('./base-provider.js').ThemeOutput>}
118
+ * @throws {ThemeError} When Tailwind is missing or its compilation fails.
119
+ */
120
+ async compile(context = {}) {
121
+ const { compile, dir } = await this.#loadTailwind();
122
+
123
+ // The classes collected from the rendered pages, plus ours in case the
124
+ // provider is compiled outside the generator. The union avoids emitting
125
+ // rules for utilities nobody writes.
126
+ const candidates = new Set(context.candidates ?? []);
127
+ for (const value of Object.values(TAILWIND_CLASSES)) {
128
+ for (const token of value.split(/\s+/)) if (token) candidates.add(token);
129
+ }
130
+
131
+ let utilities;
132
+ try {
133
+ const compiler = await compile(this.source, {
134
+ base: dir,
135
+ /** @param {string} id */
136
+ async loadStylesheet(id) {
137
+ const relative =
138
+ id === 'tailwindcss' ? 'index.css' : `${id.replace(/^tailwindcss\//, '')}.css`;
139
+ const file = path.join(dir, relative);
140
+ return { path: file, base: path.dirname(file), content: await readFile(file, 'utf8') };
141
+ },
142
+ async loadModule() {
143
+ throw new ThemeError('Tailwind plugins are not supported yet.', {
144
+ hint: 'Remove the @plugin directives from theme.source.',
145
+ });
146
+ },
147
+ });
148
+ utilities = compiler.build([...candidates]);
149
+ } catch (cause) {
150
+ if (cause instanceof ThemeError) throw cause;
151
+ throw new ThemeError('Tailwind compilation failed.', { cause });
152
+ }
153
+
154
+ const [structure, prose, bridge] = await Promise.all([
155
+ readStyle('structure.css'),
156
+ readStyle('prose.css'),
157
+ readStyle('tailwind-bridge.css'),
158
+ ]);
159
+
160
+ // Tailwind first — Preflight included: the skeleton, the prose and the
161
+ // bridge must be able to correct it.
162
+ return {
163
+ css: joinCss(utilities, structure, prose, bridge, this.extraCss),
164
+ variables: this.tokens,
165
+ };
166
+ }
167
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Registration and merging of theme providers.
3
+ *
4
+ * @module @docpensieve/theme/theme-engine
5
+ */
6
+
7
+ import { DEFAULT_THEME_CLASSES, ThemeError } from '@docpensieve/shared';
8
+
9
+ import { BaseThemeProvider } from './base-provider.js';
10
+
11
+ /**
12
+ * Serialises a table of variables into a `:root` block.
13
+ *
14
+ * @param {Record<string, string>} variables
15
+ * @returns {string} CSS block, or an empty string when there is nothing to write.
16
+ */
17
+ function toRootBlock(variables) {
18
+ const entries = Object.entries(variables);
19
+ if (entries.length === 0) return '';
20
+
21
+ const declarations = entries
22
+ .map(([name, value]) => ` ${name.startsWith('--') ? name : `--${name}`}: ${value};`)
23
+ .join('\n');
24
+
25
+ return `:root {\n${declarations}\n}\n`;
26
+ }
27
+
28
+ /** Combines several providers into a single CSS output. */
29
+ export class ThemeEngine {
30
+ constructor() {
31
+ /** @type {Map<string, BaseThemeProvider>} */
32
+ this.providers = new Map();
33
+ }
34
+
35
+ /**
36
+ * @param {string} id Provider identifier.
37
+ * @param {BaseThemeProvider} provider
38
+ * @returns {this} To chain registrations.
39
+ */
40
+ register(id, provider) {
41
+ if (!(provider instanceof BaseThemeProvider)) {
42
+ throw new TypeError(`The provider "${id}" must extend BaseThemeProvider.`);
43
+ }
44
+ this.providers.set(id, provider);
45
+ return this;
46
+ }
47
+
48
+ /**
49
+ * @param {string} id
50
+ * @returns {BaseThemeProvider | undefined}
51
+ */
52
+ get(id) {
53
+ return this.providers.get(id);
54
+ }
55
+
56
+ /**
57
+ * Shell class aliases, all providers combined.
58
+ *
59
+ * Synchronous and independent of `compile()`: templates need these classes
60
+ * to be rendered, whereas a utility provider needs the rendered pages to
61
+ * compile its CSS.
62
+ *
63
+ * @returns {Record<string, string>} Shared slots, overridden by each
64
+ * provider in registration order.
65
+ */
66
+ get classes() {
67
+ const merged = { ...DEFAULT_THEME_CLASSES };
68
+ for (const provider of this.providers.values()) Object.assign(merged, provider.classes ?? {});
69
+ return merged;
70
+ }
71
+
72
+ /**
73
+ * Merges the outputs of every registered provider.
74
+ *
75
+ * Registration order sets precedence: the last one registered wins on
76
+ * variables and class aliases, and its CSS is concatenated last, so it wins
77
+ * at equal specificity.
78
+ *
79
+ * The merged variables are emitted in a single `:root` block placed
80
+ * **before** the providers' CSS. That is what lets a provider override
81
+ * another's palette without duplicating its rules.
82
+ *
83
+ * @param {import('./base-provider.js').CompileContext} [context] Passed as
84
+ * is to every provider.
85
+ * @returns {Promise<import('./base-provider.js').ThemeOutput>}
86
+ * @throws {ThemeError} When no provider is registered.
87
+ */
88
+ async compile(context) {
89
+ if (this.providers.size === 0) {
90
+ throw new ThemeError('No theme provider registered.', {
91
+ hint: 'Register at least one provider, for example new CustomProvider(), before calling compile().',
92
+ });
93
+ }
94
+
95
+ /** @type {Record<string, string>} */
96
+ const variables = {};
97
+ /** @type {string[]} */
98
+ const sheets = [];
99
+
100
+ for (const [id, provider] of this.providers) {
101
+ let output;
102
+ try {
103
+ output = await provider.compile(context);
104
+ } catch (cause) {
105
+ if (cause instanceof ThemeError) throw cause;
106
+ throw new ThemeError(`The provider "${id}" failed.`, { cause });
107
+ }
108
+
109
+ Object.assign(variables, output?.variables ?? {});
110
+ if (output?.css) sheets.push(output.css.trim());
111
+ }
112
+
113
+ const css = [toRootBlock(variables), ...sheets].filter(Boolean).join('\n\n');
114
+ return { css: `${css}\n`, variables };
115
+ }
116
+ }