@nebulahome/package-sdk 1.0.1 → 1.2.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/CHANGELOG.md +39 -0
- package/README.md +64 -9
- package/dist/chunks/context.d.ts +46 -1
- package/dist/chunks/history.d.ts +39 -0
- package/dist/chunks/host.js +21 -7
- package/dist/chunks/json.d.ts +1 -1
- package/dist/chunks/node-build.js +185 -13
- package/dist/chunks/node-config.js +38 -36
- package/dist/chunks/node-pack.js +1 -1
- package/dist/chunks/version.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/styles.css +12 -0
- package/dist/sun.d.ts +62 -0
- package/dist/sun.js +328 -0
- package/dist/testing.d.ts +16 -1
- package/dist/testing.js +79 -0
- package/dist/ui.d.ts +157 -2
- package/dist/ui.js +97 -2
- package/dist/vite.d.ts +2 -2
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
3
3
|
The SDK's version is the version of the package API the platform implements: a minor version adds
|
|
4
4
|
to it, a major one breaks it, and a patch leaves it as it is.
|
|
5
5
|
|
|
6
|
+
## 1.2.0
|
|
7
|
+
|
|
8
|
+
- **The past of the house.** `ctx.history.states()` and `ctx.history.telemetry()` on the server
|
|
9
|
+
read the recorded changes and the numeric readings of up to 64 entities over a window, with the
|
|
10
|
+
house's authority as `ctx.entities` has; `useStateHistory()` and `useTelemetry()` in
|
|
11
|
+
`@nebulahome/package-sdk/ui` read them with the viewer's, for a chart, an activity list or a
|
|
12
|
+
replay. Each entity's points come oldest first; a window that holds more than a read returns
|
|
13
|
+
loses its oldest points and names the entity in `truncated`. A window is two moments, or in the
|
|
14
|
+
interface the last so many milliseconds, which moves with each read; telemetry is read raw, or
|
|
15
|
+
in buckets of a minute, an hour or a day, `auto` picking by the window's length.
|
|
16
|
+
`createTestContext({ history })` and `test.setHistory()` give a unit test a past.
|
|
17
|
+
- **The areas.** `useAreas()` lists the house, its floors (with their storey) and its rooms, each
|
|
18
|
+
naming its parent by key - the key `EntityView.areaId` holds - and the house its position.
|
|
19
|
+
- **What an entity can do.** `EntityView.capabilities` holds the capabilities an entity declares,
|
|
20
|
+
with the device's limits: a cover's tilt, a thermostat's range and modes, a select's options.
|
|
21
|
+
- **Pictures.** `useEntityPicture()` gives a camera's latest still, fetched again every so often,
|
|
22
|
+
or an image entity's picture, as an address an `<img>` shows.
|
|
23
|
+
- **The sun.** `@nebulahome/package-sdk/sun`: `sunPosition`, `sunTimes`, `sunState` and
|
|
24
|
+
`sunPhaseLabel`, the platform's own formulas, for a moment the sun entities did not report.
|
|
25
|
+
- A package's id may start with a digit (`3d-home`); its entities' object ids then do too.
|
|
26
|
+
- **The MQTT broker is the platform's.** The hub runs the house's broker: `requires.services:
|
|
27
|
+
["mqtt-broker"]` is always met, `{platform.mqtt.*}` and `NEBULA_PLATFORM_MQTT_*` name it, and a
|
|
28
|
+
package that `provides` `mqtt-broker` is refused at install.
|
|
29
|
+
|
|
30
|
+
## 1.1.0
|
|
31
|
+
|
|
32
|
+
- `useTranslation()` in `@nebulahome/package-sdk/ui`: a package's page, cards and entity views
|
|
33
|
+
say their strings with `t('clients.title', 'Clients')`, in the viewer's language. The English
|
|
34
|
+
is the fallback, and a plural takes `{ one, other }` with `count`; outside the interface - a
|
|
35
|
+
component's unit test - every string is its English.
|
|
36
|
+
- Catalogues: `locales/<language>.json` beside `nebula.config.ts` (`it.json`, `pt-BR.json`), JSON
|
|
37
|
+
objects of strings nested as the keys are dotted, with i18next's plural suffixes and
|
|
38
|
+
`{{placeholders}}`. `nebula()` bundles each as a chunk of the interface, loaded only for a viewer
|
|
39
|
+
who reads that language; `validate` checks them (a language for a name, strings only, plurals
|
|
40
|
+
with their `_other`, at most 256 KB); `build` warns which strings a catalogue says in English
|
|
41
|
+
and which of its keys no string uses.
|
|
42
|
+
- What `nebula.config.ts` declares - names, settings, entity types - stays English, as the
|
|
43
|
+
platform shows it everywhere.
|
|
44
|
+
|
|
6
45
|
## 1.0.1
|
|
7
46
|
|
|
8
47
|
- `nebulahome-package --help`, and a mistyped command, work where Vite is not installed -
|
package/README.md
CHANGED
|
@@ -86,17 +86,72 @@ import { nebula } from '@nebulahome/package-sdk/vite';
|
|
|
86
86
|
export default defineConfig({ plugins: [react(), tailwindcss(), nebula()] });
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
## Translating the interface
|
|
90
|
+
|
|
91
|
+
The page, the cards and the entity views say their strings through `useTranslation()`, with the
|
|
92
|
+
English beside each key; a catalogue per language goes in `locales/` next to `nebula.config.ts`:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
// src/ui/forecast-card.tsx
|
|
96
|
+
import { useTranslation } from '@nebulahome/package-sdk/ui';
|
|
97
|
+
|
|
98
|
+
const { t } = useTranslation();
|
|
99
|
+
|
|
100
|
+
t('forecast.empty', 'No forecast yet');
|
|
101
|
+
t('forecast.days', { one: '{{count}} day', other: '{{count}} days' }, { count: days.length });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
// locales/it.json
|
|
106
|
+
{
|
|
107
|
+
"forecast": {
|
|
108
|
+
"empty": "Ancora nessuna previsione",
|
|
109
|
+
"days_one": "{{count}} giorno",
|
|
110
|
+
"days_other": "{{count}} giorni"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
A string a catalogue lacks is said in English, and `nebulahome-package build` lists them. What
|
|
116
|
+
`nebula.config.ts` declares (the package's name, its cards' and settings' names, its entity types)
|
|
117
|
+
stays English: the platform shows it the same way to everyone.
|
|
118
|
+
|
|
119
|
+
## The past, the areas and the sun
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
// a sensor's chart: the last 6 hours, in minutes
|
|
123
|
+
const day = useTelemetry({ entityIds: [entityId], window: { lastMs: 6 * 3_600_000 } });
|
|
124
|
+
const points = day.series[entityId] ?? []; // oldest first: { time, value, min, max }
|
|
125
|
+
|
|
126
|
+
// what a door did today, and the rooms of the house
|
|
127
|
+
const today = useStateHistory({ entityIds: [door], window: { from: midnight, to: now } });
|
|
128
|
+
const rooms = useAreas().filter((area) => area.kind === 'room');
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// on the server, with the house's authority
|
|
133
|
+
const { series } = await ctx.history.states({ entityIds, from: yesterday, to: new Date() });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// where the sun was, for a moment the sun entities did not report
|
|
138
|
+
import { sunPosition } from '@nebulahome/package-sdk/sun';
|
|
139
|
+
|
|
140
|
+
const { elevation, azimuth } = sunPosition(at, { latitude, longitude });
|
|
141
|
+
```
|
|
142
|
+
|
|
89
143
|
## Entry points
|
|
90
144
|
|
|
91
|
-
| Import | What
|
|
92
|
-
| ------------------------- |
|
|
93
|
-
| `@nebulahome/package-sdk` | `definePackage`, `defineSettings`, `defineCard`, `defineEntityType`, `definePage`, `defineContainer`, `defineCompose`, `field`, entity id helpers, `SDK_VERSION`, the value types.
|
|
94
|
-
| `/server` | `defineServer`, and the context `start` is given: settings (live or restart), devices and entities, typed custom entities, the rest of the house, methods for the interface, services between packages.
|
|
95
|
-
| `/ui` | The props of a page, a card and an entity view; hooks for the package, its methods, its settings, the house, the viewer, the theme; navigation inside the package's page and
|
|
96
|
-
| `/
|
|
97
|
-
| `/
|
|
98
|
-
| `/
|
|
99
|
-
| `/
|
|
145
|
+
| Import | What |
|
|
146
|
+
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
147
|
+
| `@nebulahome/package-sdk` | `definePackage`, `defineSettings`, `defineCard`, `defineEntityType`, `definePage`, `defineContainer`, `defineCompose`, `field`, entity id helpers, `SDK_VERSION`, the value types. |
|
|
148
|
+
| `/server` | `defineServer`, and the context `start` is given: settings (live or restart), devices and entities, typed custom entities, the rest of the house and its past, methods for the interface, services between packages. |
|
|
149
|
+
| `/ui` | The props of a page, a card and an entity view; hooks for the package, its methods, its settings, the house, its areas, its past and its pictures, the viewer, the theme, its strings; navigation inside the package's page and the platform's. |
|
|
150
|
+
| `/sun` | Where the sun is at any moment, by the platform's own formulas. |
|
|
151
|
+
| `/components` | The part of the interface's design system a package may draw with. |
|
|
152
|
+
| `/vite` | `nebula()`, the Vite plugin that builds both halves: the interface in chunks with its CSS scoped to the package, the server bundled whole. |
|
|
153
|
+
| `/testing` | `createTestContext()`: run `start` in a unit test, every effect recorded. |
|
|
154
|
+
| `/styles.css` | The interface's Tailwind tokens, to `@reference`. |
|
|
100
155
|
|
|
101
156
|
The `nebulahome-package` command checks, builds and packs a package:
|
|
102
157
|
|
package/dist/chunks/context.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { G as CapabilityState, V as CapabilityCommand, X as EntityStateClass, Y as DeclaredCapabilities, a as SettingsOf, ct as EntityDomain, d as EntityTypeDefinition, et as ReportedCapabilityState, f as ValuesOf, i as SettingsDefinition, lt as EntityId, r as JsonValue, s as ArgsOf, t as JsonObject, u as CommandsOf } from "./json.js";
|
|
2
|
+
import { a as TelemetryResolution, i as TelemetryPoint, n as HistorySeries, r as StateHistoryPoint } from "./history.js";
|
|
2
3
|
//#region src/schema.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* What checks a value that crosses into a package - a method's input, a service's answer: any
|
|
@@ -163,6 +164,28 @@ type EntityFilter = {
|
|
|
163
164
|
readonly domain?: EntityDomain;
|
|
164
165
|
readonly entityIds?: readonly EntityId[];
|
|
165
166
|
};
|
|
167
|
+
/** Which entities' past `ctx.history` reads, over which window. Since 1.2. */
|
|
168
|
+
type HistoryRead = {
|
|
169
|
+
/** At most 64. */
|
|
170
|
+
readonly entityIds: readonly EntityId[];
|
|
171
|
+
/** Inclusive: a `Date` or ISO 8601. */
|
|
172
|
+
readonly from: Date | string;
|
|
173
|
+
/** Exclusive. */
|
|
174
|
+
readonly to: Date | string;
|
|
175
|
+
/** The most points of one entity, at most 10 000 (the default): the newest are kept. */
|
|
176
|
+
readonly limit?: number;
|
|
177
|
+
};
|
|
178
|
+
/** Which entities' readings `ctx.history.telemetry` reads: `auto` picks by the window's length. */
|
|
179
|
+
type TelemetryRead = HistoryRead & {
|
|
180
|
+
readonly resolution?: TelemetryResolution | 'auto';
|
|
181
|
+
};
|
|
182
|
+
/** What a read of the past found. */
|
|
183
|
+
type HistoryAnswer<TPoint> = {
|
|
184
|
+
/** Each entity's points, oldest first. */
|
|
185
|
+
readonly series: HistorySeries<TPoint>;
|
|
186
|
+
/** The entities whose window held more than the limit: their oldest points are gone. */
|
|
187
|
+
readonly truncated: readonly EntityId[];
|
|
188
|
+
};
|
|
166
189
|
/** Who may call a method: whoever may read packages, or whoever may configure them. */
|
|
167
190
|
type RpcAccess = 'read' | 'configure';
|
|
168
191
|
/** Who called a method of the package's interface. */
|
|
@@ -354,6 +377,28 @@ type ServerContext<TSettings = undefined> = {
|
|
|
354
377
|
*/
|
|
355
378
|
readonly subscribe: (filter: EntityFilter, handler: (change: StateChange) => Awaitable<void>) => Unsubscribe;
|
|
356
379
|
};
|
|
380
|
+
/**
|
|
381
|
+
* The past of the house's entities, read only, with the house's authority as `entities` has.
|
|
382
|
+
* Since 1.2.
|
|
383
|
+
*/
|
|
384
|
+
readonly history: {
|
|
385
|
+
/**
|
|
386
|
+
* The recorded changes of some entities over a window.
|
|
387
|
+
*
|
|
388
|
+
* @param read - Which entities, which window, how many points each at most.
|
|
389
|
+
* @returns Each entity's changes, oldest first.
|
|
390
|
+
*/
|
|
391
|
+
readonly states: (read: HistoryRead) => Promise<HistoryAnswer<StateHistoryPoint>>;
|
|
392
|
+
/**
|
|
393
|
+
* The numeric readings of some entities over a window, raw or in buckets.
|
|
394
|
+
*
|
|
395
|
+
* @param read - Which entities, which window, which resolution.
|
|
396
|
+
* @returns Each entity's readings, oldest first, and the resolution read.
|
|
397
|
+
*/
|
|
398
|
+
readonly telemetry: (read: TelemetryRead) => Promise<HistoryAnswer<TelemetryPoint> & {
|
|
399
|
+
readonly resolution: TelemetryResolution;
|
|
400
|
+
}>;
|
|
401
|
+
};
|
|
357
402
|
readonly rpc: {
|
|
358
403
|
/** Registers a method its interface calls; a name registered twice throws. */
|
|
359
404
|
readonly method: RpcMethod;
|
|
@@ -366,4 +411,4 @@ type ServerContext<TSettings = undefined> = {
|
|
|
366
411
|
};
|
|
367
412
|
};
|
|
368
413
|
//#endregion
|
|
369
|
-
export {
|
|
414
|
+
export { Schema as A, ServiceProvide as C, TelemetryRead as D, SyncResult as E, StandardResult as M, StandardSchemaV1 as N, Unsubscribe as O, ServiceCallFunction as S, StateChange as T, RpcCaller as _, CustomEntityInput as a, ServerSettings as b, EntityInput as c, HistoryRead as d, LogMethod as f, RpcCall as g, RpcAccess as h, CustomEntities as i, SchemaIssue as j, SafeParser as k, EntitySnapshot as l, ReportOptions as m, CapabilityEntityInput as n, DeviceInput as o, Logger as p, CustomCommand as r, EntityFilter as s, Awaitable as t, HistoryAnswer as u, RpcMethod as v, SettingsChange as w, ServiceCall as x, ServerContext as y };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { G as CapabilityState, lt as EntityId, t as JsonObject } from "./json.js";
|
|
2
|
+
//#region src/history.d.ts
|
|
3
|
+
/** How finely readings are read: every one, or a bucket of a minute, an hour or a day. */
|
|
4
|
+
type TelemetryResolution = 'raw' | '1m' | '1h' | '1d';
|
|
5
|
+
/** What caused a change, when the platform knows it. */
|
|
6
|
+
type ChangeCause = {
|
|
7
|
+
readonly type: 'automation' | 'scene' | 'user' | 'device' | 'alias' | 'startup';
|
|
8
|
+
/** Which run, scene or person; absent for a device and at startup. */
|
|
9
|
+
readonly id?: string;
|
|
10
|
+
};
|
|
11
|
+
/** One change of an entity's state. */
|
|
12
|
+
type StateHistoryPoint = {
|
|
13
|
+
/** When it changed, ISO 8601. */
|
|
14
|
+
readonly time: string;
|
|
15
|
+
/** What it became. */
|
|
16
|
+
readonly state: CapabilityState;
|
|
17
|
+
/** What it was; null for the first report the platform recorded. */
|
|
18
|
+
readonly previousState: CapabilityState | null;
|
|
19
|
+
readonly attributes: JsonObject;
|
|
20
|
+
readonly isAvailable: boolean;
|
|
21
|
+
/** Which package, integration or engine reported it. */
|
|
22
|
+
readonly source: string;
|
|
23
|
+
readonly cause?: ChangeCause;
|
|
24
|
+
};
|
|
25
|
+
/** One reading, or one bucket of readings. */
|
|
26
|
+
type TelemetryPoint = {
|
|
27
|
+
/** The reading's time, or the bucket's start, ISO 8601. */
|
|
28
|
+
readonly time: string;
|
|
29
|
+
/** The reading, or the bucket's average. */
|
|
30
|
+
readonly value: number;
|
|
31
|
+
/** A bucket's lowest, highest and how many readings it holds; absent for raw readings. */
|
|
32
|
+
readonly min?: number;
|
|
33
|
+
readonly max?: number;
|
|
34
|
+
readonly count?: number;
|
|
35
|
+
};
|
|
36
|
+
/** Each entity's points, oldest first: an entity never recorded, or not readable, has none. */
|
|
37
|
+
type HistorySeries<TPoint> = Readonly<Record<EntityId, readonly TPoint[]>>;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { TelemetryResolution as a, TelemetryPoint as i, HistorySeries as n, StateHistoryPoint as r, ChangeCause as t };
|
package/dist/chunks/host.js
CHANGED
|
@@ -23,15 +23,16 @@ function hostSdkVersion() {
|
|
|
23
23
|
return typeof version === "string" ? version : void 0;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* One member of a module the interface lends.
|
|
26
|
+
* One member of a module the interface lends, when it lends it.
|
|
27
|
+
*
|
|
28
|
+
* For a member with a way of its own to do without the interface - `useTranslation` says the
|
|
29
|
+
* English - where {@link hostMember} would refuse.
|
|
27
30
|
*
|
|
28
31
|
* @param module - The module's key.
|
|
29
32
|
* @param name - The member.
|
|
30
|
-
* @
|
|
31
|
-
* @returns The interface's implementation of it.
|
|
32
|
-
* @throws {Error} When there is no interface, or it does not implement the member.
|
|
33
|
+
* @returns The interface's implementation of it, or undefined when there is none.
|
|
33
34
|
*/
|
|
34
|
-
function
|
|
35
|
+
function lentMember(module, name) {
|
|
35
36
|
const nebula = Reflect.get(globalThis, "__nebula");
|
|
36
37
|
const modules = isRecord(nebula) ? nebula["modules"] : void 0;
|
|
37
38
|
const lent = isRecord(modules) ? modules[module] : void 0;
|
|
@@ -45,9 +46,22 @@ function hostMember(module, name, since) {
|
|
|
45
46
|
* @returns True when it can be called or drawn.
|
|
46
47
|
*/
|
|
47
48
|
const isLent = (value) => typeof value === "function" || isRecord(value) && "$$typeof" in value;
|
|
48
|
-
|
|
49
|
+
return isLent(member) ? member : void 0;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One member of a module the interface lends.
|
|
53
|
+
*
|
|
54
|
+
* @param module - The module's key.
|
|
55
|
+
* @param name - The member.
|
|
56
|
+
* @param since - The SDK version that added it.
|
|
57
|
+
* @returns The interface's implementation of it.
|
|
58
|
+
* @throws {Error} When there is no interface, or it does not implement the member.
|
|
59
|
+
*/
|
|
60
|
+
function hostMember(module, name, since) {
|
|
61
|
+
const member = lentMember(module, name);
|
|
62
|
+
if (member !== void 0) return member;
|
|
49
63
|
const version = hostSdkVersion();
|
|
50
64
|
throw new Error(version === void 0 ? `${name} is lent by the NebulaHome interface, and there is none here` : `${name} needs package SDK ${since}; this interface implements ${version}`);
|
|
51
65
|
}
|
|
52
66
|
//#endregion
|
|
53
|
-
export { UI_MODULE as n, hostMember as r, COMPONENTS_MODULE as t };
|
|
67
|
+
export { lentMember as i, UI_MODULE as n, hostMember as r, COMPONENTS_MODULE as t };
|
package/dist/chunks/json.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ declare function isEntityId(value: unknown): value is EntityId;
|
|
|
33
33
|
//#region src/capabilities.d.ts
|
|
34
34
|
/** Every capability an entity may declare. */
|
|
35
35
|
type CapabilityName = 'on_off' | 'level' | 'color_hs' | 'color_temp' | 'cover' | 'lock' | 'thermostat' | 'sensor_numeric' | 'sensor_binary' | 'sensor_text' | 'media_player' | 'camera' | 'button' | 'update' | 'lawn_mower' | 'select' | 'number' | 'vacuum' | 'image' | 'custom';
|
|
36
|
-
/** How a sensor's readings add up:
|
|
36
|
+
/** How a sensor's readings add up, for statistics: a reading, a running total, or a meter. */
|
|
37
37
|
type EntityStateClass = 'measurement' | 'total' | 'total_increasing';
|
|
38
38
|
/** The modes a thermostat may be set to. */
|
|
39
39
|
type ThermostatMode = 'off' | 'heat' | 'cool' | 'auto' | 'fan_only' | 'dry';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { t as PackageConfigError } from "./node-problems.js";
|
|
2
|
-
import { a as isRecord$1, b as strictObject, i as generatePackage, l as
|
|
2
|
+
import { a as isRecord$1, b as strictObject, i as generatePackage, l as DEPENDENCY_ID_PATTERN, n as keepingNodeEnv, p as packageIdSchema, r as RELEASE_PATHS, x as string, y as record } from "./node-config.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { build, resolveConfig, runnerImport } from "vite";
|
|
7
7
|
//#region \0rolldown/runtime.js
|
|
@@ -6850,7 +6850,143 @@ function composeFileProblems(text, settingKeys) {
|
|
|
6850
6850
|
* the two are compared before a package is installed or started (PACKAGES_DOCS.md 11).
|
|
6851
6851
|
*/
|
|
6852
6852
|
/** The SDK's own version, `1.0.0`. */
|
|
6853
|
-
const SDK_VERSION = "1.0
|
|
6853
|
+
const SDK_VERSION = "1.2.0";
|
|
6854
|
+
//#endregion
|
|
6855
|
+
//#region src/vite/catalogues.ts
|
|
6856
|
+
/**
|
|
6857
|
+
* A package's translation catalogues: `locales/<language>.json` beside `nebula.config.ts`
|
|
6858
|
+
* (PACKAGES_DOCS.md 6.5).
|
|
6859
|
+
*
|
|
6860
|
+
* Each is a JSON object of strings, nested as the keys are dotted - `{ "clients": { "title":
|
|
6861
|
+
* "Client" } }` for `t('clients.title', 'Clients')` - with i18next's plural suffixes (`_one`,
|
|
6862
|
+
* `_many`, `_other`) and `{{placeholders}}`. The build bundles each as a chunk of the interface,
|
|
6863
|
+
* loaded only for a viewer who reads that language; English needs no file, being the strings'
|
|
6864
|
+
* own fallback.
|
|
6865
|
+
*/
|
|
6866
|
+
/** The folder the catalogues are in, beside the config. */
|
|
6867
|
+
const LOCALES_FOLDER = "locales";
|
|
6868
|
+
/** A language tag a catalogue may be named by: `it`, `pt-BR`, `zh-Hant`. */
|
|
6869
|
+
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|\d{3}))?$/;
|
|
6870
|
+
/** The plural suffixes i18next reads. */
|
|
6871
|
+
const PLURAL_SUFFIX = /_(?:zero|one|two|few|many|other)$/;
|
|
6872
|
+
/**
|
|
6873
|
+
* The catalogues beside a config, by file name: whether they hold together is
|
|
6874
|
+
* {@link catalogueProblems}'s to say.
|
|
6875
|
+
*
|
|
6876
|
+
* @param configDirectory - The folder of `nebula.config.ts`.
|
|
6877
|
+
* @returns Them, ordered by language; none when there is no `locales` folder.
|
|
6878
|
+
*/
|
|
6879
|
+
async function findCatalogues(configDirectory) {
|
|
6880
|
+
const folder = path.join(configDirectory, LOCALES_FOLDER);
|
|
6881
|
+
let names;
|
|
6882
|
+
try {
|
|
6883
|
+
names = await readdir(folder);
|
|
6884
|
+
} catch {
|
|
6885
|
+
return [];
|
|
6886
|
+
}
|
|
6887
|
+
return names.filter((name) => name.endsWith(".json")).map((name) => ({
|
|
6888
|
+
language: name.slice(0, -5),
|
|
6889
|
+
file: path.join(folder, name)
|
|
6890
|
+
})).sort((left, right) => left.language.localeCompare(right.language));
|
|
6891
|
+
}
|
|
6892
|
+
/**
|
|
6893
|
+
* The catalogues whose name is a language: the ones the interface is given.
|
|
6894
|
+
*
|
|
6895
|
+
* @param catalogues - The catalogues found.
|
|
6896
|
+
* @returns The ones it can load.
|
|
6897
|
+
*/
|
|
6898
|
+
function namedCatalogues(catalogues) {
|
|
6899
|
+
return catalogues.filter((catalogue) => LANGUAGE_TAG.test(catalogue.language));
|
|
6900
|
+
}
|
|
6901
|
+
/**
|
|
6902
|
+
* The keys of a catalogue, dotted, plural suffixes left on.
|
|
6903
|
+
*
|
|
6904
|
+
* @param value - The catalogue, or a part of it.
|
|
6905
|
+
* @param prefix - The key of the part.
|
|
6906
|
+
* @param problems - Where what does not hold is said.
|
|
6907
|
+
* @returns The keys.
|
|
6908
|
+
*/
|
|
6909
|
+
function keysOf(value, prefix, problems) {
|
|
6910
|
+
if (typeof value === "string") return [prefix];
|
|
6911
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
6912
|
+
problems.push(`${prefix === "" ? "the catalogue" : prefix}: is not a string or an object of strings`);
|
|
6913
|
+
return [];
|
|
6914
|
+
}
|
|
6915
|
+
return Object.entries(value).flatMap(([name, child]) => {
|
|
6916
|
+
if (name === "" || name.includes(".")) {
|
|
6917
|
+
problems.push(`${prefix === "" ? "" : `${prefix}.`}${name}: a key has no dots of its own - nest it instead`);
|
|
6918
|
+
return [];
|
|
6919
|
+
}
|
|
6920
|
+
return keysOf(child, prefix === "" ? name : `${prefix}.${name}`, problems);
|
|
6921
|
+
});
|
|
6922
|
+
}
|
|
6923
|
+
/**
|
|
6924
|
+
* Reads a catalogue's keys.
|
|
6925
|
+
*
|
|
6926
|
+
* @param file - The catalogue.
|
|
6927
|
+
* @returns Its keys, and what is wrong with it.
|
|
6928
|
+
*/
|
|
6929
|
+
async function readCatalogue(file) {
|
|
6930
|
+
const problems = [];
|
|
6931
|
+
if ((await stat(file)).size > 262144) return {
|
|
6932
|
+
keys: [],
|
|
6933
|
+
problems: ["a catalogue is at most 256 KB"]
|
|
6934
|
+
};
|
|
6935
|
+
let parsed;
|
|
6936
|
+
try {
|
|
6937
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
6938
|
+
} catch {
|
|
6939
|
+
return {
|
|
6940
|
+
keys: [],
|
|
6941
|
+
problems: ["is not JSON"]
|
|
6942
|
+
};
|
|
6943
|
+
}
|
|
6944
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
6945
|
+
keys: [],
|
|
6946
|
+
problems: ["is not an object of strings"]
|
|
6947
|
+
};
|
|
6948
|
+
const keys = keysOf(parsed, "", problems);
|
|
6949
|
+
const plurals = new Set(keys.filter((key) => PLURAL_SUFFIX.test(key)).map((key) => key.replace(PLURAL_SUFFIX, "")));
|
|
6950
|
+
for (const base of plurals) if (!keys.includes(`${base}_other`)) problems.push(`${base}: a plural needs its _other form`);
|
|
6951
|
+
return {
|
|
6952
|
+
keys,
|
|
6953
|
+
problems
|
|
6954
|
+
};
|
|
6955
|
+
}
|
|
6956
|
+
/**
|
|
6957
|
+
* What is wrong with a package's catalogues, file by file.
|
|
6958
|
+
*
|
|
6959
|
+
* @param catalogues - The catalogues found.
|
|
6960
|
+
* @param configDirectory - The folder of `nebula.config.ts`, for the names said.
|
|
6961
|
+
* @returns The problems; none when they hold together.
|
|
6962
|
+
*/
|
|
6963
|
+
async function catalogueProblems(catalogues, configDirectory) {
|
|
6964
|
+
const problems = [];
|
|
6965
|
+
for (const catalogue of catalogues) {
|
|
6966
|
+
const name = path.relative(configDirectory, catalogue.file).replaceAll("\\", "/");
|
|
6967
|
+
if (!LANGUAGE_TAG.test(catalogue.language)) {
|
|
6968
|
+
problems.push(`${name}: is not named by a language, as it.json or pt-BR.json`);
|
|
6969
|
+
continue;
|
|
6970
|
+
}
|
|
6971
|
+
const { problems: found } = await readCatalogue(catalogue.file);
|
|
6972
|
+
problems.push(...found.map((problem) => `${name}: ${problem}`));
|
|
6973
|
+
}
|
|
6974
|
+
return problems;
|
|
6975
|
+
}
|
|
6976
|
+
/**
|
|
6977
|
+
* The strings a catalogue lacks and the keys no string uses.
|
|
6978
|
+
*
|
|
6979
|
+
* @param used - The keys the interface's code says, as `t('key', …)`.
|
|
6980
|
+
* @param keys - The catalogue's keys, plural suffixes left on.
|
|
6981
|
+
* @returns What is missing and what is left over, each ordered.
|
|
6982
|
+
*/
|
|
6983
|
+
function catalogueCoverage(used, keys) {
|
|
6984
|
+
const present = new Set(keys.map((key) => key.replace(PLURAL_SUFFIX, "")));
|
|
6985
|
+
return {
|
|
6986
|
+
missing: [...used].filter((key) => !present.has(key)).sort(),
|
|
6987
|
+
unknown: [...present].filter((key) => !used.has(key)).sort()
|
|
6988
|
+
};
|
|
6989
|
+
}
|
|
6854
6990
|
//#endregion
|
|
6855
6991
|
//#region src/vite/load.ts
|
|
6856
6992
|
/**
|
|
@@ -12735,8 +12871,9 @@ function sharedModuleCode(source) {
|
|
|
12735
12871
|
* `nebula()`: the Vite plugin that builds a package's two halves (PACKAGES_DOCS.md 8.1).
|
|
12736
12872
|
*
|
|
12737
12873
|
* - **The interface** (`vite build`): one entry generated from the components bound in
|
|
12738
|
-
* `nebula.config.ts`, one chunk per component, React read from
|
|
12739
|
-
* scoped to the package's own elements; into `<outDir>/ui/`.
|
|
12874
|
+
* `nebula.config.ts`, one chunk per component and per translation catalogue, React read from
|
|
12875
|
+
* the interface, the stylesheet scoped to the package's own elements; into `<outDir>/ui/`. What
|
|
12876
|
+
* each catalogue lacks of the strings the code says is reported, never refused.
|
|
12740
12877
|
* - **The server** (`vite build --ssr`): the config's `server` module with every dependency inside
|
|
12741
12878
|
* it, for Node 22, into one `<outDir>/server/index.js`. The config's loaders are emptied there,
|
|
12742
12879
|
* and an import of `/ui` or `/components` refused: the interface is never part of the server.
|
|
@@ -12749,16 +12886,24 @@ const PLUGIN_NAME = "nebulahome:package";
|
|
|
12749
12886
|
const UI_ENTRY = "virtual:nebulahome/ui";
|
|
12750
12887
|
/** An import of a module, as the config's loaders are written: `import('./card.js')`. */
|
|
12751
12888
|
const DYNAMIC_IMPORT = /\bimport\(\s*(['"`])[^'"`]+\1\s*\)/g;
|
|
12889
|
+
/**
|
|
12890
|
+
* A string the interface's code says, as `t('clients.title', …)`: a literal key, the way the
|
|
12891
|
+
* catalogues can be checked against it.
|
|
12892
|
+
*/
|
|
12893
|
+
const SAID_KEY = /\bt\(\s*(['"`])([\w.-]+)\1\s*,/g;
|
|
12894
|
+
/** The package's own modules, which say its strings: not its dependencies. */
|
|
12895
|
+
const OWN_MODULE = /^(?!.*[\\/]node_modules[\\/]).*\.[cm]?[jt]sx?$/;
|
|
12752
12896
|
/** The SDK's entry points that belong to the interface. */
|
|
12753
12897
|
const INTERFACE_ENTRIES = ["@nebulahome/package-sdk/ui", "@nebulahome/package-sdk/components"];
|
|
12754
12898
|
/**
|
|
12755
|
-
* The code of the interface's entry: the components the config binds,
|
|
12756
|
-
* them.
|
|
12899
|
+
* The code of the interface's entry: the components the config binds, and a loader per
|
|
12900
|
+
* translation catalogue, as the interface reads them.
|
|
12757
12901
|
*
|
|
12758
12902
|
* @param configPath - The config's path.
|
|
12903
|
+
* @param catalogues - The catalogues, by language.
|
|
12759
12904
|
* @returns The module.
|
|
12760
12905
|
*/
|
|
12761
|
-
function uiEntryCode(configPath) {
|
|
12906
|
+
function uiEntryCode(configPath, catalogues) {
|
|
12762
12907
|
return [
|
|
12763
12908
|
`import definition from ${JSON.stringify(configPath.replaceAll("\\", "/"))};`,
|
|
12764
12909
|
"const ui = definition.ui ?? {};",
|
|
@@ -12768,6 +12913,9 @@ function uiEntryCode(configPath) {
|
|
|
12768
12913
|
" page: ui.page?.component,",
|
|
12769
12914
|
" cards: ui.cards ?? {},",
|
|
12770
12915
|
" entityViews: ui.entityViews ?? {},",
|
|
12916
|
+
" locales: {",
|
|
12917
|
+
...catalogues.map((catalogue) => ` ${JSON.stringify(catalogue.language)}: () => import(${JSON.stringify(catalogue.file.replaceAll("\\", "/"))}),`),
|
|
12918
|
+
" },",
|
|
12771
12919
|
"};"
|
|
12772
12920
|
].join("\n");
|
|
12773
12921
|
}
|
|
@@ -12802,7 +12950,9 @@ function nebula(options = {}) {
|
|
|
12802
12950
|
state = {
|
|
12803
12951
|
side: "server",
|
|
12804
12952
|
configPath,
|
|
12805
|
-
packageId
|
|
12953
|
+
packageId,
|
|
12954
|
+
catalogues: [],
|
|
12955
|
+
said: /* @__PURE__ */ new Set()
|
|
12806
12956
|
};
|
|
12807
12957
|
return {
|
|
12808
12958
|
build: {
|
|
@@ -12830,7 +12980,9 @@ function nebula(options = {}) {
|
|
|
12830
12980
|
state = {
|
|
12831
12981
|
side: "ui",
|
|
12832
12982
|
configPath,
|
|
12833
|
-
packageId
|
|
12983
|
+
packageId,
|
|
12984
|
+
catalogues: namedCatalogues(await findCatalogues(path.dirname(configPath))),
|
|
12985
|
+
said: /* @__PURE__ */ new Set()
|
|
12834
12986
|
};
|
|
12835
12987
|
return {
|
|
12836
12988
|
base: "./",
|
|
@@ -12884,23 +13036,42 @@ function nebula(options = {}) {
|
|
|
12884
13036
|
*/
|
|
12885
13037
|
load(id) {
|
|
12886
13038
|
if (state === void 0) return null;
|
|
12887
|
-
if (id === `\0${UI_ENTRY}`) return uiEntryCode(state.configPath);
|
|
13039
|
+
if (id === `\0${UI_ENTRY}`) return uiEntryCode(state.configPath, state.catalogues);
|
|
12888
13040
|
return id.startsWith("\0nebula-shared:") ? sharedModuleCode(id.slice(15)) : null;
|
|
12889
13041
|
},
|
|
12890
13042
|
/**
|
|
12891
13043
|
* Empties the config's loaders in the server: they load components the server never draws.
|
|
13044
|
+
* In the interface, notes the keys the package's own code says, for the catalogues' report.
|
|
12892
13045
|
*
|
|
12893
13046
|
* @param code - The module's code.
|
|
12894
13047
|
* @param id - The module.
|
|
12895
13048
|
* @returns The code without them, or null to leave it.
|
|
12896
13049
|
*/
|
|
12897
13050
|
transform(code, id) {
|
|
13051
|
+
if (state?.side === "ui") {
|
|
13052
|
+
if (OWN_MODULE.test(id)) for (const match of code.matchAll(SAID_KEY)) state.said.add(match[2] ?? "");
|
|
13053
|
+
return null;
|
|
13054
|
+
}
|
|
12898
13055
|
if (state?.side !== "server" || path.resolve(id) !== state.configPath) return null;
|
|
12899
13056
|
return {
|
|
12900
13057
|
code: code.replace(DYNAMIC_IMPORT, "Promise.resolve(undefined)"),
|
|
12901
13058
|
map: null
|
|
12902
13059
|
};
|
|
12903
13060
|
},
|
|
13061
|
+
/**
|
|
13062
|
+
* Says, per catalogue, the strings it lacks and the keys no string uses: English stands in
|
|
13063
|
+
* for the first, and the second are left over from strings that changed.
|
|
13064
|
+
*/
|
|
13065
|
+
async closeBundle() {
|
|
13066
|
+
if (state?.side !== "ui") return;
|
|
13067
|
+
for (const catalogue of state.catalogues) {
|
|
13068
|
+
const { keys } = await readCatalogue(catalogue.file);
|
|
13069
|
+
const { missing, unknown } = catalogueCoverage(state.said, keys);
|
|
13070
|
+
const name = `locales/${catalogue.language}.json`;
|
|
13071
|
+
if (missing.length > 0) this.warn(`${name}: ${String(missing.length)} of ${String(state.said.size)} strings are said in English (${missing.join(", ")})`);
|
|
13072
|
+
if (unknown.length > 0) this.warn(`${name}: no string says ${unknown.join(", ")}`);
|
|
13073
|
+
}
|
|
13074
|
+
},
|
|
12904
13075
|
generateBundle: {
|
|
12905
13076
|
order: "post",
|
|
12906
13077
|
/**
|
|
@@ -12976,8 +13147,8 @@ async function pluginOptions(root, logLevel) {
|
|
|
12976
13147
|
};
|
|
12977
13148
|
}
|
|
12978
13149
|
/**
|
|
12979
|
-
* Checks a package without building it: its declaration against the platform's schemas,
|
|
12980
|
-
* files it names.
|
|
13150
|
+
* Checks a package without building it: its declaration against the platform's schemas, the
|
|
13151
|
+
* files it names, and its translation catalogues.
|
|
12981
13152
|
*
|
|
12982
13153
|
* @param options - Where the package is.
|
|
12983
13154
|
* @returns The package, checked.
|
|
@@ -13003,6 +13174,7 @@ async function checkPackage(options = {}) {
|
|
|
13003
13174
|
if (Buffer.byteLength(text) > COMPOSE_LIMIT) missing.push(`${compose.from}: a compose file is at most 64 KB`);
|
|
13004
13175
|
else missing.push(...composeFileProblems(text, settingKeysOf(generated.setup)).map((problem) => `${compose.from}: ${problem}`));
|
|
13005
13176
|
}
|
|
13177
|
+
missing.push(...await catalogueProblems(await findCatalogues(base), base));
|
|
13006
13178
|
if (missing.length > 0) throw new PackageConfigError(missing);
|
|
13007
13179
|
return {
|
|
13008
13180
|
root,
|