@pygmalionjs/pygmalion 0.4.0 → 0.5.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.
@@ -2,7 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import ts from 'typescript';
5
- import { buildSourceGraph } from './source-graph.mjs';
5
+ import { buildSourceGraph, findSourceUsage } from './source-graph.mjs';
6
+ import {
7
+ componentBranchesMiddleware,
8
+ PYGMALION_COMPONENT_BRANCHES_CONTROL,
9
+ } from './component-branches.mjs';
6
10
  import {
7
11
  canonicalizeStoryboardExecutions,
8
12
  PYGMALION_STORYBOARD_CANONICAL_CONTROL,
@@ -10,6 +14,9 @@ import {
10
14
 
11
15
  export const PYGMALION_STORYBOARD_ENVIRONMENT_CONTROL =
12
16
  '/__pygmalion-storyboard/environment';
17
+ export const PYGMALION_SOURCE_USAGE_CONTROL =
18
+ '/__pygmalion-storyboard/source-usage';
19
+ const MAX_SOURCE_USAGE_REQUEST = 100;
13
20
  export const PYGMALION_STORYBOARD_ENVIRONMENT_QUERY =
14
21
  '__pygmalion_environment';
15
22
 
@@ -1285,12 +1292,29 @@ export function storyboardEnvironmentBootstrapSource() {
1285
1292
  value && typeof value === 'object' && !Array.isArray(value) ? value : null;
1286
1293
  const environment = plainRecord(decode(encoded));
1287
1294
  if (!environment) return;
1295
+ // The Node capture runtime installs the host screen preset with an init
1296
+ // script before any page script runs; in-editor session walks reach the
1297
+ // same global through the boot URL so fixtures that read it at module or
1298
+ // mount time (auth role overrides, static mic levels) behave identically.
1299
+ const screenPreset = plainRecord(environment.screenPreset);
1300
+ if (screenPreset) {
1301
+ Object.defineProperty(globalThis, '__PYGMALION_SCREEN_PRESET__', {
1302
+ configurable: true,
1303
+ value: screenPreset,
1304
+ });
1305
+ }
1306
+ // 'sandbox' virtualizes the whole store per document: reads of undeclared
1307
+ // keys see a pristine profile and writes never reach the shared origin
1308
+ // storage. Hidden preview instances run with it so a walk that persists UI
1309
+ // state (a collapsed sidebar, a dismissed notice) cannot poison sibling
1310
+ // instances or the designer's own tab. Declared entries seed the sandbox.
1311
+ const sandboxed = environment.storageIsolation === 'sandbox';
1288
1312
  const storageOverlays = new Map();
1289
1313
  const addStorageOverlay = (storage, entries) => {
1290
1314
  const record = plainRecord(entries);
1291
- if (!storage || !record) return;
1315
+ if (!storage || (!record && !sandboxed)) return;
1292
1316
  const overlay = {};
1293
- for (const [key, value] of Object.entries(record)) {
1317
+ for (const [key, value] of Object.entries(record ?? {})) {
1294
1318
  if (
1295
1319
  typeof key === 'string' &&
1296
1320
  key.length <= 512 &&
@@ -1315,9 +1339,11 @@ export function storyboardEnvironmentBootstrapSource() {
1315
1339
  value(key) {
1316
1340
  const normalizedKey = String(key);
1317
1341
  const overlay = storageOverlays.get(this);
1318
- return overlay && Object.hasOwn(overlay, normalizedKey)
1319
- ? overlay[normalizedKey]
1320
- : originalGetItem.call(this, normalizedKey);
1342
+ if (overlay && Object.hasOwn(overlay, normalizedKey)) {
1343
+ return overlay[normalizedKey];
1344
+ }
1345
+ if (overlay && sandboxed) return null;
1346
+ return originalGetItem.call(this, normalizedKey);
1321
1347
  },
1322
1348
  },
1323
1349
  setItem: {
@@ -1325,7 +1351,7 @@ export function storyboardEnvironmentBootstrapSource() {
1325
1351
  value(key, value) {
1326
1352
  const normalizedKey = String(key);
1327
1353
  const overlay = storageOverlays.get(this);
1328
- if (overlay && Object.hasOwn(overlay, normalizedKey)) {
1354
+ if (overlay && (sandboxed || Object.hasOwn(overlay, normalizedKey))) {
1329
1355
  overlay[normalizedKey] = String(value);
1330
1356
  return;
1331
1357
  }
@@ -1337,7 +1363,7 @@ export function storyboardEnvironmentBootstrapSource() {
1337
1363
  value(key) {
1338
1364
  const normalizedKey = String(key);
1339
1365
  const overlay = storageOverlays.get(this);
1340
- if (overlay && Object.hasOwn(overlay, normalizedKey)) {
1366
+ if (overlay && (sandboxed || Object.hasOwn(overlay, normalizedKey))) {
1341
1367
  overlay[normalizedKey] = null;
1342
1368
  return;
1343
1369
  }
@@ -1351,6 +1377,7 @@ export function storyboardEnvironmentBootstrapSource() {
1351
1377
  if (overlay) {
1352
1378
  for (const key of Object.keys(overlay)) overlay[key] = null;
1353
1379
  }
1380
+ if (sandboxed) return;
1354
1381
  return originalClear.call(this);
1355
1382
  },
1356
1383
  },
@@ -1441,17 +1468,25 @@ export function storyboardEnvironmentBootstrapSource() {
1441
1468
  })();`;
1442
1469
  }
1443
1470
 
1471
+ export const PYGMALION_STORYBOARD_RUNTIME_ATTRIBUTE =
1472
+ 'data-pygmalion-storyboard-runtime';
1473
+
1444
1474
  export function pygmalionStoryboardRuntimePlugin() {
1445
1475
  return {
1446
1476
  name: 'pygmalion-storyboard-runtime',
1447
1477
  enforce: 'pre',
1448
1478
  transformIndexHtml: {
1449
1479
  order: 'pre',
1450
- handler() {
1480
+ handler(html) {
1481
+ // Composed stacks include this plugin twice (the storyboard plugin
1482
+ // embeds it and hosts also add it directly). Vite applies each hook's
1483
+ // tags before the next hook runs, so a copy already in the document
1484
+ // means another instance injected first — one bootstrap is enough.
1485
+ if (html?.includes(PYGMALION_STORYBOARD_RUNTIME_ATTRIBUTE)) return;
1451
1486
  return [
1452
1487
  {
1453
1488
  tag: 'script',
1454
- attrs: { 'data-pygmalion-storyboard-runtime': '' },
1489
+ attrs: { [PYGMALION_STORYBOARD_RUNTIME_ATTRIBUTE]: '' },
1455
1490
  children: storyboardEnvironmentBootstrapSource(),
1456
1491
  injectTo: 'head-prepend',
1457
1492
  },
@@ -1484,6 +1519,47 @@ async function readExecutionResults(request) {
1484
1519
  return parsed.executions;
1485
1520
  }
1486
1521
 
1522
+ /**
1523
+ * Serves symbol-level usage for the files a catalog sheet traces to.
1524
+ *
1525
+ * A batch, not one request per file: the scan is a whole-project pass, so
1526
+ * asking 23 times both wastes it and leaves a long tail of entries whose
1527
+ * answer has not arrived — which reads as "unused" exactly when it is least
1528
+ * true.
1529
+ */
1530
+ export function sourceUsageMiddleware(root, { sourceDirectory = 'src' } = {}) {
1531
+ const projectRoot = path.resolve(root);
1532
+ return async (request, response, next) => {
1533
+ if (request.method !== 'GET') {
1534
+ next();
1535
+ return;
1536
+ }
1537
+ try {
1538
+ const url = new URL(request.url ?? '/', 'http://localhost');
1539
+ const files = (url.searchParams.get('files') ?? '')
1540
+ .split(',')
1541
+ .map((file) => file.trim())
1542
+ .filter(Boolean);
1543
+ if (files.length === 0) {
1544
+ sendJson(response, 400, { error: 'Expected a files parameter.' });
1545
+ return;
1546
+ }
1547
+ if (files.length > MAX_SOURCE_USAGE_REQUEST) {
1548
+ sendJson(response, 413, {
1549
+ error: `At most ${MAX_SOURCE_USAGE_REQUEST} files can be requested at once.`,
1550
+ });
1551
+ return;
1552
+ }
1553
+ const usage = await findSourceUsage(projectRoot, files, { sourceDirectory });
1554
+ sendJson(response, 200, { usage });
1555
+ } catch (error) {
1556
+ sendJson(response, 400, {
1557
+ error: error instanceof Error ? error.message : String(error),
1558
+ });
1559
+ }
1560
+ };
1561
+ }
1562
+
1487
1563
  function sendJson(response, statusCode, body) {
1488
1564
  response.statusCode = statusCode;
1489
1565
  response.setHeader('content-type', 'application/json; charset=utf-8');
@@ -1527,6 +1603,17 @@ export function pygmalionStoryboardPlugin(options) {
1527
1603
  });
1528
1604
  }
1529
1605
  });
1606
+ // Component branch discovery — the editor's "discovered branches" list
1607
+ // and the host's dimension-declaration gate read the same manifest.
1608
+ server.middlewares.use(
1609
+ PYGMALION_COMPONENT_BRANCHES_CONTROL,
1610
+ componentBranchesMiddleware(root),
1611
+ );
1612
+ // Symbol-level usage — what the catalog storyboard means by "used by".
1613
+ server.middlewares.use(
1614
+ PYGMALION_SOURCE_USAGE_CONTROL,
1615
+ sourceUsageMiddleware(root, { sourceDirectory }),
1616
+ );
1530
1617
  server.middlewares.use(
1531
1618
  canonicalEndpoint,
1532
1619
  async (request, response, next) => {
package/node/vite.mjs CHANGED
@@ -208,6 +208,12 @@ export function createPygmalionVitePlugins(config) {
208
208
 
209
209
  const project = resolvePygmalionProject(config);
210
210
  const plugins = [
211
+ // The environment bootstrap must run in the editor host too, not only in
212
+ // the capture preview server: in-editor hidden instances (flow sessions,
213
+ // sweeps, live boots) carry declarative environments and the storage
214
+ // sandbox in their boot URL. Without the query parameter the bootstrap
215
+ // is a no-op, so ordinary app tabs are unaffected.
216
+ pygmalionStoryboardRuntimePlugin(),
211
217
  pygmalionStoryboardPlugin({
212
218
  root: project.appRoot,
213
219
  sourceDirectory: project.sourceDirectory,
@@ -368,6 +374,11 @@ export function createPygmalionVitePlugins(config) {
368
374
  return plugins;
369
375
  }
370
376
 
377
+ export {
378
+ PYGMALION_COMPONENT_BRANCHES_CONTROL,
379
+ scanComponentBranches,
380
+ scanComponentBranchesFile,
381
+ } from './component-branches.mjs';
371
382
  export {
372
383
  PYGMALION_DEV_CONTROL,
373
384
  PYGMALION_DEV_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "dist-lib",
35
+ "node/component-branches.mjs",
35
36
  "node/design-session.mjs",
36
37
  "node/dev-mirror.mjs",
37
38
  "node/dev-view.vite.mjs",