@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 +21 -0
- package/README.md +56 -0
- package/package.json +50 -0
- package/src/base-provider.js +62 -0
- package/src/custom-provider.js +69 -0
- package/src/index.js +17 -0
- package/src/styles.js +45 -0
- package/src/tailwind-provider.js +167 -0
- package/src/theme-engine.js +116 -0
- package/styles/custom.css +280 -0
- package/styles/prose.css +109 -0
- package/styles/structure.css +235 -0
- package/styles/tailwind-bridge.css +131 -0
- package/types/base-provider.d.ts +66 -0
- package/types/custom-provider.d.ts +66 -0
- package/types/index.d.ts +17 -0
- package/types/styles.d.ts +20 -0
- package/types/tailwind-provider.d.ts +48 -0
- package/types/theme-engine.d.ts +51 -0
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
|
+
}
|