@heroiclands/package-build 22.9.1 → 22.10.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/CONTENT.md +33 -0
  3. package/bin/content-build.mjs +3 -1
  4. package/docs/api.md +10 -6
  5. package/docs/content-format.md +384 -200
  6. package/docs/project-setup.md +23 -0
  7. package/engine/actor-compiler.mjs +20 -7
  8. package/engine/address-fields.mjs +404 -0
  9. package/engine/address-values.mjs +80 -0
  10. package/engine/address.mjs +769 -0
  11. package/engine/art-fields.mjs +112 -97
  12. package/engine/art-slots.mjs +81 -0
  13. package/engine/base-compiler.mjs +59 -7
  14. package/engine/bundle-notes.mjs +5 -2
  15. package/engine/bundles.mjs +17 -10
  16. package/engine/content-address.mjs +23 -186
  17. package/engine/content-embeds.mjs +18 -4
  18. package/engine/content-format-check.mjs +50 -22
  19. package/engine/content-index.mjs +85 -43
  20. package/engine/content-links.mjs +105 -104
  21. package/engine/content-lint.mjs +11 -1
  22. package/engine/content-tables.mjs +9 -1
  23. package/engine/field-spec.mjs +20 -4
  24. package/engine/folder-notes.mjs +44 -15
  25. package/engine/foundry-entries.mjs +3 -0
  26. package/engine/frontmatter-lint.mjs +144 -43
  27. package/engine/frontmatter.mjs +55 -10
  28. package/engine/generate.mjs +7 -1
  29. package/engine/helpers.mjs +37 -9
  30. package/engine/holdings.mjs +41 -12
  31. package/engine/index-records.mjs +2 -0
  32. package/engine/infobox-render.mjs +2 -1
  33. package/engine/infobox.mjs +26 -6
  34. package/engine/item-compiler.mjs +5 -0
  35. package/engine/item-docs.mjs +7 -44
  36. package/engine/map-dot.mjs +2 -0
  37. package/engine/map-notes.mjs +7 -2
  38. package/engine/map-places.mjs +42 -10
  39. package/engine/metadata-index.mjs +60 -5
  40. package/engine/note-addresses.mjs +310 -0
  41. package/engine/note-vocabulary.mjs +145 -26
  42. package/engine/pdf-build.mjs +1 -1
  43. package/engine/pdf-toc.mjs +17 -2
  44. package/engine/place-relations.mjs +160 -50
  45. package/engine/populations.mjs +17 -9
  46. package/engine/reference-targets.mjs +54 -0
  47. package/engine/scenes.mjs +5 -2
  48. package/engine/shortcode-references.mjs +68 -0
  49. package/engine/site-build.mjs +39 -15
  50. package/engine/site-index.mjs +57 -75
  51. package/engine/sql-tables.mjs +43 -9
  52. package/engine/system-block.mjs +21 -0
  53. package/engine/system-vocabulary.mjs +46 -0
  54. package/engine/web-wikilinks.mjs +11 -9
  55. package/engine/wikilink-syntax.mjs +21 -0
  56. package/engine/wikilinks.mjs +92 -306
  57. package/githooks/attribution.mjs +82 -0
  58. package/githooks/commit-message.mjs +26 -0
  59. package/githooks/commit-msg +3 -35
  60. package/hm3/actors.mjs +8 -24
  61. package/hm3/item-fields.mjs +15 -5
  62. package/package.json +1 -1
  63. package/sohl/infobox.mjs +80 -17
  64. package/sohl/item-fields.mjs +114 -41
  65. package/types/engine/address-fields.d.mts +90 -0
  66. package/types/engine/address-values.d.mts +34 -0
  67. package/types/engine/address.d.mts +452 -0
  68. package/types/engine/art-fields.d.mts +59 -53
  69. package/types/engine/art-slots.d.mts +40 -0
  70. package/types/engine/base-compiler.d.mts +24 -2
  71. package/types/engine/bundle-notes.d.mts +2 -2
  72. package/types/engine/content-address.d.mts +3 -139
  73. package/types/engine/content-embeds.d.mts +1 -1
  74. package/types/engine/content-format-check.d.mts +25 -0
  75. package/types/engine/content-index.d.mts +2 -2
  76. package/types/engine/content-links.d.mts +6 -5
  77. package/types/engine/field-spec.d.mts +44 -9
  78. package/types/engine/folder-notes.d.mts +11 -10
  79. package/types/engine/frontmatter-lint.d.mts +7 -97
  80. package/types/engine/frontmatter.d.mts +16 -1
  81. package/types/engine/helpers.d.mts +2 -2
  82. package/types/engine/infobox.d.mts +6 -0
  83. package/types/engine/item-docs.d.mts +1 -35
  84. package/types/engine/map-notes.d.mts +2 -2
  85. package/types/engine/note-addresses.d.mts +41 -0
  86. package/types/engine/note-vocabulary.d.mts +20 -3
  87. package/types/engine/place-relations.d.mts +12 -1
  88. package/types/engine/populations.d.mts +17 -2
  89. package/types/engine/reference-targets.d.mts +9 -0
  90. package/types/engine/shortcode-references.d.mts +14 -0
  91. package/types/engine/site-build.d.mts +3 -3
  92. package/types/engine/site-index.d.mts +9 -30
  93. package/types/engine/sql-tables.d.mts +2 -2
  94. package/types/engine/system-block.d.mts +17 -0
  95. package/types/engine/system-vocabulary.d.mts +16 -0
  96. package/types/engine/wikilink-syntax.d.mts +7 -0
  97. package/types/engine/wikilinks.d.mts +6 -109
  98. package/types/hm3/item-fields.d.mts +5 -0
  99. package/types/sohl/infobox.d.mts +4 -5
  100. package/types/sohl/item-fields.d.mts +8 -0
@@ -82,15 +82,30 @@
82
82
  * Plain ESM with no Foundry and no filesystem access, so it is unit-testable.
83
83
  */
84
84
 
85
+ import { resolveShortcodeReference } from "./shortcode-references.mjs";
86
+
87
+ import { readWikilink } from "./wikilink-syntax.mjs";
85
88
  import crypto from "crypto";
86
89
 
87
90
  import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
88
91
  import { readCanonicalKey } from "./content-address.mjs";
92
+ // The address grammar. A wikilink *contains* an Address, so what counts as one
93
+ // is stated there and read here — the anchor and the label are this module's,
94
+ // and the tuple inside them is not.
95
+ import {
96
+ ITEM_DOC_PREFIX,
97
+ readQualifier,
98
+ resolveItemDocType,
99
+ parseAddress,
100
+ renderAddress,
101
+ isAddressTuple,
102
+ completeAddress,
103
+ } from "./address.mjs";
89
104
  import { ASSET_TYPE_NAMES } from "./asset-types.mjs";
90
- import { isSystemSegment, NO_SYSTEM } from "./systems.mjs";
105
+ import { NO_SYSTEM } from "./systems.mjs";
91
106
  import { systemOf } from "./document-subtypes.mjs";
92
107
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
93
- import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
108
+ import { itemDocEntryId } from "./item-docs.mjs";
94
109
  import { replaceOutsideCode } from "./code-fences.mjs";
95
110
  // The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
96
111
  // one cannot disagree about what counts as a link.
@@ -104,227 +119,14 @@ import {
104
119
  export { ITEM_PACK, PACK_BY_TYPE, packForType };
105
120
 
106
121
  /**
107
- * The qualifier prefix that addresses an item's **documentation** rather than
108
- * the item: `docskill/wpnc` is the JournalEntry that `skill/wpnc`'s prose
109
- * compiled into. See {@link resolveItemDocType}.
110
- */
111
- const ITEM_DOC_PREFIX = "doc";
112
-
113
- const norm = (s) => String(s).toLowerCase().trim();
114
-
115
- /**
116
- * Reads a qualifier as the **virtual `doc<type>`** form, or reports that it is
117
- * not one.
118
- *
119
- * A document and its documentation are two documents in two packs, so they
120
- * need two addresses. `skill/wpnc` is the item; `docskill/wpnc` is the
121
- * JournalEntry its prose compiled into, and `docmacro/autoattack` is the same
122
- * arrangement for a macro.
123
- *
124
- * The virtual form exists for a type that carries separate documentation
125
- * ({@link sohl.utils.packs.docEntryTypes} — the set the journals compiler and
126
- * the link manifest read too), **or** for one that routes to the items pack.
127
- * The second clause is the older rule and stays: types that compile into items
128
- * are the open, unenumerated set, and a foreign package may publish an
129
- * item type this build has never heard of. Dropping it would silently unlink
130
- * every `doc<type>` address into such a package.
131
- *
132
- * A **real** type of the same name always wins: the virtual reading is only
133
- * consulted for a qualifier no authored note claims.
134
- *
135
- * @param {string} qualifier - The already-normalised text before the `/`.
136
- * @param {Set<string>} types - Every type the content tree contains.
137
- * @returns {string|null} The underlying document type, or `null` when the
138
- * qualifier is not a virtual one.
139
- */
140
- export function resolveItemDocType(qualifier, types) {
141
- if (types.has(qualifier)) return null; // a real type owns its own name
142
- if (!qualifier.startsWith(ITEM_DOC_PREFIX)) return null;
143
- const base = qualifier.slice(ITEM_DOC_PREFIX.length);
144
- if (!base || !types.has(base)) return null;
145
- if (hasDocEntry(base)) return base;
146
- return packForType(base).docType === ITEM_PACK.docType ? base : null;
147
- }
148
-
149
- /**
150
- * Read a link target as a **qualified** `type-shortcode` reference, or report
151
- * that it does not parse as one.
152
- *
153
- * Two separators are accepted, and they are **not** interchangeable in how
154
- * confidently they mark a target as qualified:
155
- *
156
- * - **`type-shortcode`** and its qualified forms — the canonical spelling.
157
- * Obsidian reads `/` inside a wikilink as a *path* and resolves it
158
- * against the vault's folders, so a slash-qualified link is a broken link in
159
- * the editor where the content is now authored.
160
- * - **`type/shortcode`** — the legacy form, still resolved so that a link
161
- * written before the vault migrated does not silently die. A slash is
162
- * *unconditionally* a qualifier: nothing else uses one, so an unknown type
163
- * before it is reported rather than guessed at. The split is at the **last**
164
- * slash, as it always was.
165
- *
166
- * **The grammar is strict, and omission runs left to right**:
167
- *
168
- * ```text
169
- * [[[[<package>-]<system>-]<type>-]<shortcode>]
170
- * ```
171
- *
172
- * So the written forms are exactly the suffixes of the canonical address —
173
- * `type-shortcode`, `system-type-shortcode`, `package-system-type-shortcode` —
174
- * and **`package-type-shortcode` is not one of them**. A link into another
175
- * package must therefore be fully qualified, which is the price of the segment
176
- * being positional rather than tagged.
177
- *
178
- * **Parsing is plain positional counting**, the same rule
179
- * {@link readCanonicalKey} follows, and it is sound for the same reason: every
180
- * segment matches `ADDRESS_SEGMENT_PATTERN` (enforced on shortcodes by
181
- * `content-lint.mjs`), so the hyphen is purely a
182
- * separator and the count alone determines every field. Verified across the
183
- * four content trees: 138,204 authored shortcodes, none carrying a separator.
184
- *
185
- * That replaced a first-hyphen split which let a shortcode contain a hyphen
186
- * (`trauma-self-pro` → `trauma` + `self-pro`). The tolerance predates the
187
- * charset rule and
188
- * outlived it; no tree has used it, and keeping it would make a three-segment
189
- * target ambiguous between a system and a hyphenated shortcode.
190
- *
191
- * **`sohl` is both a package and a system**, and positional counting is what
192
- * makes that harmless: three segments is `<system>-<type>-<shortcode>` whatever
193
- * the first segment could also have named, and four is the full form. Nothing
194
- * has to guess which sense was meant.
195
- *
196
- * **A partial address states what it states, and the rest is not invented.** An
197
- * omitted **system** is a *wildcard* — most links target items, which belong to
198
- * a system — so the caller matches on the segments supplied and requires
199
- * exactly one hit. An omitted **package** is instead *defaulted* to the citing
200
- * note's own, so an unqualified link resolves locally and only locally.
201
- *
202
- * @param {string} target - The link target, anchor already removed.
203
- * @param {Set<string>} types - Every type the content tree contains.
204
- * @param {Set<string>} [packages] - Every package an address may name. Omitted
205
- * by callers that resolve within one package, where the form cannot occur.
206
- * @param {Set<string>} [noIndexPackages] - Packages declared `contentIndex:
207
- * false` — a Foundry dependency only, with no fetched index. A fully
208
- * qualified target naming one is refused with `no-content-index` before its
209
- * type is even considered, since there is no index to resolve it against.
210
- * @returns {{type: string, shortcode: string, itemDoc: boolean,
211
- * package?: string, system?: string, reason?: undefined}
212
- * | {reason: "unknown-type"|"no-content-index", package?: string} | null}
213
- * The resolved qualifier; a `reason` when the target is definitely qualified
214
- * but names no known type or no fetched index; or `null` when it is not an
215
- * address at all.
216
- */
217
- export function readQualifier(target, types, packages, noIndexPackages) {
218
- // **Package, system and type are lowercase; the shortcode is not.** A
219
- // shortcode is case-sensitive and routinely mixed — `Clb`, `LtShoe`,
220
- // `HsTunic` — so it is written as the note declares it. The three segments
221
- // in front of it are closed vocabularies with one spelling each, and
222
- // accepting `Skill` beside `skill` would bless two ways of writing one
223
- // address. Reported rather than folded, so the corpus has one form.
224
- //
225
- // Tested only once the target *parses*: a note name is full of capitals
226
- // (`[[Shock State]]`), and calling that a badly-cased address rather than
227
- // not an address would name the wrong mistake. Neither tree carries a
228
- // violation — 10,538 authored targets — so this pins a rule already kept.
229
- const read = readQualifierCased(target, types, packages, noIndexPackages);
230
- if (read && !read.reason && qualifyingSegments(target).some((s) => /[A-Z]/.test(s))) {
231
- return { reason: "not-lowercase" };
232
- }
233
- return read;
234
- }
235
-
236
- /**
237
- * The segments of a target that must be lowercase — everything but the
238
- * shortcode, which is case-sensitive and keeps whatever the note declares.
239
- *
240
- * @param {string} target - The link target, anchor already removed.
241
- * @returns {string[]} The package / system / type segments, as written.
242
- */
243
- function qualifyingSegments(target) {
244
- const slash = target.lastIndexOf("/");
245
- // The legacy `type/shortcode` form states only a type.
246
- if (slash > 0) return [target.slice(0, slash)];
247
- const parts = target.split("-");
248
- return parts.slice(0, -1);
249
- }
250
-
251
- /**
252
- * {@link readQualifier} without the lowercase rule — the grammar alone.
122
+ * The address grammar, under the names the link resolvers hold it by.
253
123
  *
254
- * @param {string} target
255
- * @param {Set<string>} types
256
- * @param {Set<string>} [packages]
257
- * @param {Set<string>} [noIndexPackages]
258
- * @returns {object|null}
124
+ * {@link readQualifier} reads a link target as the partial Address the matchers
125
+ * consume, and {@link resolveItemDocType} is the `doc<type>` reading it applies.
259
126
  */
260
- function readQualifierCased(target, types, packages, noIndexPackages) {
261
- // The slash form is legacy and states neither package nor system, so it is
262
- // read first and separately. A slash is unconditionally a qualifier —
263
- // nothing else uses one — which is why an unknown type before it is
264
- // *reported* rather than read as prose.
265
- const slash = target.lastIndexOf("/");
266
- if (slash > 0) {
267
- const read = readTypeAndCode(target.slice(0, slash), target.slice(slash + 1), types);
268
- return read ?? { reason: "unknown-type" };
269
- }
270
-
271
- const parts = target.split("-");
272
- switch (parts.length) {
273
- // `<type>-<shortcode>`
274
- case 2:
275
- return readTypeAndCode(parts[0], parts[1], types);
276
-
277
- // `<system>-<type>-<shortcode>` — the package defaults to local.
278
- case 3: {
279
- const system = norm(parts[0]);
280
- if (!isSystemSegment(system)) return null;
281
- const read = readTypeAndCode(parts[1], parts[2], types);
282
- return read && { ...read, system };
283
- }
284
-
285
- // `<package>-<system>-<type>-<shortcode>` — the only form that names
286
- // another package, and the reason a cross-package link must be fully
287
- // qualified.
288
- case 4: {
289
- const pkg = norm(parts[0]);
290
- // Checked before the type: a package with no fetched index has no
291
- // vocabulary to resolve the rest of the target against, and the
292
- // fix is the config declaration, not the shortcode.
293
- if (noIndexPackages?.has(pkg)) return { reason: "no-content-index", package: pkg };
294
- if (!packages?.has(pkg)) return null;
295
- const system = norm(parts[1]);
296
- if (!isSystemSegment(system)) return null;
297
- const read = readTypeAndCode(parts[2], parts[3], types);
298
- return read && { ...read, system, package: pkg };
299
- }
300
-
301
- // One segment is a bare name, which is not an address; five or
302
- // more is not a hyphenated shortcode but a name that happens to carry
303
- // separators, since no segment may contain one.
304
- default:
305
- return null;
306
- }
307
- }
127
+ export { readQualifier, resolveItemDocType };
308
128
 
309
- /**
310
- * Resolve a qualifier/shortcode pair, honouring the virtual `doc<type>` form.
311
- *
312
- * @param {string} rawType
313
- * @param {string} rawCode
314
- * @param {Set<string>} types
315
- * @returns {{type: string, shortcode: string, itemDoc: boolean} | null}
316
- * `null` when the qualifier names no known type, or the shortcode is empty.
317
- */
318
- function readTypeAndCode(rawType, rawCode, types) {
319
- const shortcode = norm(rawCode);
320
- if (!shortcode) return null;
321
-
322
- let type = norm(rawType);
323
- const base = resolveItemDocType(type, types);
324
- if (base) return { type: base, shortcode, itemDoc: true };
325
- if (!types.has(type)) return null;
326
- return { type, shortcode, itemDoc: false };
327
- }
129
+ const norm = (s) => String(s).toLowerCase().trim();
328
130
 
329
131
  /**
330
132
  * The deterministic JournalEntryPage id for one anchor: SHA-256 of
@@ -384,7 +186,7 @@ export function buildWikilinkIndex(
384
186
  packageId,
385
187
  foreign,
386
188
  contentPackage,
387
- { assets, noIndexPackages } = {},
189
+ { assets, noIndexPackages, referenceTargets, foreignReferences } = {},
388
190
  ) {
389
191
  if (!packageId) {
390
192
  throw new Error(
@@ -479,6 +281,8 @@ export function buildWikilinkIndex(
479
281
 
480
282
  return {
481
283
  byShortcode,
284
+ referenceTargets,
285
+ foreignReferences,
482
286
  types,
483
287
  uuidByDoc,
484
288
  packageId,
@@ -654,8 +458,14 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
654
458
  // one capture group. It is what makes two identical unresolved links on
655
459
  // one note tellable apart, and a position reportable at all.
656
460
  const out = replaceOutsideCode(markdown, WIKILINK, (all, rawInner, offset) => {
657
- const parsed = parseWikilink(rawInner);
658
- const target = parsed.target;
461
+ const parsed = readWikilink(rawInner, {
462
+ package: index.contentPackage,
463
+ system: "none",
464
+ types: index.types,
465
+ packages: index.packages,
466
+ noIndexPackages: index.noIndexPackages,
467
+ });
468
+ const target = parsed.rawTarget;
659
469
  const slug = parsed.anchor || null;
660
470
 
661
471
  // **Every link carries a label**. Without one there is nothing
@@ -692,12 +502,7 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
692
502
  if (target === "" && slug) {
693
503
  doc = { type, id, pack, docPack };
694
504
  } else {
695
- const qualified = readQualifier(
696
- target,
697
- index.types,
698
- index.packages,
699
- index.noIndexPackages,
700
- );
505
+ const qualified = parsed.problem ?? parsed.target;
701
506
  qualifiedRead = qualified;
702
507
  // A target that does not parse as an address is a defect: there is
703
508
  // no second namespace left to fall through to.
@@ -723,11 +528,10 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
723
528
  // their own documents are core ones already at `none`, so
724
529
  // `macro-autoattack` names the Macro and `docmacro-autoattack` its
725
530
  // journal — two live addresses the redirect would collapse.
726
- itemDoc =
727
- qualified.itemDoc ||
728
- ((qualified.system ?? NO_SYSTEM) === NO_SYSTEM &&
729
- systemOf(qualified.type, KNOWN_DOCUMENT_SUBTYPE_MAPS) !== NO_SYSTEM);
730
- doc = index.byShortcode.get(`${qualified.type}/${qualified.shortcode}`);
531
+ const baseType = resolveItemDocType(qualified.type, index.types);
532
+ itemDoc = Boolean(baseType);
533
+ if (qualified.package === index.contentPackage)
534
+ doc = index.byShortcode.get(`${baseType ?? qualified.type}/${qualified.shortcode}`);
731
535
  }
732
536
  if (!doc) {
733
537
  // Nothing local answers. A foreign package may publish this
@@ -843,42 +647,6 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
843
647
  return { markdown: out, unresolved };
844
648
  }
845
649
 
846
- /**
847
- * The short `type/shortcode` view of an index's foreign entries.
848
- *
849
- * Derived once per index and memoised: a compile resolves references on every
850
- * one of thousands of notes, and rebuilding the view per note would rescan a
851
- * dependency's whole published index each time. Keyed on the index object, so
852
- * it is discarded with it.
853
- *
854
- * An address two foreign packages both claim is left out rather than resolved
855
- * to whichever loaded first — the same rule the wikilink resolver follows.
856
- *
857
- * @type {WeakMap<object, Map<string, object>>}
858
- */
859
- const FOREIGN_BY_SHORTCODE = new WeakMap();
860
-
861
- /** The memoised short view of `index.foreign`. */
862
- function foreignByShortcode(index) {
863
- const cached = FOREIGN_BY_SHORTCODE.get(index);
864
- if (cached) return cached;
865
- const short = new Map();
866
- const ambiguous = new Set();
867
- for (const [key, value] of index?.foreign ?? []) {
868
- const parts = readCanonicalKey(key);
869
- if (!parts) continue;
870
- const shortKey = `${norm(parts.type)}/${norm(parts.shortcode)}`;
871
- if (short.has(shortKey) && short.get(shortKey).package !== value.package) {
872
- ambiguous.add(shortKey);
873
- } else {
874
- short.set(shortKey, value);
875
- }
876
- }
877
- for (const key of ambiguous) short.delete(key);
878
- FOREIGN_BY_SHORTCODE.set(index, short);
879
- return short;
880
- }
881
-
882
650
  /**
883
651
  * Resolve one reference against a compile's address index.
884
652
  *
@@ -892,46 +660,64 @@ function foreignByShortcode(index) {
892
660
  * @param {object} index - From {@link buildWikilinkIndex}.
893
661
  * @param {unknown} ref - The reference, as authored.
894
662
  * @param {object} [hint] - `{type}`, where the caller knows what it expects.
895
- * @returns {{name?: string, uuid?: string, address?: string, subType?: string}|undefined}
663
+ * @returns {{name?: string, uuid?: string, address?: import("./address.mjs").AddressTuple, subType?: string}|undefined}
896
664
  * The target, or `undefined` where nothing answers.
897
665
  */
898
666
  export function resolveReference(index, ref, hint) {
899
- if (typeof ref !== "string" || !ref) return undefined;
900
- const wanted = norm(ref);
901
- const keys = [];
902
- if (hint?.type) keys.push(`${norm(hint.type)}/${wanted}`);
903
- if (wanted.includes("-")) {
904
- const segments = wanted.split("-");
905
- if (segments.length >= 2) {
906
- keys.push(`${segments[segments.length - 2]}/${segments[segments.length - 1]}`);
907
- }
908
- }
909
- if (!hint?.type) {
910
- for (const type of [...(index?.types ?? [])].sort()) keys.push(`${type}/${wanted}`);
911
- }
912
-
913
- const foreign = foreignByShortcode(index);
914
- for (const key of keys) {
915
- const local = index?.byShortcode?.get(key);
916
- if (local) {
917
- return {
918
- ...(local.name ? { name: local.name } : {}),
919
- ...(local.subType ? { subType: local.subType } : {}),
920
- ...(index.uuidByDoc?.get(local)?.uuid ?
921
- { uuid: index.uuidByDoc.get(local).uuid }
922
- : {}),
923
- address: key.replace("/", "-"),
924
- };
925
- }
926
- const away = foreign.get(key);
927
- if (away) {
928
- return {
929
- ...(away.name ? { name: away.name } : {}),
930
- ...(away.uuid ? { uuid: away.uuid } : {}),
931
- ...(away.subType ? { subType: away.subType } : {}),
932
- address: key.replace("/", "-"),
933
- };
667
+ if (hint?.kind === "shortcode")
668
+ return resolveShortcodeReference(
669
+ [index.referenceTargets, index.foreignReferences ?? index.foreign],
670
+ ref,
671
+ hint,
672
+ );
673
+ const tuple = parseAddress(ref, {
674
+ package: index.contentPackage,
675
+ system: "none",
676
+ types: index.types,
677
+ packages: index.packages,
678
+ noIndexPackages: index.noIndexPackages,
679
+ type: hint?.type,
680
+ });
681
+ if (tuple.reason) return undefined;
682
+ const key = renderAddress(tuple);
683
+ const published =
684
+ index.referenceTargets?.get(key) ??
685
+ index.foreignReferences?.get(key) ??
686
+ index.foreign?.get(key);
687
+ if (published)
688
+ return {
689
+ name: published.name,
690
+ subType: published.subType,
691
+ ...(published.uuid ? { uuid: published.uuid } : {}),
692
+ address: tuple,
693
+ };
694
+ if (index.referenceTargets) return undefined;
695
+ {
696
+ const key = renderAddress(tuple);
697
+ if (tuple.package !== index.contentPackage) {
698
+ const foreign = index.foreign?.get(key);
699
+ if (foreign) return { ...foreign, address: tuple };
700
+ return undefined;
934
701
  }
702
+ const base = resolveItemDocType(tuple.type, index.types);
703
+ const type = base ?? tuple.type;
704
+ if (tuple.system !== (base ? NO_SYSTEM : systemOf(type, KNOWN_DOCUMENT_SUBTYPE_MAPS)))
705
+ return undefined;
706
+ const local = index.byShortcode?.get(`${type}/${tuple.shortcode}`);
707
+ if (!local) return undefined;
708
+ const addresses = index.uuidByDoc?.get(local);
709
+ const uuid =
710
+ base ?
711
+ Object.hasOwn(local, "documentationUuid") ?
712
+ local.documentationUuid
713
+ : addresses?.docUuid
714
+ : addresses?.uuid;
715
+ return {
716
+ name: local.name,
717
+ subType: local.subType,
718
+ ...(uuid ? { uuid } : {}),
719
+ address: tuple,
720
+ };
935
721
  }
936
722
  return undefined;
937
723
  }
@@ -0,0 +1,82 @@
1
+ /*
2
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ /** Bounded identities and standalone credit forms shared with the commit hooks. */
7
+ export const identities = [
8
+ "Codex",
9
+ "ChatGPT",
10
+ "OpenAI",
11
+ "OpenAI Codex",
12
+ "OpenAI ChatGPT",
13
+ "Claude",
14
+ "Claude Code",
15
+ "Anthropic",
16
+ "GitHub Copilot",
17
+ "Gemini",
18
+ "Cursor",
19
+ "AI",
20
+ "AI assistant",
21
+ "an AI assistant",
22
+ ];
23
+ export const phrases = ["Generated by", "Generated with", "Assisted by", "Written by"];
24
+ export const mailboxes = [
25
+ "codex@openai.com",
26
+ "chatgpt@openai.com",
27
+ "noreply@openai.com",
28
+ "claude@anthropic.com",
29
+ "noreply@anthropic.com",
30
+ ];
31
+ const identity = new RegExp(`^(?:${identities.join("|")})$`, "i");
32
+ const signature = new RegExp(`^(?:${phrases.join("|")})\\s+(.+?)[.!]?$`, "i");
33
+
34
+ /** Normalize decoration while keeping each character's original UTF-16 offset. */
35
+ function normalize(line) {
36
+ let chars = Array.from({ length: line.length }, (_, offset) => ({
37
+ text: line[offset],
38
+ offset,
39
+ }));
40
+ const replace = (pattern, keep) => {
41
+ const text = chars.map((char) => char.text).join("");
42
+ const matches = [...text.matchAll(pattern)];
43
+ for (const match of matches.reverse()) {
44
+ const retained = keep(match);
45
+ chars.splice(
46
+ match.index,
47
+ match[0].length,
48
+ ...chars.slice(match.index + retained.start, match.index + retained.end),
49
+ );
50
+ }
51
+ };
52
+ replace(/\[([^\]]+)\]\([^\s)]*\)/g, (match) => ({ start: 1, end: 1 + match[1].length }));
53
+ replace(/[*_`~]/g, () => ({ start: 0, end: 0 }));
54
+ replace(/^[^\p{L}]+/gu, () => ({ start: 0, end: 0 }));
55
+ return {
56
+ text: chars
57
+ .map((char) => char.text)
58
+ .join("")
59
+ .trimEnd(),
60
+ column: (chars[0]?.offset ?? 0) + 1,
61
+ };
62
+ }
63
+
64
+ /** Find a complete credit clause; ordinary prose and human identities pass. */
65
+ export function attribution(line) {
66
+ const normalized = normalize(line);
67
+ const trailer = normalized.text.match(/^co-authored-by:\s*(.*?)\s*(?:<([^<>]+)>)?$/i);
68
+ if (trailer && (identity.test(trailer[1]) || mailboxes.includes(trailer[2]?.toLowerCase()))) {
69
+ return { column: normalized.column, kind: "Co-Authored-By trailer naming an assistant" };
70
+ }
71
+ const credit = normalized.text.match(signature);
72
+ const generic = normalized.text.match(
73
+ /^AI-(?:assisted|generated)(?:\s+(?:by|with)\s+(.+?))?[.!]?$/i,
74
+ );
75
+ if (
76
+ (credit && identity.test(credit[1])) ||
77
+ (generic && (!generic[1] || identity.test(generic[1])))
78
+ ) {
79
+ return { column: normalized.column, kind: "assistant signature" };
80
+ }
81
+ return null;
82
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ import { readFileSync } from "node:fs";
6
+ import { relative } from "node:path";
7
+ import { attribution } from "./attribution.mjs";
8
+
9
+ const file = process.argv[2];
10
+ const lines = readFileSync(file, "utf8").split(/\r?\n/);
11
+ let rejected = false;
12
+ for (const [index, line] of lines.entries()) {
13
+ // Git's verbose diff starts at the scissors comment; other comments are inert.
14
+ if (/^#.*>8/.test(line)) break;
15
+ if (line.startsWith("#")) continue;
16
+ const finding = attribution(line);
17
+ if (!finding) continue;
18
+ console.error(
19
+ `${relative(process.cwd(), file)}:${index + 1}:${finding.column}: error: ${finding.kind}; attribution is not allowed in commit messages.`,
20
+ );
21
+ rejected = true;
22
+ }
23
+ if (rejected) {
24
+ console.error("Remove the assistant credit and commit again.");
25
+ process.exitCode = 1;
26
+ }
@@ -1,40 +1,8 @@
1
1
  #!/usr/bin/env sh
2
- #
3
- # Reject commit messages that carry AI/assistant attribution.
4
- #
5
- # This project does not want Co-Authored-By trailers naming an AI assistant, nor
6
- # "Generated with Claude Code"-style signatures, in its history. The matching CI
7
- # guard is the shared HeroicLands/.github/actions/no-attribution action, which
8
- # this repository's .github/workflows/no-attribution.yml calls. That action holds
9
- # the pattern for every repository; this hook is the only other copy, because a
10
- # git hook runs from your checkout and cannot live in an action. Keep them in sync.
11
- #
12
- # Installed for everyone via the package.json "prepare" script, which points
13
- # git at this directory (`git config core.hooksPath .githooks`) on `npm install`.
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+ # Check the commit message using the same contract as the shared CI action.
14
4
 
15
5
  . "$(dirname "$0")/hook-enabled.sh"
16
-
17
- # On unless refused: it costs nothing, and the No Attribution workflow enforces
18
- # the same rule on GitHub, so opting out locally only moves the failure later.
19
6
  hook_enabled noAttribution true || exit 0
20
7
 
21
- msg_file="$1"
22
-
23
- # Scan only the real message: drop the diff appended by `commit --verbose` (from
24
- # the scissors comment onward) and strip remaining comment lines.
25
- content="$(sed '/^#.*>8/,$d' "$msg_file" | grep -v '^#')"
26
-
27
- # Anchored to the start of a line: real attribution is a trailer / signature at
28
- # column 0, so prose that merely *mentions* these phrases mid-sentence (e.g. this
29
- # hook's own docs) does not trip it. The generated-with branch tolerates a
30
- # leading emoji/space.
31
- pattern='^[[:space:]]*co-authored-by:.*(claude|anthropic)|^[^[:alpha:]]*generated with .*claude code'
32
-
33
- if printf '%s\n' "$content" | grep -iEq "$pattern"; then
34
- echo "commit-msg: AI/assistant attribution is not allowed in commit messages." >&2
35
- printf '%s\n' "$content" | grep -iEn "$pattern" | sed 's/^/ /' >&2
36
- echo "Remove the 'Co-Authored-By: …' / 'Generated with Claude Code' line and commit again." >&2
37
- exit 1
38
- fi
39
-
40
- exit 0
8
+ exec node "$(dirname "$0")/commit-message.mjs" "$1"