@smallpen/core 0.1.0-alpha.1

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.
@@ -0,0 +1,362 @@
1
+ import { listEffectiveTokens } from "./effective-tokens.mjs";
2
+
3
+ function compareText(left, right) {
4
+ return left < right ? -1 : left > right ? 1 : 0;
5
+ }
6
+
7
+ function componentCatalog(snapshot, source, publicOnly = false) {
8
+ const componentSets = [...snapshot.domain.componentSets.values()]
9
+ .filter((componentSet) => componentSet.deprecated !== true)
10
+ .filter((componentSet) => !publicOnly || componentSet.visibility === "public")
11
+ .map((componentSet) => ({
12
+ category: componentSet.category,
13
+ description: componentSet.description,
14
+ id: componentSet.id,
15
+ kind: "set",
16
+ legalSelections: componentSet.variants.map((variant) =>
17
+ structuredClone(variant.selection),
18
+ ),
19
+ name: componentSet.name,
20
+ packageId: snapshot.manifest.packageId,
21
+ replaces: structuredClone(componentSet.replaces),
22
+ revision: snapshot.revision,
23
+ scenarios: [...snapshot.domain.scenarios.values()]
24
+ .filter(
25
+ (scenario) =>
26
+ scenario.target.kind === "component" &&
27
+ scenario.target.component.assetId === componentSet.id,
28
+ )
29
+ .map(({ id }) => id)
30
+ .sort(),
31
+ source,
32
+ // DSP-002-B: variant identities so consumers can switch selections.
33
+ variants: componentSet.variants.map((variant) => ({
34
+ id: variant.id,
35
+ rootId: variant.rootId,
36
+ selection: structuredClone(variant.selection),
37
+ })),
38
+ }));
39
+ const located = [...snapshot.domain.locatedComponents.values()].map(
40
+ (component) => ({
41
+ category: component.path,
42
+ id: component.id,
43
+ kind: "located",
44
+ legalSelections: [{}],
45
+ mainNodeId: component.mainNodeId,
46
+ name: component.name,
47
+ packageId: snapshot.manifest.packageId,
48
+ presentationId: component.presentationId,
49
+ revision: snapshot.revision,
50
+ scenarios: [],
51
+ screenId: component.screenId,
52
+ source,
53
+ }),
54
+ );
55
+ return [...componentSets, ...located].sort((left, right) =>
56
+ compareText(left.id, right.id),
57
+ );
58
+ }
59
+
60
+ // SP-023: public asset inventory per Library snapshot with qualified ownership.
61
+ function assetInventory(snapshot) {
62
+ const assets = snapshot.manifest.entries.assets
63
+ .map((entry) => snapshot.entries[entry])
64
+ .sort((left, right) => compareText(left.id, right.id));
65
+ return {
66
+ colors: assets
67
+ .flatMap((asset) =>
68
+ asset.colors.map(({ id, name, path }) => ({ id, name, path })))
69
+ .sort((left, right) => compareText(left.id, right.id)),
70
+ fonts: assets
71
+ .flatMap((asset) =>
72
+ asset.fonts.map((family) => ({
73
+ family: family.family,
74
+ id: family.id,
75
+ variants: family.variants.map(({ id, name, style, weight }) => ({
76
+ id,
77
+ name,
78
+ style,
79
+ weight,
80
+ })),
81
+ })))
82
+ .sort((left, right) => compareText(left.id, right.id)),
83
+ media: assets
84
+ .flatMap((asset) =>
85
+ asset.media.map(({ height, id, mimeType, name, path, width }) => ({
86
+ height,
87
+ id,
88
+ mimeType,
89
+ name,
90
+ path,
91
+ width,
92
+ })))
93
+ .sort((left, right) => compareText(left.id, right.id)),
94
+ typographies: assets
95
+ .flatMap((asset) =>
96
+ asset.typographies.map(({ id, name, path }) => ({ id, name, path })))
97
+ .sort((left, right) => compareText(left.id, right.id)),
98
+ };
99
+ }
100
+
101
+ function screens(snapshot) {
102
+ return snapshot.manifest.entries.screens
103
+ .map((entry) => snapshot.entries[entry])
104
+ .sort((left, right) => compareText(left.id, right.id))
105
+ .map((screen) => ({
106
+ basePresentationId: screen.basePresentationId,
107
+ id: screen.id,
108
+ name: screen.name,
109
+ presentations: screen.presentations.map(({ id, name, platform }) => ({
110
+ id,
111
+ name,
112
+ ...(platform ? { platform } : {}),
113
+ })),
114
+ }));
115
+ }
116
+
117
+
118
+ // DSP-002-A: full token inventory across the package, its Foundation and
119
+ // linked Libraries. Rows keep a qualified key and carry an active flag so
120
+ // consumers can separate the current combination from archived cells.
121
+ export function tokenInventoryRows(snapshot, source) {
122
+ const packageId = snapshot.manifest.packageId;
123
+ const readOnly = source !== "product";
124
+ const rows = [];
125
+ for (const entry of snapshot.manifest.entries.tokens) {
126
+ const library = snapshot.entries[entry];
127
+ const activeSets = new Set([
128
+ ...(library.activeSetIds ?? []),
129
+ ...(library.themes ?? [])
130
+ .filter((theme) => (library.activeThemeIds ?? []).includes(theme.id))
131
+ .flatMap((theme) => theme.setIds),
132
+ ]);
133
+ for (const set of library.sets ?? []) {
134
+ for (const token of set.tokens ?? []) {
135
+ const raw = token.value;
136
+ const aliasMatch =
137
+ typeof raw === "string" ? /^\{([^{}]+)\}$/.exec(raw) : null;
138
+ rows.push({
139
+ active: activeSets.has(set.id),
140
+ deprecated: token.deprecated === true,
141
+ definitionSource: { packageId, setName: set.name },
142
+ path: token.name,
143
+ tokenId: token.id,
144
+ qualifiedKey: `${packageId}/${set.name}/${token.name}`,
145
+ readOnly,
146
+ setId: set.id,
147
+ setName: set.name,
148
+ source,
149
+ // aliasPath is the raw alias reference; the aggregation pass below
150
+ // resolves it against earlier sources and fills effective fields.
151
+ status: aliasMatch ? `alias:${aliasMatch[1]}` : "ok",
152
+ type: token.type,
153
+ value: structuredClone(raw),
154
+ });
155
+ }
156
+ }
157
+ }
158
+ return rows.sort(
159
+ (left, right) =>
160
+ compareText(left.qualifiedKey, right.qualifiedKey) ||
161
+ compareText(left.setId, right.setId),
162
+ );
163
+ }
164
+
165
+ // DSP-002-C: aggregate per-source inventories, resolving alias rows against
166
+ // rows defined earlier (product, then foundation, then libraries — mirroring
167
+ // the effective-token precedence). Returns rows plus a synthesized inventory
168
+ // revision over the contributing source revisions.
169
+ export function aggregateTokenInventory(sources) {
170
+ const resolvedByPath = new Map();
171
+ const rows = [];
172
+ const sourceRevisions = [];
173
+ for (const { revision, rows: sourceRows, source } of sources) {
174
+ sourceRevisions.push(`${source}:${packageIdOf(sourceRows)}@${revision}`);
175
+ for (const row of sourceRows) {
176
+ const aliasMatch = /^alias:(.+)$/.exec(row.status);
177
+ if (!aliasMatch) {
178
+ const resolved = {
179
+ ...row,
180
+ effectiveSource: {
181
+ ownerPackageId: row.definitionSource.packageId,
182
+ path: row.path,
183
+ setName: row.definitionSource.setName,
184
+ },
185
+ effectiveValue: structuredClone(row.value),
186
+ status: "ok",
187
+ };
188
+ resolvedByPath.set(row.path, {
189
+ ownerPackageId: row.definitionSource.packageId,
190
+ path: row.path,
191
+ setName: row.definitionSource.setName,
192
+ value: row.value,
193
+ });
194
+ rows.push(resolved);
195
+ continue;
196
+ }
197
+ const target = resolvedByPath.get(aliasMatch[1]);
198
+ if (!target) {
199
+ rows.push({
200
+ ...row,
201
+ effectiveSource: null,
202
+ effectiveValue: null,
203
+ status: `unresolved_alias:${aliasMatch[1]}`,
204
+ });
205
+ continue;
206
+ }
207
+ rows.push({
208
+ ...row,
209
+ effectiveSource: {
210
+ ownerPackageId: target.ownerPackageId,
211
+ path: target.path,
212
+ setName: target.setName,
213
+ },
214
+ effectiveValue: structuredClone(target.value),
215
+ status: "ok",
216
+ });
217
+ }
218
+ }
219
+ return {
220
+ rows: rows.sort(
221
+ (left, right) =>
222
+ compareText(left.qualifiedKey, right.qualifiedKey) ||
223
+ compareText(left.setId, right.setId),
224
+ ),
225
+ revision: synthesizeInventoryRevision(rows, sourceRevisions),
226
+ };
227
+ }
228
+
229
+ function packageIdOf(rows) {
230
+ return rows[0]?.ownerPackageId ?? "unknown";
231
+ }
232
+
233
+ function synthesizeInventoryRevision(rows, sourceRevisions) {
234
+ // Deterministic non-cryptographic digest: document order-sensitive inputs.
235
+ let hash = 0x811c9dc5;
236
+ const input = `${sourceRevisions.join("|")}#${rows
237
+ .map((row) => row.qualifiedKey)
238
+ .join(",")}`;
239
+ for (let index = 0; index < input.length; index += 1) {
240
+ hash ^= input.charCodeAt(index);
241
+ hash = Math.imul(hash, 0x01000193) >>> 0;
242
+ }
243
+ return `inv_${hash.toString(16).padStart(8, "0")}`;
244
+ }
245
+
246
+ export function createCatalog(product, options = {}) {
247
+ const foundation = options.foundation;
248
+ const libraries = options.libraries ?? [];
249
+ const effective = listEffectiveTokens(product, {
250
+ context: options.context,
251
+ foundation,
252
+ libraries: options.libraries,
253
+ });
254
+ const tokenInventory = aggregateTokenInventory([
255
+ {
256
+ revision: product.revision,
257
+ rows: tokenInventoryRows(product, "product"),
258
+ source: "product",
259
+ },
260
+ ...(foundation
261
+ ? [
262
+ {
263
+ revision: foundation.revision,
264
+ rows: tokenInventoryRows(foundation, "foundation"),
265
+ source: "foundation",
266
+ },
267
+ ]
268
+ : []),
269
+ ...libraries.map((library) => ({
270
+ revision: library.revision,
271
+ rows: tokenInventoryRows(library, "library"),
272
+ source: "library",
273
+ })),
274
+ ]);
275
+
276
+ return {
277
+ components: [
278
+ ...componentCatalog(product, "product"),
279
+ ...(foundation
280
+ ? componentCatalog(foundation, "foundation", true)
281
+ : []),
282
+ ...libraries.flatMap((library) =>
283
+ componentCatalog(library, "library", true),
284
+ ),
285
+ ].sort(
286
+ (left, right) =>
287
+ compareText(left.name, right.name) ||
288
+ compareText(left.packageId, right.packageId) ||
289
+ compareText(left.id, right.id),
290
+ ),
291
+ contexts: [
292
+ ...product.domain.contextProfiles.values(),
293
+ ...(foundation?.domain.contextProfiles.values() ?? []),
294
+ ]
295
+ .sort((left, right) => compareText(left.id, right.id))
296
+ .map(({ default: isDefault, id, name, values }) => ({
297
+ default: isDefault,
298
+ id,
299
+ name,
300
+ values: structuredClone(values),
301
+ })),
302
+ effectiveTokens: effective.map(({ sourceChain, target, token, value }) => ({
303
+ id: target.assetId,
304
+ path: token.path,
305
+ sourceChain,
306
+ target,
307
+ value,
308
+ })),
309
+ libraries: libraries.map((library) => ({
310
+ assets: assetInventory(library),
311
+ packageId: library.manifest.packageId,
312
+ revision: library.revision,
313
+ })),
314
+ inherited: foundation
315
+ ? {
316
+ components: [...foundation.domain.componentSets.values()]
317
+ .filter(({ deprecated, visibility }) =>
318
+ visibility === "public" && deprecated !== true)
319
+ .map(({ id }) => id)
320
+ .concat([...foundation.domain.locatedComponents.keys()])
321
+ .sort(),
322
+ packageId: foundation.manifest.packageId,
323
+ tokens: [...foundation.domain.tokens.values()]
324
+ .filter(({ visibility }) => visibility === "public")
325
+ .map(({ id }) => id)
326
+ .sort(),
327
+ }
328
+ : undefined,
329
+ packageId: product.manifest.packageId,
330
+ requirements: [...product.domain.requirements.values()]
331
+ .sort((left, right) => compareText(left.id, right.id))
332
+ .map(({ id, links, title }) => ({
333
+ coverage: links.length === 0 ? "missing" : "linked",
334
+ id,
335
+ linkCount: links.length,
336
+ title,
337
+ })),
338
+ role: product.manifest.role,
339
+ scenarios: [...product.domain.scenarios.values()]
340
+ .sort((left, right) => compareText(left.id, right.id))
341
+ .map(({ id, name, target }) => ({ id, name, targetKind: target.kind })),
342
+ screens: screens(product),
343
+ tokenInventory: tokenInventory.rows,
344
+ tokenInventoryRevision: tokenInventory.revision,
345
+ tokens: [...product.domain.tokens.values()]
346
+ .sort((left, right) => compareText(left.id, right.id))
347
+ .map(({ deprecated, id, path, type }) => ({
348
+ deprecated,
349
+ id,
350
+ path,
351
+ type,
352
+ })),
353
+ warnings: [...product.domain.requirements.values()]
354
+ .filter(({ links }) => links.length === 0)
355
+ .map(({ id }) => ({
356
+ code: "missing_requirement_coverage",
357
+ message: `Requirement has no Design Link: ${id}`,
358
+ requirementId: id,
359
+ severity: "warning",
360
+ })),
361
+ };
362
+ }
@@ -0,0 +1,84 @@
1
+ import { stableRuntimeUuid } from "./canonical.mjs";
2
+ import { projectComponentVariant } from "./design-projection.mjs";
3
+ import { resolveEffectiveToken } from "./effective-tokens.mjs";
4
+
5
+ // A view changes resolution, never the canonical active themes or node tree.
6
+ export function componentCombinationSnapshot(snapshot, combination) {
7
+ if (!combination) return snapshot;
8
+ const entries = { ...snapshot.entries };
9
+ for (const path of snapshot.manifest.entries.tokens) {
10
+ const library = entries[path];
11
+ if (!Array.isArray(library?.sets)) continue;
12
+ const themed = new Set(library.themes.flatMap((theme) => theme.setIds));
13
+ entries[path] = {
14
+ ...library,
15
+ activeThemeIds: [],
16
+ activeSetIds: [...new Set([
17
+ ...library.activeSetIds.filter((id) => !themed.has(id)),
18
+ ...combination.setIds,
19
+ ])],
20
+ };
21
+ }
22
+ return { ...snapshot, entries };
23
+ }
24
+
25
+ export async function createComponentSamples(snapshot, families, combinations) {
26
+ const samples = [];
27
+ const reverse = {};
28
+ for (const family of families.filter((item) => item.kind === "variant")) {
29
+ const set = snapshot.domain.componentSets.get(family.componentSetId);
30
+ const variant = set.variants.find((item) => item.id === family.variantId);
31
+ const occurrences = Object.values(variant.nodes).filter((node) => node.instance);
32
+ for (const combination of combinations.length ? combinations : [null]) {
33
+ const key = `${set.id}\0${variant.id}\0${combination?.id ?? "default"}`;
34
+ const sample = {
35
+ ...family, key, familyName: set.name, axes: set.axes,
36
+ selection: variant.selection,
37
+ classification: set.variants.some((item) => Object.values(item.nodes).some((node) => node.instance)) ? "Composite" : "Primitive",
38
+ combinationId: combination?.id ?? null,
39
+ combinationIndex: combination ? combinations.indexOf(combination) : 0,
40
+ combinationLabel: combination?.label ?? "Default",
41
+ caption: await stableRuntimeUuid(snapshot.manifest.packageId, "component-sample-caption", key),
42
+ runtimeNodes: {}, sources: {},
43
+ };
44
+ reverse[sample.caption] = { kind: "label" };
45
+ try {
46
+ const view = componentCombinationSnapshot(snapshot, combination);
47
+ sample.nodes = projectComponentVariant(view, variant);
48
+ for (const [nodeId, node] of Object.entries(sample.nodes)) {
49
+ const runtimeId = await stableRuntimeUuid(snapshot.manifest.packageId, "component-sample-node", `${key}\0${nodeId}`);
50
+ // Longest exact occurrence prefix, never labels or array indices.
51
+ const occurrence = occurrences.filter((item) => nodeId === item.id || nodeId.startsWith(`${item.id}__`))
52
+ .sort((a, b) => b.id.length - a.id.length)[0];
53
+ const source = {
54
+ kind: "component-sample", componentId: set.id, variantId: variant.id,
55
+ ownerPackageId: snapshot.manifest.packageId,
56
+ nodeId: occurrence?.id ?? nodeId, displayNodeId: nodeId,
57
+ combinationId: sample.combinationId, combinationLabel: sample.combinationLabel,
58
+ familyName: set.name, selection: variant.selection,
59
+ occurrencePath: occurrence ? nodeId : null,
60
+ overrideNodeId: occurrence ? (nodeId === occurrence.id ? node.sourceNodeId : nodeId.slice(occurrence.id.length + 2)) : null,
61
+ bindings: {},
62
+ };
63
+ for (const [field, reference] of Object.entries(node.tokenBindings ?? {})) {
64
+ const resolved = resolveEffectiveToken(view, reference);
65
+ source.bindings[field] = resolved ? {
66
+ tokenId: resolved.sourceTokenId, ownerPackageId: resolved.sourcePackageId,
67
+ path: resolved.token.path, value: resolved.value,
68
+ alias: typeof resolved.token.rawValue === "string" && /^\{[^{}]+\}$/.test(resolved.token.rawValue),
69
+ contextual: (resolved.token.contextValues?.length ?? 0) > 0,
70
+ } : { missing: true };
71
+ }
72
+ sample.runtimeNodes[nodeId] = runtimeId;
73
+ sample.sources[nodeId] = source;
74
+ reverse[runtimeId] = source;
75
+ }
76
+ } catch (error) {
77
+ sample.error = `${error.code ?? "component_projection_failed"}: ${error.message}`;
78
+ sample.nodes = {};
79
+ }
80
+ samples.push(sample);
81
+ }
82
+ }
83
+ return { samples, reverse };
84
+ }