@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (37) hide show
  1. package/assets/fonts/SourceSansPro-OFL.txt +46 -0
  2. package/assets/fonts/WorkSans-OFL.txt +46 -0
  3. package/package.json +4 -4
  4. package/runtime/fonts/OFL.txt +46 -0
  5. package/runtime/fonts/sourcesanspro-black.ttf +0 -0
  6. package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
  7. package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
  8. package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
  9. package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
  10. package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
  11. package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
  12. package/runtime/fonts/sourcesanspro-light.ttf +0 -0
  13. package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
  14. package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
  15. package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
  16. package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
  17. package/runtime/manifest.json +7 -0
  18. package/runtime/render-wasm.js +15 -0
  19. package/runtime/render-wasm.wasm +0 -0
  20. package/runtime/renderer.mjs +169652 -0
  21. package/src/draft-import.mjs +1 -11
  22. package/src/font-convert.mjs +23 -1
  23. package/src/fs-utils.mjs +95 -0
  24. package/src/index.mjs +19 -3
  25. package/src/initialize.mjs +363 -136
  26. package/src/kiwi-runtime.mjs +38 -36
  27. package/src/library-network.mjs +189 -0
  28. package/src/local-package-backend.mjs +215 -24
  29. package/src/local-package.mjs +589 -177
  30. package/src/local-workspace-session.mjs +278 -96
  31. package/src/migrate-themes.mjs +116 -0
  32. package/src/remote-library.mjs +167 -28
  33. package/src/render-fonts.mjs +172 -0
  34. package/src/render-shared.mjs +346 -0
  35. package/src/render.mjs +71 -3557
  36. package/src/workspace.mjs +178 -17
  37. package/src/webp-decode.mjs +0 -73
package/src/workspace.mjs CHANGED
@@ -1,24 +1,73 @@
1
1
  import { realpath } from "node:fs/promises";
2
- import { dirname, isAbsolute, relative, resolve } from "node:path";
2
+ import { dirname, isAbsolute, posix, resolve, win32 } from "node:path";
3
3
 
4
4
  import {
5
+ closestComponentVariant,
5
6
  combineContextAxes,
7
+ findComponentVariant,
6
8
  listEffectiveTokens,
7
9
  projectScenario,
8
10
  projectScreen,
9
11
  SmallPenError,
10
12
  } from "@smallpen/core";
11
13
 
14
+ import { isWithinRoot } from "./fs-utils.mjs";
12
15
  import { openPackage } from "./local-package.mjs";
13
- import { openRemoteLibrary } from "./remote-library.mjs";
16
+ import { openRemoteLibrary, remoteManifestUrl } from "./remote-library.mjs";
14
17
 
15
18
  function isRecord(value) {
16
19
  return value !== null && typeof value === "object" && !Array.isArray(value);
17
20
  }
18
21
 
19
- function isWithinRoot(rootPath, candidatePath) {
20
- const child = relative(rootPath, candidatePath);
21
- return child === "" || (!child.startsWith("..") && !isAbsolute(child));
22
+ // Local Library paths are relative to the declaring Package's directory, like
23
+ // Foundation dependency paths. Unlike dependencies they may climb out of the
24
+ // Workspace ("../libraries/x.smallpen" is the documented form and resolves
25
+ // with an external_library_path warning), but absolute, drive-letter, UNC,
26
+ // and backslash paths are machine-specific and are rejected.
27
+ export function localLibraryLocator(ownerLocator, source) {
28
+ const path = source?.path;
29
+ if (
30
+ typeof path !== "string" ||
31
+ path.length === 0 ||
32
+ path.includes("\0") ||
33
+ path.includes("\\") ||
34
+ isAbsolute(path) ||
35
+ posix.isAbsolute(path) ||
36
+ win32.isAbsolute(path)
37
+ ) {
38
+ throw new SmallPenError(
39
+ "invalid_library_source",
40
+ "Local Library path must be relative to the declaring Package",
41
+ { path },
42
+ );
43
+ }
44
+ return resolve(dirname(ownerLocator), path);
45
+ }
46
+
47
+ // `remoteLibrarySnapshots` (a Map keyed by manifest URL) lets a long-lived
48
+ // caller reuse remote snapshots it already verified; with
49
+ // `reuseRemoteLibrarySnapshots` the network and disk cache are skipped.
50
+ async function remoteLibrarySnapshot(library, options) {
51
+ const memo =
52
+ options.remoteLibrarySnapshots instanceof Map
53
+ ? options.remoteLibrarySnapshots
54
+ : undefined;
55
+ const key = remoteManifestUrl(library.source.url);
56
+ if (
57
+ memo?.has(key) &&
58
+ options.reuseRemoteLibrarySnapshots === true &&
59
+ options.refreshRemoteLibraries !== true
60
+ ) {
61
+ return memo.get(key);
62
+ }
63
+ const snapshot = await openRemoteLibrary(library.source.url, {
64
+ cacheRoot: options.libraryCacheRoot,
65
+ expectedPackageId: library.packageId,
66
+ fetchImpl: options.fetchImpl,
67
+ refresh: options.refreshRemoteLibraries === true,
68
+ });
69
+ memo?.set(key, snapshot);
70
+ return snapshot;
22
71
  }
23
72
 
24
73
  function chooseFoundation(dependency) {
@@ -213,7 +262,8 @@ function validateExternalReferences(product, libraries) {
213
262
 
214
263
  function contextSelections(product, foundation) {
215
264
  const axes = [...combineContextAxes(product, foundation).values()].sort(
216
- (left, right) => left.id.localeCompare(right.id),
265
+ // Code-unit order: localeCompare would follow the host LANG.
266
+ (left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
217
267
  );
218
268
  let selections = [{}];
219
269
  for (const axis of axes) {
@@ -246,13 +296,17 @@ const DEPENDENCY_PROJECTION_CODES = new Set([
246
296
  ]);
247
297
 
248
298
  function validateWorkspaceProjections(product, foundation, libraries = []) {
299
+ // The Presentation being projected, so a failing Instance is found in it
300
+ // and not in another Screen that reuses its node id.
301
+ let projecting;
249
302
  try {
250
303
  for (const context of contextSelections(product, foundation)) {
251
304
  listEffectiveTokens(product, { context, foundation, libraries });
252
305
  }
253
306
  for (const entry of product.manifest.entries.screens) {
254
307
  const screen = product.entries[entry];
255
- for (const presentation of screen.presentations) {
308
+ for (const [index, presentation] of screen.presentations.entries()) {
309
+ projecting = `${entry}.presentations[${index}]`;
256
310
  projectScreen(product, screen.id, {
257
311
  foundation,
258
312
  libraries,
@@ -260,6 +314,7 @@ function validateWorkspaceProjections(product, foundation, libraries = []) {
260
314
  });
261
315
  }
262
316
  }
317
+ projecting = undefined;
263
318
  for (const scenario of product.domain.scenarios.values()) {
264
319
  projectScenario(product, scenario.id, { foundation, libraries });
265
320
  }
@@ -276,7 +331,9 @@ function validateWorkspaceProjections(product, foundation, libraries = []) {
276
331
  if (!path && error.details.instanceId) {
277
332
  const suffix = `.nodes.${error.details.instanceId}.instance.component`;
278
333
  const usage = externalReferences(product).find((candidate) =>
279
- candidate.path.endsWith(suffix),
334
+ projecting
335
+ ? candidate.path === `${projecting}${suffix}`
336
+ : candidate.path.endsWith(suffix),
280
337
  );
281
338
  path = usage?.path;
282
339
  reference = usage?.reference;
@@ -305,6 +362,83 @@ function validateWorkspaceProjections(product, foundation, libraries = []) {
305
362
  }
306
363
  }
307
364
 
365
+ // Instances of the Product whose selection no variant of their Component
366
+ // Set has any more (the Foundation renamed a value or deleted the variant).
367
+ // They still draw, with the closest variant (`degraded` conflicts let reads
368
+ // go on); the Product waits in Repair for one of the typed choices: select
369
+ // a variant, or remove the Instance (or retarget it, as any reference).
370
+ function staleInstanceConflicts(product, foundation, libraries = []) {
371
+ const sets = new Map([[product.manifest.packageId, product.domain.componentSets]]);
372
+ for (const owner of [foundation, ...libraries]) {
373
+ if (owner) sets.set(owner.manifest.packageId, owner.domain.componentSets);
374
+ }
375
+ const conflicts = [];
376
+ const check = (instance, path, nodePath, target) => {
377
+ const reference = instance?.component;
378
+ const componentSet = sets.get(reference?.packageId)?.get(reference?.assetId);
379
+ if (!componentSet || findComponentVariant(componentSet, instance.variant ?? {}).variant) {
380
+ return;
381
+ }
382
+ const closest = closestComponentVariant(componentSet, instance.variant);
383
+ const select = closest
384
+ ? [
385
+ {
386
+ action: "select-instance-variant",
387
+ referencePath: `${nodePath}.instance`,
388
+ selection: structuredClone(closest.selection),
389
+ validSelections: componentSet.variants.map((variant) =>
390
+ structuredClone(variant.selection),
391
+ ),
392
+ ...target,
393
+ },
394
+ ]
395
+ : [];
396
+ conflicts.push({
397
+ ...conflict(
398
+ "missing_variant",
399
+ `Instance ${target.nodeId} selects ${JSON.stringify(instance.variant ?? {})}, ` +
400
+ `which no variant of ${reference.assetId} has. It draws as ` +
401
+ `${JSON.stringify(closest?.selection ?? {})} until it is repaired`,
402
+ `${path}`,
403
+ [...select, ...referenceRepairChoices(path, reference)],
404
+ ),
405
+ degraded: true,
406
+ reference: structuredClone(reference),
407
+ });
408
+ };
409
+ for (const entry of product.manifest.entries.screens) {
410
+ const screen = product.entries[entry];
411
+ screen.presentations.forEach((presentation, index) => {
412
+ for (const [nodeId, node] of Object.entries(presentation.nodes)) {
413
+ if (!node.instance) continue;
414
+ const nodePath = `${entry}.presentations[${index}].nodes.${nodeId}`;
415
+ check(node.instance, `${nodePath}.instance.component`, nodePath, {
416
+ nodeId,
417
+ presentationId: presentation.id,
418
+ screenId: screen.id,
419
+ });
420
+ }
421
+ });
422
+ }
423
+ for (const entry of product.manifest.entries.components) {
424
+ (product.entries[entry].componentSets ?? []).forEach((componentSet, setIndex) => {
425
+ componentSet.variants.forEach((variant, variantIndex) => {
426
+ for (const [nodeId, node] of Object.entries(variant.nodes)) {
427
+ if (!node.instance) continue;
428
+ const nodePath =
429
+ `${entry}.componentSets[${setIndex}].variants[${variantIndex}].nodes.${nodeId}`;
430
+ check(node.instance, `${nodePath}.instance.component`, nodePath, {
431
+ componentSetId: componentSet.id,
432
+ nodeId,
433
+ variantId: variant.id,
434
+ });
435
+ }
436
+ });
437
+ });
438
+ }
439
+ return conflicts;
440
+ }
441
+
308
442
  function chooseLibrarySource(library) {
309
443
  return {
310
444
  action: "choose-library-source",
@@ -313,14 +447,35 @@ function chooseLibrarySource(library) {
313
447
  };
314
448
  }
315
449
 
450
+ // Remote Libraries declare their own child Libraries, so a Library server
451
+ // could generate an endless chain of new Package IDs (or a huge fan-out) and
452
+ // keep resolution downloading and caching forever. Every declaration the
453
+ // resolution follows counts against one budget.
454
+ export const MAX_LIBRARY_DECLARATIONS = 256;
455
+
316
456
  async function resolveDeclaredLibraries(owners, options, workspaceRoot) {
317
457
  const conflicts = [];
318
458
  const records = new Map();
319
459
  const warnings = [];
460
+ let declarations = 0;
320
461
 
321
462
  const visit = async (owner, library, index, depth, stack) => {
322
463
  const path = `manifest.json.libraries[${index}]`;
323
464
  const choice = chooseLibrarySource(library);
465
+ declarations += 1;
466
+ if (declarations > MAX_LIBRARY_DECLARATIONS) {
467
+ if (declarations === MAX_LIBRARY_DECLARATIONS + 1) {
468
+ conflicts.push(
469
+ conflict(
470
+ "library_limit_exceeded",
471
+ `Library resolution stopped after ${MAX_LIBRARY_DECLARATIONS} Library declarations`,
472
+ path,
473
+ [choice],
474
+ ),
475
+ );
476
+ }
477
+ return;
478
+ }
324
479
  if (stack.has(library.packageId)) {
325
480
  conflicts.push(
326
481
  conflict(
@@ -335,12 +490,7 @@ async function resolveDeclaredLibraries(owners, options, workspaceRoot) {
335
490
  let snapshot;
336
491
  try {
337
492
  if (library.source.type === "url") {
338
- snapshot = await openRemoteLibrary(library.source.url, {
339
- cacheRoot: options.libraryCacheRoot,
340
- expectedPackageId: library.packageId,
341
- fetchImpl: options.fetchImpl,
342
- refresh: options.refreshRemoteLibraries === true,
343
- });
493
+ snapshot = await remoteLibrarySnapshot(library, options);
344
494
  } else {
345
495
  if (owner.remote) {
346
496
  throw new SmallPenError(
@@ -349,7 +499,7 @@ async function resolveDeclaredLibraries(owners, options, workspaceRoot) {
349
499
  { packageId: library.packageId },
350
500
  );
351
501
  }
352
- const candidate = resolve(dirname(owner.locator), library.source.path);
502
+ const candidate = localLibraryLocator(owner.locator, library.source);
353
503
  const locator = await realpath(candidate);
354
504
  snapshot = await openPackage(locator);
355
505
  if (!isWithinRoot(workspaceRoot, locator)) {
@@ -363,10 +513,16 @@ async function resolveDeclaredLibraries(owners, options, workspaceRoot) {
363
513
  }
364
514
  }
365
515
  } catch (error) {
516
+ // A Package chooses its URL Libraries: report only the typed code, never
517
+ // upstream status or transport text a Package could use as a probe.
518
+ const reason =
519
+ library.source.type === "url"
520
+ ? (error?.code ?? "remote_library_unavailable")
521
+ : error.message;
366
522
  conflicts.push(
367
523
  conflict(
368
524
  "library_unavailable",
369
- `Library ${library.packageId} cannot be opened: ${error.message}`,
525
+ `Library ${library.packageId} cannot be opened: ${reason}`,
370
526
  `${path}.source`,
371
527
  [choice],
372
528
  ),
@@ -407,7 +563,7 @@ async function resolveDeclaredLibraries(owners, options, workspaceRoot) {
407
563
  warnings.push(
408
564
  warning(
409
565
  "remote_library_offline",
410
- `Library ${snapshot.manifest.name} is using its last verified cache: ${snapshot.remote.warning}`,
566
+ `Library ${snapshot.manifest.name} is using its last verified cache: ${snapshot.remote.warningCode}`,
411
567
  `${path}.source.url`,
412
568
  ),
413
569
  );
@@ -563,6 +719,11 @@ export async function resolveWorkspace(productLocator, options = {}) {
563
719
  ...validateWorkspaceProjections(product, foundation, resolved.libraries),
564
720
  );
565
721
  }
722
+ if (conflicts.length === 0) {
723
+ conflicts.push(
724
+ ...staleInstanceConflicts(product, foundation, resolved.libraries),
725
+ );
726
+ }
566
727
  if (conflicts.length > 0) {
567
728
  return repair(product, conflicts, foundation, resolved.libraries, warnings);
568
729
  }
@@ -1,73 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
- import { createRequire } from "node:module";
4
- import { fileURLToPath } from "node:url";
5
-
6
- import { SmallPenError } from "@smallpen/core";
7
-
8
- // WebP static decoding via Google's libwebp compiled to WebAssembly
9
- // (@jsquash/webp, BSD-3). The emscripten codec is pre-loaded with its .wasm
10
- // bytes read from disk so decoding also works in the Node CLI (SP-031).
11
- const here = dirname(fileURLToPath(import.meta.url));
12
- const require_ = createRequire(import.meta.url);
13
-
14
- let decoderModule;
15
- let decoderInitPromise;
16
-
17
- async function loadDecoder() {
18
- if (decoderModule) return decoderModule;
19
- decoderInitPromise ??= (async () => {
20
- const { default: webpDec } = await import(
21
- "@jsquash/webp/codec/dec/webp_dec.js"
22
- );
23
- // Resolve the wasm via Node module resolution (works in workspace and
24
- // packaged CLI layouts).
25
- const wasmPath = require_.resolve("@jsquash/webp/codec/dec/webp_dec.wasm");
26
- const wasmBinary = await readFile(wasmPath);
27
- decoderModule = await webpDec({ wasmBinary, noInitialRun: true });
28
- return decoderModule;
29
- })();
30
- return decoderInitPromise;
31
- }
32
-
33
- export async function initWebpDecoder() {
34
- await loadDecoder();
35
- }
36
-
37
- // Synchronous decode of a complete WebP image to RGBA pixels. Requires a
38
- // prior initWebpDecoder(); the renderer initializes it once per projection.
39
- export function decodeWebpSync(bytes, diagnostics, path) {
40
- let module;
41
- try {
42
- module = decoderModule;
43
- } catch {
44
- module = undefined;
45
- }
46
- if (!module) {
47
- throw new SmallPenError(
48
- "media_decode_failed",
49
- "WebP decoder is not initialized",
50
- { path },
51
- );
52
- }
53
- try {
54
- const result = module.decode(
55
- bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes),
56
- );
57
- if (!result) throw new Error("decoder returned no image");
58
- return {
59
- width: result.width,
60
- height: result.height,
61
- pixels: new Uint8ClampedArray(result.data),
62
- };
63
- } catch (error) {
64
- diagnostics?.push({
65
- code: "media_decode_failed",
66
- message: `Renderer could not decode WebP image: ${
67
- error instanceof Error ? error.message : String(error)
68
- }`,
69
- path,
70
- });
71
- return undefined;
72
- }
73
- }