@notty/theme-api 0.7.1 → 1.0.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/README.md CHANGED
@@ -1,13 +1,36 @@
1
1
  # @notty/theme-api
2
2
 
3
- Theme API for customizing Notty CMS admin panel appearance.
3
+ Public SDK for building Notty admin themes.
4
4
 
5
- ## Installation
5
+ ## Current Status
6
6
 
7
- ```bash
8
- npm install @notty/theme-api
7
+ - Theme authoring workflow is available through `defineTheme()`
8
+ - Includes theme manifest types, branding tokens, layout overrides, and white-label metadata
9
+ - Active theme branding now flows into the admin `ui-config` endpoint and white-label branding pipeline
10
+ - The API is usable now, but should still be treated as early-stage and evolving, especially for full token/layout runtime application
11
+ - Distribution remains restricted to the `@notty` npm scope
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { defineTheme } from '@notty/theme-api';
17
+
18
+ export default defineTheme({
19
+ manifest: {
20
+ name: '@acme/theme-brand',
21
+ displayName: 'Brand Theme',
22
+ version: '0.1.0',
23
+ kind: 'theme',
24
+ engines: { notty: '>=0.16.0' },
25
+ branding: {
26
+ title: 'Acme CMS',
27
+ },
28
+ },
29
+ });
9
30
  ```
10
31
 
32
+ For scaffolding, use `create-notty --theme`.
33
+
11
34
  ## License
12
35
 
13
36
  MIT
package/dist/index.cjs CHANGED
@@ -20,11 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- THEME_API_VERSION: () => THEME_API_VERSION
23
+ defineTheme: () => import_types.defineTheme
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
- var THEME_API_VERSION = "0.1.0";
26
+ var import_types = require("@notty/types");
27
27
  // Annotate the CommonJS export names for ESM import in node:
28
28
  0 && (module.exports = {
29
- THEME_API_VERSION
29
+ defineTheme
30
30
  });
package/dist/index.d.cts CHANGED
@@ -1,7 +1 @@
1
- /**
2
- * @notty/theme-api
3
- * Theme SDK for ACms
4
- */
5
- declare const THEME_API_VERSION = "0.1.0";
6
-
7
- export { THEME_API_VERSION };
1
+ export { ManifestValidationResult, PluginAuthor, PluginConfigSchema, PluginContext, PluginEngines, PluginLifecycleHooks, PluginManifest, PluginSettingField, ThemeBranding, ThemeColorToken, ThemeConfig, ThemeDefinition, ThemeManifest, ThemeRadiusToken, ThemeShadowToken, ThemeSpacingToken, ThemeTokens, ThemeTypographyToken, defineTheme } from '@notty/types';
package/dist/index.d.ts CHANGED
@@ -1,7 +1 @@
1
- /**
2
- * @notty/theme-api
3
- * Theme SDK for ACms
4
- */
5
- declare const THEME_API_VERSION = "0.1.0";
6
-
7
- export { THEME_API_VERSION };
1
+ export { ManifestValidationResult, PluginAuthor, PluginConfigSchema, PluginContext, PluginEngines, PluginLifecycleHooks, PluginManifest, PluginSettingField, ThemeBranding, ThemeColorToken, ThemeConfig, ThemeDefinition, ThemeManifest, ThemeRadiusToken, ThemeShadowToken, ThemeSpacingToken, ThemeTokens, ThemeTypographyToken, defineTheme } from '@notty/types';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- var THEME_API_VERSION = "0.1.0";
2
+ import { defineTheme } from "@notty/types";
3
3
  export {
4
- THEME_API_VERSION
4
+ defineTheme
5
5
  };
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/testing/index.ts
21
+ var testing_exports = {};
22
+ __export(testing_exports, {
23
+ assertValidBranding: () => assertValidBranding,
24
+ assertValidTokens: () => assertValidTokens,
25
+ createTestThemeContext: () => createTestThemeContext,
26
+ createTestThemeHarness: () => createTestThemeHarness,
27
+ makeTestThemeDefinition: () => makeTestThemeDefinition,
28
+ makeTestThemeManifest: () => makeTestThemeManifest
29
+ });
30
+ module.exports = __toCommonJS(testing_exports);
31
+ function createMockLogger() {
32
+ const entries = [];
33
+ function log(level) {
34
+ return (message, ...args) => {
35
+ entries.push({ level, message, args });
36
+ };
37
+ }
38
+ return {
39
+ info: log("info"),
40
+ warn: log("warn"),
41
+ error: log("error"),
42
+ debug: log("debug"),
43
+ get entries() {
44
+ return entries;
45
+ },
46
+ entriesOf: (level) => entries.filter((e) => e.level === level),
47
+ clear: () => {
48
+ entries.length = 0;
49
+ }
50
+ };
51
+ }
52
+ function noop() {
53
+ }
54
+ function createNoopRouteRegistrar() {
55
+ return { get: noop, post: noop, put: noop, delete: noop, patch: noop, add: noop };
56
+ }
57
+ function createNoopServiceRegistrar() {
58
+ return { register: noop, get: () => void 0, has: () => false };
59
+ }
60
+ function createNoopHookRegistrar() {
61
+ return {
62
+ on: noop,
63
+ once: noop,
64
+ emit: (_name, payload) => Promise.resolve(payload)
65
+ };
66
+ }
67
+ function createNoopMiddlewareRegistrar() {
68
+ return { addAdmin: noop, addContent: noop, addSystem: noop };
69
+ }
70
+ function createNoopPolicyRegistrar() {
71
+ return { register: noop };
72
+ }
73
+ function createNoopDataLayerRegistrar() {
74
+ return {
75
+ contributeContentType: noop,
76
+ contributeComponent: noop,
77
+ addMigration: noop,
78
+ addSeed: noop
79
+ };
80
+ }
81
+ function createCapturingAdminRegistrar() {
82
+ const captured = {
83
+ navigationItems: [],
84
+ pages: [],
85
+ settingsSections: [],
86
+ dashboardWidgets: [],
87
+ injectionZones: [],
88
+ fieldExtensions: []
89
+ };
90
+ return {
91
+ captured,
92
+ addNavigationItem: (item) => {
93
+ captured.navigationItems.push(item);
94
+ },
95
+ addPage: (page) => {
96
+ captured.pages.push(page);
97
+ },
98
+ addSettingsSection: (section) => {
99
+ captured.settingsSections.push(section);
100
+ },
101
+ addDashboardWidget: (widget) => {
102
+ captured.dashboardWidgets.push(widget);
103
+ },
104
+ addInjectionZoneEntry: (entry) => {
105
+ captured.injectionZones.push(entry);
106
+ },
107
+ addFieldExtension: (ext) => {
108
+ captured.fieldExtensions.push(ext);
109
+ }
110
+ };
111
+ }
112
+ var DEFAULT_THEME_MANIFEST = {
113
+ name: "@test/theme",
114
+ displayName: "Test Theme",
115
+ version: "1.0.0",
116
+ kind: "theme",
117
+ engines: { notty: ">=0.14.0" },
118
+ tokens: {
119
+ colors: {
120
+ brand: { primary: { value: "#3b82f6" } },
121
+ surface: {
122
+ background: { value: "#ffffff" },
123
+ foreground: { value: "#1a1a2e" }
124
+ }
125
+ }
126
+ }
127
+ };
128
+ function makeTestThemeManifest(overrides) {
129
+ return { ...DEFAULT_THEME_MANIFEST, ...overrides };
130
+ }
131
+ function makeTestThemeDefinition(overrides) {
132
+ const { manifest: manifestOverrides, ...hooks } = overrides ?? {};
133
+ return {
134
+ manifest: makeTestThemeManifest(manifestOverrides),
135
+ ...hooks
136
+ };
137
+ }
138
+ function createTestThemeContext(options) {
139
+ const manifest = makeTestThemeManifest(options?.manifest);
140
+ const config = options?.config ?? {};
141
+ const admin = createCapturingAdminRegistrar();
142
+ return {
143
+ manifest,
144
+ config,
145
+ log: createMockLogger(),
146
+ routes: createNoopRouteRegistrar(),
147
+ services: createNoopServiceRegistrar(),
148
+ hooks: createNoopHookRegistrar(),
149
+ middleware: createNoopMiddlewareRegistrar(),
150
+ policies: createNoopPolicyRegistrar(),
151
+ dataLayer: createNoopDataLayerRegistrar(),
152
+ admin
153
+ };
154
+ }
155
+ function createTestThemeHarness(definition, options) {
156
+ const mergedManifest = {
157
+ ...DEFAULT_THEME_MANIFEST,
158
+ ...definition.manifest,
159
+ ...options?.manifest
160
+ };
161
+ const mergedDefinition = {
162
+ ...definition,
163
+ manifest: mergedManifest
164
+ };
165
+ const s = {
166
+ ctx: createTestThemeContext({ manifest: mergedManifest, config: options?.config }),
167
+ phase: "discovered",
168
+ errors: []
169
+ };
170
+ function toError(err) {
171
+ return err instanceof Error ? err : new Error(String(err));
172
+ }
173
+ function hasFailed() {
174
+ return s.phase === "failed";
175
+ }
176
+ function runRegister() {
177
+ if (s.phase !== "discovered") return;
178
+ try {
179
+ if (mergedDefinition.register) mergedDefinition.register(s.ctx);
180
+ s.phase = "registered";
181
+ } catch (err) {
182
+ s.phase = "failed";
183
+ s.errors.push(toError(err));
184
+ }
185
+ }
186
+ async function runInit() {
187
+ if (s.phase === "discovered") runRegister();
188
+ if (s.phase !== "registered") return;
189
+ try {
190
+ if (mergedDefinition.init) await mergedDefinition.init(s.ctx);
191
+ s.phase = "initialized";
192
+ } catch (err) {
193
+ s.phase = "failed";
194
+ s.errors.push(toError(err));
195
+ }
196
+ }
197
+ async function runBoot() {
198
+ if (s.phase === "discovered") runRegister();
199
+ if (s.phase === "registered") await runInit();
200
+ if (s.phase !== "initialized") return;
201
+ try {
202
+ if (mergedDefinition.boot) await mergedDefinition.boot(s.ctx);
203
+ s.phase = "active";
204
+ } catch (err) {
205
+ s.phase = "failed";
206
+ s.errors.push(toError(err));
207
+ }
208
+ }
209
+ async function runAll() {
210
+ runRegister();
211
+ if (hasFailed()) return;
212
+ await runInit();
213
+ if (hasFailed()) return;
214
+ await runBoot();
215
+ }
216
+ async function runDestroy() {
217
+ const startPhase = s.phase;
218
+ try {
219
+ if (mergedDefinition.destroy) {
220
+ await mergedDefinition.destroy(s.ctx);
221
+ }
222
+ if (startPhase !== "failed") {
223
+ s.phase = "disabled";
224
+ }
225
+ } catch (err) {
226
+ s.phase = "failed";
227
+ s.errors.push(toError(err));
228
+ }
229
+ }
230
+ function reset() {
231
+ s.ctx = createTestThemeContext({ manifest: mergedManifest, config: options?.config });
232
+ s.phase = "discovered";
233
+ s.errors = [];
234
+ }
235
+ return {
236
+ get ctx() {
237
+ return s.ctx;
238
+ },
239
+ get definition() {
240
+ return mergedDefinition;
241
+ },
242
+ get phase() {
243
+ return s.phase;
244
+ },
245
+ get errors() {
246
+ return s.errors;
247
+ },
248
+ runRegister,
249
+ runInit,
250
+ runBoot,
251
+ runDestroy,
252
+ runAll,
253
+ reset
254
+ };
255
+ }
256
+ function assertValidTokens(tokens) {
257
+ const issues = [];
258
+ if (!tokens) {
259
+ return { valid: true, issues: [] };
260
+ }
261
+ if (tokens.colors) {
262
+ for (const [group, colorGroup] of Object.entries(tokens.colors)) {
263
+ if (!colorGroup || typeof colorGroup !== "object") continue;
264
+ for (const [name, token] of Object.entries(colorGroup)) {
265
+ if (!token) continue;
266
+ if (!token.value || typeof token.value !== "string" || token.value.trim() === "") {
267
+ issues.push(`colors.${group}.${name}: missing or empty value`);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ if (tokens.typography) {
273
+ for (const [name, token] of Object.entries(tokens.typography)) {
274
+ if (!token) continue;
275
+ const hasAny = token.fontFamily || token.fontSize || token.fontWeight || token.lineHeight || token.letterSpacing;
276
+ if (!hasAny) {
277
+ issues.push(`typography.${name}: no properties set`);
278
+ }
279
+ }
280
+ }
281
+ const valueSections = [
282
+ ["spacing", tokens.spacing],
283
+ ["shadows", tokens.shadows],
284
+ ["radii", tokens.radii]
285
+ ];
286
+ for (const [section, sectionTokens] of valueSections) {
287
+ if (!sectionTokens) continue;
288
+ for (const [name, token] of Object.entries(sectionTokens)) {
289
+ if (!token) continue;
290
+ if (!token.value || typeof token.value !== "string" || token.value.trim() === "") {
291
+ issues.push(`${section}.${name}: missing or empty value`);
292
+ }
293
+ }
294
+ }
295
+ return { valid: issues.length === 0, issues };
296
+ }
297
+ function assertValidBranding(branding) {
298
+ const issues = [];
299
+ if (!branding) {
300
+ return { valid: true, issues: [] };
301
+ }
302
+ if (branding.logo && typeof branding.logo !== "string") {
303
+ issues.push("branding.logo: must be a string URL/path");
304
+ }
305
+ if (branding.favicon && typeof branding.favicon !== "string") {
306
+ issues.push("branding.favicon: must be a string URL/path");
307
+ }
308
+ if (branding.title && typeof branding.title !== "string") {
309
+ issues.push("branding.title: must be a string");
310
+ }
311
+ return { valid: issues.length === 0, issues };
312
+ }
313
+ // Annotate the CommonJS export names for ESM import in node:
314
+ 0 && (module.exports = {
315
+ assertValidBranding,
316
+ assertValidTokens,
317
+ createTestThemeContext,
318
+ createTestThemeHarness,
319
+ makeTestThemeDefinition,
320
+ makeTestThemeManifest
321
+ });
@@ -0,0 +1,150 @@
1
+ import { AdminNavigationItem, AdminPage, AdminSettingsSection, AdminDashboardWidget, AdminInjectionZoneEntry, AdminFieldExtension, PluginContext, ThemeManifest, ThemeDefinition, PluginLifecyclePhase, ThemeTokens, ThemeBranding } from '@notty/types';
2
+
3
+ /**
4
+ * @notty/theme-api/testing
5
+ *
6
+ * Test utilities for Notty theme authors.
7
+ *
8
+ * Re-exports core plugin testing utilities from @notty/plugin-api/testing
9
+ * plus theme-specific helpers for design tokens and branding.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import {
14
+ * createTestThemeContext,
15
+ * createTestThemeHarness,
16
+ * makeTestThemeManifest,
17
+ * assertValidTokens,
18
+ * } from '@notty/theme-api/testing';
19
+ * ```
20
+ */
21
+
22
+ interface LogEntry {
23
+ level: 'info' | 'warn' | 'error' | 'debug';
24
+ message: string;
25
+ args: unknown[];
26
+ }
27
+ interface MockLogger {
28
+ info: (message: string, ...args: unknown[]) => void;
29
+ warn: (message: string, ...args: unknown[]) => void;
30
+ error: (message: string, ...args: unknown[]) => void;
31
+ debug: (message: string, ...args: unknown[]) => void;
32
+ readonly entries: ReadonlyArray<LogEntry>;
33
+ entriesOf(level: LogEntry['level']): LogEntry[];
34
+ clear(): void;
35
+ }
36
+ /** Captured admin extensions for theme testing */
37
+ interface CapturedAdminExtensions {
38
+ navigationItems: AdminNavigationItem[];
39
+ pages: AdminPage[];
40
+ settingsSections: AdminSettingsSection[];
41
+ dashboardWidgets: AdminDashboardWidget[];
42
+ injectionZones: AdminInjectionZoneEntry[];
43
+ fieldExtensions: AdminFieldExtension[];
44
+ }
45
+ interface TestThemeContext extends PluginContext {
46
+ log: MockLogger;
47
+ admin: PluginContext['admin'] & {
48
+ captured: CapturedAdminExtensions;
49
+ };
50
+ }
51
+ interface CreateTestThemeContextOptions {
52
+ manifest?: Partial<ThemeManifest>;
53
+ config?: Record<string, unknown>;
54
+ }
55
+ /**
56
+ * Create a valid ThemeManifest with sensible defaults including design tokens.
57
+ */
58
+ declare function makeTestThemeManifest(overrides?: Partial<ThemeManifest>): ThemeManifest;
59
+ /**
60
+ * Create a valid ThemeDefinition with sensible defaults.
61
+ */
62
+ declare function makeTestThemeDefinition(overrides?: Partial<ThemeDefinition> & {
63
+ manifest?: Partial<ThemeManifest>;
64
+ }): ThemeDefinition;
65
+ /**
66
+ * Create a mock PluginContext tailored for theme testing.
67
+ *
68
+ * Backend registrars (routes, services, hooks, etc.) are no-op stubs.
69
+ * The admin registrar captures calls so you can verify theme admin extensions.
70
+ * The logger captures entries for inspection.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { createTestThemeContext } from '@notty/theme-api/testing';
75
+ *
76
+ * test('theme registers settings section', () => {
77
+ * const ctx = createTestThemeContext({ manifest: { name: '@my/dark-theme' } });
78
+ *
79
+ * myTheme.register(ctx);
80
+ *
81
+ * expect(ctx.admin.captured.settingsSections).toHaveLength(1);
82
+ * });
83
+ * ```
84
+ */
85
+ declare function createTestThemeContext(options?: CreateTestThemeContextOptions): TestThemeContext;
86
+ interface ThemeTestHarness {
87
+ readonly ctx: TestThemeContext;
88
+ readonly definition: ThemeDefinition;
89
+ readonly phase: PluginLifecyclePhase;
90
+ readonly errors: ReadonlyArray<Error>;
91
+ /** Run register phase */
92
+ runRegister(): void;
93
+ /** Run init phase (auto-registers if needed) */
94
+ runInit(): Promise<void>;
95
+ /** Run boot phase (auto-registers and inits if needed) */
96
+ runBoot(): Promise<void>;
97
+ /** Run destroy phase from any current phase */
98
+ runDestroy(): Promise<void>;
99
+ /** Run all phases */
100
+ runAll(): Promise<void>;
101
+ /** Reset to initial state */
102
+ reset(): void;
103
+ }
104
+ /**
105
+ * Create a test harness for a theme definition.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * import { createTestThemeHarness } from '@notty/theme-api/testing';
110
+ * import myTheme from '../src/index';
111
+ *
112
+ * test('theme lifecycle completes', async () => {
113
+ * const harness = createTestThemeHarness(myTheme);
114
+ * await harness.runAll();
115
+ * expect(harness.phase).toBe('active');
116
+ * });
117
+ * ```
118
+ */
119
+ declare function createTestThemeHarness(definition: ThemeDefinition, options?: CreateTestThemeContextOptions): ThemeTestHarness;
120
+ interface TokenValidationResult {
121
+ valid: boolean;
122
+ issues: string[];
123
+ }
124
+ /**
125
+ * Validate that theme tokens follow expected structure.
126
+ *
127
+ * Checks:
128
+ * - Color tokens have non-empty `value` strings
129
+ * - Typography tokens have at least one property set
130
+ * - Spacing/shadow/radius tokens have non-empty `value` strings
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const result = assertValidTokens(myTheme.manifest.tokens);
135
+ * expect(result.valid).toBe(true);
136
+ * ```
137
+ */
138
+ declare function assertValidTokens(tokens?: ThemeTokens): TokenValidationResult;
139
+ /**
140
+ * Validate that theme branding has required fields set.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const result = assertValidBranding(myTheme.manifest.branding);
145
+ * expect(result.valid).toBe(true);
146
+ * ```
147
+ */
148
+ declare function assertValidBranding(branding?: ThemeBranding): TokenValidationResult;
149
+
150
+ export { type CapturedAdminExtensions, type CreateTestThemeContextOptions, type LogEntry, type MockLogger, type TestThemeContext, type ThemeTestHarness, type TokenValidationResult, assertValidBranding, assertValidTokens, createTestThemeContext, createTestThemeHarness, makeTestThemeDefinition, makeTestThemeManifest };
@@ -0,0 +1,150 @@
1
+ import { AdminNavigationItem, AdminPage, AdminSettingsSection, AdminDashboardWidget, AdminInjectionZoneEntry, AdminFieldExtension, PluginContext, ThemeManifest, ThemeDefinition, PluginLifecyclePhase, ThemeTokens, ThemeBranding } from '@notty/types';
2
+
3
+ /**
4
+ * @notty/theme-api/testing
5
+ *
6
+ * Test utilities for Notty theme authors.
7
+ *
8
+ * Re-exports core plugin testing utilities from @notty/plugin-api/testing
9
+ * plus theme-specific helpers for design tokens and branding.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import {
14
+ * createTestThemeContext,
15
+ * createTestThemeHarness,
16
+ * makeTestThemeManifest,
17
+ * assertValidTokens,
18
+ * } from '@notty/theme-api/testing';
19
+ * ```
20
+ */
21
+
22
+ interface LogEntry {
23
+ level: 'info' | 'warn' | 'error' | 'debug';
24
+ message: string;
25
+ args: unknown[];
26
+ }
27
+ interface MockLogger {
28
+ info: (message: string, ...args: unknown[]) => void;
29
+ warn: (message: string, ...args: unknown[]) => void;
30
+ error: (message: string, ...args: unknown[]) => void;
31
+ debug: (message: string, ...args: unknown[]) => void;
32
+ readonly entries: ReadonlyArray<LogEntry>;
33
+ entriesOf(level: LogEntry['level']): LogEntry[];
34
+ clear(): void;
35
+ }
36
+ /** Captured admin extensions for theme testing */
37
+ interface CapturedAdminExtensions {
38
+ navigationItems: AdminNavigationItem[];
39
+ pages: AdminPage[];
40
+ settingsSections: AdminSettingsSection[];
41
+ dashboardWidgets: AdminDashboardWidget[];
42
+ injectionZones: AdminInjectionZoneEntry[];
43
+ fieldExtensions: AdminFieldExtension[];
44
+ }
45
+ interface TestThemeContext extends PluginContext {
46
+ log: MockLogger;
47
+ admin: PluginContext['admin'] & {
48
+ captured: CapturedAdminExtensions;
49
+ };
50
+ }
51
+ interface CreateTestThemeContextOptions {
52
+ manifest?: Partial<ThemeManifest>;
53
+ config?: Record<string, unknown>;
54
+ }
55
+ /**
56
+ * Create a valid ThemeManifest with sensible defaults including design tokens.
57
+ */
58
+ declare function makeTestThemeManifest(overrides?: Partial<ThemeManifest>): ThemeManifest;
59
+ /**
60
+ * Create a valid ThemeDefinition with sensible defaults.
61
+ */
62
+ declare function makeTestThemeDefinition(overrides?: Partial<ThemeDefinition> & {
63
+ manifest?: Partial<ThemeManifest>;
64
+ }): ThemeDefinition;
65
+ /**
66
+ * Create a mock PluginContext tailored for theme testing.
67
+ *
68
+ * Backend registrars (routes, services, hooks, etc.) are no-op stubs.
69
+ * The admin registrar captures calls so you can verify theme admin extensions.
70
+ * The logger captures entries for inspection.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { createTestThemeContext } from '@notty/theme-api/testing';
75
+ *
76
+ * test('theme registers settings section', () => {
77
+ * const ctx = createTestThemeContext({ manifest: { name: '@my/dark-theme' } });
78
+ *
79
+ * myTheme.register(ctx);
80
+ *
81
+ * expect(ctx.admin.captured.settingsSections).toHaveLength(1);
82
+ * });
83
+ * ```
84
+ */
85
+ declare function createTestThemeContext(options?: CreateTestThemeContextOptions): TestThemeContext;
86
+ interface ThemeTestHarness {
87
+ readonly ctx: TestThemeContext;
88
+ readonly definition: ThemeDefinition;
89
+ readonly phase: PluginLifecyclePhase;
90
+ readonly errors: ReadonlyArray<Error>;
91
+ /** Run register phase */
92
+ runRegister(): void;
93
+ /** Run init phase (auto-registers if needed) */
94
+ runInit(): Promise<void>;
95
+ /** Run boot phase (auto-registers and inits if needed) */
96
+ runBoot(): Promise<void>;
97
+ /** Run destroy phase from any current phase */
98
+ runDestroy(): Promise<void>;
99
+ /** Run all phases */
100
+ runAll(): Promise<void>;
101
+ /** Reset to initial state */
102
+ reset(): void;
103
+ }
104
+ /**
105
+ * Create a test harness for a theme definition.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * import { createTestThemeHarness } from '@notty/theme-api/testing';
110
+ * import myTheme from '../src/index';
111
+ *
112
+ * test('theme lifecycle completes', async () => {
113
+ * const harness = createTestThemeHarness(myTheme);
114
+ * await harness.runAll();
115
+ * expect(harness.phase).toBe('active');
116
+ * });
117
+ * ```
118
+ */
119
+ declare function createTestThemeHarness(definition: ThemeDefinition, options?: CreateTestThemeContextOptions): ThemeTestHarness;
120
+ interface TokenValidationResult {
121
+ valid: boolean;
122
+ issues: string[];
123
+ }
124
+ /**
125
+ * Validate that theme tokens follow expected structure.
126
+ *
127
+ * Checks:
128
+ * - Color tokens have non-empty `value` strings
129
+ * - Typography tokens have at least one property set
130
+ * - Spacing/shadow/radius tokens have non-empty `value` strings
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * const result = assertValidTokens(myTheme.manifest.tokens);
135
+ * expect(result.valid).toBe(true);
136
+ * ```
137
+ */
138
+ declare function assertValidTokens(tokens?: ThemeTokens): TokenValidationResult;
139
+ /**
140
+ * Validate that theme branding has required fields set.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const result = assertValidBranding(myTheme.manifest.branding);
145
+ * expect(result.valid).toBe(true);
146
+ * ```
147
+ */
148
+ declare function assertValidBranding(branding?: ThemeBranding): TokenValidationResult;
149
+
150
+ export { type CapturedAdminExtensions, type CreateTestThemeContextOptions, type LogEntry, type MockLogger, type TestThemeContext, type ThemeTestHarness, type TokenValidationResult, assertValidBranding, assertValidTokens, createTestThemeContext, createTestThemeHarness, makeTestThemeDefinition, makeTestThemeManifest };
@@ -0,0 +1,291 @@
1
+ // src/testing/index.ts
2
+ function createMockLogger() {
3
+ const entries = [];
4
+ function log(level) {
5
+ return (message, ...args) => {
6
+ entries.push({ level, message, args });
7
+ };
8
+ }
9
+ return {
10
+ info: log("info"),
11
+ warn: log("warn"),
12
+ error: log("error"),
13
+ debug: log("debug"),
14
+ get entries() {
15
+ return entries;
16
+ },
17
+ entriesOf: (level) => entries.filter((e) => e.level === level),
18
+ clear: () => {
19
+ entries.length = 0;
20
+ }
21
+ };
22
+ }
23
+ function noop() {
24
+ }
25
+ function createNoopRouteRegistrar() {
26
+ return { get: noop, post: noop, put: noop, delete: noop, patch: noop, add: noop };
27
+ }
28
+ function createNoopServiceRegistrar() {
29
+ return { register: noop, get: () => void 0, has: () => false };
30
+ }
31
+ function createNoopHookRegistrar() {
32
+ return {
33
+ on: noop,
34
+ once: noop,
35
+ emit: (_name, payload) => Promise.resolve(payload)
36
+ };
37
+ }
38
+ function createNoopMiddlewareRegistrar() {
39
+ return { addAdmin: noop, addContent: noop, addSystem: noop };
40
+ }
41
+ function createNoopPolicyRegistrar() {
42
+ return { register: noop };
43
+ }
44
+ function createNoopDataLayerRegistrar() {
45
+ return {
46
+ contributeContentType: noop,
47
+ contributeComponent: noop,
48
+ addMigration: noop,
49
+ addSeed: noop
50
+ };
51
+ }
52
+ function createCapturingAdminRegistrar() {
53
+ const captured = {
54
+ navigationItems: [],
55
+ pages: [],
56
+ settingsSections: [],
57
+ dashboardWidgets: [],
58
+ injectionZones: [],
59
+ fieldExtensions: []
60
+ };
61
+ return {
62
+ captured,
63
+ addNavigationItem: (item) => {
64
+ captured.navigationItems.push(item);
65
+ },
66
+ addPage: (page) => {
67
+ captured.pages.push(page);
68
+ },
69
+ addSettingsSection: (section) => {
70
+ captured.settingsSections.push(section);
71
+ },
72
+ addDashboardWidget: (widget) => {
73
+ captured.dashboardWidgets.push(widget);
74
+ },
75
+ addInjectionZoneEntry: (entry) => {
76
+ captured.injectionZones.push(entry);
77
+ },
78
+ addFieldExtension: (ext) => {
79
+ captured.fieldExtensions.push(ext);
80
+ }
81
+ };
82
+ }
83
+ var DEFAULT_THEME_MANIFEST = {
84
+ name: "@test/theme",
85
+ displayName: "Test Theme",
86
+ version: "1.0.0",
87
+ kind: "theme",
88
+ engines: { notty: ">=0.14.0" },
89
+ tokens: {
90
+ colors: {
91
+ brand: { primary: { value: "#3b82f6" } },
92
+ surface: {
93
+ background: { value: "#ffffff" },
94
+ foreground: { value: "#1a1a2e" }
95
+ }
96
+ }
97
+ }
98
+ };
99
+ function makeTestThemeManifest(overrides) {
100
+ return { ...DEFAULT_THEME_MANIFEST, ...overrides };
101
+ }
102
+ function makeTestThemeDefinition(overrides) {
103
+ const { manifest: manifestOverrides, ...hooks } = overrides ?? {};
104
+ return {
105
+ manifest: makeTestThemeManifest(manifestOverrides),
106
+ ...hooks
107
+ };
108
+ }
109
+ function createTestThemeContext(options) {
110
+ const manifest = makeTestThemeManifest(options?.manifest);
111
+ const config = options?.config ?? {};
112
+ const admin = createCapturingAdminRegistrar();
113
+ return {
114
+ manifest,
115
+ config,
116
+ log: createMockLogger(),
117
+ routes: createNoopRouteRegistrar(),
118
+ services: createNoopServiceRegistrar(),
119
+ hooks: createNoopHookRegistrar(),
120
+ middleware: createNoopMiddlewareRegistrar(),
121
+ policies: createNoopPolicyRegistrar(),
122
+ dataLayer: createNoopDataLayerRegistrar(),
123
+ admin
124
+ };
125
+ }
126
+ function createTestThemeHarness(definition, options) {
127
+ const mergedManifest = {
128
+ ...DEFAULT_THEME_MANIFEST,
129
+ ...definition.manifest,
130
+ ...options?.manifest
131
+ };
132
+ const mergedDefinition = {
133
+ ...definition,
134
+ manifest: mergedManifest
135
+ };
136
+ const s = {
137
+ ctx: createTestThemeContext({ manifest: mergedManifest, config: options?.config }),
138
+ phase: "discovered",
139
+ errors: []
140
+ };
141
+ function toError(err) {
142
+ return err instanceof Error ? err : new Error(String(err));
143
+ }
144
+ function hasFailed() {
145
+ return s.phase === "failed";
146
+ }
147
+ function runRegister() {
148
+ if (s.phase !== "discovered") return;
149
+ try {
150
+ if (mergedDefinition.register) mergedDefinition.register(s.ctx);
151
+ s.phase = "registered";
152
+ } catch (err) {
153
+ s.phase = "failed";
154
+ s.errors.push(toError(err));
155
+ }
156
+ }
157
+ async function runInit() {
158
+ if (s.phase === "discovered") runRegister();
159
+ if (s.phase !== "registered") return;
160
+ try {
161
+ if (mergedDefinition.init) await mergedDefinition.init(s.ctx);
162
+ s.phase = "initialized";
163
+ } catch (err) {
164
+ s.phase = "failed";
165
+ s.errors.push(toError(err));
166
+ }
167
+ }
168
+ async function runBoot() {
169
+ if (s.phase === "discovered") runRegister();
170
+ if (s.phase === "registered") await runInit();
171
+ if (s.phase !== "initialized") return;
172
+ try {
173
+ if (mergedDefinition.boot) await mergedDefinition.boot(s.ctx);
174
+ s.phase = "active";
175
+ } catch (err) {
176
+ s.phase = "failed";
177
+ s.errors.push(toError(err));
178
+ }
179
+ }
180
+ async function runAll() {
181
+ runRegister();
182
+ if (hasFailed()) return;
183
+ await runInit();
184
+ if (hasFailed()) return;
185
+ await runBoot();
186
+ }
187
+ async function runDestroy() {
188
+ const startPhase = s.phase;
189
+ try {
190
+ if (mergedDefinition.destroy) {
191
+ await mergedDefinition.destroy(s.ctx);
192
+ }
193
+ if (startPhase !== "failed") {
194
+ s.phase = "disabled";
195
+ }
196
+ } catch (err) {
197
+ s.phase = "failed";
198
+ s.errors.push(toError(err));
199
+ }
200
+ }
201
+ function reset() {
202
+ s.ctx = createTestThemeContext({ manifest: mergedManifest, config: options?.config });
203
+ s.phase = "discovered";
204
+ s.errors = [];
205
+ }
206
+ return {
207
+ get ctx() {
208
+ return s.ctx;
209
+ },
210
+ get definition() {
211
+ return mergedDefinition;
212
+ },
213
+ get phase() {
214
+ return s.phase;
215
+ },
216
+ get errors() {
217
+ return s.errors;
218
+ },
219
+ runRegister,
220
+ runInit,
221
+ runBoot,
222
+ runDestroy,
223
+ runAll,
224
+ reset
225
+ };
226
+ }
227
+ function assertValidTokens(tokens) {
228
+ const issues = [];
229
+ if (!tokens) {
230
+ return { valid: true, issues: [] };
231
+ }
232
+ if (tokens.colors) {
233
+ for (const [group, colorGroup] of Object.entries(tokens.colors)) {
234
+ if (!colorGroup || typeof colorGroup !== "object") continue;
235
+ for (const [name, token] of Object.entries(colorGroup)) {
236
+ if (!token) continue;
237
+ if (!token.value || typeof token.value !== "string" || token.value.trim() === "") {
238
+ issues.push(`colors.${group}.${name}: missing or empty value`);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ if (tokens.typography) {
244
+ for (const [name, token] of Object.entries(tokens.typography)) {
245
+ if (!token) continue;
246
+ const hasAny = token.fontFamily || token.fontSize || token.fontWeight || token.lineHeight || token.letterSpacing;
247
+ if (!hasAny) {
248
+ issues.push(`typography.${name}: no properties set`);
249
+ }
250
+ }
251
+ }
252
+ const valueSections = [
253
+ ["spacing", tokens.spacing],
254
+ ["shadows", tokens.shadows],
255
+ ["radii", tokens.radii]
256
+ ];
257
+ for (const [section, sectionTokens] of valueSections) {
258
+ if (!sectionTokens) continue;
259
+ for (const [name, token] of Object.entries(sectionTokens)) {
260
+ if (!token) continue;
261
+ if (!token.value || typeof token.value !== "string" || token.value.trim() === "") {
262
+ issues.push(`${section}.${name}: missing or empty value`);
263
+ }
264
+ }
265
+ }
266
+ return { valid: issues.length === 0, issues };
267
+ }
268
+ function assertValidBranding(branding) {
269
+ const issues = [];
270
+ if (!branding) {
271
+ return { valid: true, issues: [] };
272
+ }
273
+ if (branding.logo && typeof branding.logo !== "string") {
274
+ issues.push("branding.logo: must be a string URL/path");
275
+ }
276
+ if (branding.favicon && typeof branding.favicon !== "string") {
277
+ issues.push("branding.favicon: must be a string URL/path");
278
+ }
279
+ if (branding.title && typeof branding.title !== "string") {
280
+ issues.push("branding.title: must be a string");
281
+ }
282
+ return { valid: issues.length === 0, issues };
283
+ }
284
+ export {
285
+ assertValidBranding,
286
+ assertValidTokens,
287
+ createTestThemeContext,
288
+ createTestThemeHarness,
289
+ makeTestThemeDefinition,
290
+ makeTestThemeManifest
291
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@notty/theme-api",
3
- "version": "0.7.1",
4
- "description": "Notty Theme SDK for building themes",
3
+ "version": "1.0.0",
4
+ "description": "Theme API and testing toolkit for Notty CMS themes",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -11,27 +11,33 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
+ },
15
+ "./testing": {
16
+ "types": "./dist/testing/index.d.ts",
17
+ "import": "./dist/testing/index.js",
18
+ "require": "./dist/testing/index.cjs"
14
19
  }
15
20
  },
16
21
  "files": [
17
22
  "dist"
18
23
  ],
19
24
  "publishConfig": {
20
- "access": "restricted"
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup src/index.ts src/testing/index.ts --format cjs,esm --dts",
29
+ "build:libs": "tsup src/index.ts src/testing/index.ts --format cjs,esm --dts",
30
+ "dev": "tsup src/index.ts src/testing/index.ts --format cjs,esm --dts --watch",
31
+ "test": "vitest run",
32
+ "lint": "eslint src",
33
+ "type-check": "tsc --noEmit",
34
+ "clean": "rm -rf dist"
21
35
  },
22
36
  "dependencies": {
23
- "@notty/types": "0.7.1"
37
+ "@notty/types": "1.0.0"
24
38
  },
25
39
  "devDependencies": {
26
40
  "tsup": "^8.3.0",
27
41
  "typescript": "^5.6.3"
28
- },
29
- "scripts": {
30
- "build": "tsup src/index.ts --format cjs,esm --dts",
31
- "build:libs": "tsup src/index.ts --format cjs,esm --dts",
32
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
33
- "lint": "eslint src",
34
- "type-check": "tsc --noEmit",
35
- "clean": "rm -rf dist"
36
42
  }
37
43
  }