@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.
- package/CHANGELOG.md +54 -0
- package/CONTENT.md +33 -0
- package/bin/content-build.mjs +3 -1
- package/docs/api.md +10 -6
- package/docs/content-format.md +384 -200
- package/docs/project-setup.md +23 -0
- package/engine/actor-compiler.mjs +20 -7
- package/engine/address-fields.mjs +404 -0
- package/engine/address-values.mjs +80 -0
- package/engine/address.mjs +769 -0
- package/engine/art-fields.mjs +112 -97
- package/engine/art-slots.mjs +81 -0
- package/engine/base-compiler.mjs +59 -7
- package/engine/bundle-notes.mjs +5 -2
- package/engine/bundles.mjs +17 -10
- package/engine/content-address.mjs +23 -186
- package/engine/content-embeds.mjs +18 -4
- package/engine/content-format-check.mjs +50 -22
- package/engine/content-index.mjs +85 -43
- package/engine/content-links.mjs +105 -104
- package/engine/content-lint.mjs +11 -1
- package/engine/content-tables.mjs +9 -1
- package/engine/field-spec.mjs +20 -4
- package/engine/folder-notes.mjs +44 -15
- package/engine/foundry-entries.mjs +3 -0
- package/engine/frontmatter-lint.mjs +144 -43
- package/engine/frontmatter.mjs +55 -10
- package/engine/generate.mjs +7 -1
- package/engine/helpers.mjs +37 -9
- package/engine/holdings.mjs +41 -12
- package/engine/index-records.mjs +2 -0
- package/engine/infobox-render.mjs +2 -1
- package/engine/infobox.mjs +26 -6
- package/engine/item-compiler.mjs +5 -0
- package/engine/item-docs.mjs +7 -44
- package/engine/map-dot.mjs +2 -0
- package/engine/map-notes.mjs +7 -2
- package/engine/map-places.mjs +42 -10
- package/engine/metadata-index.mjs +60 -5
- package/engine/note-addresses.mjs +310 -0
- package/engine/note-vocabulary.mjs +145 -26
- package/engine/pdf-build.mjs +1 -1
- package/engine/pdf-toc.mjs +17 -2
- package/engine/place-relations.mjs +160 -50
- package/engine/populations.mjs +17 -9
- package/engine/reference-targets.mjs +54 -0
- package/engine/scenes.mjs +5 -2
- package/engine/shortcode-references.mjs +68 -0
- package/engine/site-build.mjs +39 -15
- package/engine/site-index.mjs +57 -75
- package/engine/sql-tables.mjs +43 -9
- package/engine/system-block.mjs +21 -0
- package/engine/system-vocabulary.mjs +46 -0
- package/engine/web-wikilinks.mjs +11 -9
- package/engine/wikilink-syntax.mjs +21 -0
- package/engine/wikilinks.mjs +92 -306
- package/githooks/attribution.mjs +82 -0
- package/githooks/commit-message.mjs +26 -0
- package/githooks/commit-msg +3 -35
- package/hm3/actors.mjs +8 -24
- package/hm3/item-fields.mjs +15 -5
- package/package.json +1 -1
- package/sohl/infobox.mjs +80 -17
- package/sohl/item-fields.mjs +114 -41
- package/types/engine/address-fields.d.mts +90 -0
- package/types/engine/address-values.d.mts +34 -0
- package/types/engine/address.d.mts +452 -0
- package/types/engine/art-fields.d.mts +59 -53
- package/types/engine/art-slots.d.mts +40 -0
- package/types/engine/base-compiler.d.mts +24 -2
- package/types/engine/bundle-notes.d.mts +2 -2
- package/types/engine/content-address.d.mts +3 -139
- package/types/engine/content-embeds.d.mts +1 -1
- package/types/engine/content-format-check.d.mts +25 -0
- package/types/engine/content-index.d.mts +2 -2
- package/types/engine/content-links.d.mts +6 -5
- package/types/engine/field-spec.d.mts +44 -9
- package/types/engine/folder-notes.d.mts +11 -10
- package/types/engine/frontmatter-lint.d.mts +7 -97
- package/types/engine/frontmatter.d.mts +16 -1
- package/types/engine/helpers.d.mts +2 -2
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/item-docs.d.mts +1 -35
- package/types/engine/map-notes.d.mts +2 -2
- package/types/engine/note-addresses.d.mts +41 -0
- package/types/engine/note-vocabulary.d.mts +20 -3
- package/types/engine/place-relations.d.mts +12 -1
- package/types/engine/populations.d.mts +17 -2
- package/types/engine/reference-targets.d.mts +9 -0
- package/types/engine/shortcode-references.d.mts +14 -0
- package/types/engine/site-build.d.mts +3 -3
- package/types/engine/site-index.d.mts +9 -30
- package/types/engine/sql-tables.d.mts +2 -2
- package/types/engine/system-block.d.mts +17 -0
- package/types/engine/system-vocabulary.d.mts +16 -0
- package/types/engine/wikilink-syntax.d.mts +7 -0
- package/types/engine/wikilinks.d.mts +6 -109
- package/types/hm3/item-fields.d.mts +5 -0
- package/types/sohl/infobox.d.mts +4 -5
- package/types/sohl/item-fields.d.mts +8 -0
package/engine/wikilinks.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
* @
|
|
255
|
-
* @
|
|
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
|
-
|
|
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 =
|
|
658
|
-
|
|
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 =
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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?:
|
|
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 (
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
+
}
|
package/githooks/commit-msg
CHANGED
|
@@ -1,40 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|
-
|
|
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"
|