@ogygie/generate 0.22.0 → 0.23.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.
@@ -982,7 +982,7 @@ function readYamlBlock(yamlText, location) {
982
982
  function blockKindOf(raw) {
983
983
  if (typeof raw !== "object" || raw === null) return "neither";
984
984
  const hasContracts = "contracts" in raw;
985
- const hasConsumes = "consumes" in raw;
985
+ const hasConsumes = "consumes" in raw || "views" in raw;
986
986
  if (hasContracts && hasConsumes) return "both";
987
987
  if (hasConsumes) return "screen";
988
988
  if (hasContracts) return "feature";
@@ -1031,7 +1031,18 @@ var FrontTargetSchema = z4.strictObject({
1031
1031
  contracts: z4.string().regex(/\{feature\}/u, "the contracts specifier names the feature with {feature}, e.g. @acme/api/features/{feature}/schemas"),
1032
1032
  testBench: ModuleExportSchema,
1033
1033
  /** The product's stylesheet, relative to the front app (e.g. `src/styles.css`): inlined in the preview pages the screen emits (spec ogygie-hosted-mode B10). Absent, a preview carries its markup alone. */
1034
- stylesheet: z4.string().min(1).optional()
1034
+ stylesheet: z4.string().min(1).optional(),
1035
+ /** D56: the points where a WRITTEN screen of the product accepts contributions — a tab bar in v1; the generator maintains `<folder>/extensions.gen.ts`, the host imports it once. */
1036
+ extensionPoints: z4.array(
1037
+ z4.strictObject({
1038
+ id: z4.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*$/u, "kebab segments joined by dots, e.g. fiche-societe.tabs"),
1039
+ kind: z4.literal("tab"),
1040
+ /** The host screen's folder, relative to the front app. */
1041
+ folder: z4.string().min(1),
1042
+ /** The front app carrying the host (an apps[] name) — required only when the map has several front apps. */
1043
+ app: z4.string().min(1).optional()
1044
+ })
1045
+ ).optional()
1035
1046
  });
1036
1047
  var GuardDenialSchema = z4.strictObject({
1037
1048
  status: z4.number().int().min(400).max(499),
@@ -1411,7 +1422,7 @@ function parseScreenBehavior(input) {
1411
1422
 
1412
1423
  // src/screen-block.schema.ts
1413
1424
  import { z as z5 } from "zod";
1414
- var SCREEN_BLOCK_KEYS = ["route", "consumes", "views", "holes"];
1425
+ var SCREEN_BLOCK_KEYS = ["route", "mounts", "consumes", "content", "views", "holes"];
1415
1426
  var FeatureIdSchema = z5.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u, "kebab-case feature id expected");
1416
1427
  var ScreenRouteSchema = z5.string().regex(/^\/(?:[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*)?$/u, "front path expected, lowercase segments under /");
1417
1428
  var ConsumedFeatureSchema = z5.strictObject({
@@ -1442,7 +1453,30 @@ var CustomViewSchema = z5.strictObject({
1442
1453
  kind: z5.literal("custom"),
1443
1454
  hole: IdentifierSchema
1444
1455
  });
1445
- var ViewSchema = z5.discriminatedUnion("kind", [ListViewSchema, FormViewSchema, CustomViewSchema]);
1456
+ var ContentViewSchema = z5.strictObject({
1457
+ id: IdentifierSchema,
1458
+ kind: z5.literal("content"),
1459
+ /** The id of a `content[]` list of the block. */
1460
+ content: IdentifierSchema
1461
+ });
1462
+ var ViewSchema = z5.discriminatedUnion("kind", [ListViewSchema, FormViewSchema, CustomViewSchema, ContentViewSchema]);
1463
+ var isOperationView = (view) => view.kind !== "content";
1464
+ var ContentEntrySchema = z5.strictObject({
1465
+ id: FeatureIdSchema,
1466
+ title: z5.string().min(1),
1467
+ body: z5.string().min(1),
1468
+ /** An in-app path, a query or an anchor — never a script. */
1469
+ href: z5.string().min(1).regex(/^(?!javascript:)/iu, "an href is a path, a query or an anchor").optional()
1470
+ });
1471
+ var ContentListSchema = z5.strictObject({
1472
+ id: IdentifierSchema,
1473
+ entries: z5.array(ContentEntrySchema).min(1)
1474
+ });
1475
+ var ExtensionPointIdSchema = z5.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*$/u, "an extension point id is kebab segments joined by dots, e.g. fiche-societe.tabs");
1476
+ var MountSchema = z5.strictObject({
1477
+ point: ExtensionPointIdSchema,
1478
+ id: FeatureIdSchema
1479
+ });
1446
1480
  var ScreenHoleSchema = z5.strictObject({
1447
1481
  id: IdentifierSchema,
1448
1482
  /** Fields of the operation's output the view receives. */
@@ -1458,7 +1492,10 @@ var ScreenHoleSchema = z5.strictObject({
1458
1492
  });
1459
1493
  var ScreenBlockShapeSchema = z5.strictObject({
1460
1494
  route: ScreenRouteSchema.optional(),
1461
- consumes: z5.array(ConsumedFeatureSchema).min(1),
1495
+ mounts: MountSchema.optional(),
1496
+ /** Empty for a screen whose every view is content (D56). */
1497
+ consumes: z5.array(ConsumedFeatureSchema).default([]),
1498
+ content: z5.array(ContentListSchema).default([]),
1462
1499
  views: z5.array(ViewSchema).min(1),
1463
1500
  holes: z5.array(ScreenHoleSchema).default([])
1464
1501
  });
@@ -1475,12 +1512,30 @@ function checkScreenCrossReferences(block, issue) {
1475
1512
  consumedOperations.set(operation, index);
1476
1513
  });
1477
1514
  });
1515
+ if (block.route !== void 0 && block.mounts !== void 0) issue(["mounts"], "a screen mounts on a route or on an extension point, never both \u2014 drop route or mounts");
1516
+ const contentIds = /* @__PURE__ */ new Set();
1517
+ block.content.forEach((list, index) => {
1518
+ if (contentIds.has(list.id)) issue(["content", index, "id"], `content list "${list.id}" is declared twice`);
1519
+ contentIds.add(list.id);
1520
+ const entryIds = /* @__PURE__ */ new Set();
1521
+ list.entries.forEach((entry, entryIndex) => {
1522
+ if (entryIds.has(entry.id)) issue(["content", index, "entries", entryIndex, "id"], `entry "${entry.id}" is declared twice in "${list.id}" \u2014 ids anchor the entries`);
1523
+ entryIds.add(entry.id);
1524
+ });
1525
+ });
1526
+ if (block.consumes.length === 0 && block.views.some((view) => view.kind !== "content")) {
1527
+ issue(["consumes"], "a screen that consumes nothing has content views only \u2014 a list, form or custom view needs a consumed operation");
1528
+ }
1478
1529
  const seenViews = /* @__PURE__ */ new Set();
1479
1530
  const holeIds = new Set(block.holes.map((hole) => hole.id));
1480
1531
  const carriedHoles = /* @__PURE__ */ new Map();
1481
1532
  block.views.forEach((view, index) => {
1482
1533
  if (seenViews.has(view.id)) issue(["views", index, "id"], `view id "${view.id}" is declared twice`);
1483
1534
  seenViews.add(view.id);
1535
+ if (view.kind === "content") {
1536
+ if (!contentIds.has(view.content)) issue(["views", index, "content"], `content list "${view.content}" is not declared in content`);
1537
+ return;
1538
+ }
1484
1539
  if (!consumedOperations.has(view.operation)) {
1485
1540
  issue(["views", index, "operation"], `operation "${view.operation}" is not consumed \u2014 list it under consumes[].operations`);
1486
1541
  }
@@ -1518,7 +1573,7 @@ function validateScreenBlock(raw, location) {
1518
1573
  (key) => blockProblemOf(
1519
1574
  location,
1520
1575
  `${[...path, key].join(".") || key}: unknown key "${key}"`,
1521
- path.length === 0 ? `A screen block has three keys \u2014 consumes, views, holes \u2014 and an optional route (D41). What fits none of them is a hole.` : `Remove "${key}" or fix its spelling \u2014 see packages/ogygie-generate/src/screen-block.schema.ts.`,
1576
+ path.length === 0 ? `A screen block has consumes, views, holes, content, and an optional route or mounts (D41, D56). What fits none of them is a hole.` : `Remove "${key}" or fix its spelling \u2014 see packages/ogygie-generate/src/screen-block.schema.ts.`,
1522
1577
  { path: [...path, key].join(".") }
1523
1578
  )
1524
1579
  );
@@ -1540,7 +1595,7 @@ function consumerOf(block, operationId) {
1540
1595
  return block.consumes.find((consumed) => consumed.operations.includes(operationId)) ?? null;
1541
1596
  }
1542
1597
  function primaryViewOf(block) {
1543
- return block.views.find((view) => view.kind !== "form") ?? null;
1598
+ return block.views.find((view) => view.kind === "list" || view.kind === "custom") ?? null;
1544
1599
  }
1545
1600
 
1546
1601
  // src/consumed-feature.ts
@@ -1581,6 +1636,20 @@ function elementFieldsOf(outputModel) {
1581
1636
  function checkScreenBlock(input) {
1582
1637
  const problems = [];
1583
1638
  const { block, specPath } = input;
1639
+ if (block.mounts !== void 0 && input.front !== void 0 && input.front !== null) {
1640
+ const points = input.front.extensionPoints ?? [];
1641
+ if (!points.some((point) => point.id === block.mounts?.point)) {
1642
+ problems.push(
1643
+ problem(
1644
+ specPath,
1645
+ "mounts.point",
1646
+ `"${block.mounts.point}" is not an extension point of the front target${points.length === 0 ? " (none is declared)" : ` (declared: ${points.map((point) => point.id).join(", ")})`}`,
1647
+ "Declare the point on the host screen in ogygie.manifest.json \u2014 generation.front.extensionPoints: [{ id, kind: tab, folder }] \u2014 or mount on one that exists."
1648
+ )
1649
+ );
1650
+ }
1651
+ }
1652
+ if (input.mountOnly === true) return problems;
1584
1653
  const operationsByView = /* @__PURE__ */ new Map();
1585
1654
  function findOperation(featureId, operationId) {
1586
1655
  const consumed = input.consumed.get(featureId);
@@ -1613,6 +1682,7 @@ function checkScreenBlock(input) {
1613
1682
  });
1614
1683
  });
1615
1684
  block.views.forEach((view, index) => {
1685
+ if (!isOperationView(view)) return;
1616
1686
  const consumer = block.consumes.find((consumed) => consumed.operations.includes(view.operation));
1617
1687
  if (consumer === void 0) return;
1618
1688
  const found = findOperation(consumer.feature, view.operation);
@@ -1671,13 +1741,20 @@ var READ_STATES = ["with:chargement", "with:erreur", "with:acc\xE8s refus\xE9",
1671
1741
  var READ_RENDERS = ["squelette", "bandeau d'erreur", "explication d'acc\xE8s", "\xE9tat vide", "lignes"];
1672
1742
  var FORM_STATES = ["with:formulaire valide", "with:formulaire invalide", "with:\xE9criture refus\xE9e"];
1673
1743
  var FORM_RENDERS = ["phrases d'erreur", "rien n'est \xE9crit", "bandeau d'erreur"];
1744
+ var CONTENT_STATES = ["with:contenu"];
1745
+ var CONTENT_RENDERS = ["entr\xE9es", "repli\xE9es"];
1674
1746
  function screenCaseKey(polarity, phrase) {
1675
1747
  return `${polarity}:${phrase}`;
1676
1748
  }
1677
1749
  function screenVocabularyOf(view, block, sortableFields = []) {
1678
1750
  const states = /* @__PURE__ */ new Set();
1679
1751
  const renders = /* @__PURE__ */ new Set();
1680
- if (view === null || view.kind !== "form") {
1752
+ if (view?.kind === "content") {
1753
+ for (const state of CONTENT_STATES) states.add(state);
1754
+ for (const render of CONTENT_RENDERS) renders.add(render);
1755
+ const list = block.content.find((candidate) => candidate.id === view.content);
1756
+ for (const entry of list?.entries ?? []) renders.add(`ancre ${entry.id}`);
1757
+ } else if (view === null || view.kind !== "form") {
1681
1758
  for (const state of READ_STATES) states.add(state);
1682
1759
  for (const render of READ_RENDERS) renders.add(render);
1683
1760
  const fields = view?.kind === "list" && view.fields !== void 0 ? view.fields : sortableFields;
@@ -1729,7 +1806,7 @@ function bindScreenBehavior(parse, context) {
1729
1806
  );
1730
1807
  }
1731
1808
  if (view === null && primaryViewOf(block) === null) {
1732
- return unbound2(context, behavior.raw, "\xAB \xE9cran \xBB speaks of the primary read, and the block has no list or custom view", "Declare a list or custom view, or name a form view.", { view: SCREEN_SUBJECT });
1809
+ return unbound2(context, behavior.raw, "\xAB \xE9cran \xBB speaks of the primary read, and the block has no list or custom view", "Name a view of the block by its id \u2014 a content screen has no primary read, its lines speak of its content views.", { view: SCREEN_SUBJECT });
1733
1810
  }
1734
1811
  if (behavior.hole !== null) {
1735
1812
  const hole = block.holes.find((candidate) => candidate.id === behavior.hole);
@@ -2677,8 +2754,8 @@ function checkSpec(input) {
2677
2754
  code: GENERATION_BLOCK_INVALID,
2678
2755
  message: `The # G\xE9n\xE9ration block of ${input.specPath} is neither a feature block nor a screen block.`,
2679
2756
  file: input.specPath,
2680
- cause: kind === "both" ? "the block carries both `contracts` (a feature, D18) and `consumes` (a screen, D41)" : "the block carries neither `contracts` (a feature, D18) nor `consumes` (a screen, D41)",
2681
- suggestedFix: "A feature block has contracts, operations, motifs, holes; a screen block has consumes, views, holes and an optional route \u2014 one spec, one kind.",
2757
+ cause: kind === "both" ? "the block carries both `contracts` (a feature, D18) and `consumes` or `views` (a screen, D41)" : "the block carries neither `contracts` (a feature, D18) nor `consumes` or `views` (a screen, D41)",
2758
+ suggestedFix: "A feature block has contracts, operations, motifs, holes; a screen block has consumes, views, holes, content, and an optional route or mounts \u2014 one spec, one kind.",
2682
2759
  details: { path: "" }
2683
2760
  })
2684
2761
  ],
@@ -2788,10 +2865,12 @@ function checkScreenSpec(input, raw, location) {
2788
2865
  const screen = parsed.block;
2789
2866
  const problems = [];
2790
2867
  const consumed = input.consumed ?? null;
2791
- if (consumed !== null) problems.push(...checkScreenBlock({ block: screen, specPath: input.specPath, consumed }));
2868
+ if (consumed !== null) problems.push(...checkScreenBlock({ block: screen, specPath: input.specPath, consumed, front: input.generation?.front ?? null }));
2869
+ else if (screen.mounts !== void 0 && input.generation?.front !== void 0) problems.push(...checkScreenBlock({ block: screen, specPath: input.specPath, consumed: /* @__PURE__ */ new Map(), front: input.generation.front, mountOnly: true }));
2792
2870
  const sortableFieldsOf = (view) => {
2793
- if (consumed === null || view.kind !== "list") return [];
2794
- const consumer = screen.consumes.find((candidate) => candidate.operations.includes(view.operation));
2871
+ const operationId = view.operation;
2872
+ if (consumed === null || view.kind !== "list" || operationId === void 0) return [];
2873
+ const consumer = screen.consumes.find((candidate) => candidate.operations.includes(operationId));
2795
2874
  const feature = consumer === void 0 ? void 0 : consumed.get(consumer.feature);
2796
2875
  const operation = feature === void 0 ? void 0 : consumableOperationsOf(feature).find((candidate) => candidate.id === view.operation);
2797
2876
  if (operation === void 0) return [];
@@ -8422,10 +8501,18 @@ var SUBMIT_LABEL_BY_KIND = {
8422
8501
  setOrClear: "Set",
8423
8502
  delete: "Delete"
8424
8503
  };
8425
- function viewPlanOf(view, operations, block) {
8504
+ function contentConstNameOf(listId) {
8505
+ return listId.replace(/([a-z0-9])([A-Z])/gu, "$1_$2").toUpperCase();
8506
+ }
8507
+ function viewPlanOf(view, operations, block, contentLists) {
8508
+ const componentName = pascalCaseOf(view.id);
8509
+ if (view.kind === "content") {
8510
+ const list = contentLists.find((candidate) => candidate.id === view.content);
8511
+ invariant3(list !== void 0, `content view ${view.id} names an unknown content list`);
8512
+ return { kind: "content", id: view.id, componentName, list };
8513
+ }
8426
8514
  const operation = operations.find((candidate) => candidate.id === view.operation);
8427
8515
  invariant3(operation !== void 0, `view ${view.id} serves an operation the plan does not know`);
8428
- const componentName = pascalCaseOf(view.id);
8429
8516
  if (view.kind === "list") {
8430
8517
  invariant3(operation.collection !== null, `list view ${view.id} serves an operation without a collection`);
8431
8518
  const fields = fieldPlansOf(operation.collection.elementModel, view.fields, `the element of ${operation.outputSchema}`);
@@ -8467,11 +8554,15 @@ function viewPlanOf(view, operations, block) {
8467
8554
  }
8468
8555
  function buildScreenPlan(input) {
8469
8556
  const operations = operationPlansOf(input);
8470
- const views = input.block.views.map((view) => viewPlanOf(view, operations, input.block));
8557
+ const contentLists = input.block.content.map((list) => ({ id: list.id, constName: contentConstNameOf(list.id), entries: list.entries }));
8558
+ const views = input.block.views.map((view) => viewPlanOf(view, operations, input.block, contentLists));
8471
8559
  const primaryView = primaryViewOf(input.block);
8472
- invariant3(primaryView !== null, "a screen has a list or custom view");
8473
- const primary = views.find((view) => view.id === primaryView.id);
8474
- invariant3(primary !== void 0 && primary.kind !== "form", "the primary view is a list or custom view");
8560
+ const primaryCandidate = primaryView === null ? null : views.find((view) => view.id === primaryView.id);
8561
+ const primary = primaryCandidate !== null && primaryCandidate !== void 0 && (primaryCandidate.kind === "list" || primaryCandidate.kind === "custom") ? primaryCandidate : null;
8562
+ invariant3(primaryView === null || primary !== null, "the primary view is a list or custom view");
8563
+ const mountPoint = input.block.mounts === void 0 ? void 0 : (input.front.extensionPoints ?? []).find((point) => point.id === input.block.mounts?.point);
8564
+ invariant3(input.block.mounts === void 0 || mountPoint !== void 0, "a mount names an extension point of the front target");
8565
+ const mount = input.block.mounts === void 0 || mountPoint === void 0 ? null : { point: mountPoint, id: input.block.mounts.id };
8475
8566
  const pascalName = pascalCaseOf(input.featureId);
8476
8567
  const screenName = `${pascalName}Screen`;
8477
8568
  const deniedDenial = operations.flatMap((operation) => [...operation.guardDenials]).find((denial) => denial.status === 401) ?? { code: "AUTH_REQUIRED", status: 401, case: "sans session", fixture: "withoutSession" };
@@ -8500,6 +8591,8 @@ function buildScreenPlan(input) {
8500
8591
  propsName: `${screenName}BodyProps`,
8501
8592
  routeFactoryName: `${camelCaseOf(input.featureId)}Route`,
8502
8593
  primary,
8594
+ contentLists,
8595
+ mount,
8503
8596
  operations,
8504
8597
  reads: operations.filter((operation) => operation.kind === "read"),
8505
8598
  writes: operations.filter((operation) => operation.kind !== "read"),
@@ -8523,22 +8616,26 @@ function emitScreenSpecContracts(plan) {
8523
8616
  (operation) => `| ${tick4(operation.id)} | ${tick4(operation.featureId)} | ${operation.method} ${tick4(operation.path)} | ${tick4(operation.hookName)} | ${tick4(operation.keyLiteral)} | ${operation.kind === "read" ? "\u2014" : operation.invalidationRoots.map((root) => tick4(`["${root}"]`)).join(", ")} | ${tick4(operation.inputSchema)} \u2192 ${tick4(operation.outputSchema)} |`
8524
8617
  );
8525
8618
  const viewRows = plan.views.map((view) => {
8619
+ if (view.kind === "content") {
8620
+ return `| ${tick4(view.id)} | content | \u2014 | ${view.list.entries.map((entry) => tick4(entry.id)).join(", ")} | liste ${tick4(view.list.id)} (${view.list.entries.length} entr\xE9es, ${tick4(`content/${view.list.id}.gen.ts`)}) | ${tick4(`views/${view.componentName}.gen.tsx`)} |`;
8621
+ }
8526
8622
  const fields = view.kind === "custom" ? view.receives.map((field) => tick4(field.name)).join(", ") : view.fields.map((field) => tick4(field.name)).join(", ");
8527
8623
  const file = view.kind === "custom" ? `views/${view.componentName}.tsx (corps \xE9crit)` : `views/${view.componentName}.gen.tsx`;
8528
8624
  const detail = view.kind === "list" ? view.sort === null ? "\u2014" : `tri ${view.sort.field} ${view.sort.direction}` : view.kind === "form" ? `bouton \xAB ${view.submitLabel} \xBB${view.multiline.length === 0 ? "" : `, textarea ${view.multiline.map(tick4).join(", ")}`}` : `trou ${tick4(view.hole.id)}${view.actions.length === 0 ? "" : `, actions ${view.actions.map((action) => tick4(action.operation.id)).join(", ")}`}`;
8529
8625
  return `| ${tick4(view.id)} | ${view.kind} | ${tick4(view.operation.id)} | ${fields} | ${detail} | ${tick4(file)} |`;
8530
8626
  });
8627
+ const mounting = plan.mount === null ? plan.route === null ? " ; aucune route \xE9mise" : ` ; route ${tick4(plan.route)} (${tick4("routes.gen.tsx")})` : ` ; mont\xE9 sur le point ${tick4(plan.mount.point.id)} sous l'id ${tick4(plan.mount.id)} (registre ${tick4(`${plan.mount.point.folder}/extensions.gen.ts`)} de l'\xE9cran h\xF4te), aucune route`;
8531
8628
  const customViews = plan.views.filter((view) => view.kind === "custom");
8532
8629
  return fileOf(
8533
8630
  `G\xE9n\xE9r\xE9 par ogygie-generate ${plan.generatorVersion} depuis le bloc \`# G\xE9n\xE9ration\` de l'\xE9cran et les contrats des features consomm\xE9es ; \`verify\` compare ce qui tient entre les marqueurs. Ce qui est \xE0 changer se change dans le bloc, dans la spec consomm\xE9e ou dans ses \`schemas.ts\`, jamais ici.`,
8534
8631
  "",
8535
8632
  "## Op\xE9rations consomm\xE9es",
8536
8633
  "",
8537
- `Lecture principale : ${tick4(plan.primary.operation.id)} (le conteneur l'attend par ScreenState)${plan.route === null ? " ; aucune route \xE9mise" : ` ; route ${tick4(plan.route)} (${tick4("routes.gen.tsx")})`}. Les cl\xE9s de cache sont celles que le client d\xE9riv\xE9 du manifeste calcule, \xE9crites en litt\xE9raux ; une \xE9criture invalide les racines que la carte lui donne.`,
8634
+ `${plan.primary === null ? "Aucune lecture principale : un \xE9cran de contenu, le conteneur rend le corps sans ScreenState" : `Lecture principale : ${tick4(plan.primary.operation.id)} (le conteneur l'attend par ScreenState)`}${mounting}. Les cl\xE9s de cache sont celles que le client d\xE9riv\xE9 du manifeste calcule, \xE9crites en litt\xE9raux ; une \xE9criture invalide les racines que la carte lui donne.`,
8538
8635
  "",
8539
8636
  "| Op\xE9ration | Feature | Route | Hook | Cl\xE9 de cache | Invalide | Contrats |",
8540
8637
  "|---|---|---|---|---|---|---|",
8541
- ...operationRows,
8638
+ ...operationRows.length === 0 ? ["| \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | aucune op\xE9ration consomm\xE9e |"] : operationRows,
8542
8639
  "",
8543
8640
  "## Vues",
8544
8641
  "",
@@ -8552,7 +8649,7 @@ function emitScreenSpecContracts(plan) {
8552
8649
  "",
8553
8650
  `## Corps`,
8554
8651
  "",
8555
- `${tick4(`${plan.screenName}.tsx`)} est \xC9CRIT : \xE9mis une fois comme composition des vues dans l'ordre du bloc, jamais compar\xE9. Le conteneur ${tick4(`${plan.screenName}.container.gen.tsx`)} lui passe ${tick4("data")} (la lecture principale servie)${plan.writes.length === 0 ? "" : ` et ${plan.writes.map((operation) => tick4(`${operation.id}Mutation`)).join(", ")}`}.`
8652
+ `${tick4(`${plan.screenName}.tsx`)} est \xC9CRIT : \xE9mis une fois comme composition des vues dans l'ordre du bloc, jamais compar\xE9. Le conteneur ${tick4(`${plan.screenName}.container.gen.tsx`)} ${plan.primary === null ? plan.writes.length === 0 ? "l'appelle sans donn\xE9e" : `lui passe ${plan.writes.map((operation) => tick4(`${operation.id}Mutation`)).join(", ")}` : `lui passe ${tick4("data")} (la lecture principale servie)${plan.writes.length === 0 ? "" : ` et ${plan.writes.map((operation) => tick4(`${operation.id}Mutation`)).join(", ")}`}`}${plan.mount === null ? "" : ` ; sa copie porte ${tick4("mountLabel")}, le libell\xE9 de l'onglet que le registre du point lit`}.`
8556
8653
  );
8557
8654
  }
8558
8655
 
@@ -8624,58 +8721,69 @@ function jsonLiteral(value, indent = 0) {
8624
8721
  }
8625
8722
 
8626
8723
  // src/emit/screen/container.ts
8724
+ function secondaryReadsOf(plan) {
8725
+ return plan.reads.filter((operation) => operation.id !== plan.primary?.operation.id);
8726
+ }
8727
+ function screenCopyLines(plan) {
8728
+ return [
8729
+ "/** The words of the states, written in the body file and translated there. */",
8730
+ "export interface ScreenCopy {",
8731
+ " errorTitle: ReactNode;",
8732
+ " accessDenied: StateCopy;",
8733
+ ...plan.mount === null ? [] : [` /** The label of this screen's tab on the \`${plan.mount.point.id}\` point of its host (the registry reads it). */`, " mountLabel: ReactNode;"],
8734
+ "}"
8735
+ ];
8736
+ }
8627
8737
  function bodyPropsLines(plan) {
8628
- const secondary = plan.reads.filter((operation) => operation.id !== plan.primary.operation.id);
8738
+ const secondary = secondaryReadsOf(plan);
8629
8739
  return [
8630
- `/** What the body receives: the primary read SERVED, the other reads as QueryLike, the writes as MutationLike \u2014 never a hook. */`,
8740
+ `/** What the body receives: ${plan.primary === null ? "no served read \u2014 a screen of content" : "the primary read SERVED"}, the other reads as QueryLike, the writes as MutationLike \u2014 never a hook. */`,
8631
8741
  `export interface ${plan.propsName} {`,
8632
- ` data: ${plan.primary.operation.outputType};`,
8742
+ ...plan.primary === null ? [] : [` data: ${plan.primary.operation.outputType};`],
8633
8743
  ...secondary.map((operation) => ` ${operation.id}Query: QueryLike<${operation.outputType}>;`),
8634
8744
  ...plan.writes.map((operation) => ` ${operation.id}Mutation: MutationLike<${mutationVariablesTypeOf(operation)}, ${operation.outputType}>;`),
8635
8745
  "}"
8636
8746
  ];
8637
8747
  }
8638
8748
  function emitScreenContainer(plan) {
8639
- const secondary = plan.reads.filter((operation) => operation.id !== plan.primary.operation.id);
8640
- const primaryHook = plan.primary.operation;
8641
- const bodyArguments = ["data={data}", ...secondary.map((operation) => `${operation.id}Query={${operation.id}Query}`), ...plan.writes.map((operation) => `${operation.id}Mutation={${operation.id}Mutation}`)];
8749
+ const secondary = secondaryReadsOf(plan);
8750
+ const bodyArguments = [...plan.primary === null ? [] : ["data={data}"], ...secondary.map((operation) => `${operation.id}Query={${operation.id}Query}`), ...plan.writes.map((operation) => `${operation.id}Mutation={${operation.id}Mutation}`)];
8642
8751
  const needsQueryLike = secondary.length > 0;
8643
8752
  const needsMutationLike = plan.writes.length > 0;
8644
8753
  const uiTypes = ["type StateCopy", ...needsQueryLike ? ["type QueryLike"] : [], ...needsMutationLike ? ["type MutationLike"] : []];
8645
- const tableSkeleton = plan.primary.kind === "list";
8754
+ const tableSkeleton = plan.primary?.kind === "list";
8646
8755
  const readsImport = plan.reads.map((operation) => operation.hookName).sort();
8647
8756
  const writesImport = [...plan.writes.map((operation) => operation.hookName).sort(), ...variablesTypesOf(plan.writes).map((name) => `type ${name}`)];
8757
+ const uiImports = plan.primary === null ? uiTypes : ["ScreenState", ...tableSkeleton ? [] : ["Skeleton"], ...uiTypes];
8648
8758
  return fileOf(
8649
8759
  tsGeneratedHeader(plan.generatorVersion),
8650
8760
  intentBlock(
8651
8761
  wrap(
8652
- `The container of the ${plan.featureId} screen: reads the hooks \u2014 ${plan.operations.map((operation) => operation.hookName).join(", ")} \u2014, waits for the primary read \`${primaryHook.id}\` through ScreenState (access denied, error, loading, then the body) and calls the written body ${plan.bodyName} with served data and the writes as MutationLike. The copy of the states comes from the body (screenCopy): the words are the product's, the wiring is generated.`
8762
+ plan.primary === null ? `The container of the ${plan.featureId} screen: a screen of CONTENT (D56) \u2014 no read to wait for, no ScreenState; it ${plan.operations.length === 0 ? "reads no hook and " : `reads the hooks \u2014 ${plan.operations.map((operation) => operation.hookName).join(", ")} \u2014 and `}calls the written body ${plan.bodyName}. The copy comes from the body (screenCopy): the words are the product's, the wiring is generated.` : `The container of the ${plan.featureId} screen: reads the hooks \u2014 ${plan.operations.map((operation) => operation.hookName).join(", ")} \u2014, waits for the primary read \`${plan.primary.operation.id}\` through ScreenState (access denied, error, loading, then the body) and calls the written body ${plan.bodyName} with served data and the writes as MutationLike. The copy of the states comes from the body (screenCopy): the words are the product's, the wiring is generated.`
8653
8763
  )
8654
8764
  ),
8655
8765
  'import type { ReactNode } from "react";',
8656
- `import { ScreenState, ${tableSkeleton ? "" : "Skeleton, "}${uiTypes.join(", ")} } from "@ogygie/ui";`,
8766
+ `import { ${uiImports.join(", ")} } from "@ogygie/ui";`,
8657
8767
  ...tableSkeleton ? ['import { DataTable } from "@ogygie/ui/data-table";'] : [],
8658
8768
  ...contractsImportLines(plan, (operation) => ({ values: [], types: [operation.outputType, ...operation.kind === "read" ? [] : [operation.inputType]] })),
8659
8769
  ...readsImport.length === 0 ? [] : [`import { ${readsImport.join(", ")} } from "./queries.gen";`],
8660
8770
  ...writesImport.length === 0 ? [] : [`import { ${writesImport.join(", ")} } from "./writes.gen";`],
8661
- `import { ${plan.bodyName}, screenCopy } from "./${plan.screenName}";`,
8771
+ plan.primary === null ? `import { ${plan.bodyName} } from "./${plan.screenName}";` : `import { ${plan.bodyName}, screenCopy } from "./${plan.screenName}";`,
8662
8772
  "",
8663
- "/** The words of the states, written in the body file and translated there. */",
8664
- "export interface ScreenCopy {",
8665
- " errorTitle: ReactNode;",
8666
- " accessDenied: StateCopy;",
8667
- "}",
8773
+ ...screenCopyLines(plan),
8668
8774
  "",
8669
8775
  ...bodyPropsLines(plan),
8670
8776
  "",
8671
8777
  `export function ${plan.screenName}() {`,
8672
8778
  ...plan.reads.map((operation) => ` const ${operation.id}Query = ${operation.hookName}();`),
8673
8779
  ...plan.writes.map((operation) => ` const ${operation.id}Mutation = ${operation.hookName}();`),
8674
- " return (",
8675
- ` <ScreenState query={${primaryHook.id}Query} errorTitle={screenCopy.errorTitle} accessDenied={screenCopy.accessDenied} loading={${tableSkeleton ? "<DataTable.Skeleton />" : "<Skeleton />"}}>`,
8676
- ` {(data) => <${plan.bodyName} ${bodyArguments.join(" ")} />}`,
8677
- " </ScreenState>",
8678
- " );",
8780
+ ...plan.primary === null ? [` return <${plan.bodyName}${bodyArguments.length === 0 ? "" : ` ${bodyArguments.join(" ")}`} />;`] : [
8781
+ " return (",
8782
+ ` <ScreenState query={${plan.primary.operation.id}Query} errorTitle={screenCopy.errorTitle} accessDenied={screenCopy.accessDenied} loading={${tableSkeleton ? "<DataTable.Skeleton />" : "<Skeleton />"}}>`,
8783
+ ` {(data) => <${plan.bodyName} ${bodyArguments.join(" ")} />}`,
8784
+ " </ScreenState>",
8785
+ " );"
8786
+ ],
8679
8787
  "}"
8680
8788
  );
8681
8789
  }
@@ -8684,10 +8792,12 @@ function emitScreenBodyDefault(plan) {
8684
8792
  const viewElements = plan.views.map((view) => {
8685
8793
  if (view.kind === "list") return ` <${view.componentName} rows={data.${view.operation.collection?.field ?? "rows"}} />`;
8686
8794
  if (view.kind === "form") return ` <${view.componentName} ${view.mutationProp}={${view.mutationProp}} />`;
8795
+ if (view.kind === "content") return ` <${view.componentName} />`;
8687
8796
  const actions = view.actions.map((action) => ` ${action.mutationProp}={${action.mutationProp}}`).join("");
8688
8797
  return ` <${view.componentName} data={data}${actions} />`;
8689
8798
  });
8690
- const destructured = ["data", ...plan.writes.map((operation) => `${operation.id}Mutation`)].filter((name) => name === "data" || plan.views.some((view) => view.kind === "form" && view.mutationProp === name || view.kind === "custom" && view.actions.some((action) => action.mutationProp === name)));
8799
+ const destructured = [...plan.primary === null ? [] : ["data"], ...plan.writes.map((operation) => `${operation.id}Mutation`)].filter((name) => name === "data" || plan.views.some((view) => view.kind === "form" && view.mutationProp === name || view.kind === "custom" && view.actions.some((action) => action.mutationProp === name)));
8800
+ const mountLabel = plan.mount === null ? null : plan.mount.id.split("-").map((word, index) => index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word).join(" ");
8691
8801
  return fileOf(
8692
8802
  intentBlock(
8693
8803
  wrap(
@@ -8695,15 +8805,16 @@ function emitScreenBodyDefault(plan) {
8695
8805
  )
8696
8806
  ),
8697
8807
  'import { Trans } from "@lingui/react/macro";',
8698
- `import type { ScreenCopy, ${plan.propsName} } from "./${plan.screenName}.container.gen";`,
8808
+ destructured.length === 0 ? `import type { ScreenCopy } from "./${plan.screenName}.container.gen";` : `import type { ScreenCopy, ${plan.propsName} } from "./${plan.screenName}.container.gen";`,
8699
8809
  ...viewImports,
8700
8810
  "",
8701
8811
  "export const screenCopy: ScreenCopy = {",
8702
8812
  ` errorTitle: <Trans>This view could not be loaded</Trans>,`,
8703
8813
  ` accessDenied: { title: <Trans>Access denied</Trans>, explanation: <Trans>Sign in with an account allowed to read this screen.</Trans> },`,
8814
+ ...mountLabel === null ? [] : [` mountLabel: <Trans>${mountLabel}</Trans>,`],
8704
8815
  "};",
8705
8816
  "",
8706
- `export function ${plan.bodyName}({ ${destructured.join(", ")} }: ${plan.propsName}) {`,
8817
+ destructured.length === 0 ? `export function ${plan.bodyName}() {` : `export function ${plan.bodyName}({ ${destructured.join(", ")} }: ${plan.propsName}) {`,
8707
8818
  " return (",
8708
8819
  ' <section className="space-y-6">',
8709
8820
  ...viewElements,
@@ -8734,9 +8845,10 @@ function emitScreenIndex(plan) {
8734
8845
  return fileOf(
8735
8846
  tsGeneratedHeader(plan.generatorVersion),
8736
8847
  intentBlock(
8737
- wrap(`SOLE public export point of the ${plan.featureId} screen feature: its container, its body props${plan.route === null ? "" : ", its route factory"} and its hooks. Other features import ONLY from here.`)
8848
+ wrap(`SOLE public export point of the ${plan.featureId} screen feature: its container, its body props${plan.route === null ? "" : ", its route factory"}${plan.mount === null ? "" : ", the copy its tab label lives in"} and its hooks. Other features import ONLY from here.`)
8738
8849
  ),
8739
8850
  `export { ${plan.screenName}, type ${plan.propsName}, type ScreenCopy } from "./${plan.screenName}.container.gen";`,
8851
+ ...plan.mount === null ? [] : [`export { screenCopy } from "./${plan.screenName}";`],
8740
8852
  ...plan.route === null ? [] : [`export { ${plan.routeFactoryName} } from "./routes.gen";`],
8741
8853
  ...plan.reads.length === 0 ? [] : [`export { ${plan.reads.map((operation) => operation.hookName).sort().join(", ")} } from "./queries.gen";`],
8742
8854
  ...plan.writes.length === 0 ? [] : [`export { ${plan.writes.map((operation) => operation.hookName).sort().join(", ")} } from "./writes.gen";`],
@@ -8744,6 +8856,24 @@ function emitScreenIndex(plan) {
8744
8856
  );
8745
8857
  }
8746
8858
 
8859
+ // src/emit/screen/content.ts
8860
+ function contentFilePath(list) {
8861
+ return `content/${list.id}.gen.ts`;
8862
+ }
8863
+ function emitContentList(plan, list) {
8864
+ return fileOf(
8865
+ tsGeneratedHeader(plan.generatorVersion),
8866
+ intentBlock(
8867
+ wrap(
8868
+ `The content list \`${list.id}\` of the ${plan.featureId} screen: ${list.entries.length} entr${list.entries.length === 1 ? "y" : "ies"} written in the spec's block, the material a content view folds under their titles. Plain text in the product's source language; a wording change is a spec change, regenerated.`
8869
+ )
8870
+ ),
8871
+ 'import type { DisclosureEntry } from "@ogygie/ui";',
8872
+ "",
8873
+ `export const ${list.constName}: readonly DisclosureEntry[] = ${jsonLiteral(list.entries)};`
8874
+ );
8875
+ }
8876
+
8747
8877
  // src/emit/screen/fixtures.ts
8748
8878
  var FIXTURE_TIMESTAMP = "2026-01-31T12:00:00.000Z";
8749
8879
  function failureBodyOf(code, message, cause, suggestedFix) {
@@ -8907,6 +9037,28 @@ function emitFormView(plan, view) {
8907
9037
  "}"
8908
9038
  );
8909
9039
  }
9040
+ function emitContentView(plan, view) {
9041
+ const linked = view.list.entries.some((entry) => entry.href !== void 0);
9042
+ return fileOf(
9043
+ tsGeneratedHeader(plan.generatorVersion),
9044
+ intentBlock(
9045
+ wrap(
9046
+ `The ${view.id} view of the ${plan.featureId} screen (content): the ${view.list.entries.length} entries of \`${view.list.id}\` (content/${view.list.id}.gen.ts), each folded under its title and anchored \`${view.id}-<id>\`${linked ? ", its link rendered when the entry carries one" : ""}. The material is the spec's; this view only lays it out and never fetches.`
9047
+ )
9048
+ ),
9049
+ ...linked ? ['import { Trans } from "@lingui/react/macro";'] : [],
9050
+ 'import { DisclosureList } from "@ogygie/ui";',
9051
+ `import { ${view.list.constName} } from "../content/${view.list.id}.gen";`,
9052
+ "",
9053
+ `export function ${view.componentName}() {`,
9054
+ " return (",
9055
+ ` <section data-view=${quote(view.id)}>`,
9056
+ ` <DisclosureList entries={${view.list.constName}} anchorPrefix=${quote(view.id)}${linked ? ` linkLabel={${trans("Open")}}` : ""} />`,
9057
+ " </section>",
9058
+ " );",
9059
+ "}"
9060
+ );
9061
+ }
8910
9062
  function customViewPropsLines(view) {
8911
9063
  return [
8912
9064
  `export interface ${view.componentName}Props {`,
@@ -9008,19 +9160,19 @@ function emitScreenHoleDoc(plan, view) {
9008
9160
  // src/emit/screen/previews.ts
9009
9161
  var PREVIEWS_FILE_SUFFIX = ".previews.gen.test.tsx";
9010
9162
  function emitScreenPreviews(plan) {
9011
- const primary = plan.primary.operation;
9012
- const primaryPrefix = fixturePrefixOf(primary);
9013
- const others = plan.reads.filter((read) => read.id !== primary.id);
9014
- const used = /* @__PURE__ */ new Set(["ACCESS_DENIED_FAILURE", "INTERNAL_FAILURE", `${primaryPrefix}_OUTPUT`]);
9163
+ const primary = plan.primary?.operation ?? null;
9164
+ const primaryPrefix = primary === null ? "" : fixturePrefixOf(primary);
9165
+ const others = plan.reads.filter((read) => read.id !== primary?.id);
9166
+ const used = new Set(primary === null ? [] : ["ACCESS_DENIED_FAILURE", "INTERNAL_FAILURE", `${primaryPrefix}_OUTPUT`]);
9015
9167
  for (const read of others) used.add(`${fixturePrefixOf(read)}_OUTPUT`);
9016
- if (primary.emptyOutputFixture !== null) used.add(`${primaryPrefix}_EMPTY`);
9168
+ if (primary !== null && primary.emptyOutputFixture !== null) used.add(`${primaryPrefix}_EMPTY`);
9017
9169
  const serveOthers = others.map((read) => ` client.setQueryData(${read.keyLiteral}, ${fixturePrefixOf(read)}_OUTPUT);`);
9018
9170
  const arranged = (lines) => [
9019
9171
  ` render: () => renderStatic(<${plan.screenName} />, (client) => {`,
9020
9172
  ...lines,
9021
9173
  " }),"
9022
9174
  ];
9023
- const previews = [
9175
+ const previews = primary === null ? [" {", ` state: "nominal",`, ...serveOthers.length === 0 ? [` render: () => renderStatic(<${plan.screenName} />),`] : arranged(serveOthers), " },"] : [
9024
9176
  " {",
9025
9177
  ` state: "loading",`,
9026
9178
  ` render: () => renderStatic(<${plan.screenName} />),`,
@@ -9044,25 +9196,26 @@ function emitScreenPreviews(plan) {
9044
9196
  "// @vitest-environment node",
9045
9197
  intentBlock(
9046
9198
  wrap(
9047
- `Previews of the ${plan.featureId} screen: one self-contained HTML page per UI state \u2014 loading, error, denied${primary.emptyOutputFixture === null ? "" : ", empty"}, nominal \u2014 rendered by the front's bench on the fixtures of fixtures.gen.ts, the state arranged in the query cache before the render as the static tests do. A test first: every state renders in the loop. With OGYGIE_PREVIEW_OUT set to a directory, the pages are also written there (<state>.html) \u2014 how the speculate job publishes them on the spec branch under previews/<id>/, where the hosted studio frames them.`
9199
+ `Previews of the ${plan.featureId} screen: one self-contained HTML page per UI state \u2014 ${primary === null ? "nominal alone, a screen of content waits for no read" : `loading, error, denied${primary.emptyOutputFixture === null ? "" : ", empty"}, nominal`} \u2014 rendered by the front's bench${used.size === 0 ? "" : " on the fixtures of fixtures.gen.ts, the state arranged in the query cache before the render as the static tests do"}. A test first: every state renders in the loop. With OGYGIE_PREVIEW_OUT set to a directory, the pages are also written there (<state>.html) \u2014 how the speculate job publishes them on the spec branch under previews/<id>/, where the hosted studio frames them.`
9048
9200
  )
9049
9201
  ),
9050
9202
  'import { mkdirSync, writeFileSync } from "node:fs";',
9051
9203
  'import { join } from "node:path";',
9052
9204
  'import { describe, expect, it } from "vitest";',
9053
- 'import type { QueryClient } from "@tanstack/react-query";',
9054
- 'import { OgygieFailure, type OgygieError } from "@ogygie/ui";',
9205
+ ...primary === null ? [] : ['import type { QueryClient } from "@tanstack/react-query";', 'import { OgygieFailure, type OgygieError } from "@ogygie/ui";'],
9055
9206
  `import { ${plan.testBench.exportName === "renderStatic" ? "renderStatic" : `${plan.testBench.exportName} as renderStatic`} } from ${quote(plan.testBench.importPath)};`,
9056
9207
  `import { ${plan.screenName} } from "./${plan.screenName}.container.gen";`,
9057
- `import { ${[...used].sort().join(", ")} } from "./fixtures.gen";`,
9208
+ ...used.size === 0 ? [] : [`import { ${[...used].sort().join(", ")} } from "./fixtures.gen";`],
9058
9209
  ...plan.stylesheetImport === null ? [] : [`import stylesheet from ${quote(plan.stylesheetImport)};`],
9059
9210
  "",
9060
- "/** Arranges a read as FAILED: the cache holds the OgygieFailure the api would have answered, and the read is not retried on mount. */",
9061
- "function failRead(client: QueryClient, key: readonly unknown[], failure: { readonly status: number; readonly body: OgygieError }): void {",
9062
- " client.setQueryDefaults(key, { retryOnMount: false });",
9063
- ' client.getQueryCache().build(client, { queryKey: key }).setState({ status: "error", error: new OgygieFailure(failure.body, failure.status), fetchStatus: "idle" });',
9064
- "}",
9065
- "",
9211
+ ...primary === null ? [] : [
9212
+ "/** Arranges a read as FAILED: the cache holds the OgygieFailure the api would have answered, and the read is not retried on mount. */",
9213
+ "function failRead(client: QueryClient, key: readonly unknown[], failure: { readonly status: number; readonly body: OgygieError }): void {",
9214
+ " client.setQueryDefaults(key, { retryOnMount: false });",
9215
+ ' client.getQueryCache().build(client, { queryKey: key }).setState({ status: "error", error: new OgygieFailure(failure.body, failure.status), fetchStatus: "idle" });',
9216
+ "}",
9217
+ ""
9218
+ ],
9066
9219
  plan.stylesheetImport === null ? '/** No stylesheet is declared on the front target (generation.front.stylesheet): the page carries the markup alone. */\nconst STYLESHEET = "";' : "/** The product's stylesheet, compiled by Vite and inlined: the page carries the product's tokens. */\nconst STYLESHEET: string = stylesheet;",
9067
9220
  "",
9068
9221
  "/** A self-contained page around the rendered screen: the hosted studio frames it in a sandbox, nothing else is loaded. */",
@@ -9190,6 +9343,26 @@ function readLineBody(plan, behavior, view, used) {
9190
9343
  }
9191
9344
  return lines;
9192
9345
  }
9346
+ function contentLineBody(plan, behavior, view, used) {
9347
+ const others = plan.reads.map((read) => {
9348
+ used.add(`${fixturePrefixOf(read)}_OUTPUT`);
9349
+ return `client.setQueryData(${read.keyLiteral}, ${fixturePrefixOf(read)}_OUTPUT);`;
9350
+ });
9351
+ const lines = others.length === 0 ? [` const html = renderStatic(<${plan.screenName} />);`] : [` const html = renderStatic(<${plan.screenName} />, (client) => {`, ...others.map((line) => ` ${line}`), " });"];
9352
+ lines.push(` const view = viewMarkup(html, ${quote(view.id)});`);
9353
+ for (const render of behavior.behavior.renders) {
9354
+ if (render === "entr\xE9es") {
9355
+ lines.push(...view.list.entries.map((entry) => ` expect(textOf(view)).toContain(${quote(entry.title)});`));
9356
+ } else if (render === "repli\xE9es") {
9357
+ lines.push(` expect(/<details\\b[^>]*\\sopen\\b/u.test(view)).toBe(false);`);
9358
+ } else {
9359
+ const anchor = /^ancre (\S+)$/u.exec(render);
9360
+ invariant4(anchor !== null && anchor[1] !== void 0, `render ${render} is not of a content view's vocabulary`);
9361
+ lines.push(` expect(view).toContain(${quote(`id="${view.id}-${anchor[1]}"`)});`);
9362
+ }
9363
+ }
9364
+ return lines;
9365
+ }
9193
9366
  function customViewTest(view) {
9194
9367
  const actions = view.actions.map((action) => `${action.mutationProp}={idleMutation()}`).join(" ");
9195
9368
  return [
@@ -9205,7 +9378,9 @@ function emitStaticScreenTests(plan) {
9205
9378
  const tests = plan.behaviors.flatMap((behavior) => {
9206
9379
  if (behavior.hole !== null) return ["", ` it.todo(${quote(behavior.raw)});`];
9207
9380
  const view = behavior.view ?? plan.primary;
9381
+ invariant4(view !== null, "a line speaks of a view, or of the primary read of a screen that has one");
9208
9382
  if (view.kind === "form") return ["", ` it.todo(${quote(`${behavior.raw} \u2014 ${STATIC_RENDER_GESTURE_REASON}`)});`];
9383
+ if (view.kind === "content") return ["", ` it(${quote(behavior.raw)}, () => {`, ...contentLineBody(plan, behavior, view, used), " });"];
9209
9384
  return ["", ` it(${quote(behavior.raw)}, () => {`, ...readLineBody(plan, behavior, view, used), " });"];
9210
9385
  });
9211
9386
  return fileOf(
@@ -9217,9 +9392,8 @@ function emitStaticScreenTests(plan) {
9217
9392
  )
9218
9393
  ),
9219
9394
  'import { describe, expect, it } from "vitest";',
9220
- 'import type { QueryClient } from "@tanstack/react-query";',
9221
- // Everything the test needs of the socle comes from `@ogygie/ui`, the core included: a front product declares ui alone.
9222
- `import { ${[...customViews.some((view) => view.actions.length > 0) ? ["idleMutation"] : [], "OgygieFailure", "type OgygieError"].join(", ")} } from "@ogygie/ui";`,
9395
+ ...plan.reads.length === 0 ? [] : ['import type { QueryClient } from "@tanstack/react-query";'],
9396
+ ...plan.reads.length === 0 && !customViews.some((view) => view.actions.length > 0) ? [] : [`import { ${[...customViews.some((view) => view.actions.length > 0) ? ["idleMutation"] : [], ...plan.reads.length === 0 ? [] : ["OgygieFailure", "type OgygieError"]].join(", ")} } from "@ogygie/ui";`],
9223
9397
  `import { ${plan.testBench.exportName === "renderStatic" ? "renderStatic" : `${plan.testBench.exportName} as renderStatic`} } from ${quote(plan.testBench.importPath)};`,
9224
9398
  `import { ${plan.screenName} } from "./${plan.screenName}.container.gen";`,
9225
9399
  ...customViews.map((view) => `import { ${view.componentName} } from "./views/${view.componentName}";`),
@@ -9228,12 +9402,14 @@ function emitStaticScreenTests(plan) {
9228
9402
  "// failRead, screenStateOf, viewMarkup, bodyRows, columnTexts, sortedTexts and textOf are emitted into every generated static screen test:",
9229
9403
  "// candidates for a test kit of @ogygie/ui at the third repetition (AGENTS.md workflow 5) \u2014 not promoted yet.",
9230
9404
  "",
9231
- "/** Arranges a read as FAILED: the cache holds the OgygieFailure the api would have answered, and the read is not retried on mount \u2014 the markup shows the failure, not the skeleton again. */",
9232
- "function failRead(client: QueryClient, key: readonly unknown[], failure: { readonly status: number; readonly body: OgygieError }): void {",
9233
- " client.setQueryDefaults(key, { retryOnMount: false });",
9234
- ' client.getQueryCache().build(client, { queryKey: key }).setState({ status: "error", error: new OgygieFailure(failure.body, failure.status), fetchStatus: "idle" });',
9235
- "}",
9236
- "",
9405
+ ...plan.reads.length === 0 ? [] : [
9406
+ "/** Arranges a read as FAILED: the cache holds the OgygieFailure the api would have answered, and the read is not retried on mount \u2014 the markup shows the failure, not the skeleton again. */",
9407
+ "function failRead(client: QueryClient, key: readonly unknown[], failure: { readonly status: number; readonly body: OgygieError }): void {",
9408
+ " client.setQueryDefaults(key, { retryOnMount: false });",
9409
+ ' client.getQueryCache().build(client, { queryKey: key }).setState({ status: "error", error: new OgygieFailure(failure.body, failure.status), fetchStatus: "idle" });',
9410
+ "}",
9411
+ ""
9412
+ ],
9237
9413
  "/** The state ScreenState shows, null once the body renders. */",
9238
9414
  "function screenStateOf(html: string): string | null {",
9239
9415
  ' const wrapper = /<[^<>]*\\sdata-pattern="ScreenState"[^<>]*>/u.exec(html)?.[0];',
@@ -9373,6 +9549,21 @@ function formLineBody(plan, behavior, view) {
9373
9549
  }
9374
9550
  return lines;
9375
9551
  }
9552
+ function contentLineBody2(plan, behavior, view) {
9553
+ const lines = [` stubApi(${routesLiteral(defaultReadRoutes(plan, null))});`, ` renderWithProviders(<${plan.screenName} />);`, ` const view = await waitFor(() => viewOf(${quote(view.id)}));`];
9554
+ for (const render of behavior.behavior.renders) {
9555
+ if (render === "entr\xE9es") {
9556
+ lines.push(...view.list.entries.map((entry) => ` expect(view.textContent ?? "").toContain(${quote(entry.title)});`));
9557
+ } else if (render === "repli\xE9es") {
9558
+ lines.push(` expect(view.querySelectorAll("details[open]")).toHaveLength(0);`);
9559
+ } else {
9560
+ const anchor = /^ancre (\S+)$/u.exec(render);
9561
+ invariant5(anchor !== null && anchor[1] !== void 0, `render ${render} is not of a content view's vocabulary`);
9562
+ lines.push(` expect(view.querySelector(${quote(`#${view.id}-${anchor[1]}`)})).not.toBeNull();`);
9563
+ }
9564
+ }
9565
+ return lines;
9566
+ }
9376
9567
  function successStatusOf(operation) {
9377
9568
  return operation.kind === "create" || operation.kind === "import" ? 201 : 200;
9378
9569
  }
@@ -9390,7 +9581,7 @@ function emitScreenTests(plan) {
9390
9581
  }
9391
9582
  function emitJsdomScreenTests(plan) {
9392
9583
  const customViews = plan.views.filter((view) => view.kind === "custom");
9393
- const fixtureNames = /* @__PURE__ */ new Set(["ACCESS_DENIED_FAILURE", "INTERNAL_FAILURE"]);
9584
+ const fixtureNames = new Set(plan.reads.length === 0 ? [] : ["ACCESS_DENIED_FAILURE", "INTERNAL_FAILURE"]);
9394
9585
  for (const operation of plan.reads) {
9395
9586
  fixtureNames.add(`${fixturePrefixOf(operation)}_OUTPUT`);
9396
9587
  if (operation.emptyOutputFixture !== null) fixtureNames.add(`${fixturePrefixOf(operation)}_EMPTY`);
@@ -9403,7 +9594,8 @@ function emitJsdomScreenTests(plan) {
9403
9594
  const tests = plan.behaviors.flatMap((behavior) => {
9404
9595
  if (behavior.hole !== null) return ["", ` it.todo(${quote(behavior.raw)});`];
9405
9596
  const view = behavior.view ?? plan.primary;
9406
- const body = view.kind === "form" ? formLineBody(plan, behavior, view) : readLineBody2(plan, behavior, view);
9597
+ invariant5(view !== null, "a line speaks of a view, or of the primary read of a screen that has one");
9598
+ const body = view.kind === "form" ? formLineBody(plan, behavior, view) : view.kind === "content" ? contentLineBody2(plan, behavior, view) : readLineBody2(plan, behavior, view);
9407
9599
  return ["", ` it(${quote(behavior.raw)}, async () => {`, ...body, " });"];
9408
9600
  });
9409
9601
  return fileOf(
@@ -9420,7 +9612,7 @@ function emitJsdomScreenTests(plan) {
9420
9612
  `import { ${plan.testBench.exportName === "renderWithProviders" ? "renderWithProviders" : `${plan.testBench.exportName} as renderWithProviders`} } from ${quote(plan.testBench.importPath)};`,
9421
9613
  `import { ${plan.screenName} } from "./${plan.screenName}.container.gen";`,
9422
9614
  ...customViews.map((view) => `import { ${view.componentName} } from "./views/${view.componentName}";`),
9423
- `import { ${[...fixtureNames].sort().join(", ")} } from "./fixtures.gen";`,
9615
+ ...fixtureNames.size === 0 ? [] : [`import { ${[...fixtureNames].sort().join(", ")} } from "./fixtures.gen";`],
9424
9616
  "",
9425
9617
  "interface StubRoute {",
9426
9618
  " method?: string;",
@@ -9576,9 +9768,12 @@ function generateScreen(plan, options = { existingCustomViews: /* @__PURE__ */ n
9576
9768
  return [
9577
9769
  ...plan.reads.length === 0 ? [] : [inFolder("queries.gen.ts", emitScreenQueries(plan))],
9578
9770
  ...plan.writes.length === 0 ? [] : [inFolder("writes.gen.ts", emitScreenWrites(plan))],
9771
+ // D56: the material of the content views, one constant per list, before the views that render it.
9772
+ ...plan.contentLists.map((list) => inFolder(contentFilePath(list), emitContentList(plan, list))),
9579
9773
  ...plan.views.map((view) => {
9580
9774
  if (view.kind === "list") return inFolder(`views/${view.componentName}.gen.tsx`, emitListView(plan, view));
9581
9775
  if (view.kind === "form") return inFolder(`views/${view.componentName}.gen.tsx`, emitFormView(plan, view));
9776
+ if (view.kind === "content") return inFolder(`views/${view.componentName}.gen.tsx`, emitContentView(plan, view));
9582
9777
  return inFolder(`views/${view.componentName}.tsx`, emitCustomView(plan, view, options.existingCustomViews.get(view.id) ?? null));
9583
9778
  }),
9584
9779
  inFolder(`${plan.screenName}.container.gen.tsx`, emitScreenContainer(plan)),
@@ -9593,7 +9788,7 @@ function generateScreen(plan, options = { existingCustomViews: /* @__PURE__ */ n
9593
9788
  }
9594
9789
 
9595
9790
  // src/verify-generation.ts
9596
- import { posix as posix3 } from "node:path";
9791
+ import { posix as posix4 } from "node:path";
9597
9792
 
9598
9793
  // ../ogygie-core/src/scan.ts
9599
9794
  var NEVER_SCANNED_DIRECTORIES = [
@@ -9608,8 +9803,42 @@ function isScannableDirectory(name) {
9608
9803
  return !NEVER_SCANNED_DIRECTORIES.includes(name);
9609
9804
  }
9610
9805
 
9806
+ // src/emit/screen/extensions.ts
9807
+ import { posix as posix3 } from "node:path";
9808
+ var EXTENSION_REGISTRY_FILE = "extensions.gen.ts";
9809
+ function extensionRegistryPath(appPath, point) {
9810
+ return `${appPath}/${point.folder.replace(/^\.?\//u, "").replace(/\/+$/u, "")}/${EXTENSION_REGISTRY_FILE}`;
9811
+ }
9812
+ function emitExtensionRegistry(input) {
9813
+ const contributions = [...input.contributions].sort((left, right) => left.mountId.localeCompare(right.mountId, "en"));
9814
+ const imports = contributions.map((contribution) => {
9815
+ const relative = posix3.relative(input.hostFolder, contribution.folder);
9816
+ const specifier = relative.startsWith(".") ? relative : `./${relative}`;
9817
+ return `import { ${pascalCaseOf(contribution.featureId)}Screen, screenCopy as ${camelCaseOf(contribution.featureId)}Copy } from ${quote(specifier)};`;
9818
+ });
9819
+ const entries = contributions.map(
9820
+ (contribution) => ` { id: ${quote(contribution.mountId)}, label: ${camelCaseOf(contribution.featureId)}Copy.mountLabel, render: () => createElement(${pascalCaseOf(contribution.featureId)}Screen) },`
9821
+ );
9822
+ return fileOf(
9823
+ tsGeneratedHeader(input.generatorVersion),
9824
+ intentBlock(
9825
+ wrap(
9826
+ `The registry of the \`${input.point.id}\` extension point (${input.point.kind}): ${contributions.length === 0 ? "no screen mounts here yet \u2014 the host's import holds on an empty list" : `the ${contributions.length} screen${contributions.length === 1 ? "" : "s"} mounted here (${contributions.map((contribution) => contribution.featureId).join(", ")}), each a TabContribution \u2014 its label from the copy of its written body, its container to render`}. Regenerated with every mounted screen; the host screen imports it once and keeps its own tab bar.`
9827
+ )
9828
+ ),
9829
+ ...contributions.length === 0 ? [] : ['import { createElement } from "react";'],
9830
+ 'import type { TabContribution } from "@ogygie/ui";',
9831
+ ...imports,
9832
+ "",
9833
+ `export const point = ${quote(input.point.id)};`,
9834
+ "",
9835
+ ...contributions.length === 0 ? ["export const contributions: readonly TabContribution[] = [];"] : ["export const contributions: readonly TabContribution[] = [", ...entries, "];"]
9836
+ );
9837
+ }
9838
+
9611
9839
  // src/verify-generation.ts
9612
9840
  var GENERATED_DRIFT = "OGYGIE_GENERATED_DRIFT";
9841
+ var EXTENSION_POINT_UNWIRED = "OGYGIE_EXTENSION_POINT_UNWIRED";
9613
9842
  var GENERATOR_VERSION = PACKAGE_VERSION;
9614
9843
  function normalizeForComparison(text) {
9615
9844
  return `${text.replace(/\r\n?/gu, "\n").split("\n").map((line) => line.replace(/[ \t]+$/u, "")).join("\n").replace(/\n+$/u, "")}
@@ -9619,6 +9848,20 @@ var GENERATED_HEADER = /@generated by ogygie-generate (\S+)/u;
9619
9848
  var GENERATED_FILE_NAME = /\.gen\.(?:test\.)?(?:ts|tsx|sql)$/u;
9620
9849
  var HEADER_CANDIDATE = /\.(?:ts|tsx|sql)$/u;
9621
9850
  var SPEC_FILE_NAME = /^[^/_][^/]*\.spec\.md$/u;
9851
+ function extensionHostAppPathOf(point, frontApps) {
9852
+ if (point.app !== void 0) return frontApps.find((app) => app.name === point.app)?.path ?? null;
9853
+ return frontApps.length === 1 ? frontApps[0]?.path ?? null : null;
9854
+ }
9855
+ function extensionRegistriesOf(input) {
9856
+ return input.points.map((point) => {
9857
+ const appPath = extensionHostAppPathOf(point, input.frontApps);
9858
+ if (appPath === null) return { point, hostFolder: null, file: null };
9859
+ const path = extensionRegistryPath(appPath, point);
9860
+ const hostFolder = path.slice(0, -`/${EXTENSION_REGISTRY_FILE}`.length);
9861
+ const contributions = input.mounted.filter((contribution) => contribution.point === point.id);
9862
+ return { point, hostFolder, file: { path, content: emitExtensionRegistry({ point, hostFolder, contributions, generatorVersion: input.generatorVersion }) } };
9863
+ });
9864
+ }
9622
9865
  var GOLDEN_DIRECTORY = "__golden__";
9623
9866
  function isScannablePath(relativePath) {
9624
9867
  const segments = relativePath.split("/");
@@ -9748,7 +9991,7 @@ async function regenerateScreenAndCompare(input, target) {
9748
9991
  }
9749
9992
  async function regenerateAndCompare(input, target) {
9750
9993
  const problems = [];
9751
- const migrationsFolder = posix3.normalize(`${target.apiAppPath}/${target.generation.target.migrations}`);
9994
+ const migrationsFolder = posix4.normalize(`${target.apiAppPath}/${target.generation.target.migrations}`);
9752
9995
  const migrationNames = (await input.repo.listDirectory(migrationsFolder)).map((entry) => entry.path).filter((name) => !name.includes("/"));
9753
9996
  const featureId = target.specPath.split("/").pop()?.replace(/\.spec\.md$/u, "") ?? target.specPath;
9754
9997
  const plan = buildFeaturePlan({
@@ -9846,6 +10089,7 @@ async function verifyGeneration(input) {
9846
10089
  if (input.generation !== null && input.apiAppPath !== void 0) {
9847
10090
  await checkDeclaredExports(input.repo, input.generation, input.apiAppPath, problems);
9848
10091
  }
10092
+ const mounted = [];
9849
10093
  const specEntries = (await input.repo.listDirectory(specsFolder)).filter(
9850
10094
  (entry) => SPEC_FILE_NAME.test(entry.path)
9851
10095
  );
@@ -9859,6 +10103,9 @@ async function verifyGeneration(input) {
9859
10103
  if (first.kind === "screen" && first.screen !== null && input.loadConsumed !== void 0) {
9860
10104
  const consumed = await input.loadConsumed(first.screen);
9861
10105
  report2 = checkSpec({ markdown, specPath, guards, generation: input.generation, consumed });
10106
+ if (report2.screen?.mounts !== void 0 && owner !== void 0 && folder !== void 0 && owner.appType === "front") {
10107
+ mounted.push({ featureId: owner.featureId ?? folder.split("/").pop() ?? folder, folder, mountId: report2.screen.mounts.id, point: report2.screen.mounts.point });
10108
+ }
9862
10109
  if (report2.ok && report2.screen !== null && owner !== void 0 && folder !== void 0 && owner.appType === "front" && owner.appPath !== void 0 && input.generation !== null) {
9863
10110
  const regenerated = await regenerateScreenAndCompare(input, {
9864
10111
  specPath,
@@ -9910,6 +10157,48 @@ async function verifyGeneration(input) {
9910
10157
  specs.push({ specPath, generated: report2.generated, tally: report2.tally });
9911
10158
  problems.push(...report2.problems);
9912
10159
  }
10160
+ const points = input.generation?.front?.extensionPoints ?? [];
10161
+ if (points.length > 0 && input.frontApps !== void 0) {
10162
+ for (const registry of extensionRegistriesOf({ points, frontApps: input.frontApps, mounted, generatorVersion: GENERATOR_VERSION })) {
10163
+ if (registry.file === null) {
10164
+ problems.push(
10165
+ ogygieError({
10166
+ code: "OGYGIE_GENERATION_BLOCK_INVALID",
10167
+ message: `The extension point ${registry.point.id} names no front app the map knows.`,
10168
+ file: "ogygie.manifest.json",
10169
+ cause: `generation.front.extensionPoints[${registry.point.id}]: the map has ${input.frontApps.length} front apps and the point names none of them`,
10170
+ suggestedFix: "Set `app` on the point to the name of the front app carrying the host screen.",
10171
+ details: { path: "generation.front.extensionPoints", point: registry.point.id }
10172
+ })
10173
+ );
10174
+ continue;
10175
+ }
10176
+ expectedPaths.add(registry.file.path);
10177
+ problems.push(...await compareGeneratedFiles(input, [registry.file], "ogygie.manifest.json", `--all`));
10178
+ if (!await input.repo.exists(registry.hostFolder)) continue;
10179
+ const hostFiles = (await input.repo.listDirectory(registry.hostFolder)).filter((entry) => /\.tsx?$/u.test(entry.path) && !entry.path.endsWith(EXTENSION_REGISTRY_FILE) && !entry.path.includes("/"));
10180
+ let wired = false;
10181
+ for (const entry of hostFiles) {
10182
+ const source = await input.repo.readFile(`${registry.hostFolder}/${entry.path}`);
10183
+ if (/from\s+["']\.\/extensions\.gen(?:\.js)?["']/u.test(source)) {
10184
+ wired = true;
10185
+ break;
10186
+ }
10187
+ }
10188
+ if (!wired) {
10189
+ problems.push(
10190
+ ogygieError({
10191
+ code: EXTENSION_POINT_UNWIRED,
10192
+ message: `The extension point ${registry.point.id} has a registry nobody reads.`,
10193
+ file: registry.hostFolder,
10194
+ cause: `no file of ${registry.hostFolder} imports "./extensions.gen"`,
10195
+ suggestedFix: `In the host screen, import { contributions } from "./extensions.gen" and render them in its tab bar \u2014 once, by hand; the registry is regenerated with every mounted screen.`,
10196
+ details: { point: registry.point.id, folder: registry.hostFolder }
10197
+ })
10198
+ );
10199
+ }
10200
+ }
10201
+ }
9913
10202
  const generatedFiles = [];
9914
10203
  for (const directory of input.scanDirs) {
9915
10204
  for (const entry of await input.repo.listDirectory(directory)) {
@@ -10585,7 +10874,7 @@ function loadGenerateProject(root) {
10585
10874
  if (feature.spec === null) return [];
10586
10875
  const owningApp = feature.app === void 0 ? apiApp : manifest.apps.find((app) => app.name === feature.app);
10587
10876
  if (owningApp === void 0) return [];
10588
- return [{ specPath: feature.spec, folder: `${owningApp.path}/${feature.folder}`, declared: { routes: feature.routes, dbTables: feature.dbTables }, appPath: owningApp.path, appType: owningApp.type }];
10877
+ return [{ featureId: feature.name, specPath: feature.spec, folder: `${owningApp.path}/${feature.folder}`, declared: { routes: feature.routes, dbTables: feature.dbTables }, appPath: owningApp.path, appType: owningApp.type }];
10589
10878
  });
10590
10879
  return {
10591
10880
  ok: true,
@@ -10712,9 +11001,54 @@ async function loadConsumedFeatures(project, block) {
10712
11001
  }
10713
11002
 
10714
11003
  // src/cli/generate-screen-command.ts
11004
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
11005
+ import { dirname as dirname3, join as join5 } from "node:path";
11006
+ import { performance } from "node:perf_hooks";
11007
+
11008
+ // src/cli/extension-registries.ts
10715
11009
  import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, writeFileSync } from "node:fs";
10716
11010
  import { dirname as dirname2, join as join4 } from "node:path";
10717
- import { performance } from "node:perf_hooks";
11011
+ function mountedScreensOf(project) {
11012
+ const mounted = [];
11013
+ for (const feature of project.manifest.features) {
11014
+ if (feature.spec === null || targetKindOf(project, feature.name) !== "screen") continue;
11015
+ const specPath = join4(project.root, feature.spec);
11016
+ if (!existsSync3(specPath)) continue;
11017
+ const report2 = checkSpec({ markdown: readFileSync4(specPath, "utf8"), specPath: feature.spec, generation: project.generation });
11018
+ const mounts = report2.screen?.mounts;
11019
+ if (mounts === void 0) continue;
11020
+ const owningApp = feature.app === void 0 ? void 0 : project.manifest.apps.find((app) => app.name === feature.app);
11021
+ if (owningApp === void 0) continue;
11022
+ mounted.push({ featureId: feature.name, folder: `${owningApp.path}/${feature.folder}`, mountId: mounts.id, point: mounts.point });
11023
+ }
11024
+ return mounted;
11025
+ }
11026
+ function writeExtensionRegistries(project, out, pointIds) {
11027
+ const declared = project.generation?.front?.extensionPoints ?? [];
11028
+ const points = pointIds === void 0 ? declared : declared.filter((point) => pointIds.includes(point.id));
11029
+ if (points.length === 0) return 0;
11030
+ const frontApps = project.manifest.apps.filter((app) => app.type === "front").map((app) => ({ name: app.name, path: app.path }));
11031
+ let code = 0;
11032
+ for (const registry of extensionRegistriesOf({ points, frontApps, mounted: mountedScreensOf(project), generatorVersion: GENERATOR_VERSION })) {
11033
+ if (registry.file === null) {
11034
+ out.error(`ogygie generate \u2014 le point d'extension ${registry.point.id} ne nomme aucune app front de la carte (${frontApps.length} app(s) front) : posez \`app\` sur le point.`);
11035
+ code = 1;
11036
+ continue;
11037
+ }
11038
+ const absolute = join4(project.root, registry.file.path);
11039
+ const exists = existsSync3(absolute);
11040
+ if (exists && readFileSync4(absolute, "utf8") === registry.file.content) {
11041
+ out.log(` identique ${registry.file.path}`);
11042
+ continue;
11043
+ }
11044
+ mkdirSync(dirname2(absolute), { recursive: true });
11045
+ writeFileSync(absolute, registry.file.content, "utf8");
11046
+ out.log(` ${exists ? "r\xE9\xE9crit " : "\xE9crit "} ${registry.file.path} (registre du point ${registry.point.id})`);
11047
+ }
11048
+ return code;
11049
+ }
11050
+
11051
+ // src/cli/generate-screen-command.ts
10718
11052
  function consumedInputsOf(loaded) {
10719
11053
  const inputs = [];
10720
11054
  for (const feature of loaded.values()) {
@@ -10730,7 +11064,7 @@ async function screenPlanOf(project, featureId, out, command = "ogygie generate"
10730
11064
  return null;
10731
11065
  }
10732
11066
  const { target } = lookup;
10733
- const markdown = readFileSync4(join4(project.root, target.specPath), "utf8");
11067
+ const markdown = readFileSync5(join5(project.root, target.specPath), "utf8");
10734
11068
  const first = checkSpec({ markdown, specPath: target.specPath, generation: target.generation });
10735
11069
  if (first.kind !== "screen" || first.screen === null) {
10736
11070
  for (const problem2 of first.problems) out.error(formatProblem(problem2));
@@ -10767,8 +11101,8 @@ async function runGenerateScreenCommand(project, featureId, out) {
10767
11101
  const existingCustomViews = /* @__PURE__ */ new Map();
10768
11102
  for (const view of plan.views) {
10769
11103
  if (view.kind !== "custom") continue;
10770
- const path = join4(project.root, plan.folder, "views", `${view.componentName}.tsx`);
10771
- existingCustomViews.set(view.id, existsSync3(path) ? readFileSync4(path, "utf8") : null);
11104
+ const path = join5(project.root, plan.folder, "views", `${view.componentName}.tsx`);
11105
+ existingCustomViews.set(view.id, existsSync4(path) ? readFileSync5(path, "utf8") : null);
10772
11106
  }
10773
11107
  const files = generateScreen(plan, { existingCustomViews });
10774
11108
  const specContracts = applyGeneratedContracts(markdown, emitScreenSpecContracts(plan), GENERATOR_VERSION);
@@ -10776,25 +11110,25 @@ async function runGenerateScreenCommand(project, featureId, out) {
10776
11110
  let written = 0;
10777
11111
  let emitted = 0;
10778
11112
  for (const file of files) {
10779
- const absolute = join4(project.root, file.path);
10780
- const exists = existsSync3(absolute);
11113
+ const absolute = join5(project.root, file.path);
11114
+ const exists = existsSync4(absolute);
10781
11115
  emitted += file.content.split("\n").length - 1;
10782
- if (exists && readFileSync4(absolute, "utf8") === file.content) {
11116
+ if (exists && readFileSync5(absolute, "utf8") === file.content) {
10783
11117
  out.log(` identique ${file.path}`);
10784
11118
  continue;
10785
11119
  }
10786
- mkdirSync(dirname2(absolute), { recursive: true });
10787
- writeFileSync(absolute, file.content, "utf8");
11120
+ mkdirSync2(dirname3(absolute), { recursive: true });
11121
+ writeFileSync2(absolute, file.content, "utf8");
10788
11122
  written += 1;
10789
11123
  out.log(` ${exists ? "r\xE9\xE9crit " : "\xE9crit "} ${file.path}`);
10790
11124
  }
10791
11125
  const bodyPath = screenBodyPath(plan);
10792
- const bodyAbsolute = join4(project.root, bodyPath);
10793
- if (existsSync3(bodyAbsolute)) {
11126
+ const bodyAbsolute = join5(project.root, bodyPath);
11127
+ if (existsSync4(bodyAbsolute)) {
10794
11128
  out.log(` conserv\xE9 ${bodyPath} (corps \xE9crit, jamais compar\xE9 \u2014 D40)`);
10795
11129
  } else {
10796
- mkdirSync(dirname2(bodyAbsolute), { recursive: true });
10797
- writeFileSync(bodyAbsolute, screenBodyDefault(plan), "utf8");
11130
+ mkdirSync2(dirname3(bodyAbsolute), { recursive: true });
11131
+ writeFileSync2(bodyAbsolute, screenBodyDefault(plan), "utf8");
10798
11132
  written += 1;
10799
11133
  out.log(` \xE9crit ${bodyPath} (corps par d\xE9faut, une fois : le v\xF4tre d\xE9sormais \u2014 D40)`);
10800
11134
  }
@@ -10805,19 +11139,20 @@ async function runGenerateScreenCommand(project, featureId, out) {
10805
11139
  if (specContracts.outcome === "unchanged") {
10806
11140
  out.log(` identique ${target.specPath} \u203A bloc @generated de # Contrats de donn\xE9es`);
10807
11141
  } else {
10808
- writeFileSync(join4(project.root, target.specPath), specContracts.markdown, "utf8");
11142
+ writeFileSync2(join5(project.root, target.specPath), specContracts.markdown, "utf8");
10809
11143
  out.log(` ${specContracts.outcome === "inserted" ? "ins\xE9r\xE9 " : "r\xE9\xE9crit "} ${target.specPath} \u203A bloc @generated de # Contrats de donn\xE9es`);
10810
11144
  }
11145
+ const registryCode = plan.mount === null ? 0 : writeExtensionRegistries(project, out, [plan.mount.point.id]);
10811
11146
  out.log(
10812
- `ogygie generate \u2014 ${featureId} (\xE9cran) : ${files.length} fichier(s) \xE9mis en ${elapsedMs} ms (${emitted} lignes \xE9mises, bloc de la spec compris), ${written} \xE9crit(s), ${report2.tally.derivable} ligne(s) li\xE9e(s), ${report2.tally.hole} port\xE9e(s) par un trou ; ${plan.views.filter((view) => view.kind === "custom").length} vue(s) custom sur ${plan.views.length}.`
11147
+ `ogygie generate \u2014 ${featureId} (\xE9cran) : ${files.length} fichier(s) \xE9mis en ${elapsedMs} ms (${emitted} lignes \xE9mises, bloc de la spec compris), ${written} \xE9crit(s), ${report2.tally.derivable} ligne(s) li\xE9e(s), ${report2.tally.hole} port\xE9e(s) par un trou ; ${plan.views.filter((view) => view.kind === "custom").length} vue(s) custom sur ${plan.views.length}${plan.views.some((view) => view.kind === "content") ? `, ${plan.views.filter((view) => view.kind === "content").length} de contenu` : ""}${plan.mount === null ? "" : ` ; mont\xE9 sur ${plan.mount.point.id}`}.`
10813
11148
  );
10814
- return 0;
11149
+ return registryCode;
10815
11150
  }
10816
11151
 
10817
11152
  // src/cli/fill-command.ts
10818
11153
  import { spawnSync } from "node:child_process";
10819
- import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
10820
- import { join as join5 } from "node:path";
11154
+ import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync6, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "node:fs";
11155
+ import { join as join6 } from "node:path";
10821
11156
 
10822
11157
  // ../ogygie-core/src/model.ts
10823
11158
  function createMessagesClient(deps) {
@@ -10925,7 +11260,7 @@ async function planOf(project, featureId, out) {
10925
11260
  return null;
10926
11261
  }
10927
11262
  const { target } = lookup;
10928
- const markdown = readFileSync5(join5(project.root, target.specPath), "utf8");
11263
+ const markdown = readFileSync6(join6(project.root, target.specPath), "utf8");
10929
11264
  const firstPass = checkSpec({ markdown, specPath: target.specPath, generation: target.generation });
10930
11265
  if (firstPass.block === null) {
10931
11266
  for (const problem2 of firstPass.problems) out.error(formatProblem(problem2));
@@ -10943,7 +11278,7 @@ async function planOf(project, featureId, out) {
10943
11278
  for (const problem2 of report2.problems) out.error(formatProblem(problem2));
10944
11279
  return null;
10945
11280
  }
10946
- const migrationsDir = join5(project.root, target.appPath, target.generation.target.migrations);
11281
+ const migrationsDir = join6(project.root, target.appPath, target.generation.target.migrations);
10947
11282
  const plan = buildFeaturePlan({
10948
11283
  featureId,
10949
11284
  folder: target.folder,
@@ -10953,24 +11288,24 @@ async function planOf(project, featureId, out) {
10953
11288
  schemas,
10954
11289
  generation: target.generation,
10955
11290
  ...target.tenantColumn === void 0 ? {} : { tenantColumn: target.tenantColumn },
10956
- migrationFileName: migrationFileNameFor(existsSync4(migrationsDir) ? readdirSync2(migrationsDir) : [], report2.block.contracts.table === void 0 ? featureId : tableNameOf(report2.block.contracts.table)),
11291
+ migrationFileName: migrationFileNameFor(existsSync5(migrationsDir) ? readdirSync2(migrationsDir) : [], report2.block.contracts.table === void 0 ? featureId : tableNameOf(report2.block.contracts.table)),
10957
11292
  generatorVersion: GENERATOR_VERSION,
10958
11293
  behaviors: boundBehaviorsOf(report2.behaviors)
10959
11294
  });
10960
11295
  return { plan, folder: target.folder };
10961
11296
  }
10962
11297
  function recordLibraryEntry(libraryDir, entry) {
10963
- mkdirSync2(libraryDir, { recursive: true });
10964
- const path = join5(libraryDir, libraryFileNameOf(entry));
10965
- if (!existsSync4(path)) {
10966
- writeFileSync2(path, `${JSON.stringify(entry, null, 2)}
11298
+ mkdirSync3(libraryDir, { recursive: true });
11299
+ const path = join6(libraryDir, libraryFileNameOf(entry));
11300
+ if (!existsSync5(path)) {
11301
+ writeFileSync3(path, `${JSON.stringify(entry, null, 2)}
10967
11302
  `, "utf8");
10968
11303
  return "created";
10969
11304
  }
10970
- const existing = HoleLibraryEntrySchema.parse(JSON.parse(readFileSync5(path, "utf8")));
11305
+ const existing = HoleLibraryEntrySchema.parse(JSON.parse(readFileSync6(path, "utf8")));
10971
11306
  const origin = entry.provenance[0];
10972
11307
  if (origin === void 0 || existing.provenance.some((known) => known.repo === origin.repo && known.feature === origin.feature && known.hole === origin.hole)) return "unchanged";
10973
- writeFileSync2(path, `${JSON.stringify({ ...existing, provenance: [...existing.provenance, origin] }, null, 2)}
11308
+ writeFileSync3(path, `${JSON.stringify({ ...existing, provenance: [...existing.provenance, origin] }, null, 2)}
10974
11309
  `, "utf8");
10975
11310
  return "extended";
10976
11311
  }
@@ -11034,21 +11369,21 @@ async function fillLogicHoles(project, featureId, argv, out, deps) {
11034
11369
  const built = await planOf(project, featureId, out);
11035
11370
  if (built === null) return 1;
11036
11371
  const { plan, folder } = built;
11037
- const logicPath = join5(project.root, folder, "logic.ts");
11038
- if (!existsSync4(logicPath)) {
11372
+ const logicPath = join6(project.root, folder, "logic.ts");
11373
+ if (!existsSync5(logicPath)) {
11039
11374
  out.error(`ogygie generate fill \u2014 ${folder}/logic.ts n'existe pas : lancez \`ogygie generate generate ${featureId}\` d'abord.`);
11040
11375
  return 1;
11041
11376
  }
11042
11377
  const testPath = `${folder}/holes.gen.test.ts`;
11043
- const sections = parseLogicSections(readFileSync5(logicPath, "utf8"));
11378
+ const sections = parseLogicSections(readFileSync6(logicPath, "utf8"));
11044
11379
  const wiring = wiringOf(project, featureId, argv, out, deps, "logic");
11045
11380
  const library = loadHolesLibrary(wiring.libraryDir);
11046
11381
  const inputs = plan.holes.filter((hole) => hole.kind !== "view").map((hole) => {
11047
- const contextPath = join5(project.root, folder, "holes", `${hole.id}.gen.md`);
11382
+ const contextPath = join6(project.root, folder, "holes", `${hole.id}.gen.md`);
11048
11383
  return {
11049
11384
  holeId: hole.id,
11050
11385
  featureId: plan.featureId,
11051
- context: existsSync4(contextPath) ? readFileSync5(contextPath, "utf8") : `# ${hole.id}`,
11386
+ context: existsSync5(contextPath) ? readFileSync6(contextPath, "utf8") : `# ${hole.id}`,
11052
11387
  currentBody: sections.get(hole.id)?.body ?? "",
11053
11388
  rules: logicHoleRules(hole, plan, library)
11054
11389
  };
@@ -11060,8 +11395,8 @@ async function fillLogicHoles(project, featureId, argv, out, deps) {
11060
11395
  const runner = deps.runner ?? vitestHoleRunner;
11061
11396
  const fillDeps = fillDepsOf(wiring, {
11062
11397
  async writeBody(holeId, body) {
11063
- const logic = readFileSync5(logicPath, "utf8");
11064
- writeFileSync2(logicPath, replaceHoleBody(logic, holeId, body ?? ` return todo(${JSON.stringify(holeId)});`), "utf8");
11398
+ const logic = readFileSync6(logicPath, "utf8");
11399
+ writeFileSync3(logicPath, replaceHoleBody(logic, holeId, body ?? ` return todo(${JSON.stringify(holeId)});`), "utf8");
11065
11400
  },
11066
11401
  async runHoleTest(holeId) {
11067
11402
  return runner({ projectRoot: project.root, testPath, testName: holeId });
@@ -11072,15 +11407,15 @@ async function fillLogicHoles(project, featureId, argv, out, deps) {
11072
11407
  return summary.unfilled === 0 ? 0 : 1;
11073
11408
  }
11074
11409
  function viewContextOf(projectRoot, plan, view) {
11075
- const docPath = join5(projectRoot, plan.folder, "holes", `${view.hole.id}.gen.md`);
11076
- const doc = existsSync4(docPath) ? readFileSync5(docPath, "utf8") : `# ${view.hole.id}`;
11077
- const cataloguePath = join5(projectRoot, PATTERN_CATALOGUE_PATH);
11078
- if (!existsSync4(cataloguePath)) return doc;
11410
+ const docPath = join6(projectRoot, plan.folder, "holes", `${view.hole.id}.gen.md`);
11411
+ const doc = existsSync5(docPath) ? readFileSync6(docPath, "utf8") : `# ${view.hole.id}`;
11412
+ const cataloguePath = join6(projectRoot, PATTERN_CATALOGUE_PATH);
11413
+ if (!existsSync5(cataloguePath)) return doc;
11079
11414
  return `${doc.trimEnd()}
11080
11415
 
11081
11416
  ## Catalogue des patterns du produit (${PATTERN_CATALOGUE_PATH})
11082
11417
 
11083
- ${readFileSync5(cataloguePath, "utf8").trimEnd()}
11418
+ ${readFileSync6(cataloguePath, "utf8").trimEnd()}
11084
11419
  `;
11085
11420
  }
11086
11421
  async function fillCustomViews(project, featureId, argv, out, deps) {
@@ -11092,16 +11427,16 @@ async function fillCustomViews(project, featureId, argv, out, deps) {
11092
11427
  out.log(`ogygie generate fill \u2014 ${featureId} (\xE9cran) n'a aucune vue custom : rien \xE0 remplir.`);
11093
11428
  return 0;
11094
11429
  }
11095
- const fileOf2 = (view) => join5(project.root, plan.folder, "views", `${view.componentName}.tsx`);
11430
+ const fileOf2 = (view) => join6(project.root, plan.folder, "views", `${view.componentName}.tsx`);
11096
11431
  for (const view of views) {
11097
- if (!existsSync4(fileOf2(view))) {
11432
+ if (!existsSync5(fileOf2(view))) {
11098
11433
  out.error(`ogygie generate fill \u2014 ${plan.folder}/views/${view.componentName}.tsx n'existe pas : lancez \`ogygie generate generate ${featureId}\` d'abord.`);
11099
11434
  return 1;
11100
11435
  }
11101
11436
  }
11102
11437
  const testPath = `${plan.folder}/${plan.screenName}.gen.test.tsx`;
11103
11438
  const previewsPath = `${plan.folder}/${plan.screenName}${PREVIEWS_FILE_SUFFIX}`;
11104
- const judgesPreview = plan.front.tests === "static-render" && existsSync4(join5(project.root, previewsPath));
11439
+ const judgesPreview = plan.front.tests === "static-render" && existsSync5(join6(project.root, previewsPath));
11105
11440
  const wiring = wiringOf(project, featureId, argv, out, deps, "view");
11106
11441
  const library = loadHolesLibrary(wiring.libraryDir);
11107
11442
  const byHole = new Map(views.map((view) => [view.hole.id, view]));
@@ -11109,7 +11444,7 @@ async function fillCustomViews(project, featureId, argv, out, deps) {
11109
11444
  holeId: view.hole.id,
11110
11445
  featureId: plan.featureId,
11111
11446
  context: viewContextOf(project.root, plan, view),
11112
- currentBody: currentViewBody(readFileSync5(fileOf2(view), "utf8"), view.hole.id),
11447
+ currentBody: currentViewBody(readFileSync6(fileOf2(view), "utf8"), view.hole.id),
11113
11448
  rules: customViewRules(view, plan, library)
11114
11449
  }));
11115
11450
  const runner = deps.runner ?? vitestHoleRunner;
@@ -11117,7 +11452,7 @@ async function fillCustomViews(project, featureId, argv, out, deps) {
11117
11452
  async writeBody(holeId, body) {
11118
11453
  const view = byHole.get(holeId);
11119
11454
  if (view === void 0) throw new Error(`fill: no custom view carries the hole ${holeId}`);
11120
- writeFileSync2(fileOf2(view), replaceViewSections(readFileSync5(fileOf2(view), "utf8"), holeId, body), "utf8");
11455
+ writeFileSync3(fileOf2(view), replaceViewSections(readFileSync6(fileOf2(view), "utf8"), holeId, body), "utf8");
11121
11456
  },
11122
11457
  async runHoleTest(holeId) {
11123
11458
  const view = byHole.get(holeId);
@@ -11226,6 +11561,8 @@ export {
11226
11561
  READ_RENDERS,
11227
11562
  FORM_STATES,
11228
11563
  FORM_RENDERS,
11564
+ CONTENT_STATES,
11565
+ CONTENT_RENDERS,
11229
11566
  screenCaseKey,
11230
11567
  screenVocabularyOf,
11231
11568
  bindScreenBehavior,
@@ -11339,6 +11676,7 @@ export {
11339
11676
  targetKindOf,
11340
11677
  screenTargetOf,
11341
11678
  loadConsumedFeatures,
11679
+ writeExtensionRegistries,
11342
11680
  screenPlanOf,
11343
11681
  runGenerateScreenCommand,
11344
11682
  parseFillFlags,