@kiosko/core 0.0.0-stage → 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.
Files changed (71) hide show
  1. package/dist/compositor.d.ts +99 -0
  2. package/dist/compositor.js +292 -0
  3. package/dist/config/check.d.ts +20 -0
  4. package/dist/config/check.js +163 -0
  5. package/dist/config/diff.d.ts +18 -0
  6. package/dist/config/diff.js +153 -0
  7. package/dist/config/jsonschema.d.ts +73 -0
  8. package/dist/config/jsonschema.js +364 -0
  9. package/dist/config/model.d.ts +196 -0
  10. package/dist/config/model.js +87 -0
  11. package/dist/config/normalize.d.ts +7 -0
  12. package/dist/config/normalize.js +61 -0
  13. package/dist/config/review.d.ts +115 -0
  14. package/dist/config/review.js +333 -0
  15. package/dist/config/schema.d.ts +31 -0
  16. package/dist/config/schema.js +350 -0
  17. package/dist/config/sources.d.ts +44 -0
  18. package/dist/config/sources.js +90 -0
  19. package/dist/config/store.d.ts +9 -0
  20. package/dist/config/store.js +45 -0
  21. package/dist/config/tools.d.ts +12 -0
  22. package/dist/config/tools.js +12 -0
  23. package/dist/data.d.ts +72 -0
  24. package/dist/data.js +293 -0
  25. package/dist/devices.d.ts +27 -0
  26. package/dist/devices.js +122 -0
  27. package/dist/env.d.ts +10 -0
  28. package/dist/env.js +43 -0
  29. package/dist/fonts.d.ts +8 -0
  30. package/dist/fonts.js +50 -0
  31. package/dist/index.d.ts +16 -0
  32. package/dist/index.js +16 -0
  33. package/dist/layout/grid.d.ts +105 -0
  34. package/dist/layout/grid.js +229 -0
  35. package/dist/layout/index.d.ts +2 -0
  36. package/dist/layout/index.js +2 -0
  37. package/dist/layout/stack.d.ts +25 -0
  38. package/dist/layout/stack.js +50 -0
  39. package/dist/paths.d.ts +11 -0
  40. package/dist/paths.js +43 -0
  41. package/dist/registry.d.ts +81 -0
  42. package/dist/registry.js +175 -0
  43. package/dist/runtime.d.ts +128 -0
  44. package/dist/runtime.js +419 -0
  45. package/dist/sdk-resolver.d.ts +1 -0
  46. package/dist/sdk-resolver.js +45 -0
  47. package/package.json +55 -4
  48. package/src/compositor.ts +358 -0
  49. package/src/config/check.ts +158 -0
  50. package/src/config/diff.ts +139 -0
  51. package/src/config/jsonschema.ts +373 -0
  52. package/src/config/model.ts +229 -0
  53. package/src/config/normalize.ts +63 -0
  54. package/src/config/review.ts +388 -0
  55. package/src/config/schema.ts +369 -0
  56. package/src/config/sources.ts +95 -0
  57. package/src/config/store.ts +44 -0
  58. package/src/config/tools.ts +12 -0
  59. package/src/data.ts +330 -0
  60. package/src/devices.ts +138 -0
  61. package/src/env.ts +48 -0
  62. package/src/fonts.ts +49 -0
  63. package/src/index.ts +16 -0
  64. package/src/layout/grid.ts +258 -0
  65. package/src/layout/index.ts +2 -0
  66. package/src/layout/stack.ts +52 -0
  67. package/src/paths.ts +45 -0
  68. package/src/registry.ts +174 -0
  69. package/src/runtime.ts +448 -0
  70. package/src/sdk-resolver.ts +43 -0
  71. package/README.md +0 -3
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Paints one page of a screen: background, then every tile with its card
3
+ * and the widget whose turn it is. Pure function of its input (plus the
4
+ * widgets' own code), so the panel, the editor preview and the gallery all
5
+ * go through here and can't disagree.
6
+ */
7
+ import { type Canvas, type SKRSContext2D } from '@napi-rs/canvas';
8
+ import { type Theme, type WidgetDefinition } from '@kiosko/sdk';
9
+ import { type PxRect } from './layout/grid.ts';
10
+ import { type Config, type Page, type Screen, type Tile, type WidgetInstance } from './config/model.ts';
11
+ import { type LogFn } from './env.ts';
12
+ import type { Registry } from './registry.ts';
13
+ export interface ComposeInput {
14
+ cfg: Config;
15
+ screen: Pick<Screen, 'width' | 'height' | 'pixelShift'>;
16
+ page: Page;
17
+ theme: Theme;
18
+ registry: Registry;
19
+ now: number;
20
+ working: boolean;
21
+ /** Data of an instance's own fetch. */
22
+ dataOf: (inst: WidgetInstance) => unknown;
23
+ /** Latest data of shared sources. */
24
+ sourcesOf: (types: readonly string[] | undefined) => Record<string, unknown>;
25
+ /** Painted for the editor, not for the panel. */
26
+ preview?: boolean;
27
+ /** Use each widget's demo data where there's no real data yet. */
28
+ demo?: boolean;
29
+ /** Also its demo options (gallery thumbnails only). */
30
+ demoOptions?: boolean;
31
+ /** Force which widget a tile shows (editor: the one selected in its stack). */
32
+ forceShown?: Record<string, string>;
33
+ log?: LogFn;
34
+ /** Disable pixel shift (editor, gallery). */
35
+ noShift?: boolean;
36
+ /** Reuse tiles whose inputs didn't change (the panel; not previews). */
37
+ cache?: TileCache;
38
+ }
39
+ export interface TileResult {
40
+ id: string;
41
+ rect: PxRect;
42
+ /** Id of the widget instance shown, or null if none is visible. */
43
+ shown: string | null;
44
+ error?: string;
45
+ }
46
+ export interface ComposeResult {
47
+ canvas: Canvas;
48
+ tiles: TileResult[];
49
+ /** Every widget shown says it's idle (and there's at least one). */
50
+ idle: boolean;
51
+ /** When something will change by itself (clock, rotation): next repaint. */
52
+ nextAt: number;
53
+ }
54
+ /**
55
+ * The instance's switches over the widget's hooks: `takeOver` (keep the tile
56
+ * while urgent) and `hideWhenEmpty` (skip it in the rotation while `empty`).
57
+ * Each defaults to what the widget declares.
58
+ */
59
+ export declare function tileState(def: WidgetDefinition<any, any>, inst: Pick<WidgetInstance, 'takeOver' | 'hideWhenEmpty'>, state: Parameters<NonNullable<WidgetDefinition['visible']>>[0]): {
60
+ visible: boolean;
61
+ urgent: boolean;
62
+ };
63
+ /** Cuts `text` with an ellipsis to fit `maxW` with the current font (plain fillText, no tracking). */
64
+ export declare function fitText(g: SKRSContext2D, text: string, maxW: number): string;
65
+ export declare function roundRect(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number): void;
66
+ /** Card padding and title size scale with the panel: a 320×240 can't afford 16 px margins. */
67
+ export declare function chromeMetrics(theme: Theme, screenH: number, rect: PxRect): {
68
+ pad: number;
69
+ title: number;
70
+ titleBar: number;
71
+ };
72
+ /** Rendered tiles kept between frames of one screen, so a frame only repaints what changed. */
73
+ export type TileCache = Map<string, {
74
+ key: string;
75
+ refs: unknown[];
76
+ canvas: Canvas;
77
+ error?: string;
78
+ }>;
79
+ /** Paints `page` on a new canvas of the screen size. */
80
+ export declare function composePage(input: ComposeInput): ComposeResult;
81
+ /** A canvas rotated for a panel mounted at `rotate` degrees. */
82
+ export declare function rotateCanvas(src: Canvas, rotate: number): Canvas;
83
+ /** Renders a single widget alone (gallery thumbnails, plugin tests), with demo data unless given. */
84
+ export declare function composeWidget(o: {
85
+ cfg: Config;
86
+ registry: Registry;
87
+ theme: Theme;
88
+ type: string;
89
+ width: number;
90
+ height: number;
91
+ options?: Record<string, unknown>;
92
+ title?: string;
93
+ data?: unknown;
94
+ sources?: Record<string, unknown>;
95
+ now?: number;
96
+ chrome?: Tile['chrome'];
97
+ /** The instance's zoom (see `WidgetInstance.zoom`). */
98
+ zoom?: number;
99
+ }): ComposeResult;
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Paints one page of a screen: background, then every tile with its card
3
+ * and the widget whose turn it is. Pure function of its input (plus the
4
+ * widgets' own code), so the panel, the editor preview and the gallery all
5
+ * go through here and can't disagree.
6
+ */
7
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
8
+ import { pixelShift } from '@loksly/lcd-canvas-kit';
9
+ import { localize, validate } from '@kiosko/sdk';
10
+ import { toPx } from "./layout/grid.js";
11
+ import { nextTurnAt, pickInStack } from "./layout/stack.js";
12
+ import { whenMatches, zoomOf } from "./config/model.js";
13
+ import { CANVAS, baseContext, kit, resolveLocale } from "./env.js";
14
+ import { registerFonts } from "./fonts.js";
15
+ const images = new Map();
16
+ /** Background images are loaded once, in the background; the first frames go without. */
17
+ function backgroundImage(path) {
18
+ if (!images.has(path)) {
19
+ images.set(path, null);
20
+ loadImage(path).then((img) => images.set(path, img)).catch(() => undefined);
21
+ }
22
+ return images.get(path) ?? null;
23
+ }
24
+ function resolve(input, inst) {
25
+ const def = input.registry.widget(inst.type);
26
+ if (!def)
27
+ return null;
28
+ let options = def.options ? validate(def.options, inst.options).value : {};
29
+ let data = input.dataOf(inst);
30
+ let sources = input.sourcesOf(def.sources);
31
+ if (input.demo && def.demo) {
32
+ const needData = data === undefined;
33
+ const needSources = (def.sources ?? []).some((s) => sources[s] === undefined);
34
+ if (needData || needSources || input.demoOptions) {
35
+ try {
36
+ const d = def.demo({ options, now: input.now, locale: resolveLocale(input.cfg) });
37
+ if (input.demoOptions && d.options && def.options)
38
+ options = validate(def.options, { ...d.options, ...inst.options }).value;
39
+ if (needData)
40
+ data = d.data;
41
+ if (needSources)
42
+ sources = { ...(d.sources ?? {}), ...Object.fromEntries(Object.entries(sources).filter(([, v]) => v !== undefined)) };
43
+ }
44
+ catch {
45
+ /* a broken demo shouldn't break the editor */
46
+ }
47
+ }
48
+ }
49
+ return { inst, def, options, data, sources };
50
+ }
51
+ function stateCtx(r, now, working, cfg) {
52
+ return { options: r.options, data: r.data, sources: r.sources, now, working, workday: cfg.workday, ...(cfg.location ? { location: cfg.location } : {}) };
53
+ }
54
+ /**
55
+ * The instance's switches over the widget's hooks: `takeOver` (keep the tile
56
+ * while urgent) and `hideWhenEmpty` (skip it in the rotation while `empty`).
57
+ * Each defaults to what the widget declares.
58
+ */
59
+ export function tileState(def, inst, state) {
60
+ const takeOver = inst.takeOver ?? def.takeOver ?? true;
61
+ const hideEmpty = inst.hideWhenEmpty ?? def.hideWhenEmpty ?? false;
62
+ const visible = safe(() => def.visible?.(state) ?? true, true) && !(hideEmpty && def.empty && safe(() => def.empty(state), false));
63
+ const urgent = takeOver && safe(() => def.urgent?.(state) ?? false, false);
64
+ return { visible, urgent };
65
+ }
66
+ function safe(fn, fallback) {
67
+ if (!fn)
68
+ return fallback;
69
+ try {
70
+ return fn();
71
+ }
72
+ catch {
73
+ return fallback;
74
+ }
75
+ }
76
+ /** How often a widget wants to be repainted on its own, in ms (0 = only when its data changes). */
77
+ function redrawStep(def, state) {
78
+ const r = (typeof def.redraw === 'function' ? safe(() => def.redraw(state), 'never') : def.redraw) ?? 'never';
79
+ if (r === 'never')
80
+ return 0;
81
+ return r === 'second' ? 1000 : r === 'minute' ? 60_000 : Math.max(250, r.everyMs);
82
+ }
83
+ /** Cuts `text` with an ellipsis to fit `maxW` with the current font (plain fillText, no tracking). */
84
+ export function fitText(g, text, maxW) {
85
+ if (g.measureText(text).width <= maxW)
86
+ return text;
87
+ const chars = [...text];
88
+ let lo = 0;
89
+ let hi = chars.length;
90
+ while (lo < hi) {
91
+ const mid = Math.ceil((lo + hi) / 2);
92
+ if (g.measureText(`${chars.slice(0, mid).join('').trimEnd()}…`).width <= maxW)
93
+ lo = mid;
94
+ else
95
+ hi = mid - 1;
96
+ }
97
+ return lo > 0 ? `${chars.slice(0, lo).join('').trimEnd()}…` : '…';
98
+ }
99
+ export function roundRect(g, x, y, w, h, r) {
100
+ g.beginPath();
101
+ g.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
102
+ }
103
+ /** Card padding and title size scale with the panel: a 320×240 can't afford 16 px margins. */
104
+ export function chromeMetrics(theme, screenH, rect) {
105
+ const scale = Math.max(0.45, Math.min(1, screenH / 462, rect.h / 180));
106
+ const pad = Math.round(16 * scale);
107
+ const title = Math.round(theme.titleSize * scale);
108
+ return { pad, title, titleBar: Math.round(title * 1.55) };
109
+ }
110
+ function drawCard(g, rect, theme) {
111
+ roundRect(g, rect.x, rect.y, rect.w, rect.h, theme.radius);
112
+ g.fillStyle = theme.colors.surface;
113
+ g.fill();
114
+ g.strokeStyle = theme.colors.surfaceEdge;
115
+ g.lineWidth = 1;
116
+ roundRect(g, rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1, theme.radius);
117
+ g.stroke();
118
+ }
119
+ function paintTile(g, input, tile, rect, out, nextAt, idles) {
120
+ const { theme, now, working } = input;
121
+ const candidates = tile.widgets
122
+ .filter((w) => !w.disabled && whenMatches(w.when, working))
123
+ .map((w) => resolve(input, w))
124
+ .filter((r) => !!r);
125
+ const entries = candidates.map((r) => ({ id: r.inst.id, ...tileState(r.def, r.inst, stateCtx(r, now, working, input.cfg)) }));
126
+ const forced = input.forceShown?.[tile.id];
127
+ const shownId = forced && candidates.some((c) => c.inst.id === forced) ? forced : pickInStack(entries, now, tile.rotateMs);
128
+ if (entries.filter((e) => e.visible).length > 1 && !forced)
129
+ nextAt.v = Math.min(nextAt.v, nextTurnAt(now, tile.rotateMs));
130
+ out.shown = shownId;
131
+ const r = candidates.find((c) => c.inst.id === shownId);
132
+ const chrome = tile.chrome === 'auto' ? (r?.def.chrome ?? 'card') : tile.chrome;
133
+ if (!r) {
134
+ if (chrome === 'card')
135
+ drawCard(g, rect, theme);
136
+ // An empty tile is invisible on the panel; in the editor it's a hint.
137
+ if (input.preview && !tile.widgets.length) {
138
+ g.setLineDash([6, 6]);
139
+ g.strokeStyle = theme.colors.inkFaint;
140
+ roundRect(g, rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2, theme.radius);
141
+ g.stroke();
142
+ g.setLineDash([]);
143
+ }
144
+ return;
145
+ }
146
+ const state = stateCtx(r, now, working, input.cfg);
147
+ idles.push(safe(() => r.def.idle?.(state) ?? false, false));
148
+ const step = redrawStep(r.def, state);
149
+ if (step)
150
+ nextAt.v = Math.min(nextAt.v, (Math.floor(now / step) + 1) * step);
151
+ const draw = (tg) => drawTileContent(tg, input, r, rect, chrome, out);
152
+ if (!input.cache) {
153
+ draw(g);
154
+ return;
155
+ }
156
+ // Same widget, options, size, data and sources (by reference: they're
157
+ // replaced, never mutated, when they change) and same redraw slot: reuse.
158
+ const cfg = input.cfg;
159
+ const key = JSON.stringify([r.inst.id, r.inst.type, r.inst.options, r.inst.title ?? '', zoomOf(r.inst), chrome, rect, theme.id, step ? Math.floor(now / step) : 0, cfg.locale, cfg.timeFormat, cfg.workday, cfg.location ?? null, working]);
160
+ const refs = [r.data, ...(r.def.sources ?? []).map((s) => r.sources[s])];
161
+ const hit = input.cache.get(tile.id);
162
+ if (hit && hit.key === key && hit.refs.length === refs.length && hit.refs.every((x, i) => x === refs[i])) {
163
+ g.drawImage(hit.canvas, rect.x, rect.y);
164
+ if (hit.error)
165
+ out.error = hit.error;
166
+ return;
167
+ }
168
+ const canvas = createCanvas(rect.w, rect.h);
169
+ const tg = canvas.getContext('2d');
170
+ tg.translate(-rect.x, -rect.y);
171
+ draw(tg);
172
+ g.drawImage(canvas, rect.x, rect.y);
173
+ input.cache.set(tile.id, { key, refs, canvas, ...(out.error ? { error: out.error } : {}) });
174
+ }
175
+ function drawTileContent(g, input, r, rect, chrome, out) {
176
+ const { theme, now, working } = input;
177
+ const m = chromeMetrics(theme, input.screen.height, rect);
178
+ if (chrome === 'card')
179
+ drawCard(g, rect, theme);
180
+ const pad = chrome === 'card' ? m.pad : 0;
181
+ let top = rect.y + pad;
182
+ const title = r.inst.title?.trim();
183
+ if (title && chrome === 'card') {
184
+ g.font = `${m.title}px "${theme.fonts.sans}"`;
185
+ g.fillStyle = theme.colors.inkDim;
186
+ g.textBaseline = 'alphabetic';
187
+ g.fillText(fitText(g, title, rect.w - 2 * pad), rect.x + pad, rect.y + pad + m.title * 0.8);
188
+ top = rect.y + pad + m.titleBar;
189
+ }
190
+ const outer = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
191
+ // Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
192
+ // times bigger, so it fills the same space with bigger (or smaller) type.
193
+ // Its box keeps its top-left corner, so widgets see nothing unusual.
194
+ const zoom = zoomOf(r.inst);
195
+ const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
196
+ const [plugin] = r.inst.type.split('/');
197
+ const ctx = {
198
+ ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), now),
199
+ ...stateCtx(r, now, working, input.cfg),
200
+ box,
201
+ theme,
202
+ fonts: theme.fonts,
203
+ kit,
204
+ canvas: CANVAS,
205
+ preview: !!input.preview,
206
+ };
207
+ g.save();
208
+ roundRect(g, rect.x, rect.y, rect.w, rect.h, chrome === 'card' ? theme.radius : 0);
209
+ g.clip();
210
+ if (zoom !== 1) {
211
+ g.translate(outer.x * (1 - zoom), outer.y * (1 - zoom));
212
+ g.scale(zoom, zoom);
213
+ }
214
+ try {
215
+ r.def.render(g, ctx);
216
+ }
217
+ catch (err) {
218
+ out.error = err instanceof Error ? err.message : String(err);
219
+ input.log?.(r.inst.type, `render failed: ${out.error}`);
220
+ const size = Math.max(12, Math.min(20, box.h / 4));
221
+ kit.drawTracked(g, kit.truncate(g, `⚠ ${localize(r.def.name, ctx.locale)}: ${out.error}`, box.w, { size, family: theme.fonts.mono }), box.x, box.y + size * 1.2, { size, family: theme.fonts.mono, color: theme.colors.alert });
222
+ }
223
+ g.restore();
224
+ }
225
+ /** Paints `page` on a new canvas of the screen size. */
226
+ export function composePage(input) {
227
+ registerFonts();
228
+ const { screen, page, theme, now } = input;
229
+ const canvas = createCanvas(screen.width, screen.height);
230
+ const g = canvas.getContext('2d');
231
+ g.fillStyle = page.background.color || theme.colors.bg;
232
+ g.fillRect(0, 0, screen.width, screen.height);
233
+ if (page.background.image) {
234
+ const img = backgroundImage(page.background.image);
235
+ if (img) {
236
+ const s = Math.max(screen.width / img.width, screen.height / img.height);
237
+ g.drawImage(img, (screen.width - img.width * s) / 2, (screen.height - img.height * s) / 2, img.width * s, img.height * s);
238
+ }
239
+ }
240
+ const shift = input.noShift || input.preview ? { x: 0, y: 0 } : pixelShift(now, screen.pixelShift);
241
+ g.save();
242
+ g.translate(shift.x, shift.y);
243
+ const nextAt = { v: Infinity };
244
+ const idles = [];
245
+ const tiles = [];
246
+ for (const tile of page.tiles) {
247
+ const rect = toPx(tile, page.grid, screen.width, screen.height);
248
+ const out = { id: tile.id, rect, shown: null };
249
+ tiles.push(out);
250
+ paintTile(g, input, tile, rect, out, nextAt, idles);
251
+ }
252
+ if (input.cache)
253
+ for (const id of input.cache.keys())
254
+ if (!page.tiles.some((t) => t.id === id))
255
+ input.cache.delete(id);
256
+ g.restore();
257
+ if (screen.pixelShift.enabled && !input.preview)
258
+ nextAt.v = Math.min(nextAt.v, (Math.floor(now / screen.pixelShift.periodMs) + 1) * screen.pixelShift.periodMs);
259
+ return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v };
260
+ }
261
+ /** A canvas rotated for a panel mounted at `rotate` degrees. */
262
+ export function rotateCanvas(src, rotate) {
263
+ if (!rotate)
264
+ return src;
265
+ const swap = rotate === 90 || rotate === 270;
266
+ const out = createCanvas(swap ? src.height : src.width, swap ? src.width : src.height);
267
+ const g = out.getContext('2d');
268
+ g.translate(out.width / 2, out.height / 2);
269
+ g.rotate((rotate * Math.PI) / 180);
270
+ g.drawImage(src, -src.width / 2, -src.height / 2);
271
+ return out;
272
+ }
273
+ /** Renders a single widget alone (gallery thumbnails, plugin tests), with demo data unless given. */
274
+ export function composeWidget(o) {
275
+ const inst = { id: 'w', type: o.type, options: o.options ?? {}, ...(o.title ? { title: o.title } : {}), ...(o.zoom !== undefined && o.zoom !== 1 ? { zoom: o.zoom } : {}) };
276
+ const page = { id: 'p', name: 'p', grid: { cols: 1, rows: 1, gap: 0, padding: 0 }, gravity: 'none', background: { color: o.theme.colors.bg }, tiles: [{ id: 't', x: 0, y: 0, w: 1, h: 1, widgets: [inst], rotateMs: 0, chrome: o.chrome ?? 'auto' }] };
277
+ return composePage({
278
+ cfg: o.cfg,
279
+ screen: { width: o.width, height: o.height, pixelShift: { enabled: false, amplitude: 0, periodMs: 1 } },
280
+ page,
281
+ theme: o.theme,
282
+ registry: o.registry,
283
+ now: o.now ?? Date.now(),
284
+ working: true,
285
+ dataOf: () => o.data,
286
+ sourcesOf: (types) => Object.fromEntries((types ?? []).map((t) => [t, o.sources?.[t]])),
287
+ preview: true,
288
+ demo: o.data === undefined,
289
+ demoOptions: !o.options || !Object.keys(o.options).length,
290
+ noShift: true,
291
+ });
292
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Validating a config: against its JSON Schema (shape, types, known widget
3
+ * types and option values) and against what `normalize` makes of it (ids,
4
+ * tiles that fit the grid and don't overlap, sizes widgets need, option
5
+ * names no plugin knows). Errors make Kiosko misbehave or lose something;
6
+ * warnings are worth a look but harmless.
7
+ *
8
+ * The messages carry a JSON path and say what to change, so a user can paste
9
+ * them back to the AI that wrote the file. Pure (no Node).
10
+ */
11
+ import type { PluginDefinition } from '@kiosko/sdk';
12
+ import { type JsonIssue, type JsonSchema } from './jsonschema.ts';
13
+ export interface ConfigReport {
14
+ errors: JsonIssue[];
15
+ warnings: JsonIssue[];
16
+ }
17
+ /** What the schema can't say: checked on `normalize(raw)`, with paths into `raw` (normalize keeps every list's order). */
18
+ export declare function checkConfig(raw: unknown, plugins: readonly PluginDefinition[]): ConfigReport;
19
+ /** Schema errors, then what only `normalize` reveals. `schema` defaults to the one of `plugins`. */
20
+ export declare function validateConfig(raw: unknown, plugins: readonly PluginDefinition[], schema?: JsonSchema): ConfigReport;
@@ -0,0 +1,163 @@
1
+ import { cellsFor, inBounds, overlaps, toPx } from "../layout/grid.js";
2
+ import { closest, formatPath, toPointer, validateJson } from "./jsonschema.js";
3
+ import { zoomOf } from "./model.js";
4
+ import { normalize } from "./normalize.js";
5
+ import { configSchema, TYPE_PATTERN } from "./schema.js";
6
+ import { sourceOptionsField } from "./sources.js";
7
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ const issue = (path, message, context) => ({ path: formatPath(path), pointer: toPointer(path), message, ...(context ? { context } : {}) });
9
+ const near = (word, names) => {
10
+ const c = closest(word, names);
11
+ return c.length ? ` (did you mean ${c.map((x) => JSON.stringify(x)).join(' or ')}?)` : '';
12
+ };
13
+ /** Option names a plugin doesn't know (typos, invented ones): ignored by Kiosko, so worth a warning. */
14
+ function unknownOptions(f, v, path, out, owner) {
15
+ if (f.kind === 'object' && isObj(v)) {
16
+ const props = f.props;
17
+ const names = Object.keys(props);
18
+ for (const [k, x] of Object.entries(v)) {
19
+ const sub = props[k];
20
+ if (sub)
21
+ unknownOptions(sub, x, [...path, k], out, owner);
22
+ else
23
+ out.push(issue([...path, k], `is not an option of ${owner}${near(k, names)}, so it is ignored. Its options: ${names.join(', ') || 'none'}`, owner));
24
+ }
25
+ }
26
+ else if (f.kind === 'list' && Array.isArray(v))
27
+ v.forEach((x, i) => unknownOptions(f.item, x, [...path, i], out, owner));
28
+ }
29
+ const validGrid = (g) => isObj(g) && ['cols', 'rows', 'gap', 'padding'].every((k) => Number.isInteger(g[k])) && g.cols >= 1 && g.rows >= 1;
30
+ const validRect = (t) => isObj(t) && ['x', 'y', 'w', 'h'].every((k) => Number.isInteger(t[k]));
31
+ function repeated(items, path, what, out) {
32
+ const seen = new Map();
33
+ items.forEach((x, i) => {
34
+ if (!isObj(x) || typeof x.id !== 'string')
35
+ return;
36
+ const first = seen.get(x.id);
37
+ if (first !== undefined)
38
+ out.push(issue([...path, i, 'id'], `repeats the id ${JSON.stringify(x.id)} of ${formatPath([...path, first])}: ${what} ids must be unique`));
39
+ else
40
+ seen.set(x.id, i);
41
+ });
42
+ }
43
+ /** What the schema can't say: checked on `normalize(raw)`, with paths into `raw` (normalize keeps every list's order). */
44
+ export function checkConfig(raw, plugins) {
45
+ const errors = [];
46
+ const warnings = [];
47
+ if (!isObj(raw))
48
+ return { errors: [issue([], 'the config must be a JSON object')], warnings };
49
+ let cfg;
50
+ try {
51
+ cfg = normalize(raw);
52
+ }
53
+ catch {
54
+ // Lists where objects should be (or the other way round): the schema errors say where.
55
+ return { errors, warnings };
56
+ }
57
+ const widgets = new Map(plugins.flatMap((p) => (p.widgets ?? []).map((w) => [`${p.id}/${w.id}`, w])));
58
+ const sources = new Map(plugins.flatMap((p) => (p.sources ?? []).map((s) => [`${p.id}/${s.id}`, s])));
59
+ const interludes = new Map(plugins.flatMap((p) => (p.interludes ?? []).map((i) => [`${p.id}/${i.id}`, i])));
60
+ const pluginIds = plugins.map((p) => p.id);
61
+ const disabled = new Set(Array.isArray(cfg.plugins.disabled) ? cfg.plugins.disabled : []);
62
+ const widgetIds = new Map();
63
+ if (!Array.isArray(cfg.screens))
64
+ return { errors, warnings };
65
+ repeated(cfg.screens, ['screens'], 'screen', errors);
66
+ cfg.screens.forEach((s, si) => {
67
+ const sp = ['screens', si];
68
+ if (!isObj(s) || !Array.isArray(s.pages))
69
+ return;
70
+ repeated(s.pages, [...sp, 'pages'], 'page', errors);
71
+ if (!s.pages.length)
72
+ warnings.push(issue([...sp, 'pages'], 'the screen has no pages, so it shows nothing'));
73
+ if (s.pinnedPage && !s.pages.some((p) => p.id === s.pinnedPage))
74
+ errors.push(issue([...sp, 'pinnedPage'], `names no page of this screen; its pages are ${s.pages.map((p) => JSON.stringify(p.id)).join(', ') || 'none'}`));
75
+ s.pages.forEach((p, pi) => {
76
+ const pp = [...sp, 'pages', pi];
77
+ if (!isObj(p) || !Array.isArray(p.tiles))
78
+ return;
79
+ repeated(p.tiles, [...pp, 'tiles'], 'tile', errors);
80
+ const grid = validGrid(p.grid) ? p.grid : null;
81
+ p.tiles.forEach((t, ti) => {
82
+ const tp = [...pp, 'tiles', ti];
83
+ if (!isObj(t))
84
+ return;
85
+ const geometry = grid && validRect(t) && Number.isInteger(s.width) && Number.isInteger(s.height);
86
+ if (geometry && !inBounds(t, grid)) {
87
+ const past = [t.x + t.w > grid.cols && `x + w = ${t.x + t.w} but the grid has ${grid.cols} columns`, t.y + t.h > grid.rows && `y + h = ${t.y + t.h} but the grid has ${grid.rows} rows`, (t.x < 0 || t.y < 0) && 'x and y start at 0', (t.w < 1 || t.h < 1) && 'w and h are at least 1'].filter(Boolean);
88
+ errors.push(issue(tp, `does not fit the ${grid.cols}×${grid.rows} grid: ${past.join('; ')}`));
89
+ }
90
+ if (geometry) {
91
+ p.tiles.forEach((o, oi) => {
92
+ if (oi > ti && isObj(o) && validRect(o) && overlaps(t, o))
93
+ warnings.push(issue(tp, `overlaps ${formatPath([...pp, 'tiles', oi])} (${JSON.stringify(o.id)}): one hides part of the other; move or shrink one`));
94
+ });
95
+ }
96
+ if (!Array.isArray(t.widgets))
97
+ return;
98
+ if (!t.widgets.length)
99
+ warnings.push(issue([...tp, 'widgets'], 'the tile has no widgets, so it stays empty'));
100
+ t.widgets.forEach((w, wi) => {
101
+ const wp = [...tp, 'widgets', wi];
102
+ if (!isObj(w))
103
+ return;
104
+ if (typeof w.id === 'string') {
105
+ const first = widgetIds.get(w.id);
106
+ if (first)
107
+ errors.push(issue([...wp, 'id'], `repeats the id ${JSON.stringify(w.id)} of ${formatPath(first)}: widget ids must be unique in the whole config`));
108
+ else
109
+ widgetIds.set(w.id, wp);
110
+ }
111
+ const def = typeof w.type === 'string' ? widgets.get(w.type) : undefined;
112
+ if (!def)
113
+ return; // unknown types are schema errors
114
+ const plugin = w.type.split('/')[0];
115
+ if (disabled.has(plugin))
116
+ warnings.push(issue(wp, `its plugin ${JSON.stringify(plugin)} is switched off in plugins.disabled, so it won't show`, w.type));
117
+ const zoom = zoomOf(w);
118
+ const min = def.size?.min && { w: Math.ceil(def.size.min.w * zoom), h: Math.ceil(def.size.min.h * zoom) };
119
+ if (min && geometry) {
120
+ const px = toPx(t, grid, s.width, s.height);
121
+ if (px.w < min.w || px.h < min.h) {
122
+ const need = cellsFor(min, grid, s.width, s.height);
123
+ warnings.push(issue(tp, `is ${px.w}×${px.h} px but ${w.type} needs at least ${min.w}×${min.h} px${zoom !== 1 ? ` at zoom ${zoom}` : ''}: make it at least ${Math.max(need.w, t.w)}×${Math.max(need.h, t.h)} cells (w×h)${zoom !== 1 ? ' or lower its zoom' : ''}`, w.type));
124
+ }
125
+ }
126
+ if (def.options)
127
+ unknownOptions(def.options, w.options, [...wp, 'options'], warnings, w.type);
128
+ });
129
+ });
130
+ });
131
+ if (!Array.isArray(s.interludes))
132
+ return;
133
+ repeated(s.interludes, [...sp, 'interludes'], 'interlude', errors);
134
+ s.interludes.forEach((x, xi) => {
135
+ const def = isObj(x) && typeof x.type === 'string' ? interludes.get(x.type) : undefined;
136
+ if (def?.options)
137
+ unknownOptions(def.options, x.options, [...sp, 'interludes', xi, 'options'], warnings, x.type);
138
+ });
139
+ });
140
+ if (isObj(cfg.sources)) {
141
+ for (const [type, opts] of Object.entries(cfg.sources)) {
142
+ const def = sources.get(type);
143
+ const field = def && sourceOptionsField(def);
144
+ if (field)
145
+ unknownOptions(field, opts, ['sources', type], warnings, type);
146
+ else if (!def && new RegExp(TYPE_PATTERN).test(type))
147
+ warnings.push(issue(['sources', type], `no installed plugin provides this source, so it is ignored${near(type, [...sources.keys()])}`));
148
+ }
149
+ }
150
+ if (Array.isArray(cfg.plugins.disabled)) {
151
+ cfg.plugins.disabled.forEach((id, i) => {
152
+ if (typeof id === 'string' && !pluginIds.includes(id))
153
+ warnings.push(issue(['plugins', 'disabled', i], `no installed plugin is called ${JSON.stringify(id)}${near(id, pluginIds)}`));
154
+ });
155
+ }
156
+ return { errors, warnings };
157
+ }
158
+ /** Schema errors, then what only `normalize` reveals. `schema` defaults to the one of `plugins`. */
159
+ export function validateConfig(raw, plugins, schema = configSchema(plugins)) {
160
+ const shape = validateJson(schema, raw);
161
+ const more = checkConfig(raw, plugins);
162
+ return { errors: [...shape, ...more.errors], warnings: more.warnings };
163
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A short, readable summary of what changes between two configs: screens,
3
+ * pages and tiles added or removed, sources and settings changed. For
4
+ * `kiosko config import`, so a config written elsewhere can be reviewed
5
+ * before it replaces the current one. Never prints option values (they may
6
+ * be secrets), only which ones changed. Pure (no Node).
7
+ */
8
+ import type { Config } from './model.ts';
9
+ export interface DiffLine {
10
+ /** Nesting: 0 for screens, sources and settings, 1 for pages, 2 for tiles. */
11
+ depth: number;
12
+ op: '+' | '-' | '~';
13
+ kind: 'setting' | 'source' | 'screen' | 'page' | 'tile' | 'interlude';
14
+ text: string;
15
+ }
16
+ export declare function diffConfigs(before: Config, after: Config): DiffLine[];
17
+ /** "1 screen added, 3 pages added, 5 tiles removed, 1 source changed". */
18
+ export declare function summarizeDiff(lines: readonly DiffLine[]): string;