glossarist 0.4.14 → 0.4.16
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/package.json +11 -1
- package/src/concept-diff.js +19 -0
- package/src/diff/change.js +111 -0
- package/src/diff/collection-diff.js +164 -0
- package/src/diff/concept-diff.js +609 -0
- package/src/diff/diff-patch.js +234 -0
- package/src/diff/diff-renderer.js +220 -0
- package/src/diff/index.d.ts +179 -0
- package/src/diff/index.js +48 -0
- package/src/diff/list-diff.js +227 -0
- package/src/diff/text-diff.js +128 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +19 -0
- package/src/models/concept.js +5 -0
- package/src/rdf/bnode-id.js +31 -11
- package/src/rdf/deterministic-id.js +60 -6
- package/src/rdf/index.d.ts +2 -4
- package/src/rdf/index.js +3 -1
- package/src/rdf/shacl-entry.d.ts +6 -0
- package/src/rdf/shacl-entry.js +9 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { GlossaristModel } from '../models/base.js';
|
|
2
|
+
import { Added, Removed, Changed, deserializeChange } from './change.js';
|
|
3
|
+
import { diffText } from './text-diff.js';
|
|
4
|
+
|
|
5
|
+
export class ListDiff extends GlossaristModel {
|
|
6
|
+
constructor(data = {}) {
|
|
7
|
+
super();
|
|
8
|
+
this._added = (data.added ?? []).map(c => c instanceof Added ? c : Added.fromJSON(c));
|
|
9
|
+
this._removed = (data.removed ?? []).map(c => c instanceof Removed ? c : Removed.fromJSON(c));
|
|
10
|
+
this._changed = (data.changed ?? []).map(c => {
|
|
11
|
+
if (c instanceof Changed) return c;
|
|
12
|
+
return deserializeChange(c);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get added() { return this._added; }
|
|
17
|
+
get removed() { return this._removed; }
|
|
18
|
+
get changed() { return this._changed; }
|
|
19
|
+
|
|
20
|
+
get hasChanges() {
|
|
21
|
+
return this._added.length > 0 || this._removed.length > 0 || this._changed.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get count() {
|
|
25
|
+
return this._added.length + this._removed.length + this._changed.length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
*entries() {
|
|
29
|
+
yield* this._added;
|
|
30
|
+
yield* this._removed;
|
|
31
|
+
yield* this._changed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
toJSON() {
|
|
35
|
+
return {
|
|
36
|
+
added: this._added.map(c => c.toJSON()),
|
|
37
|
+
removed: this._removed.map(c => c.toJSON()),
|
|
38
|
+
changed: this._changed.map(c => c.toJSON()),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static fromJSON(data) {
|
|
43
|
+
return new ListDiff(data);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Diff two ordered lists using LCS alignment.
|
|
49
|
+
*
|
|
50
|
+
* - Items present in both (by identityKey) at the same relative position
|
|
51
|
+
* are "unchanged" and omitted from the result.
|
|
52
|
+
* - Items only in the old list are "removed".
|
|
53
|
+
* - Items only in the new list are "added".
|
|
54
|
+
* - When an adjacent removed+added pair is detected at the same edit
|
|
55
|
+
* position, they are merged into a single "changed" entry. If both
|
|
56
|
+
* items expose text via textKey, a word-level TextDiff is attached.
|
|
57
|
+
*
|
|
58
|
+
* @param {Array} oldList
|
|
59
|
+
* @param {Array} newList
|
|
60
|
+
* @param {Object} [options]
|
|
61
|
+
* @param {(item: any) => string} [options.identityKey] - equality key for matching items across lists
|
|
62
|
+
* @param {(item: any) => string|null} [options.textKey] - extracts text content for word-level diff on changed items
|
|
63
|
+
* @returns {ListDiff}
|
|
64
|
+
*/
|
|
65
|
+
export function diffList(oldList = [], newList = [], options = {}) {
|
|
66
|
+
const identityKey = options.identityKey ?? defaultIdentityKey;
|
|
67
|
+
const textKey = options.textKey ?? null;
|
|
68
|
+
|
|
69
|
+
const oldArr = [...oldList];
|
|
70
|
+
const newArr = [...newList];
|
|
71
|
+
|
|
72
|
+
const oldKeys = oldArr.map(identityKey);
|
|
73
|
+
const newKeys = newArr.map(identityKey);
|
|
74
|
+
|
|
75
|
+
const dp = lcsTable(oldKeys, newKeys);
|
|
76
|
+
const ops = backtrackOps(dp, oldKeys, newKeys);
|
|
77
|
+
|
|
78
|
+
const added = [];
|
|
79
|
+
const removed = [];
|
|
80
|
+
const changed = [];
|
|
81
|
+
|
|
82
|
+
for (let k = 0; k < ops.length; k++) {
|
|
83
|
+
const op = ops[k];
|
|
84
|
+
if (op.type === 'equal') continue;
|
|
85
|
+
|
|
86
|
+
if (op.type === 'remove' && k + 1 < ops.length && ops[k + 1].type === 'add') {
|
|
87
|
+
const next = ops[k + 1];
|
|
88
|
+
const oldItem = oldArr[op.i];
|
|
89
|
+
const newItem = newArr[next.j];
|
|
90
|
+
changed.push(buildChanged(oldItem, newItem, textKey));
|
|
91
|
+
k++;
|
|
92
|
+
} else if (op.type === 'add' && k + 1 < ops.length && ops[k + 1].type === 'remove') {
|
|
93
|
+
const next = ops[k + 1];
|
|
94
|
+
const oldItem = oldArr[next.i];
|
|
95
|
+
const newItem = newArr[op.j];
|
|
96
|
+
changed.push(buildChanged(oldItem, newItem, textKey));
|
|
97
|
+
k++;
|
|
98
|
+
} else if (op.type === 'remove') {
|
|
99
|
+
removed.push(new Removed({ value: oldArr[op.i] }));
|
|
100
|
+
} else if (op.type === 'add') {
|
|
101
|
+
added.push(new Added({ value: newArr[op.j] }));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new ListDiff({ added, removed, changed });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Diff two unordered collections by identity key (set-based).
|
|
110
|
+
*
|
|
111
|
+
* - Items matched by identityKey in both lists are compared for full
|
|
112
|
+
* equality; if they differ → "changed".
|
|
113
|
+
* - Items only in the old list are "removed".
|
|
114
|
+
* - Items only in the new list are "added".
|
|
115
|
+
*
|
|
116
|
+
* Use this for collections where order does not carry meaning (e.g.
|
|
117
|
+
* designations matched by term text, sources matched by ref, dates
|
|
118
|
+
* matched by type).
|
|
119
|
+
*
|
|
120
|
+
* @param {Array} oldList
|
|
121
|
+
* @param {Array} newList
|
|
122
|
+
* @param {Object} options
|
|
123
|
+
* @param {(item: any) => string} options.identityKey - key that identifies the same item across lists
|
|
124
|
+
* @param {(item: any) => string|null} [options.textKey] - extracts text for word-level diff on changed items
|
|
125
|
+
* @returns {ListDiff}
|
|
126
|
+
*/
|
|
127
|
+
export function diffSet(oldList = [], newList = [], options = {}) {
|
|
128
|
+
const identityKey = options.identityKey ?? defaultIdentityKey;
|
|
129
|
+
const textKey = options.textKey ?? null;
|
|
130
|
+
|
|
131
|
+
const oldMap = new Map();
|
|
132
|
+
for (const item of oldList) {
|
|
133
|
+
oldMap.set(identityKey(item), item);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const seenKeys = new Set();
|
|
137
|
+
const added = [];
|
|
138
|
+
const changed = [];
|
|
139
|
+
|
|
140
|
+
for (const item of newList) {
|
|
141
|
+
const key = identityKey(item);
|
|
142
|
+
seenKeys.add(key);
|
|
143
|
+
if (oldMap.has(key)) {
|
|
144
|
+
const oldItem = oldMap.get(key);
|
|
145
|
+
if (!itemsEqual(oldItem, item)) {
|
|
146
|
+
changed.push(buildChanged(oldItem, item, textKey));
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
added.push(new Added({ value: item }));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const removed = [];
|
|
154
|
+
for (const item of oldList) {
|
|
155
|
+
if (!seenKeys.has(identityKey(item))) {
|
|
156
|
+
removed.push(new Removed({ value: item }));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new ListDiff({ added, removed, changed });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildChanged(oldItem, newItem, textKey) {
|
|
164
|
+
const opts = { oldValue: oldItem, newValue: newItem };
|
|
165
|
+
if (textKey) {
|
|
166
|
+
const oldText = textKey(oldItem);
|
|
167
|
+
const newText = textKey(newItem);
|
|
168
|
+
if (oldText != null || newText != null) {
|
|
169
|
+
opts.textDiff = diffText(oldText ?? '', newText ?? '');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return new Changed(opts);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function itemsEqual(a, b) {
|
|
176
|
+
if (a === b) return true;
|
|
177
|
+
if (a instanceof GlossaristModel && b instanceof GlossaristModel) {
|
|
178
|
+
return a.equals(b);
|
|
179
|
+
}
|
|
180
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function defaultIdentityKey(item) {
|
|
184
|
+
if (item == null) return '';
|
|
185
|
+
if (typeof item === 'string') return item;
|
|
186
|
+
if (typeof item === 'number') return String(item);
|
|
187
|
+
if (item instanceof GlossaristModel) {
|
|
188
|
+
return JSON.stringify(item.toJSON());
|
|
189
|
+
}
|
|
190
|
+
return JSON.stringify(item);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function lcsTable(aKeys, bKeys) {
|
|
194
|
+
const m = aKeys.length;
|
|
195
|
+
const n = bKeys.length;
|
|
196
|
+
const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
|
|
197
|
+
for (let i = 1; i <= m; i++) {
|
|
198
|
+
for (let j = 1; j <= n; j++) {
|
|
199
|
+
dp[i][j] = aKeys[i - 1] === bKeys[j - 1]
|
|
200
|
+
? dp[i - 1][j - 1] + 1
|
|
201
|
+
: Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return dp;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function backtrackOps(dp, aKeys, bKeys) {
|
|
208
|
+
const ops = [];
|
|
209
|
+
let i = aKeys.length;
|
|
210
|
+
let j = bKeys.length;
|
|
211
|
+
while (i > 0 && j > 0) {
|
|
212
|
+
if (aKeys[i - 1] === bKeys[j - 1]) {
|
|
213
|
+
ops.push({ type: 'equal', i: i - 1, j: j - 1 });
|
|
214
|
+
i--; j--;
|
|
215
|
+
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
216
|
+
ops.push({ type: 'remove', i: i - 1, j });
|
|
217
|
+
i--;
|
|
218
|
+
} else {
|
|
219
|
+
ops.push({ type: 'add', i, j: j - 1 });
|
|
220
|
+
j--;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
while (i > 0) { ops.push({ type: 'remove', i: i - 1, j }); i--; }
|
|
224
|
+
while (j > 0) { ops.push({ type: 'add', i, j: j - 1 }); j--; }
|
|
225
|
+
ops.reverse();
|
|
226
|
+
return ops;
|
|
227
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { GlossaristModel } from '../models/base.js';
|
|
2
|
+
|
|
3
|
+
const HUNK_EQUAL = 'equal';
|
|
4
|
+
const HUNK_ADDED = 'added';
|
|
5
|
+
const HUNK_REMOVED = 'removed';
|
|
6
|
+
|
|
7
|
+
const TOKEN_RE = /(\s+)|(\S+)/g;
|
|
8
|
+
|
|
9
|
+
export class TextHunk extends GlossaristModel {
|
|
10
|
+
constructor(data = {}) {
|
|
11
|
+
super();
|
|
12
|
+
if (![HUNK_EQUAL, HUNK_ADDED, HUNK_REMOVED].includes(data.type)) {
|
|
13
|
+
throw new Error(`TextHunk type must be 'equal', 'added', or 'removed' (got ${data.type})`);
|
|
14
|
+
}
|
|
15
|
+
this.type = data.type;
|
|
16
|
+
this.text = data.text ?? '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
toJSON() {
|
|
20
|
+
return { type: this.type, text: this.text };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static fromJSON(data) {
|
|
24
|
+
return new TextHunk(data);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class TextDiff extends GlossaristModel {
|
|
29
|
+
constructor(data = {}) {
|
|
30
|
+
super();
|
|
31
|
+
this.oldText = data.oldText ?? data.old_text ?? '';
|
|
32
|
+
this.newText = data.newText ?? data.new_text ?? '';
|
|
33
|
+
this._hunks = (data.hunks ?? []).map(h => h instanceof TextHunk ? h : new TextHunk(h));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get hunks() {
|
|
37
|
+
return this._hunks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get hasChanges() {
|
|
41
|
+
return this._hunks.some(h => h.type !== HUNK_EQUAL);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get addedText() {
|
|
45
|
+
return this._hunks.filter(h => h.type === HUNK_ADDED).map(h => h.text).join('');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get removedText() {
|
|
49
|
+
return this._hunks.filter(h => h.type === HUNK_REMOVED).map(h => h.text).join('');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
toJSON() {
|
|
53
|
+
return {
|
|
54
|
+
old_text: this.oldText,
|
|
55
|
+
new_text: this.newText,
|
|
56
|
+
hunks: this._hunks.map(h => h.toJSON()),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static fromJSON(data) {
|
|
61
|
+
return new TextDiff(data);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function diffText(oldText, newText) {
|
|
66
|
+
const oldStr = String(oldText ?? '');
|
|
67
|
+
const newStr = String(newText ?? '');
|
|
68
|
+
const oldTokens = tokenize(oldStr);
|
|
69
|
+
const newTokens = tokenize(newStr);
|
|
70
|
+
const hunks = lcsHunks(oldTokens, newTokens);
|
|
71
|
+
return new TextDiff({ oldText: oldStr, newText: newStr, hunks });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tokenize(text) {
|
|
75
|
+
if (text.length === 0) return [];
|
|
76
|
+
return text.match(TOKEN_RE) ?? [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function lcsHunks(a, b) {
|
|
80
|
+
const n = a.length;
|
|
81
|
+
const m = b.length;
|
|
82
|
+
|
|
83
|
+
if (n === 0) return m === 0 ? [] : [new TextHunk({ type: HUNK_ADDED, text: b.join('') })];
|
|
84
|
+
if (m === 0) return [new TextHunk({ type: HUNK_REMOVED, text: a.join('') })];
|
|
85
|
+
|
|
86
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
87
|
+
for (let i = 1; i <= n; i++) {
|
|
88
|
+
for (let j = 1; j <= m; j++) {
|
|
89
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
90
|
+
? dp[i - 1][j - 1] + 1
|
|
91
|
+
: Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = [];
|
|
96
|
+
let i = n;
|
|
97
|
+
let j = m;
|
|
98
|
+
while (i > 0 && j > 0) {
|
|
99
|
+
if (a[i - 1] === b[j - 1]) {
|
|
100
|
+
raw.push({ type: HUNK_EQUAL, text: a[i - 1] });
|
|
101
|
+
i--; j--;
|
|
102
|
+
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
103
|
+
raw.push({ type: HUNK_REMOVED, text: a[i - 1] });
|
|
104
|
+
i--;
|
|
105
|
+
} else {
|
|
106
|
+
raw.push({ type: HUNK_ADDED, text: b[j - 1] });
|
|
107
|
+
j--;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
while (i > 0) { raw.push({ type: HUNK_REMOVED, text: a[i - 1] }); i--; }
|
|
111
|
+
while (j > 0) { raw.push({ type: HUNK_ADDED, text: b[j - 1] }); j--; }
|
|
112
|
+
raw.reverse();
|
|
113
|
+
|
|
114
|
+
return coalesce(raw).map(h => new TextHunk(h));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function coalesce(raw) {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const r of raw) {
|
|
120
|
+
const last = out[out.length - 1];
|
|
121
|
+
if (last && last.type === r.type) {
|
|
122
|
+
last.text += r.text;
|
|
123
|
+
} else {
|
|
124
|
+
out.push({ type: r.type, text: r.text });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -15,6 +15,27 @@ export {
|
|
|
15
15
|
RELATIONSHIP_TYPES, DESIGNATION_RELATIONSHIP_TYPES, DATE_TYPES,
|
|
16
16
|
} from './models/index';
|
|
17
17
|
|
|
18
|
+
// Concept diff
|
|
19
|
+
export {
|
|
20
|
+
diffConcepts,
|
|
21
|
+
diffLocalizedConcepts,
|
|
22
|
+
diffText,
|
|
23
|
+
diffList,
|
|
24
|
+
diffSet,
|
|
25
|
+
ConceptDiff,
|
|
26
|
+
ConceptLevelDiff,
|
|
27
|
+
LocalizedConceptDiff,
|
|
28
|
+
MetadataDiff,
|
|
29
|
+
DiffStats,
|
|
30
|
+
ListDiff,
|
|
31
|
+
TextDiff,
|
|
32
|
+
TextHunk,
|
|
33
|
+
Change,
|
|
34
|
+
Added,
|
|
35
|
+
Removed,
|
|
36
|
+
Changed,
|
|
37
|
+
} from './diff/index';
|
|
38
|
+
|
|
18
39
|
// GCR reader
|
|
19
40
|
export { loadGcr, GcrPackage, parseConceptYaml, naturalSort } from './gcr-reader';
|
|
20
41
|
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
export { naturalSort } from './sort.js';
|
|
2
|
+
export {
|
|
3
|
+
diffConcepts,
|
|
4
|
+
diffLocalizedConcepts,
|
|
5
|
+
diffText,
|
|
6
|
+
diffList,
|
|
7
|
+
diffSet,
|
|
8
|
+
ConceptDiff,
|
|
9
|
+
ConceptLevelDiff,
|
|
10
|
+
LocalizedConceptDiff,
|
|
11
|
+
MetadataDiff,
|
|
12
|
+
DiffStats,
|
|
13
|
+
ListDiff,
|
|
14
|
+
TextDiff,
|
|
15
|
+
TextHunk,
|
|
16
|
+
Change,
|
|
17
|
+
Added,
|
|
18
|
+
Removed,
|
|
19
|
+
Changed,
|
|
20
|
+
} from './diff/index.js';
|
|
2
21
|
export { loadGcr, GcrPackage, parseConceptYaml } from './gcr-reader.js';
|
|
3
22
|
export { readConcepts, readConcept, listConceptIds, readRegister } from './concept-reader.js';
|
|
4
23
|
export { writeConcept, writeConcepts } from './concept-writer.js';
|
package/src/models/concept.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ConceptSource } from './concept-source.js';
|
|
|
7
7
|
import { FigureReference } from './non-verbal-references.js';
|
|
8
8
|
import { TableReference } from './non-verbal-references.js';
|
|
9
9
|
import { FormulaReference } from './non-verbal-references.js';
|
|
10
|
+
import { diffConcepts } from '../diff/concept-diff.js';
|
|
10
11
|
|
|
11
12
|
export class Concept extends GlossaristModel {
|
|
12
13
|
constructor(data = {}) {
|
|
@@ -58,6 +59,10 @@ export class Concept extends GlossaristModel {
|
|
|
58
59
|
return this._cache[lang];
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
diff(other, language = 'eng') {
|
|
63
|
+
return diffConcepts(this, other, language);
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
primaryDesignation(lang) {
|
|
62
67
|
return this.localization(lang)?.primaryDesignation ?? null;
|
|
63
68
|
}
|
package/src/rdf/bnode-id.js
CHANGED
|
@@ -6,11 +6,30 @@
|
|
|
6
6
|
// strategy: MD5 (via node:crypto) when available, FNV-1a fallback for
|
|
7
7
|
// browser bundles.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// also stable.
|
|
9
|
+
// Browser safety: node:crypto is loaded lazily via runtime check so
|
|
10
|
+
// bundlers don't tree-shake the import into the browser bundle.
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
let nodeCreateHash = null;
|
|
13
|
+
let nodeCryptoChecked = false;
|
|
14
|
+
|
|
15
|
+
function getNodeCreateHash() {
|
|
16
|
+
if (nodeCryptoChecked) return nodeCreateHash;
|
|
17
|
+
nodeCryptoChecked = true;
|
|
18
|
+
try {
|
|
19
|
+
let requireFn;
|
|
20
|
+
try {
|
|
21
|
+
const mod = (Function('m', 'return require(m)'))('node:module');
|
|
22
|
+
requireFn = mod.createRequire(import.meta.url);
|
|
23
|
+
} catch {
|
|
24
|
+
requireFn = (m) => (Function('m', 'return require(m)'))(m);
|
|
25
|
+
}
|
|
26
|
+
const crypto = requireFn('node:crypto');
|
|
27
|
+
nodeCreateHash = crypto.createHash.bind(crypto);
|
|
28
|
+
} catch {
|
|
29
|
+
nodeCreateHash = null;
|
|
30
|
+
}
|
|
31
|
+
return nodeCreateHash;
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
function fnv1a(seed) {
|
|
16
35
|
let h = 0x811c9dc5;
|
|
@@ -24,12 +43,13 @@ function fnv1a(seed) {
|
|
|
24
43
|
export function deterministicBnodeId(...parts) {
|
|
25
44
|
const seed = parts.filter(p => p != null && p !== '').join('|');
|
|
26
45
|
if (!seed) return `b${fnv1a('empty')}`;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
const createHash = getNodeCreateHash();
|
|
47
|
+
if (createHash) {
|
|
48
|
+
try {
|
|
49
|
+
return createHash('md5').update(seed).digest('hex').slice(0, 12);
|
|
50
|
+
} catch {
|
|
51
|
+
// fall through to FNV
|
|
52
|
+
}
|
|
34
53
|
}
|
|
54
|
+
return `b${fnv1a(seed)}`;
|
|
35
55
|
}
|
|
@@ -1,17 +1,71 @@
|
|
|
1
|
-
|
|
1
|
+
// Stable ID helpers for RDF emission. Mirrors glossarist-ruby's
|
|
2
|
+
// Digest::MD5.hexdigest(content)[0..11] for cross-runtime stability.
|
|
3
|
+
//
|
|
4
|
+
// Browser safety: node:crypto is loaded lazily so bundlers don't
|
|
5
|
+
// tree-shake the import into browser bundles. The runtime check uses
|
|
6
|
+
// `createRequire` from `node:module` (also lazy) which is the
|
|
7
|
+
// documented way to use CommonJS `require` from ESM.
|
|
8
|
+
|
|
9
|
+
let nodeCreateHash = null;
|
|
10
|
+
let nodeCryptoChecked = false;
|
|
11
|
+
|
|
12
|
+
function getNodeCreateHash() {
|
|
13
|
+
if (nodeCryptoChecked) return nodeCreateHash;
|
|
14
|
+
nodeCryptoChecked = true;
|
|
15
|
+
try {
|
|
16
|
+
// Lazy require — works in both CJS and ESM (Node ≥ 12).
|
|
17
|
+
// Wrapped in Function() so bundlers don't statically resolve it.
|
|
18
|
+
let requireFn;
|
|
19
|
+
try {
|
|
20
|
+
// ESM: use createRequire from node:module
|
|
21
|
+
const mod = (Function('m', 'return require(m)'))('node:module');
|
|
22
|
+
requireFn = mod.createRequire(import.meta.url);
|
|
23
|
+
} catch {
|
|
24
|
+
// CJS or already-require-available environment
|
|
25
|
+
requireFn = (m) => (Function('m', 'return require(m)'))(m);
|
|
26
|
+
}
|
|
27
|
+
const crypto = requireFn('node:crypto');
|
|
28
|
+
nodeCreateHash = crypto.createHash.bind(crypto);
|
|
29
|
+
} catch {
|
|
30
|
+
nodeCreateHash = null;
|
|
31
|
+
}
|
|
32
|
+
return nodeCreateHash;
|
|
33
|
+
}
|
|
2
34
|
|
|
3
35
|
// 12-char hex prefix, mirroring glossarist-ruby's
|
|
4
36
|
// `Digest::MD5.hexdigest(content)[0..11]`. Same content → same ID across
|
|
5
|
-
// processes, machines, and language runtimes (Ruby vs JS)
|
|
37
|
+
// processes, machines, and language runtimes (Ruby vs JS) when MD5 is
|
|
38
|
+
// available. Falls back to a 12-char FNV-1a hash in the browser.
|
|
6
39
|
export function deterministicId(...parts) {
|
|
7
40
|
const seed = parts.filter(p => p !== null && p !== undefined).join('|');
|
|
8
|
-
|
|
41
|
+
const createHash = getNodeCreateHash();
|
|
42
|
+
if (createHash) {
|
|
43
|
+
return createHash('md5').update(seed).digest('hex').slice(0, 12);
|
|
44
|
+
}
|
|
45
|
+
return fnv1a12(seed);
|
|
9
46
|
}
|
|
10
47
|
|
|
11
48
|
// Stable blank-node label for an RDF fragment identified by its parent
|
|
12
|
-
// subject + role + index.
|
|
13
|
-
// Designation, ConceptSource where the bnode ID must be deterministic to
|
|
14
|
-
// produce byte-equivalent Turtle across runs.
|
|
49
|
+
// subject + role + index.
|
|
15
50
|
export function deterministicBnode(subject, role, index) {
|
|
16
51
|
return `_:b${deterministicId(subject, role, index)}`;
|
|
17
52
|
}
|
|
53
|
+
|
|
54
|
+
// 12-char FNV-1a hash. Two 32-bit passes concatenated and truncated.
|
|
55
|
+
// Not as collision-resistant as MD5 but adequate for bnode IDs in a
|
|
56
|
+
// single document. Used only when node:crypto is unavailable.
|
|
57
|
+
function fnv1a12(seed) {
|
|
58
|
+
let h1 = 0x811c9dc5;
|
|
59
|
+
for (let i = 0; i < seed.length; i++) {
|
|
60
|
+
h1 ^= seed.charCodeAt(i);
|
|
61
|
+
h1 = Math.imul(h1, 0x01000193);
|
|
62
|
+
}
|
|
63
|
+
const seed2 = `${seed}|fnv2`;
|
|
64
|
+
let h2 = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < seed2.length; i++) {
|
|
66
|
+
h2 ^= seed2.charCodeAt(i);
|
|
67
|
+
h2 = Math.imul(h2, 0x01000193);
|
|
68
|
+
}
|
|
69
|
+
return ((h1 >>> 0).toString(16).padStart(8, '0')
|
|
70
|
+
+ (h2 >>> 0).toString(16).padStart(8, '0')).slice(0, 12);
|
|
71
|
+
}
|
package/src/rdf/index.d.ts
CHANGED
|
@@ -315,7 +315,5 @@ export interface ValidationReport {
|
|
|
315
315
|
}>;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
export declare function clearShapesCache(): void;
|
|
321
|
-
export declare function quadsToDataset(quads: Iterable<Quad>): Dataset;
|
|
318
|
+
// SHACL validator moved to ./shacl-entry subpath. Importing from
|
|
319
|
+
// 'glossarist/rdf/shacl' in Node-only; 'glossarist/rdf' is browser-safe.
|
package/src/rdf/index.js
CHANGED
|
@@ -50,6 +50,8 @@ export {
|
|
|
50
50
|
sortQuads,
|
|
51
51
|
} from './document-writer.js';
|
|
52
52
|
export { writeTurtleSync } from './write-turtle-sync.js';
|
|
53
|
-
|
|
53
|
+
// SHACL validator moved to glossarist/rdf/shacl subpath — it depends on
|
|
54
|
+
// node:fs/promises, node:path, node:url at module top-level, which would
|
|
55
|
+
// break browser bundles. Importing 'glossarist/rdf' alone is browser-safe.
|
|
54
56
|
export { quadSectionsToClassInstances } from './sections-builder.js';
|
|
55
57
|
export { provenanceToQuads } from './provenance-emitter.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Type declarations for the Node-only SHACL validator subpath.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors src/rdf/shacl-entry.js. Separated from ./rdf so that browser
|
|
4
|
+
// bundles don't pull in node:fs/promises, node:path, node:url.
|
|
5
|
+
|
|
6
|
+
export { validateShacl, loadShapes, clearShapesCache, quadsToDataset } from './shacl';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Node-only SHACL validator entry. Separated from glossarist/rdf so that
|
|
2
|
+
// browser bundles can import RDF emitters/writers without pulling in
|
|
3
|
+
// node:fs/promises, node:path, node:url.
|
|
4
|
+
//
|
|
5
|
+
// Usage (Node only):
|
|
6
|
+
// import { validateShacl, loadShapes, clearShapesCache, quadsToDataset }
|
|
7
|
+
// from 'glossarist/rdf/shacl';
|
|
8
|
+
|
|
9
|
+
export { validateShacl, loadShapes, clearShapesCache, quadsToDataset } from './shacl.js';
|