@mosetta/core 0.0.0-stage → 0.0.10

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,44 @@
1
+ # @mosetta/core
2
+
3
+ ## 0.0.10
4
+
5
+ ## 0.0.9
6
+
7
+ ## 0.0.8
8
+
9
+ ### Patch Changes
10
+
11
+ - A project's own tile palette. The registry keeps the palettes a project's site takes tiles from;
12
+ the factory reads each one's tile host after the platform's, and takes a tile of a host other than
13
+ the primary only with its integrity. The rule a site's tile hosts merge by lives in
14
+ `@mosetta/core` (`mergeTileHosts`), so the editor offers the blocks of every tile host of the site
15
+ by the same rule (`TILE_HOSTS_TOKEN` in `@mosetta/server-action`). The platform's API publishes
16
+ into `palettes/<brand>` for the members of the project of that namespace, with the palette's stores
17
+ granted to that one request. `mosetta build --released <descriptor>` leaves out what a platform
18
+ already holds; `checkPaletteBuild` says what a palette may hold. The CLI's and the build's output
19
+ speaks the vocabulary: tile palettes, tiles, package tiles.
20
+
21
+ ## 0.0.7
22
+
23
+ ### Patch Changes
24
+
25
+ - One vocabulary: code is tiles, content is blueprints, running requests is the factory, and a tile and a
26
+ blueprint made in the factory give a mosaic. A tile is a folder under `tiles/` (was `modules/`) whose
27
+ files are its sides, each defined with `define<Side>Side` — `defineComponentSide`, `defineEditorSide`,
28
+ `defineFallbackSide`, `defineRootComponentSide`, `defineServerSide`, `defineSsrSide` (were
29
+ `define…Module`); `useTile` (was `useModule`), `markInternalTile`, `BlueprintRoot`,
30
+ `BlueprintNodeRenderer`. `mosetta.json` names the tile `brand` (was `namespace`; a config that still
31
+ says `namespace` is refused with the word to use). The store connectors' package is `@mosetta/stores`
32
+ (was `@mosetta/provider`): `IStoreConnector`, `StoreConnectors`, `tileHostsOf`. The server's runner
33
+ is the factory gate (`@mosetta/server/factoryGate`, was `/runner`). Nothing on the wire moves: tile
34
+ ids keep `_ns_`, a build keeps `modules/<id>/`, stored JSON keeps its fields.
35
+
36
+ ## 0.0.6
37
+
38
+ ## 0.0.5
39
+
40
+ ## 0.0.4
41
+
42
+ ## 0.0.3
43
+
44
+ ## 0.0.2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mosetta contributors
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,8 @@
1
- # Temporary Holding Version
1
+ # @mosetta/core
2
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.
3
+ Framework-agnostic models of the mosetta protocol: tile references (`_ns_<brand>_t_<side>_n_<name>_v_<version>`),
4
+ blueprint node trees and pages, routing patterns, languages, the styler and theme models, the rule
5
+ a site's tile hosts merge by (`mergeTileHosts`), and the application error hierarchy.
6
+
7
+ `@mosetta/core/tsconfig.base.json` is the TypeScript base the protocol's packages and tile palettes
8
+ extend.
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@mosetta/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "mosetta core models: tile references, blueprint node trees, pages, the rule a site's tile hosts merge by, and the application error hierarchy. Framework-agnostic.",
4
+ "version": "0.0.10",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "exports": {
8
+ ".": "./src/core.ts",
9
+ "./tsconfig.base.json": "./tsconfig.base.json"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "*",
16
+ "typescript": "*"
17
+ },
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit"
20
+ }
6
21
  }
package/src/core.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from './models/commonModels';
2
+ export * from './models/pageModels';
3
+ export * from './models/tileHosts';
4
+ export * from './errors/appError';
5
+ export * from './errors/errors';
6
+ export * from './styler';
7
+ export * from './theme';
8
+ export * from './languages';
9
+ export * from './routing';
@@ -0,0 +1,42 @@
1
+ export interface IWithError {
2
+ readonly error?: AppError;
3
+ }
4
+
5
+ export abstract class AppError extends Error {
6
+ readonly appErrorType: string;
7
+
8
+ readonly message: string;
9
+
10
+ readonly parentError?: Error;
11
+
12
+ static isAppError<T extends AppError>(
13
+ error: unknown,
14
+ type: T['appErrorType'],
15
+ ): error is T {
16
+ if (error && typeof error === 'object' && 'appErrorType' in error) {
17
+ return error.appErrorType === type;
18
+ }
19
+
20
+ return false;
21
+ }
22
+
23
+ protected constructor({
24
+ appErrorType,
25
+ message = 'app error',
26
+ parentError,
27
+ }: {
28
+ readonly appErrorType: string;
29
+ readonly message?: string;
30
+ readonly parentError?: Error;
31
+ }) {
32
+ super(`[${appErrorType}] ${message}`);
33
+
34
+ this.appErrorType = appErrorType;
35
+ this.message = message;
36
+ this.parentError = parentError;
37
+ }
38
+
39
+ isMe(error: unknown): error is this {
40
+ return AppError.isAppError(error, this.appErrorType);
41
+ }
42
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Error utilities shared across packages. Coercion of `unknown` values caught
3
+ * in `catch` blocks lives here, not at call sites.
4
+ */
5
+ export class Errors {
6
+ static toError(value: unknown): Error {
7
+ if (value instanceof Error) {
8
+ return value;
9
+ }
10
+
11
+ if (typeof value === 'string') {
12
+ return new Error(value);
13
+ }
14
+
15
+ try {
16
+ return new Error(JSON.stringify(value) ?? String(value));
17
+ } catch {
18
+ // circular structures and exotic values still deserve an Error, not a crash
19
+ return new Error(String(value));
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,133 @@
1
+ // LANGUAGE ADDRESSING — how a page's address names its language. The platform owns it: the router
2
+ // reads the language off an address, the server writes canonical addresses and the sitemap, and
3
+ // blocks never learn it — they ask for "this path" or "this page in German" (`useHref` in
4
+ // @mosetta/react), and `resolveHref` below puts the language where the scheme says. One scheme
5
+ // today — the language as the path's first segment, none for the project's default
6
+ // (`/ru/blog/x`) — behind a port, so another (a suffix, a subdomain) is a new scheme, not a change
7
+ // of every block.
8
+
9
+ export interface ILanguageSet {
10
+ /** ISO 639-1 codes, the default first */
11
+ readonly languages: readonly string[];
12
+ readonly defaultLanguage: string;
13
+ }
14
+
15
+ export interface ILanguageOfAddress {
16
+ readonly lang: string;
17
+ /** the address's path with the language taken out — what the route table is matched against */
18
+ readonly path: string;
19
+ /** the address named the language itself (the default may be named too) */
20
+ readonly named: boolean;
21
+ }
22
+
23
+ /** the port: an address → its language and path; a path and a language → the address */
24
+ export interface ILanguageScheme {
25
+ read(pathname: string, languages: ILanguageSet): ILanguageOfAddress;
26
+ write(path: string, lang: string, languages: ILanguageSet): string;
27
+ }
28
+
29
+ export type TLanguageSchemeName = 'prefix';
30
+
31
+ const PREFIX_RE = /^\/([A-Za-z]{2}(?:-[A-Za-z]{2})?)(?=\/|$)/;
32
+
33
+ /** `/ru/blog/x` ↔ ru + `/blog/x`; the default language has no prefix */
34
+ const prefixScheme: ILanguageScheme = {
35
+ read(pathname, languages) {
36
+ const code = PREFIX_RE.exec(pathname)?.[1].toLowerCase();
37
+ if (code && languages.languages.includes(code)) {
38
+ return { lang: code, path: pathname.slice(code.length + 1) || '/', named: true };
39
+ }
40
+ return { lang: languages.defaultLanguage, path: pathname, named: false };
41
+ },
42
+ write(path, lang, languages) {
43
+ if (lang === languages.defaultLanguage || !languages.languages.includes(lang)) return path;
44
+ return path === '/' ? `/${lang}` : `/${lang}${path}`;
45
+ },
46
+ };
47
+
48
+ const SCHEMES: Readonly<Record<TLanguageSchemeName, ILanguageScheme>> = { prefix: prefixScheme };
49
+
50
+ /** the named scheme; anything unknown is the default one */
51
+ export const languageScheme = (name?: string): ILanguageScheme => SCHEMES[name as TLanguageSchemeName] ?? prefixScheme;
52
+
53
+ /** a path in a language, the default scheme — what the server writes canonical addresses with */
54
+ export const pathInLanguage = (path: string, lang: string, languages: ILanguageSet): string => prefixScheme.write(path, lang, languages);
55
+
56
+ /** languages written right to left — a page in one of them is laid out mirrored */
57
+ const RTL = new Set(['ar', 'he', 'fa', 'ur']);
58
+ export const isRightToLeft = (lang: string | undefined): boolean => RTL.has((lang ?? '').split('-')[0].toLowerCase());
59
+
60
+ // ---- addresses for blocks ----
61
+
62
+ /** what the server stamps for the routing hooks: the scheme and the project's languages */
63
+ export interface IAddressing extends ILanguageSet {
64
+ readonly scheme: TLanguageSchemeName;
65
+ }
66
+
67
+ /** where the reader is, as the hooks see it */
68
+ export interface IHrefContext {
69
+ /** the current page's path without its language */
70
+ readonly path: string;
71
+ readonly search: Readonly<Record<string, string>>;
72
+ readonly lang?: string;
73
+ readonly addressing?: IAddressing;
74
+ }
75
+
76
+ export interface IHrefOptions {
77
+ /** the address in this language instead of the reader's (a language switcher) */
78
+ readonly lang?: string;
79
+ /**
80
+ * the query: for the current page it is MERGED into the current one (null removes a key); for
81
+ * another path it is the whole query
82
+ */
83
+ readonly query?: Readonly<Record<string, string | null>>;
84
+ }
85
+
86
+ const EXTERNAL_RE = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
87
+
88
+ /** a target's path, query and fragment, a relative path resolved against the current page */
89
+ function partsOf(target: string, currentPath: string): { path: string; search: string; hash: string } {
90
+ const url = new URL(target, `http://site${currentPath}`);
91
+ return { path: url.pathname, search: url.search, hash: url.hash };
92
+ }
93
+
94
+ const queryString = (entries: Iterable<[string, string]>): string => {
95
+ const params = new URLSearchParams([...entries]);
96
+ const s = params.toString();
97
+ return s ? `?${s}` : '';
98
+ };
99
+
100
+ /**
101
+ * The address a block links to: `target` is a path of this site as its author wrote it (`/blog`,
102
+ * `blog/x` relative to the page, `?page=2`), or nothing for the current page; the language goes
103
+ * where the scheme puts it — the reader's, or the one asked for. An address of another site, a
104
+ * `mailto:`, a bare `#fragment` is returned as it is: no language of ours belongs there.
105
+ */
106
+ export function resolveHref(context: IHrefContext, target?: string, options: IHrefOptions = {}): string {
107
+ if (target !== undefined && (EXTERNAL_RE.test(target) || target.startsWith('#'))) return target;
108
+ const addressing = context.addressing;
109
+ const lang = options.lang ?? context.lang ?? addressing?.defaultLanguage ?? '';
110
+ const write = (path: string): string => (addressing ? languageScheme(addressing.scheme).write(path, lang, addressing) : path);
111
+ if (target === undefined || target === '') {
112
+ const merged = new Map(Object.entries(context.search));
113
+ for (const [key, value] of Object.entries(options.query ?? {})) {
114
+ if (value === null) merged.delete(key);
115
+ else merged.set(key, value);
116
+ }
117
+ return `${write(context.path || '/')}${queryString(merged)}`;
118
+ }
119
+ const parts = partsOf(target, context.path || '/');
120
+ const own = options.query
121
+ ? queryString(Object.entries(options.query).filter((e): e is [string, string] => e[1] !== null))
122
+ : parts.search;
123
+ return `${write(parts.path)}${own}${parts.hash}`;
124
+ }
125
+
126
+ /** whether a path (as its author wrote it) is where the reader is — exactly, or anywhere under it */
127
+ export function isCurrentPath(context: IHrefContext, target: string, under = false): boolean {
128
+ if (EXTERNAL_RE.test(target) || target.startsWith('#')) return false;
129
+ const own = partsOf(target, context.path || '/').path;
130
+ const here = context.path || '/';
131
+ if (here === own) return true;
132
+ return under && here.startsWith(own.endsWith('/') ? own : `${own}/`);
133
+ }
@@ -0,0 +1,42 @@
1
+ export interface IWithVersion {
2
+ readonly version: string;
3
+ }
4
+
5
+ export interface IWithId {
6
+ readonly id: string;
7
+ }
8
+
9
+ export interface IWithTileName {
10
+ readonly name: string;
11
+ }
12
+
13
+ export interface IWithNamespace {
14
+ readonly namespace: string;
15
+ }
16
+ export interface IWithType {
17
+ readonly type: string;
18
+ }
19
+
20
+ export interface ITileReference extends IWithTileName, IWithVersion, IWithNamespace, IWithType {
21
+ }
22
+
23
+ export interface IWithTileReference {
24
+ readonly reference: ITileReference;
25
+ }
26
+
27
+ export interface IBlueprintNode extends IWithId {
28
+ readonly reference: string;
29
+ readonly children: IBlueprintNode[];
30
+ readonly slot?: string;
31
+ /**
32
+ * Node settings: seeded from defaultSettings() at
33
+ * insertion in the editor, complete-and-valid by the time they reach prod — the runtime never
34
+ * merges or validates. On a PAGE root the `versions` key is reserved for version resolution config.
35
+ */
36
+ readonly settings?: Record<string, unknown>;
37
+ }
38
+
39
+ export interface IWithBlueprintNode {
40
+ readonly moduleTreeNode: IBlueprintNode;
41
+ }
42
+
@@ -0,0 +1,38 @@
1
+ import type { IAddressing } from '../languages';
2
+ import { IBlueprintNode } from './commonModels';
3
+
4
+ /**
5
+ * What the request URL said to the page's route pattern: named segments (`{slug}`), a rest
6
+ * segment (`{...path}`, an array) and captured query values (`?q={query}`). Stamped into the
7
+ * envelope by the server render; a page reads it with `useRoute()`.
8
+ */
9
+ export interface IRouteMatch {
10
+ readonly pattern: string;
11
+ readonly params: Readonly<Record<string, string | string[]>>;
12
+ readonly query: Readonly<Record<string, string>>;
13
+ /** the language the address names (ISO 639-1); the project's default when the URL names none */
14
+ readonly lang?: string;
15
+ /** how addresses name the language, and the project's languages — read by the routing hooks
16
+ * (`useHref`, `useLanguages`); a block has no business with it */
17
+ readonly addressing?: IAddressing;
18
+ /** the request's path without its language (`/blog/x` for `/ru/blog/x`) */
19
+ readonly path?: string;
20
+ /** every value of the request's query string (`query` holds only those the pattern captures) */
21
+ readonly search?: Readonly<Record<string, string>>;
22
+ }
23
+
24
+ export interface IBlueprint extends IBlueprintNode {
25
+ readonly pattern: string;
26
+ /** the route match of the current request (server-stamped; absent in a bare static build) */
27
+ readonly route?: IRouteMatch;
28
+ /**
29
+ * Module rendered in place of a branch that failed. Optional because a page document may simply
30
+ * not name one — every reader already guards, and the boot envelope has always been typed that
31
+ * way (the manager used to take it through a cast).
32
+ */
33
+ readonly fallback?: string;
34
+ }
35
+
36
+ export interface IWithPage {
37
+ readonly page: IBlueprint;
38
+ }
@@ -0,0 +1,112 @@
1
+ // A SITE'S TILE HOSTS, merged (ADR-0070): the tile host descriptors (`modules.json`) of every tile
2
+ // host the site's tiles come from — the platform's (the primary, first), a project's tile palettes,
3
+ // another vendor's — as ONE factory tile descriptor, where a tile's address is its host's base and
4
+ // the formula (`modules/<id>/<id>.js`), found by its id. A blueprint never says where its tiles lie:
5
+ // the descriptor does.
6
+ //
7
+ // Conflicts are decided here, by the factory, never in the browser:
8
+ // - a tile BRAND belongs to one tile host — the first that has anything of it: tiles of `acme` are
9
+ // read only from the host that holds `acme`, and a second host's tiles of it are left out (said,
10
+ // in the warnings);
11
+ // - PACKAGE tiles (`_ns_package_…`) may sit with several: one id is one set of bytes, and the first
12
+ // host that has it is the one read from (as the first released copy of a package stands); other
13
+ // bytes under the same id are said, the first still stands;
14
+ // - a tile of a host other than the primary is taken only with its TILE INTEGRITY: the bytes the
15
+ // factory and the browser load from there are checked against it. A tile palette descriptor and a
16
+ // fragment name no artifact of their own and need none.
17
+ /** what the merge reads of a tile descriptor — the factory's and the editor's carry more */
18
+ export interface ITileDescriptorLike {
19
+ readonly id: string;
20
+ readonly reference?: { readonly type?: string };
21
+ readonly integrity?: string;
22
+ /** the tile host it is read from; absent — the primary */
23
+ readonly provider?: string;
24
+ }
25
+
26
+ const NAMESPACE_RE = /^_ns_([a-z0-9-]+)_t_/;
27
+
28
+ /** the namespace whose entities any source may hold: one id is one set of bytes */
29
+ const SHARED_NAMESPACE = 'package';
30
+
31
+ /** the sides that name no artifact of their own — a tile palette descriptor, a fragment */
32
+ const WITHOUT_ARTIFACT = new Set(['distribution', 'prefab']);
33
+
34
+ /** whether a tile vouches for the bytes of its artifact — what a host other than the primary must do */
35
+ const vouched = (entity: ITileDescriptorLike): boolean => WITHOUT_ARTIFACT.has(entity.reference?.type ?? '')
36
+ || (typeof entity.integrity === 'string' && /^sha(256|384|512)-/.test(entity.integrity));
37
+
38
+ /** the namespace an id names, or null for an id of no namespace */
39
+ export const brandOfId = (id: string): string | null => NAMESPACE_RE.exec(id)?.[1] ?? null;
40
+
41
+ /** one source's catalogs as read: its key, its public and its private entities */
42
+ export interface ITileHostDescriptors<T extends ITileDescriptorLike = ITileDescriptorLike> {
43
+ readonly key: string;
44
+ readonly public: readonly T[];
45
+ readonly private: readonly T[];
46
+ }
47
+
48
+ export interface IMergedDescriptors<T extends ITileDescriptorLike = ITileDescriptorLike> {
49
+ /** every entity once — one of a source other than the host carries that source's key (`provider`) */
50
+ readonly entities: readonly T[];
51
+ /** id → the key of the source it is read from */
52
+ readonly tileHostOf: ReadonlyMap<string, string>;
53
+ }
54
+
55
+ export interface IMergedTileHosts<T extends ITileDescriptorLike = ITileDescriptorLike> {
56
+ readonly public: IMergedDescriptors<T>;
57
+ readonly private: IMergedDescriptors<T>;
58
+ /** what was left out, and why */
59
+ readonly warnings: readonly string[];
60
+ }
61
+
62
+ /** the first source in order that has anything of a namespace owns it — over both sides of every source */
63
+ function ownersOf(parts: readonly ITileHostDescriptors<ITileDescriptorLike>[]): Map<string, string> {
64
+ const owners = new Map<string, string>();
65
+ for (const part of parts) {
66
+ for (const entity of [...part.public, ...part.private]) {
67
+ const ns = typeof entity?.id === 'string' ? brandOfId(entity.id) : null;
68
+ if (ns && ns !== SHARED_NAMESPACE && !owners.has(ns)) owners.set(ns, part.key);
69
+ }
70
+ }
71
+ return owners;
72
+ }
73
+
74
+ export function mergeTileHosts<T extends ITileDescriptorLike>(parts: readonly ITileHostDescriptors<T>[]): IMergedTileHosts<T> {
75
+ const host = parts[0]?.key;
76
+ const owners = ownersOf(parts);
77
+ const warnings: string[] = [];
78
+ const said = new Set<string>();
79
+ const say = (line: string): void => {
80
+ if (said.has(line)) return;
81
+ said.add(line);
82
+ warnings.push(line);
83
+ };
84
+ const side = (pick: (part: ITileHostDescriptors<T>) => readonly T[]): IMergedDescriptors<T> => {
85
+ const taken = new Map<string, T>();
86
+ const tileHostOf = new Map<string, string>();
87
+ for (const part of parts) {
88
+ for (const entity of pick(part)) {
89
+ if (!entity || typeof entity.id !== 'string') continue;
90
+ const ns = brandOfId(entity.id);
91
+ const owner = ns && ns !== SHARED_NAMESPACE ? owners.get(ns) : undefined;
92
+ if (owner && owner !== part.key) {
93
+ say(`${part.key}: ${ns} belongs to ${owner} — its entities of ${ns} are not taken`);
94
+ continue;
95
+ }
96
+ if (part.key !== host && !vouched(entity)) {
97
+ say(`${part.key}: ${entity.id} names no integrity — not taken`);
98
+ continue;
99
+ }
100
+ const held = taken.get(entity.id);
101
+ if (held) {
102
+ if (held.integrity !== entity.integrity) say(`${entity.id}: ${part.key} has other bytes than ${tileHostOf.get(entity.id)} — the first stands`);
103
+ continue;
104
+ }
105
+ taken.set(entity.id, part.key === host ? entity : { ...entity, provider: part.key });
106
+ tileHostOf.set(entity.id, part.key);
107
+ }
108
+ }
109
+ return { entities: [...taken.values()], tileHostOf };
110
+ };
111
+ return { public: side((p) => p.public), private: side((p) => p.private), warnings };
112
+ }
package/src/routing.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { IRouteMatch } from './models/pageModels';
2
+
3
+ // What the BROWSER and the server share about addresses: the pattern matcher (a page's route
4
+ // against a URL — the server picks the page, a client router re-reads the route of the page it
5
+ // already shows on a shallow move) and the marker that asks a page's address for its DOCUMENT
6
+ // instead of its HTML. Pure functions, no DOM, no node.
7
+
8
+ /**
9
+ * The query parameter that turns a page's address into a request for its DOCUMENT: the same
10
+ * envelope the HTML carries in `#pageContent`, as JSON, for a client-side move. In the ADDRESS,
11
+ * not in a header: the edge caches key by the whole URL and look at no header, so the two forms
12
+ * of one address must differ there. The server leaves it out of `route.search`.
13
+ */
14
+ export const DOCUMENT_QUERY_PARAM = '__mosetta_doc';
15
+
16
+ // Route PATTERNS. A page's pattern is a path with parameters, optionally followed by query
17
+ // requirements:
18
+ //
19
+ // /blog/{slug} one segment, captured
20
+ // /{lang}/blog/{slug} two captures
21
+ // /files/{...path} a REST: zero or more segments, captured as an array
22
+ // /{lang}/{...path}/edit a rest in the middle: the segments before it match from the
23
+ // left, the ones after it from the right, it takes what is between
24
+ // /search?q={query} a query value captured when present
25
+ // /promo/{id}?kind=static&ref={r} a query literal REQUIRED to match, a capture beside it
26
+ //
27
+ // Matching never reads page documents: the pattern lives in the published listing's key. When
28
+ // several routes match, the most SPECIFIC one wins — compared segment by segment (a literal
29
+ // beats a capture, a capture beats a rest), then by the number of query requirements, then by
30
+ // the pattern text, so the choice is deterministic whatever the store's order. No match is no
31
+ // page: an honest 404, never "the first route".
32
+
33
+ type TSegment
34
+ = | { readonly kind: 'literal'; readonly text: string }
35
+ | { readonly kind: 'param'; readonly name: string }
36
+ | { readonly kind: 'rest'; readonly name: string };
37
+
38
+ type TQueryRule
39
+ = | { readonly key: string; readonly kind: 'literal'; readonly value: string }
40
+ | { readonly key: string; readonly kind: 'param'; readonly name: string };
41
+
42
+ export interface IParsedPattern {
43
+ readonly pattern: string;
44
+ readonly segments: readonly TSegment[];
45
+ readonly query: readonly TQueryRule[];
46
+ }
47
+
48
+ const PARAM_RE = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
49
+ const REST_RE = /^\{\.\.\.([A-Za-z_][A-Za-z0-9_]*)\}$/;
50
+
51
+ const safeDecode = (s: string): string => {
52
+ try {
53
+ return decodeURIComponent(s);
54
+ } catch {
55
+ return s;
56
+ }
57
+ };
58
+
59
+ /** path → decoded segments, the root and a trailing slash both meaning "no segment there" */
60
+ export const splitPath = (pathname: string): string[] => pathname.split('/').filter(Boolean).map(safeDecode);
61
+
62
+ export function parsePattern(pattern: string): IParsedPattern {
63
+ const at = pattern.indexOf('?');
64
+ const path = at === -1 ? pattern : pattern.slice(0, at);
65
+ const search = at === -1 ? '' : pattern.slice(at + 1);
66
+ const names = new Set<string>();
67
+ const claim = (name: string): string => {
68
+ if (names.has(name)) throw new Error(`route pattern "${pattern}": {${name}} is captured twice`);
69
+ names.add(name);
70
+ return name;
71
+ };
72
+ const segments: TSegment[] = splitPath(path).map((raw) => {
73
+ const rest = REST_RE.exec(raw);
74
+ if (rest) return { kind: 'rest', name: claim(rest[1]) };
75
+ const param = PARAM_RE.exec(raw);
76
+ return param ? { kind: 'param', name: claim(param[1]) } : { kind: 'literal', text: raw };
77
+ });
78
+ // one rest at most: with two, the split between them would be a guess
79
+ if (segments.filter((s) => s.kind === 'rest').length > 1) throw new Error(`route pattern "${pattern}": only one {...rest} segment`);
80
+ const query: TQueryRule[] = [];
81
+ new URLSearchParams(search).forEach((value, key) => {
82
+ const param = PARAM_RE.exec(value);
83
+ query.push(param ? { key, kind: 'param', name: claim(param[1]) } : { key, kind: 'literal', value });
84
+ });
85
+ return { pattern, segments, query };
86
+ }
87
+
88
+ /** the match of one pattern against a request URL, or null */
89
+ export function matchPattern(parsed: IParsedPattern, url: URL): IRouteMatch | null {
90
+ const path = splitPath(url.pathname);
91
+ const params: Record<string, string | string[]> = {};
92
+ const { segments } = parsed;
93
+ const restAt = segments.findIndex((s) => s.kind === 'rest');
94
+ const one = (seg: TSegment, value: string): boolean => {
95
+ if (seg.kind === 'literal') return seg.text === value;
96
+ if (seg.kind === 'param') params[seg.name] = value;
97
+ return true;
98
+ };
99
+ if (restAt === -1) {
100
+ if (path.length !== segments.length) return null;
101
+ for (let i = 0; i < segments.length; i++) if (!one(segments[i], path[i])) return null;
102
+ } else {
103
+ // the prefix matches from the left, the suffix from the right, the rest takes the middle
104
+ const before = segments.slice(0, restAt);
105
+ const after = segments.slice(restAt + 1);
106
+ if (path.length < before.length + after.length) return null;
107
+ for (let i = 0; i < before.length; i++) if (!one(before[i], path[i])) return null;
108
+ for (let i = 0; i < after.length; i++) if (!one(after[i], path[path.length - after.length + i])) return null;
109
+ const rest = segments[restAt] as { kind: 'rest'; name: string };
110
+ params[rest.name] = path.slice(before.length, path.length - after.length);
111
+ }
112
+ const query: Record<string, string> = {};
113
+ for (const rule of parsed.query) {
114
+ const value = url.searchParams.get(rule.key);
115
+ if (rule.kind === 'literal') {
116
+ if (value !== rule.value) return null;
117
+ } else if (value !== null) {
118
+ query[rule.name] = value;
119
+ }
120
+ }
121
+ return { pattern: parsed.pattern, params, query };
122
+ }
123
+
124
+ // a literal beats a capture, a capture beats a rest — and a pattern that ENDS where the other
125
+ // still swallows a rest is the more exact of the two (`/` over `/{...all}`)
126
+ const KIND_RANK: Record<TSegment['kind'], number> = { literal: 3, param: 2, rest: 1 };
127
+ const ENDED_RANK = 1.5;
128
+
129
+ /** negative when `a` is the more specific pattern */
130
+ export function compareSpecificity(a: IParsedPattern, b: IParsedPattern): number {
131
+ const n = Math.max(a.segments.length, b.segments.length);
132
+ for (let i = 0; i < n; i++) {
133
+ const ra = a.segments[i] ? KIND_RANK[a.segments[i].kind] : ENDED_RANK;
134
+ const rb = b.segments[i] ? KIND_RANK[b.segments[i].kind] : ENDED_RANK;
135
+ if (ra !== rb) return rb - ra;
136
+ }
137
+ const qa = a.query.filter((q) => q.kind === 'literal').length;
138
+ const qb = b.query.filter((q) => q.kind === 'literal').length;
139
+ if (qa !== qb) return qb - qa;
140
+ if (a.query.length !== b.query.length) return b.query.length - a.query.length;
141
+ return a.pattern.localeCompare(b.pattern);
142
+ }
143
+
package/src/styler.ts ADDED
@@ -0,0 +1,152 @@
1
+ // THE STYLER: what a block lets its users restyle from outside, without touching its code. A
2
+ // block names its PARTS — the elements worth styling (`root`, `label`, `cover`…); a node keeps its
3
+ // overrides in `settings.styles`: part → state → a set of allowed CSS properties. The block's
4
+ // runtime marks each part with one attribute (`useStyler` in @mosetta/react), and the rules below
5
+ // are generated per node — so a hover state and a narrow screen work, and the server render
6
+ // ships them in the head. This module is pure data: the vocabulary, the cleaning, the CSS.
7
+
8
+ /** the settings key a node keeps its styles under */
9
+ export const STYLES_SETTING = 'styles';
10
+
11
+ /** the attribute a part carries: `<node id>.<part>` — unique on the page, whatever nests inside */
12
+ export const STYLE_ATTRIBUTE = 'data-mosetta-style';
13
+
14
+ /** where a declaration applies: always, under the pointer, on a narrow screen, or on the link to
15
+ * where the reader is (`aria-current`: the current category, the current language, this page) */
16
+ export type TStyleState = 'base' | 'hover' | 'mobile' | 'current';
17
+ export const STYLE_STATES: readonly TStyleState[] = ['base', 'hover', 'current', 'mobile'];
18
+
19
+ /** the width at and below which `mobile` applies */
20
+ export const MOBILE_MAX_WIDTH = 640;
21
+
22
+ export type TStyleGroup = 'text' | 'box' | 'border' | 'layout' | 'effects';
23
+
24
+ export interface IStyleProperty {
25
+ /** the camelCase name the settings store */
26
+ readonly name: string;
27
+ readonly label: string;
28
+ readonly group: TStyleGroup;
29
+ /** how the form edits it: free text, a colour, or one of a few values */
30
+ readonly input: 'text' | 'color' | 'choice';
31
+ readonly choices?: readonly string[];
32
+ readonly placeholder?: string;
33
+ }
34
+
35
+ /** the properties a user may set — deliberately not all of CSS: what shapes a block's look */
36
+ export const STYLE_PROPERTIES: readonly IStyleProperty[] = [
37
+ { name: 'fontFamily', label: 'Font', group: 'text', input: 'text', placeholder: 'var(--theme-font-body)' },
38
+ { name: 'fontSize', label: 'Size', group: 'text', input: 'text', placeholder: '16px' },
39
+ { name: 'fontWeight', label: 'Weight', group: 'text', input: 'choice', choices: ['300', '400', '500', '600', '700', '800'] },
40
+ { name: 'fontStyle', label: 'Slant', group: 'text', input: 'choice', choices: ['normal', 'italic'] },
41
+ { name: 'lineHeight', label: 'Line height', group: 'text', input: 'text', placeholder: '1.5' },
42
+ { name: 'letterSpacing', label: 'Letter spacing', group: 'text', input: 'text', placeholder: '-0.02em' },
43
+ { name: 'color', label: 'Colour', group: 'text', input: 'color' },
44
+ { name: 'textAlign', label: 'Align', group: 'text', input: 'choice', choices: ['left', 'center', 'right'] },
45
+ { name: 'textTransform', label: 'Case', group: 'text', input: 'choice', choices: ['none', 'uppercase', 'lowercase', 'capitalize'] },
46
+ { name: 'textDecoration', label: 'Decoration', group: 'text', input: 'choice', choices: ['none', 'underline'] },
47
+ { name: 'background', label: 'Background', group: 'box', input: 'color' },
48
+ { name: 'padding', label: 'Padding', group: 'box', input: 'text', placeholder: '16px 24px' },
49
+ { name: 'margin', label: 'Margin', group: 'box', input: 'text', placeholder: '0 auto' },
50
+ { name: 'width', label: 'Width', group: 'box', input: 'text', placeholder: '100%' },
51
+ { name: 'maxWidth', label: 'Max width', group: 'box', input: 'text', placeholder: '1200px' },
52
+ { name: 'height', label: 'Height', group: 'box', input: 'text', placeholder: 'auto' },
53
+ { name: 'minHeight', label: 'Min height', group: 'box', input: 'text', placeholder: '400px' },
54
+ { name: 'aspectRatio', label: 'Aspect ratio', group: 'box', input: 'text', placeholder: '16 / 9' },
55
+ { name: 'objectFit', label: 'Fit', group: 'box', input: 'choice', choices: ['cover', 'contain', 'fill'] },
56
+ { name: 'border', label: 'Border', group: 'border', input: 'text', placeholder: '1px solid #e4e4e7' },
57
+ { name: 'borderRadius', label: 'Radius', group: 'border', input: 'text', placeholder: '12px' },
58
+ { name: 'boxShadow', label: 'Shadow', group: 'border', input: 'text', placeholder: '0 8px 24px rgb(0 0 0 / 8%)' },
59
+ { name: 'display', label: 'Display', group: 'layout', input: 'choice', choices: ['block', 'flex', 'grid', 'inline-flex', 'none'] },
60
+ { name: 'flexDirection', label: 'Direction', group: 'layout', input: 'choice', choices: ['row', 'column'] },
61
+ { name: 'flexWrap', label: 'Wrap', group: 'layout', input: 'choice', choices: ['nowrap', 'wrap'] },
62
+ { name: 'gap', label: 'Gap', group: 'layout', input: 'text', placeholder: '16px' },
63
+ { name: 'justifyContent', label: 'Justify', group: 'layout', input: 'choice', choices: ['flex-start', 'center', 'flex-end', 'space-between'] },
64
+ { name: 'alignItems', label: 'Align items', group: 'layout', input: 'choice', choices: ['stretch', 'flex-start', 'center', 'flex-end'] },
65
+ { name: 'opacity', label: 'Opacity', group: 'effects', input: 'text', placeholder: '0.8' },
66
+ { name: 'filter', label: 'Filter', group: 'effects', input: 'text', placeholder: 'blur(24px)' },
67
+ { name: 'transition', label: 'Transition', group: 'effects', input: 'text', placeholder: 'all 150ms ease' },
68
+ ];
69
+
70
+ const KNOWN = new Map(STYLE_PROPERTIES.map((p) => [p.name, p]));
71
+
72
+ /** one state's declarations: property → value */
73
+ export type TStyleDeclarations = Readonly<Record<string, string>>;
74
+ /** one part's styles: state → declarations */
75
+ export type TPartStyles = Readonly<Partial<Record<TStyleState, TStyleDeclarations>>>;
76
+ /** a node's styles: part → its styles */
77
+ export type TStyles = Readonly<Record<string, TPartStyles>>;
78
+
79
+ /**
80
+ * A value as it may enter a stylesheet, or null: nothing that ends a declaration or a rule, opens
81
+ * an at-rule, a comment or markup; an image only from an https address.
82
+ */
83
+ export function cleanStyleValue(value: unknown): string | null {
84
+ if (typeof value !== 'string') return null;
85
+ const v = value.trim();
86
+ if (!v || v.length > 300) return null;
87
+ if (/[;{}<>@\\]|\/\*/.test(v)) return null;
88
+ const urls = v.match(/url\(([^)]*)\)/gi) ?? [];
89
+ if (urls.some((u) => !/^url\(\s*["']?https:\/\//i.test(u))) return null;
90
+ return v;
91
+ }
92
+
93
+ /** the styles as they may be rendered: known parts shape, known states, known properties, clean values */
94
+ export function cleanStyles(styles: unknown): TStyles {
95
+ if (!styles || typeof styles !== 'object' || Array.isArray(styles)) return {};
96
+ const out: Record<string, TPartStyles> = {};
97
+ for (const [part, states] of Object.entries(styles as Record<string, unknown>)) {
98
+ if (!/^[a-z][A-Za-z0-9]*$/.test(part) || !states || typeof states !== 'object') continue;
99
+ const partOut: Partial<Record<TStyleState, TStyleDeclarations>> = {};
100
+ for (const state of STYLE_STATES) {
101
+ const decls = (states as Record<string, unknown>)[state];
102
+ if (!decls || typeof decls !== 'object') continue;
103
+ const clean: Record<string, string> = {};
104
+ for (const [name, value] of Object.entries(decls as Record<string, unknown>)) {
105
+ const v = KNOWN.has(name) ? cleanStyleValue(value) : null;
106
+ if (v !== null) clean[name] = v;
107
+ }
108
+ if (Object.keys(clean).length) partOut[state] = clean;
109
+ }
110
+ if (Object.keys(partOut).length) out[part] = partOut;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * A variable a style value may name — `var(--theme-accent)`: what the themes around a block
117
+ * declare, as the styler's form offers them. The value is the one in the theme the author sees.
118
+ */
119
+ export interface IStyleVariable {
120
+ /** the custom property: `--theme-accent` */
121
+ readonly name: string;
122
+ readonly label: string;
123
+ readonly kind: 'color' | 'font' | 'value';
124
+ readonly value?: string;
125
+ }
126
+
127
+ /** the value of a part's attribute */
128
+ export const stylePartKey = (nodeId: string, part: string): string => `${nodeId}.${part}`;
129
+
130
+ const kebab = (name: string): string => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
131
+ const cssString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
132
+ const block = (decls: TStyleDeclarations): string => Object.entries(decls).map(([n, v]) => `${kebab(n)}:${v}`).join(';');
133
+
134
+ /**
135
+ * The stylesheet of one node: a rule per part and state. The selector carries an id-level weight
136
+ * (`:not(#\#)` matches everything and counts as an id), so an override wins over the block's own
137
+ * class-scoped css without `!important` — the user's styling is the last word.
138
+ */
139
+ export function stylerCss(nodeId: string, styles: unknown): string {
140
+ // the sheet travels as raw inner html of a <style>: an id that could close it renders nothing
141
+ if (/[<>]/.test(nodeId)) return '';
142
+ const clean = cleanStyles(styles);
143
+ const rules: string[] = [];
144
+ for (const [part, states] of Object.entries(clean)) {
145
+ const selector = `[${STYLE_ATTRIBUTE}="${cssString(stylePartKey(nodeId, part))}"]:not(#\\#)`;
146
+ if (states.base) rules.push(`${selector}{${block(states.base)}}`);
147
+ if (states.hover) rules.push(`${selector}:hover{${block(states.hover)}}`);
148
+ if (states.current) rules.push(`${selector}[aria-current]{${block(states.current)}}`);
149
+ if (states.mobile) rules.push(`@media (max-width:${MOBILE_MAX_WIDTH}px){${selector}{${block(states.mobile)}}}`);
150
+ }
151
+ return rules.join('\n');
152
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,224 @@
1
+ // THEMING: a project's look as CSS variables, defined once and switched as a whole.
2
+ //
3
+ // A theme block wraps a part of the page (the whole page, from its root): it declares VARIABLES —
4
+ // a key and a human name each (`primary` · "Primary") — and THEMES that give every variable a
5
+ // value (light, dark, a festive one…). Its element carries the active theme's values as
6
+ // `--theme-<key>` custom properties, which every block inside inherits. Blocks never learn which
7
+ // theme is on: they read the variables, each with a fallback of its own, and look as they always
8
+ // did where no theme is.
9
+ //
10
+ // The THEME ROLES below are the protocol: variables any block of any library may count on (the
11
+ // ui kit's whole palette is built from them). A theme may declare more variables of its own —
12
+ // for the styler, for a project's own blocks.
13
+ //
14
+ // Which theme is on: the one the reader picked (kept in their browser), else — when the theme
15
+ // follows the system — the one matching the system's light or dark, else the default. All of it
16
+ // is CSS, rendered on the server: the system's choice is a media query, the reader's an attribute
17
+ // on the document root that a tiny script in the head sets before the body paints. No flash of
18
+ // the wrong theme, no script needed for the system's.
19
+ import { cleanStyleValue } from './styler';
20
+
21
+ export type TThemeVariableKind = 'color' | 'font' | 'value';
22
+ export type TColorScheme = 'light' | 'dark';
23
+
24
+ export interface IThemeVariable {
25
+ /** what the CSS names it: `--theme-<key>` */
26
+ readonly key: string;
27
+ /** what the editor calls it */
28
+ readonly label: string;
29
+ readonly kind: TThemeVariableKind;
30
+ /** a font: fetched from Google Fonts by its family */
31
+ readonly google?: boolean;
32
+ }
33
+
34
+ export interface IThemeVariant {
35
+ readonly id: string;
36
+ readonly label: string;
37
+ /** what the browser's own controls (scrollbars, inputs) should look like under it */
38
+ readonly scheme: TColorScheme;
39
+ /** variable key → value; a font's value is its family */
40
+ readonly values: Readonly<Record<string, string>>;
41
+ }
42
+
43
+ export interface IThemeSettings {
44
+ readonly variables: readonly IThemeVariable[];
45
+ readonly themes: readonly IThemeVariant[];
46
+ /** the theme on when nothing else decides */
47
+ readonly defaultTheme: string;
48
+ /** the system's light or dark picks the theme of that scheme, unless the reader picked one */
49
+ readonly followSystem: boolean;
50
+ /** where the reader's pick is kept — one name per site, so every page shares it */
51
+ readonly storageKey: string;
52
+ /** the page's own background follows the theme too (a theme at the page's root) */
53
+ readonly paintPage?: boolean;
54
+ }
55
+
56
+ interface IThemeRole extends IThemeVariable {
57
+ readonly light: string;
58
+ readonly dark: string;
59
+ }
60
+
61
+ /** the variables every block may count on, with the values a new theme starts from */
62
+ export const THEME_ROLES: readonly IThemeRole[] = [
63
+ { key: 'background', label: 'Background', kind: 'color', light: '#ffffff', dark: '#0f0f14' },
64
+ { key: 'text', label: 'Text', kind: 'color', light: '#09090b', dark: '#f4f4f5' },
65
+ { key: 'muted', label: 'Muted surface', kind: 'color', light: '#f4f4f5', dark: '#1d1d24' },
66
+ { key: 'muted-text', label: 'Secondary text', kind: 'color', light: '#71717a', dark: '#a1a1aa' },
67
+ { key: 'primary', label: 'Primary', kind: 'color', light: '#18181b', dark: '#fafafa' },
68
+ { key: 'primary-text', label: 'Text on primary', kind: 'color', light: '#fafafa', dark: '#18181b' },
69
+ { key: 'accent', label: 'Accent', kind: 'color', light: '#2f5bea', dark: '#7c9cff' },
70
+ { key: 'border', label: 'Borders', kind: 'color', light: '#e4e4e7', dark: '#2e2e38' },
71
+ { key: 'radius', label: 'Corner radius', kind: 'value', light: '0.5rem', dark: '0.5rem' },
72
+ { key: 'font-body', label: 'Body font', kind: 'font', google: true, light: 'Inter', dark: 'Inter' },
73
+ { key: 'font-heading', label: 'Heading font', kind: 'font', google: true, light: 'Inter', dark: 'Inter' },
74
+ ];
75
+
76
+ /** the custom property a variable lands in */
77
+ export const themeVariable = (key: string): string => `--theme-${key}`;
78
+
79
+ /** where the reader's pick is kept in their browser */
80
+ export const themeStorageName = (storageKey: string): string => `mosetta-theme:${storageKey}`;
81
+
82
+ /** the document root's attribute naming the reader's pick (set before the body paints) */
83
+ export const themeChoiceAttribute = (storageKey: string): string => `data-theme-${storageKey}`;
84
+
85
+ /** the attribute a theme block's element carries: its node id */
86
+ export const THEME_ATTRIBUTE = 'data-mosetta-theme';
87
+
88
+ /** a fresh theme: every role, a light and a dark theme, following the system */
89
+ export function defaultThemeSettings(): IThemeSettings {
90
+ const variables = THEME_ROLES.map(({ key, label, kind, google }) => ({ key, label, kind, ...(google ? { google } : {}) }));
91
+ const valuesOf = (scheme: TColorScheme) => Object.fromEntries(THEME_ROLES.map((r) => [r.key, r[scheme]]));
92
+ return {
93
+ variables,
94
+ themes: [
95
+ { id: 'light', label: 'Light', scheme: 'light', values: valuesOf('light') },
96
+ { id: 'dark', label: 'Dark', scheme: 'dark', values: valuesOf('dark') },
97
+ ],
98
+ defaultTheme: 'light',
99
+ followSystem: true,
100
+ storageKey: 'site',
101
+ paintPage: true,
102
+ };
103
+ }
104
+
105
+ const NAME = /^[a-z][a-z0-9-]{0,31}$/;
106
+ const FAMILY = /^[A-Za-z0-9][A-Za-z0-9 ]{0,63}$/;
107
+
108
+ /** the settings as they may be rendered: known shapes, safe names — whatever else is dropped */
109
+ export function cleanThemeSettings(settings: unknown): IThemeSettings | null {
110
+ if (!settings || typeof settings !== 'object') return null;
111
+ const s = settings as Partial<IThemeSettings>;
112
+ const list = (value: unknown): Record<string, unknown>[] => (Array.isArray(value) ? value : [])
113
+ .filter((item): item is Record<string, unknown> => !!item && typeof item === 'object');
114
+ const variables = list(s.variables)
115
+ .filter((v) => typeof v.key === 'string' && NAME.test(v.key) && ['color', 'font', 'value'].includes(v.kind as string)) as unknown as IThemeVariable[];
116
+ const themes = list(s.themes)
117
+ .filter((t) => typeof t.id === 'string' && NAME.test(t.id) && !!t.values && typeof t.values === 'object') as unknown as IThemeVariant[];
118
+ if (!themes.length) return null;
119
+ const storageKey = typeof s.storageKey === 'string' && NAME.test(s.storageKey) ? s.storageKey : 'site';
120
+ const defaultTheme = themes.some((t) => t.id === s.defaultTheme) ? s.defaultTheme! : themes[0].id;
121
+ return {
122
+ variables,
123
+ themes: themes.map((t) => ({ ...t, label: typeof t.label === 'string' ? t.label : t.id, scheme: t.scheme === 'dark' ? 'dark' : 'light' })),
124
+ defaultTheme,
125
+ followSystem: s.followSystem === true,
126
+ storageKey,
127
+ paintPage: s.paintPage !== false,
128
+ };
129
+ }
130
+
131
+ /** a variable's value as CSS, or null: a font becomes a family with fallbacks, the rest is cleaned */
132
+ function cssValue(variable: IThemeVariable, value: unknown): string | null {
133
+ if (variable.kind !== 'font') return cleanStyleValue(value);
134
+ if (typeof value !== 'string' || !FAMILY.test(value.trim())) return null;
135
+ return `"${value.trim()}",ui-sans-serif,system-ui,sans-serif`;
136
+ }
137
+
138
+ function declarations(settings: IThemeSettings, theme: IThemeVariant): string {
139
+ const decls = settings.variables.flatMap((v) => {
140
+ const value = cssValue(v, theme.values[v.key]);
141
+ return value === null ? [] : [`${themeVariable(v.key)}:${value}`];
142
+ });
143
+ // what inherits — text colour and font — inherits from the theme's element too (it renders no box
144
+ // of its own, `display: contents`, yet its children inherit from it): a block that styles nothing
145
+ // reads the theme as well
146
+ const declared = new Set(settings.variables.map((v) => v.key));
147
+ const inherited = [
148
+ ...(declared.has('text') ? [`color:var(${themeVariable('text')})`] : []),
149
+ ...(declared.has('font-body') ? [`font-family:var(${themeVariable('font-body')})`] : []),
150
+ ];
151
+ return [...decls, ...inherited, `color-scheme:${theme.scheme}`].join(';');
152
+ }
153
+
154
+ /** the theme of a scheme the system asks for, when the theme follows the system */
155
+ export function systemTheme(settings: IThemeSettings, scheme: TColorScheme): IThemeVariant | null {
156
+ return settings.themes.find((t) => t.scheme === scheme) ?? null;
157
+ }
158
+
159
+ /** which theme is on: the reader's pick, else the system's (when followed), else the default */
160
+ export function activeThemeId(settings: IThemeSettings, chosen: string | null, systemScheme: TColorScheme | null): string {
161
+ if (chosen && settings.themes.some((t) => t.id === chosen)) return chosen;
162
+ if (settings.followSystem && systemScheme) {
163
+ const fromSystem = systemTheme(settings, systemScheme);
164
+ if (fromSystem) return fromSystem.id;
165
+ }
166
+ return settings.defaultTheme;
167
+ }
168
+
169
+ /**
170
+ * The stylesheet of one theme block: every theme's variables on its element — the default, the
171
+ * reader's pick by the root attribute, the system's by a media query — and, when it paints the
172
+ * page, the same choice for the page's own background.
173
+ */
174
+ export function themeCss(nodeId: string, raw: unknown): string {
175
+ // the sheet travels as raw inner html of a <style>: an id that could close it renders nothing
176
+ if (/[<>"\\]/.test(nodeId)) return '';
177
+ const settings = cleanThemeSettings(raw);
178
+ if (!settings) return '';
179
+ const host = `[${THEME_ATTRIBUTE}="${nodeId}"]`;
180
+ const attr = themeChoiceAttribute(settings.storageKey);
181
+ const byId = new Map(settings.themes.map((t) => [t.id, t]));
182
+ const fallback = byId.get(settings.defaultTheme)!;
183
+ const backgroundOf = (theme: IThemeVariant): string => {
184
+ const bg = cleanStyleValue(theme.values.background);
185
+ return `${bg ? `background-color:${bg};` : ''}color-scheme:${theme.scheme}`;
186
+ };
187
+ const rules: string[] = [];
188
+ const both = (selectorHost: string, selectorPage: string, theme: IThemeVariant) => {
189
+ rules.push(`${selectorHost}{${declarations(settings, theme)}}`);
190
+ if (settings.paintPage) rules.push(`${selectorPage}{${backgroundOf(theme)}}`);
191
+ };
192
+ both(host, ':root', fallback);
193
+ if (settings.followSystem) {
194
+ for (const scheme of ['light', 'dark'] as const) {
195
+ const theme = systemTheme(settings, scheme);
196
+ if (!theme || theme === fallback) continue;
197
+ const hostRule = `:root:not([${attr}]) ${host}{${declarations(settings, theme)}}`;
198
+ const pageRule = settings.paintPage ? `:root:not([${attr}]){${backgroundOf(theme)}}` : '';
199
+ rules.push(`@media (prefers-color-scheme:${scheme}){${hostRule}${pageRule}}`);
200
+ }
201
+ }
202
+ // the reader's pick wins over the system's: these come last and weigh as much
203
+ for (const theme of settings.themes) both(`:root[${attr}="${theme.id}"] ${host}`, `:root[${attr}="${theme.id}"]`, theme);
204
+ return rules.join('\n');
205
+ }
206
+
207
+ /** the one Google Fonts stylesheet the themes' fonts need, or null when they name none */
208
+ export function themeFontsHref(raw: unknown): string | null {
209
+ const settings = cleanThemeSettings(raw);
210
+ if (!settings) return null;
211
+ const googleKeys = settings.variables.filter((v) => v.kind === 'font' && v.google).map((v) => v.key);
212
+ const families = [...new Set(settings.themes.flatMap((t) => googleKeys.map((k) => t.values[k]))
213
+ .filter((f): f is string => typeof f === 'string' && FAMILY.test(f.trim()))
214
+ .map((f) => f.trim()))];
215
+ if (!families.length) return null;
216
+ return `https://fonts.googleapis.com/css2?${families.map((f) => `family=${f.replace(/ /g, '+')}:wght@400;500;600;700`).join('&')}&display=swap`;
217
+ }
218
+
219
+ /** the head script that puts the reader's kept pick on the document root before the body paints */
220
+ export function themeChoiceScript(storageKey: string): string {
221
+ if (!NAME.test(storageKey)) return '';
222
+ return `try{var t=localStorage.getItem(${JSON.stringify(themeStorageName(storageKey))});`
223
+ + `if(t&&/^[a-z][a-z0-9-]{0,31}$/.test(t))document.documentElement.setAttribute(${JSON.stringify(themeChoiceAttribute(storageKey))},t)}catch(e){}`;
224
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "compilerOptions": {
3
+ "emitDecoratorMetadata": true,
4
+ "experimentalDecorators": true,
5
+ "lib": ["dom", "dom.iterable", "esnext"],
6
+ "sourceMap": true,
7
+ "allowJs": true,
8
+ "skipLibCheck": true,
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
14
+ "downlevelIteration": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "jsx": "react-jsx",
18
+ "incremental": true,
19
+ "target": "ESNext",
20
+ "outDir": "${configDir}/../../dist",
21
+ "rootDir": "${configDir}"
22
+ },
23
+ "include": [
24
+ "${configDir}/**/*.ts",
25
+ "${configDir}/**/*.tsx"
26
+ ],
27
+ "exclude": [
28
+ "${configDir}/node_modules"
29
+ ]
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./tsconfig.base.json",
3
+ }