@yahoo/uds-create-config 5.27.0 → 5.28.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.
@@ -68,6 +68,7 @@ import { linkOverrideRefMigration } from "../migrations/20261009193004_link_over
68
68
  import { linkedComponentModulesMigration } from "../migrations/20261012200606_linked_component_modules.js";
69
69
  import { componentStatesMigration } from "../migrations/20261013154037_component_states.js";
70
70
  import { tokenOverridesListMigration } from "../migrations/20261013164300_token_overrides_list.js";
71
+ import { linkExcludeOpMigration } from "../migrations/20261013182150_link_exclude_op.js";
71
72
  const System = defineConfig({
72
73
  name: WEB_CONFIG_NAME,
73
74
  description: "A design system: the values, primitives and compositions an app is built from. A ref is written `{ \"__ref\": \"<kind>:<path>\" }`, so a token is `{ \"__ref\": \"token:<group>/<name>\" }` and a token group is `{ \"__ref\": \"token:<group>\" }`. Build it in this order. Tokens first, in groups by purpose (colors, spacing, radii, type sizes), since everything else names them. Then style properties, each with `values` holding one ref to the token group whose members it accepts (`{ \"__ref\": \"token:color\" }`), so a prop takes token names rather than raw CSS; add a string keyword only for a value no token holds. A style rule's bag holds a prop's values (`\"padding\": \"4\"`, `\"bg\": \"brand\"`), never raw CSS. Then a few primitive components, one layer each, whose props are style properties (a box for layout and surface, a text for type, a pressable for interaction). Then every other component composed from those primitives: its layers name a primitive or an element, its anatomy places them (a node renders a layer, a tag or `Slot` where a slot prop's content lands; `children` is the edge list; `visible` is `{ \"$state\": \"<prop>\" }`; a prop shown in a nested layer is a `forward` route, and `$state` never sits in a node's props), and its style rules set style-property values per layer, keyed by prop values when a variant changes them. Modes (a dark mode is `{ type: \"selector\", value: \".dark\" }`) change token values through a token's `overrides`, so a component never restyles for a mode; modifiers (hover, focus, disabled) name a state, and a style rule applies under one as a `_hover` block inside a layer bag. Composites bundle style values that recur. Last, register canvas roles: the canvas draws with them, one entity per tool (`frame` for the layout primitive, `text` for the text primitive, `image`) whose entries name the components that tool inserts, and a tool whose role is unregistered is disabled. Give each component a `preview` entry under `*` so it shows content, and never write a CSS property name where a style-property name belongs.",
@@ -147,5 +148,6 @@ if (!registeredSchemaMigrations().has(20261009193004)) registerSchemaMigrations(
147
148
  if (!registeredSchemaMigrations().has(20261012200606)) registerSchemaMigrations(linkedComponentModulesMigration);
148
149
  if (!registeredSchemaMigrations().has(20261013154037)) registerSchemaMigrations(componentStatesMigration);
149
150
  if (!registeredSchemaMigrations().has(20261013164300)) registerSchemaMigrations(tokenOverridesListMigration);
151
+ if (!registeredSchemaMigrations().has(20261013182150)) registerSchemaMigrations(linkExcludeOpMigration);
150
152
  //#endregion
151
153
  export { System };
@@ -143,6 +143,18 @@ declare const LinkedSystemDefinition: EntityClass<z.ZodObject<{
143
143
  data: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<"*">, z.ZodArray<z.ZodString>]>>]>>;
144
144
  }, z.core.$strip>;
145
145
  };
146
+ readonly exclude: {
147
+ readonly input: z.ZodObject<{
148
+ path: z.ZodType<string, string | undefined, z.core.$ZodTypeInternals<string, string | undefined>>;
149
+ data: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
150
+ }, z.core.$strip>;
151
+ };
152
+ readonly 'exclude-plan': {
153
+ readonly input: z.ZodObject<{
154
+ path: z.ZodType<string, string | undefined, z.core.$ZodTypeInternals<string, string | undefined>>;
155
+ data: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
156
+ }, z.core.$strip>;
157
+ };
146
158
  readonly break: {
147
159
  readonly input: z.ZodObject<{
148
160
  path: z.ZodType<string, string | undefined, z.core.$ZodTypeInternals<string, string | undefined>>;
@@ -6,8 +6,9 @@ import { kindBorrowable } from "../../framework/registry.js";
6
6
  import { defineEntity } from "../../framework/defineEntity.js";
7
7
  import { storedStyleRules } from "./Component.js";
8
8
  import { sourceUnreachable } from "../../framework/sources.js";
9
+ import { covers, excludes } from "./link-declaration.js";
9
10
  import { planCopy } from "./copy-plan.js";
10
- import { planBreak, planLink, planUnlink, sourceRowStates } from "./link-plan.js";
11
+ import { planBreak, planExclude, planLink, planUnlink, sourceRowStates } from "./link-plan.js";
11
12
  import { z } from "zod";
12
13
  //#region src/entities/system/LinkedSystem.ts
13
14
  /**
@@ -177,6 +178,12 @@ const unlinkInput = z.object({
177
178
  /** What to stop drawing, in `link`'s shape; everything when omitted. */
178
179
  data: z.union([z.literal("*"), z.record(z.string(), copyPaths)]).optional()
179
180
  });
181
+ const excludeInput = z.object({
182
+ path: pathInput("linked-system"),
183
+ /** What to leave out, keyed by accessor the way the pin's `link` is: the source's paths, where a
184
+ * folder leaves out its members. */
185
+ data: z.record(z.string(), z.array(z.string().min(1)).min(1))
186
+ });
180
187
  const breakInput = z.object({
181
188
  path: pathInput("linked-system"),
182
189
  /** Whether every value drawn through the pin becomes this config's own first. */
@@ -197,6 +204,13 @@ function copyInputOf(input) {
197
204
  ...input.onConflict === void 0 ? {} : { onConflict: input.onConflict }
198
205
  };
199
206
  }
207
+ /** The parsed `exclude` input as the plan takes it. */
208
+ function excludePlanInputOf(input) {
209
+ return {
210
+ path: String(input.path),
211
+ data: input.data
212
+ };
213
+ }
200
214
  /** The parsed `link` input as the plan takes it. */
201
215
  function linkPlanInputOf(input) {
202
216
  return {
@@ -270,7 +284,11 @@ const LinkedSystemDefinition = defineEntity({
270
284
  kind: "linked-system",
271
285
  label: "Linked system",
272
286
  labelPlural: "Linked systems",
273
- invariants: (config) => [...config.list("linked-system").filter(({ path }) => !isLinkSlug(path)).map(({ path }) => `Link "${path}": ${LINK_SLUG_MESSAGE}`), ...linkRedirects(config).filter((redirect) => redirect.problem !== void 0 && redirect.problem !== "missing").map(redirectSentence)],
287
+ invariants: (config) => [
288
+ ...config.list("linked-system").filter(({ path }) => !isLinkSlug(path)).map(({ path }) => `Link "${path}": ${LINK_SLUG_MESSAGE}`),
289
+ ...linkRedirects(config).filter((redirect) => redirect.problem !== void 0 && redirect.problem !== "missing").map(redirectSentence),
290
+ ...excludedDirectives(config)
291
+ ],
274
292
  schemas: {
275
293
  create: { example: ({ config }) => {
276
294
  const path = freeExampleSlug(config);
@@ -372,6 +390,45 @@ const LinkedSystemDefinition = defineEntity({
372
390
  }
373
391
  },
374
392
  /**
393
+ * Leave part of a linked system out for good: a `!` entry per path in the pin's `link`. Unlike
394
+ * `unlink`, nothing derives there however the link reaches it, and the linked tokens' overrides
395
+ * under an excluded modifier are left out of the build. `link` with the same path lifts it.
396
+ */
397
+ exclude: {
398
+ input: excludeInput,
399
+ readOnly: false,
400
+ label: "Exclude from",
401
+ description: "Leave paths of a linked system out, keyed by accessor the way `link` is: `{ modifiers: ['text'] }` leaves out the `text` folder's members. Stronger than `unlink`: nothing derives at an excluded path however the link reaches it, even when a linked token's override names it, and the source's token overrides under an excluded modifier are left out of the build. The pin's `options` entries at those paths go with them. `exclude-plan` names what goes; `link` with the same path brings it back.",
402
+ scope: "item",
403
+ approval: "reference-change",
404
+ title: (entity) => `Exclude something from ${entity}`,
405
+ example: linkExample,
406
+ handler: (input, config) => {
407
+ const { patches, blocked } = planExclude({
408
+ config,
409
+ input: excludePlanInputOf(input)
410
+ });
411
+ if (blocked !== void 0) rejectData(blocked);
412
+ return config.applyAll(patches);
413
+ }
414
+ },
415
+ "exclude-plan": {
416
+ input: excludeInput,
417
+ readOnly: true,
418
+ label: "Plan an exclusion",
419
+ description: "What `exclude` with the same input leaves out — the minted rows that stop deriving, the linked tokens' overrides under an excluded modifier (`leftOut`), and the pin's `options` entries that go with the paths (`dropped`) — and why the config would refuse it. Writes nothing.",
420
+ scope: "item",
421
+ title: (entity) => `Plan an exclusion from ${entity}`,
422
+ example: linkExample,
423
+ handler: (input, config) => {
424
+ const { patches: _patches, ...plan } = planExclude({
425
+ config,
426
+ input: excludePlanInputOf(input)
427
+ });
428
+ return plan;
429
+ }
430
+ },
431
+ /**
375
432
  * Break the link: the pin goes, and everything drawn through it either becomes this config's own
376
433
  * or goes with it.
377
434
  */
@@ -541,6 +598,7 @@ const LinkedSystemDefinition = defineEntity({
541
598
  * link: { tokens: '*', icons: '*' } // those kinds whole
542
599
  * link: { tokens: ['color/brand', 'color/paper'] } // those entities
543
600
  * link: { components: ['actions'] } // a folder: its members
601
+ * link: { '*': '*', modifiers: ['*', '!text'] } // everything but one folder
544
602
  * ```
545
603
  * `Config.withDerivedRows` reads it and makes a pointer row per entity it covers — on every build,
546
604
  * and inside the apply of any patch to this pin — so a link's cost in authored source is this one
@@ -566,14 +624,38 @@ const LinkedSystemDefinition = defineEntity({
566
624
  * build would make it again on the next pass, and a delete that comes back is worse than one that
567
625
  * was refused. To own one of their entities, `copy` it; to stop drawing it, `unlink` it.
568
626
  *
569
- * The gestures that write this are the kind's own ops — `link`, `unlink`, `break` — each with a
570
- * `-plan` read beside it, so a surface never assembles the declaration by hand.
627
+ * The gestures that write this are the kind's own ops — `link`, `unlink`, `exclude`, `break` —
628
+ * each with a `-plan` read beside it, so a surface never assembles the declaration by hand.
571
629
  *
572
630
  * `'*'` means every kind that may be drawn on, which is every kind that may be borrowed — each
573
631
  * kind says which it is (`EntityKind.linkable`). Nothing is subtracted: a pin and a `file` aren't
574
632
  * borrowable, so no borrow of one exists for this to cover.
633
+ *
634
+ * **Leaving part of a kind out.** A record's `'*'` key stands for every accessor it doesn't name,
635
+ * and a list entry `'*'` for every path, so everything but one folder is spelled
636
+ * ```ts
637
+ * link: { '*': '*', modifiers: ['*', '!text'] }
638
+ * ```
639
+ * A `!` entry leaves that path out, or a folder's members, whatever else in the list reaches it.
640
+ * Unlike a path a list doesn't name, an excluded path derives no row however the link reaches it —
641
+ * a linked token whose override is under a `text` modifier names that modifier, and it still
642
+ * stays out — so the name is free for this config to author, and the source's entity stays
643
+ * reachable qualified. A token's overrides under an excluded modifier are left out of the build
644
+ * rather than refused (`unstatableConditions`), which is what a consumer without one of the
645
+ * source's mode axes means. A linked component's rule keyed on one still reaches the source's
646
+ * modifier, as a rule on any modifier the link doesn't take does. See `covers` and `excludes`.
647
+ *
648
+ * Exclusions win wherever they sit, so `['!text', '*']` is `['*', '!text']`, and a list of them
649
+ * alone (`['!text']`) takes nothing by name and still keeps `text` out. An `options` entry at an
650
+ * excluded path is refused (an invariant), since no row derives there for it to adjust. `exclude`
651
+ * writes one (and drops such entries with it), and `link` lifts one over what it takes. `unlink`
652
+ * keeps them and writes none, because a `!` also drops the overrides under the path, which
653
+ * unlinking never has.
654
+ *
655
+ * No entity path can be misread here: `entityPathProblem` refuses a segment that starts with `!`,
656
+ * a segment that is `*`, and an empty segment, wherever a path becomes an identity.
575
657
  */
576
- link: z.union([z.literal("*"), z.record(z.string(), z.union([z.literal("*"), z.array(z.string().min(1)).min(1)]))]).optional().describe("What this system draws from the source, standing: `'*'` for everything, or a record by kind."),
658
+ link: z.union([z.literal("*"), z.record(z.string(), z.union([z.literal("*"), z.array(z.string().min(1)).min(1)]))]).optional().describe("What this system draws from the source, standing: `'*'` for everything, or a record by kind (`'*'` keys every kind it doesn't name). A list's `'!path'` entry leaves that path out."),
577
659
  /**
578
660
  * What this system does to individual entities of the source, keyed by accessor, then by the path
579
661
  * the source spells, then by verb.
@@ -715,20 +797,22 @@ displayName: ({ member, path }) => typeof member.name === "string" && member.nam
715
797
  * question and the delete gate's, answered here because `link` is this kind's field and its shape
716
798
  * is this kind's to interpret.
717
799
  *
718
- * `'*'` at either level covers everything under it; a list covers the paths it names, as the source
719
- * spells them, and a named folder covers its members. Nothing excludes a kind here — a kind a
720
- * whole-source take does not reach is one no borrow of it exists for (`EntityKind.linkable`), so
721
- * the claim is unreachable rather than filtered.
800
+ * `'*'` at either level covers everything under it, and a record's `'*'` key stands for every
801
+ * accessor it doesn't name; a list covers the paths it names, as the source spells them, and a named
802
+ * folder covers its members, less what its `!` entries leave out. Nothing else excludes a kind here
803
+ * — a kind a whole-source take does not reach is one no borrow of it exists for
804
+ * (`EntityKind.linkable`), so the claim is unreachable rather than filtered.
722
805
  */
723
- bulkAdopts: ({ body, kind, accessor, path }) => {
724
- const declared = body.link;
725
- if (declared === void 0) return false;
726
- if (declared === "*") return true;
727
- const paths = declared[accessor ?? kind];
728
- if (paths === void 0) return false;
729
- if (paths === "*") return true;
730
- return paths.some((declaredPath) => declaredPath === path || path.startsWith(`${declaredPath}/`));
731
- },
806
+ bulkAdopts: ({ body, kind, accessor, path }) => covers({
807
+ declared: body.link,
808
+ accessor: accessor ?? kind,
809
+ path
810
+ }),
811
+ bulkExcludes: ({ body, kind, accessor, path }) => excludes({
812
+ declared: body.link,
813
+ accessor: accessor ?? kind,
814
+ path
815
+ }),
732
816
  bulkOverlay: ({ body, kind, accessor, path }) => {
733
817
  const directive = directiveAt(body, accessor ?? kind, path);
734
818
  if (!directive) return void 0;
@@ -794,6 +878,18 @@ function linkedSystemItem(def) {
794
878
  };
795
879
  }
796
880
  const LinkedSystem = Object.assign(LinkedSystemDefinition, { item: linkedSystemItem });
881
+ /** Each `options` entry at a path its pin's `link` excludes, which derives no row for it to adjust. */
882
+ function excludedDirectives(config) {
883
+ return config.list("linked-system").flatMap(({ path: slug }) => {
884
+ const body = config.storedBody("linked-system", slug);
885
+ if (!body?.options) return [];
886
+ return Object.entries(body.options).flatMap(([accessor, entries]) => isPlainObject(entries) ? Object.keys(entries).filter((path) => excludes({
887
+ declared: body.link,
888
+ accessor,
889
+ path
890
+ })).map((path) => `Link "${slug}": options.${accessor}["${path}"] adjusts a path its \`link\` excludes, which derives no row. Drop the entry, or the exclusion.`) : []);
891
+ });
892
+ }
797
893
  /** The `options` entry for one path, or `undefined` where the pin says nothing about it. */
798
894
  function directiveAt(body, accessor, path) {
799
895
  return body.options?.[accessor]?.[path];
@@ -3,19 +3,60 @@ import type { LinkDeclaration } from './LinkedSystem';
3
3
  export declare const PIN_KIND = "linked-system";
4
4
  /** The pin's `link` as stored. */
5
5
  export declare function declarationOf(config: Config, slug: string): LinkDeclaration | undefined;
6
- /** Whether a declaration covers the accessor's path: the whole source, the kind, the path, or a folder above it. */
6
+ /**
7
+ * What a declaration says about one accessor: its own entry, or the record's `'*'` entry, which stands
8
+ * for every accessor the record doesn't name (`{ '*': '*', modifiers: ['*', '!text'] }`).
9
+ */
10
+ export declare function entriesFor(declared: LinkDeclaration | undefined, accessor: string): '*' | readonly string[] | undefined;
11
+ /** The paths a list leaves out, without their `!`. */
12
+ export declare function excludedIn(entries: '*' | readonly string[] | undefined): string[];
13
+ /**
14
+ * Whether a declaration leaves the accessor's path out on purpose: a `!` entry names it or a folder
15
+ * above it. An excluded path derives no row however the link reaches it, a ref pulling it in included.
16
+ */
17
+ export declare function excludes({ declared, accessor, path, }: {
18
+ declared: LinkDeclaration | undefined;
19
+ accessor: string;
20
+ path: string;
21
+ }): boolean;
22
+ /**
23
+ * Whether a declaration covers the accessor's path: the whole source, the kind, the path, or a folder
24
+ * above it, and no `!` entry leaves it out. An exclusion wins whatever else in the list reaches the
25
+ * path, so `['*', '!text']` is every path but `text`'s.
26
+ */
7
27
  export declare function covers({ declared, accessor, path, }: {
8
28
  declared: LinkDeclaration | undefined;
9
29
  accessor: string;
10
30
  path: string;
11
31
  }): boolean;
32
+ /**
33
+ * A list with `paths` taken into it: each is added unless a path the list takes already reaches it,
34
+ * and an exclusion that would leave one out is lifted. One naming the path or a folder under it goes;
35
+ * one naming a folder above it is spelled out as the folder's other members, so they stay out.
36
+ */
37
+ export declare function includePaths({ config, slug, kind, entries, paths, }: {
38
+ config: Config;
39
+ slug: string;
40
+ kind: string;
41
+ entries: readonly string[];
42
+ paths: readonly string[];
43
+ }): string[];
44
+ /**
45
+ * A list with `paths` left out: a `!` entry for each the list doesn't exclude already. A path the
46
+ * list names under one of them goes, since the exclusion now says it, and so does a narrower
47
+ * exclusion a new one reaches. A folder named above an excluded path stays, less that path. With
48
+ * nothing listed, the list is the exclusions alone, which still keep the paths out when a linked row
49
+ * reaches them.
50
+ */
51
+ export declare function excludePaths(entries: '*' | readonly string[] | undefined, paths: readonly string[]): string[];
12
52
  /**
13
53
  * The declaration with `removal` taken out of it, or `undefined` when nothing stays declared.
14
54
  *
15
55
  * A declaration of everything is spelled out first, one kind per accessor the source has rows for, so
16
56
  * one kind can be taken out of it; a kind taken whole is spelled out as the source's top-level paths
17
57
  * so some can go; a declared folder above a removed path is spelled out as its other members, so the
18
- * rest of it stays covered.
58
+ * rest of it stays covered. A list that takes every path (`['*', …]`) is spelled out the same way.
59
+ * Its `!` entries stay, a list of them alone included, and narrowing writes none.
19
60
  */
20
61
  export declare function narrowDeclaration({ config, slug, current, removal, verb, }: {
21
62
  config: Config;
@@ -7,14 +7,90 @@ function declarationOf(config, slug) {
7
7
  if (!pin) rejectData(`No linked system "${slug}".`);
8
8
  return pin.link;
9
9
  }
10
- /** Whether a declaration covers the accessor's path: the whole source, the kind, the path, or a folder above it. */
10
+ /** An entry that leaves a path out: `'!text'`. */
11
+ const EXCLUDE = "!";
12
+ /** Whether a declared entry names `path` or a folder above it. */
13
+ const reaches = (entry, path) => entry === "*" || entry === path || path.startsWith(`${entry}/`);
14
+ /**
15
+ * What a declaration says about one accessor: its own entry, or the record's `'*'` entry, which stands
16
+ * for every accessor the record doesn't name (`{ '*': '*', modifiers: ['*', '!text'] }`).
17
+ */
18
+ function entriesFor(declared, accessor) {
19
+ if (declared === void 0 || declared === "*") return declared;
20
+ return declared[accessor] ?? declared["*"];
21
+ }
22
+ /** The paths a list leaves out, without their `!`. */
23
+ function excludedIn(entries) {
24
+ if (entries === void 0 || entries === "*") return [];
25
+ return entries.filter((entry) => entry.startsWith(EXCLUDE)).map((entry) => entry.slice(1));
26
+ }
27
+ /**
28
+ * Whether a declaration leaves the accessor's path out on purpose: a `!` entry names it or a folder
29
+ * above it. An excluded path derives no row however the link reaches it, a ref pulling it in included.
30
+ */
31
+ function excludes({ declared, accessor, path }) {
32
+ return excludedIn(entriesFor(declared, accessor)).some((excluded) => reaches(excluded, path));
33
+ }
34
+ /**
35
+ * Whether a declaration covers the accessor's path: the whole source, the kind, the path, or a folder
36
+ * above it, and no `!` entry leaves it out. An exclusion wins whatever else in the list reaches the
37
+ * path, so `['*', '!text']` is every path but `text`'s.
38
+ */
11
39
  function covers({ declared, accessor, path }) {
12
- if (declared === void 0) return false;
13
- if (declared === "*") return true;
14
- const paths = declared[accessor];
15
- if (paths === void 0) return false;
16
- if (paths === "*") return true;
17
- return paths.some((declaredPath) => declaredPath === path || path.startsWith(`${declaredPath}/`));
40
+ const entries = entriesFor(declared, accessor);
41
+ if (entries === void 0) return false;
42
+ if (entries === "*") return true;
43
+ if (excludes({
44
+ declared,
45
+ accessor,
46
+ path
47
+ })) return false;
48
+ return entries.some((entry) => !entry.startsWith(EXCLUDE) && reaches(entry, path));
49
+ }
50
+ /**
51
+ * A list with `paths` taken into it: each is added unless a path the list takes already reaches it,
52
+ * and an exclusion that would leave one out is lifted. One naming the path or a folder under it goes;
53
+ * one naming a folder above it is spelled out as the folder's other members, so they stay out.
54
+ */
55
+ function includePaths({ config, slug, kind, entries, paths }) {
56
+ const source = config.sourceFor(slug);
57
+ const included = [];
58
+ const exclusions = [];
59
+ for (const entry of entries) {
60
+ if (!entry.startsWith(EXCLUDE)) {
61
+ included.push(entry);
62
+ continue;
63
+ }
64
+ const excluded = entry.slice(1);
65
+ if (paths.some((path) => reaches(path, excluded))) continue;
66
+ if (!paths.some((path) => reaches(excluded, path))) {
67
+ exclusions.push(entry);
68
+ continue;
69
+ }
70
+ for (const member of source?.members(kind, excluded) ?? []) if (!paths.some((path) => reaches(path, member.path))) exclusions.push(`${EXCLUDE}${member.path}`);
71
+ }
72
+ for (const path of paths) {
73
+ if (included.some((entry) => reaches(entry, path))) continue;
74
+ included.push(path);
75
+ }
76
+ return [...included, ...exclusions];
77
+ }
78
+ /**
79
+ * A list with `paths` left out: a `!` entry for each the list doesn't exclude already. A path the
80
+ * list names under one of them goes, since the exclusion now says it, and so does a narrower
81
+ * exclusion a new one reaches. A folder named above an excluded path stays, less that path. With
82
+ * nothing listed, the list is the exclusions alone, which still keep the paths out when a linked row
83
+ * reaches them.
84
+ */
85
+ function excludePaths(entries, paths) {
86
+ const list = entries === void 0 ? [] : entries === "*" ? ["*"] : entries;
87
+ const excluded = excludedIn(list);
88
+ const added = [...new Set(paths)].filter((path) => !excluded.some((already) => reaches(already, path)));
89
+ return [...list.filter((entry) => {
90
+ if (entry === "*") return true;
91
+ const path = entry.startsWith(EXCLUDE) ? entry.slice(1) : entry;
92
+ return !added.some((adding) => reaches(adding, path));
93
+ }), ...added.map((path) => `${EXCLUDE}${path}`)];
18
94
  }
19
95
  /**
20
96
  * The declaration with `removal` taken out of it, or `undefined` when nothing stays declared.
@@ -22,7 +98,8 @@ function covers({ declared, accessor, path }) {
22
98
  * A declaration of everything is spelled out first, one kind per accessor the source has rows for, so
23
99
  * one kind can be taken out of it; a kind taken whole is spelled out as the source's top-level paths
24
100
  * so some can go; a declared folder above a removed path is spelled out as its other members, so the
25
- * rest of it stays covered.
101
+ * rest of it stays covered. A list that takes every path (`['*', …]`) is spelled out the same way.
102
+ * Its `!` entries stay, a list of them alone included, and narrowing writes none.
26
103
  */
27
104
  function narrowDeclaration({ config, slug, current, removal, verb }) {
28
105
  const source = config.sourceFor(slug);
@@ -31,7 +108,18 @@ function narrowDeclaration({ config, slug, current, removal, verb }) {
31
108
  if (source && source.list(kind).length === 0) continue;
32
109
  next[config.accessorOf(kind) ?? kind] = "*";
33
110
  }
34
- else Object.assign(next, current);
111
+ else {
112
+ Object.assign(next, current);
113
+ const fallback = current["*"];
114
+ if (fallback !== void 0) {
115
+ delete next["*"];
116
+ for (const kind of config.borrowableKinds(slug)) {
117
+ if (source && source.list(kind).length === 0) continue;
118
+ const accessor = config.accessorOf(kind) ?? kind;
119
+ if (!(accessor in current)) next[accessor] = fallback;
120
+ }
121
+ }
122
+ }
35
123
  for (const [accessor, paths] of removal) {
36
124
  const before = next[accessor];
37
125
  if (before === void 0) continue;
@@ -40,25 +128,32 @@ function narrowDeclaration({ config, slug, current, removal, verb }) {
40
128
  continue;
41
129
  }
42
130
  const kind = config.accessors()[accessor]?.kind ?? accessor;
131
+ const excluded = excludedIn(before);
43
132
  let declaredPaths;
44
- if (before === "*") {
133
+ if (before === "*" || before.includes("*")) {
45
134
  if (!source) rejectData(`Cannot ${verb} ${paths.join(", ")} through "${slug}" — the declaration takes every "${accessor}", and nothing has resolved that link to spell them out. Unlink the kind (\`{ "${accessor}": "*" }\`) instead.`, ["data", accessor]);
46
135
  declaredPaths = source.tree(kind).map((node) => node.path);
47
- } else declaredPaths = before;
136
+ } else declaredPaths = before.filter((entry) => !entry.startsWith(EXCLUDE));
48
137
  const removed = (candidate) => paths.some((path) => path === candidate || candidate.startsWith(`${path}/`));
49
138
  const kept = [];
50
139
  for (const declaredPath of declaredPaths) {
51
140
  if (removed(declaredPath)) continue;
141
+ if (excluded.some((path) => reaches(path, declaredPath))) continue;
52
142
  if (!paths.some((path) => path.startsWith(`${declaredPath}/`)) || !source) {
53
143
  kept.push(declaredPath);
54
144
  continue;
55
145
  }
56
- for (const member of source.members(kind, declaredPath)) if (!removed(member.path)) kept.push(member.path);
146
+ for (const member of source.members(kind, declaredPath)) {
147
+ if (removed(member.path)) continue;
148
+ if (excluded.some((path) => reaches(path, member.path))) continue;
149
+ kept.push(member.path);
150
+ }
57
151
  }
58
- if (kept.length === 0) delete next[accessor];
59
- else next[accessor] = kept;
152
+ const exclusions = excluded.map((path) => `${EXCLUDE}${path}`);
153
+ if (kept.length === 0 && exclusions.length === 0) delete next[accessor];
154
+ else next[accessor] = [...kept, ...exclusions];
60
155
  }
61
156
  return Object.keys(next).length === 0 ? void 0 : next;
62
157
  }
63
158
  //#endregion
64
- export { PIN_KIND, covers, declarationOf, narrowDeclaration };
159
+ export { PIN_KIND, covers, declarationOf, entriesFor, excludePaths, excludes, includePaths, narrowDeclaration };
@@ -1,7 +1,8 @@
1
1
  import { rejectData } from "../../framework/rejection.js";
2
2
  import { collectRefs, isMintedRow, ref, sourceOf, splitRef } from "../../framework/utils/refs.js";
3
3
  import { blockedBy } from "../../framework/plan.js";
4
- import { PIN_KIND, covers, declarationOf, narrowDeclaration } from "./link-declaration.js";
4
+ import { excludedCondition } from "../../framework/source-integrity.js";
5
+ import { PIN_KIND, covers, declarationOf, entriesFor, excludePaths, excludes, includePaths, narrowDeclaration } from "./link-declaration.js";
5
6
  import { planCopy, sourceEntries } from "./copy-plan.js";
6
7
  //#region src/entities/system/link-plan.ts
7
8
  /** The kinds `data` names, with what to take of each. An accessor this config has no kind for is a typo, refused by name. */
@@ -201,7 +202,7 @@ function planLink({ config, input }) {
201
202
  } else {
202
203
  const next = { ...current ?? {} };
203
204
  for (const [accessor, gain] of gains) {
204
- const before = next[accessor];
205
+ const before = entriesFor(current, accessor);
205
206
  if (before === "*") continue;
206
207
  if (gain === "*") {
207
208
  next[accessor] = "*";
@@ -213,7 +214,13 @@ function planLink({ config, input }) {
213
214
  path
214
215
  }));
215
216
  if (added.length === 0) continue;
216
- next[accessor] = [...before ?? [], ...new Set(added)];
217
+ next[accessor] = includePaths({
218
+ config,
219
+ slug,
220
+ kind: config.accessors()[accessor]?.kind ?? accessor,
221
+ entries: before ?? [],
222
+ paths: [...new Set(added)]
223
+ });
217
224
  }
218
225
  for (const [accessor, value] of Object.entries(next)) {
219
226
  if (current !== void 0 && current[accessor] === value) continue;
@@ -361,6 +368,104 @@ function planUnlink({ config, input }) {
361
368
  };
362
369
  }
363
370
  /**
371
+ * Leave paths of a linked system out, with a `!` entry each in the pin's `link`. Stronger than
372
+ * `unlink`: nothing derives there however the link reaches it, a linked token naming it included,
373
+ * and the linked tokens' overrides under an excluded modifier are left out of the build. `link` with
374
+ * the same path lifts it again.
375
+ */
376
+ function planExclude({ config, input }) {
377
+ const { path: slug } = input;
378
+ const source = config.sourceFor(slug);
379
+ if (!source) rejectData(`Cannot exclude through "${slug}" — nothing has resolved that link.`);
380
+ const current = declarationOf(config, slug);
381
+ const next = current === void 0 ? {} : current === "*" ? { "*": "*" } : { ...current };
382
+ for (const { kind, accessor, paths } of wantedKinds({
383
+ config,
384
+ slug,
385
+ data: input.data,
386
+ verb: "exclude"
387
+ })) {
388
+ if (paths === "*") rejectData(`Cannot exclude every "${accessor}" through "${slug}" — name the paths to leave out, or \`unlink\` the kind.`, ["data", accessor]);
389
+ for (const path of paths) if (sourceEntries({
390
+ source,
391
+ kind,
392
+ path
393
+ }).length === 0) rejectData(`Cannot exclude "${path}" through "${slug}" — the source has no ${kind} there.`, ["data", accessor]);
394
+ next[accessor] = excludePaths(entriesFor(current, accessor), paths);
395
+ }
396
+ const declared = next;
397
+ const dropped = [];
398
+ const optionDeletes = {};
399
+ const options = config.storedBody("linked-system", slug).options ?? {};
400
+ for (const [accessor, entries] of Object.entries(options)) {
401
+ if (!entries || typeof entries !== "object") continue;
402
+ const kind = config.accessors()[accessor]?.kind ?? accessor;
403
+ for (const path of Object.keys(entries)) {
404
+ if (!excludes({
405
+ declared,
406
+ accessor,
407
+ path
408
+ })) continue;
409
+ dropped.push({
410
+ kind,
411
+ path
412
+ });
413
+ optionDeletes[`options.${accessor}.${path}`] = null;
414
+ }
415
+ }
416
+ const patches = [{
417
+ kind: PIN_KIND,
418
+ operation: "update",
419
+ path: slug,
420
+ data: {
421
+ link: declared,
422
+ ...optionDeletes
423
+ }
424
+ }];
425
+ if (Object.entries(input.data).every(([accessor, paths]) => paths.every((path) => excludes({
426
+ declared: current,
427
+ accessor,
428
+ path
429
+ })))) return {
430
+ declared: current,
431
+ removed: [],
432
+ leftOut: [],
433
+ dropped: [],
434
+ blocked: `Nothing would change: what "${slug}" declares already leaves everything named out.`,
435
+ patches: []
436
+ };
437
+ const blocked = blockedBy(() => config.applyAll(patches));
438
+ const removed = [];
439
+ const leftOut = [];
440
+ if (blocked === void 0) {
441
+ const after = config.applyAll(patches);
442
+ for (const kind of config.borrowableKinds(slug)) for (const item of config.list(kind)) {
443
+ if (item.borrowedFrom !== slug) continue;
444
+ if (after.resolve(kind, item.path)?.borrowedFrom === slug) continue;
445
+ removed.push({
446
+ kind,
447
+ path: item.path
448
+ });
449
+ }
450
+ for (const item of after.list("token")) {
451
+ if (item.borrowedFrom !== slug) continue;
452
+ const body = source.resolve("token", item.path)?.toJSON();
453
+ for (const [override, { on }] of Object.entries(body?.overrides ?? {})) if (excludedCondition(after, on)) leftOut.push({
454
+ token: item.path,
455
+ override
456
+ });
457
+ }
458
+ }
459
+ return {
460
+ declared,
461
+ removed,
462
+ leftOut,
463
+ dropped,
464
+ ...blocked === void 0 ? {} : { blocked },
465
+ patches
466
+ };
467
+ }
468
+ /**
364
469
  * The linked rows that point at what `data` names, followed to a fixpoint: a row pointing at one of
365
470
  * those, and so on. Read off both ref graphs, since a linked row's refs are the source's spelling and
366
471
  * this config's index holds them qualified.
@@ -832,4 +937,4 @@ function sourceRowStates({ config, slug }) {
832
937
  return rows;
833
938
  }
834
939
  //#endregion
835
- export { planBreak, planLink, planUnlink, sourceRowStates };
940
+ export { planBreak, planExclude, planLink, planUnlink, sourceRowStates };
@@ -73,7 +73,7 @@ interface RecordedSource {
73
73
  * The dense-counter `2` a short-lived scheme stamped before the chain existed reads as the mint
74
74
  * that replaced it (see `detectedWireVersion`).
75
75
  */
76
- declare const SERIALIZED_CONFIG_VERSION = 20261013164300;
76
+ declare const SERIALIZED_CONFIG_VERSION = 20261013182150;
77
77
  interface SerializedConfig {
78
78
  /** See {@link SERIALIZED_CONFIG_VERSION}. Always written; a stored artifact may lack it. */
79
79
  readonly version: number;
@@ -1785,6 +1785,11 @@ declare class Config {
1785
1785
  * subject is the overlay rather than whether a row is minted at all.
1786
1786
  */
1787
1787
  sourceOverlay(slug: string, kind: string, path: string): SourceOverlay | undefined;
1788
+ /**
1789
+ * Whether the declared source at `slug` excludes one of its paths ({@link EntityKind.bulkExcludes}):
1790
+ * left out on purpose, so no row derives there. Matched on the path the source spells.
1791
+ */
1792
+ sourceExcludes(slug: string, kind: string, path: string): boolean;
1788
1793
  /**
1789
1794
  * The field-path delta that records `overlay` as the pin's directive for one path, or `null` when
1790
1795
  * there is nothing to write.
@@ -2179,9 +2184,8 @@ declare class Config {
2179
2184
  * only the reached ones would leave those rows absent from the artifact. Reachability stays the
2180
2185
  * floor either way, so a ref always brings its target whether or not a declaration claims it.
2181
2186
  *
2182
- * Shared rather than computed per caller: the CSS emitter needs it to know what to declare, and the
2183
- * build's validation needs the same set to know what to check — two answers to that question would
2184
- * drift, and the drift would look like a token that emits but isn't validated.
2187
+ * Preserves the original source definitions for saved configs and Web emission. Native emission
2188
+ * and condition validation use runtimeSourceClosure to follow the definitions after overrides.
2185
2189
  *
2186
2190
  * `kind` narrows it for a caller that emits or validates one kind, which is the caller's business
2187
2191
  * to name; the walk itself follows refs wherever they go, so borrowing something other than a token
@@ -2190,6 +2194,14 @@ declare class Config {
2190
2194
  * Sorted, so anything built from it is deterministic.
2191
2195
  */
2192
2196
  sourceClosure(slug: string, kind?: string): string[];
2197
+ /** References used after local overrides; sourceClosure also preserves the original bases. */
2198
+ runtimeSourceClosure({
2199
+ slug,
2200
+ kind
2201
+ }: {
2202
+ slug: string;
2203
+ kind?: string;
2204
+ }): string[];
2193
2205
  /**
2194
2206
  * The walk behind {@link sourceClosure}, run once per snapshot and source set.
2195
2207
  *