@opentermsarchive/engine 11.0.0 → 11.0.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "11.0.0",
3
+ "version": "11.0.2",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -84,7 +84,7 @@
84
84
  "iconv-lite": "^0.7.0",
85
85
  "joplin-turndown-plugin-gfm": "^1.0.12",
86
86
  "js-yaml": "^4.1.0",
87
- "jsdom": "^27.0.0",
87
+ "jsdom": "^29.0.1",
88
88
  "json-source-map": "^0.6.1",
89
89
  "mime": "^4.1.0",
90
90
  "mocha": "^11.7.2",
@@ -116,5 +116,10 @@
116
116
  },
117
117
  "engines": {
118
118
  "node": ">=20 <=24"
119
+ },
120
+ "overrides": {
121
+ "jsdom": {
122
+ "lru-cache": "11.2.7"
123
+ }
119
124
  }
120
125
  }
@@ -1,3 +1,7 @@
1
+ import path from 'path';
2
+
3
+ import mime from 'mime';
4
+
1
5
  export default class SourceDocument {
2
6
  /**
3
7
  * Represents a source document containing web content and metadata for extraction.
@@ -21,7 +25,7 @@ export default class SourceDocument {
21
25
  this.filters = filters;
22
26
  this.content = content;
23
27
  this.mimeType = mimeType;
24
- this.id = new URL(location).pathname.split('/').filter(Boolean).join('-');
28
+ this.id = SourceDocument.generateId(location);
25
29
  }
26
30
 
27
31
  get cssSelectors() {
@@ -60,6 +64,20 @@ export default class SourceDocument {
60
64
  return [selector];
61
65
  }
62
66
 
67
+ static generateId(location) {
68
+ const ILLEGAL_CHARACTERS = /[\\:"<>|*?]/g; // Characters forbidden in filenames for cross-platform compatibility; see https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/upload/path-and-artifact-name-validation.ts
69
+
70
+ const pathname = decodeURIComponent(new URL(location).pathname);
71
+ const extension = path.extname(pathname);
72
+ const pathnameWithoutExtension = mime.getType(extension) ? pathname.slice(0, -extension.length) : pathname; // Remove file extension when it corresponds to a known MIME type, as the extension is not part of the document's identity but a web server implementation detail
73
+
74
+ return pathnameWithoutExtension
75
+ .split('/')
76
+ .filter(Boolean)
77
+ .join('-')
78
+ .replace(ILLEGAL_CHARACTERS, '_');
79
+ }
80
+
63
81
  toPersistence() {
64
82
  return {
65
83
  fetch: this.location,
@@ -157,6 +157,63 @@ describe('SourceDocument', () => {
157
157
  });
158
158
  });
159
159
 
160
+ describe('#generateId', () => {
161
+ it('generates ID from URL pathname', () => {
162
+ expect(new SourceDocument({ location: 'https://example.com/legal/terms' }).id).to.equal('legal-terms');
163
+ });
164
+
165
+ it('returns empty string for root URL', () => {
166
+ expect(new SourceDocument({ location: 'https://example.com/' }).id).to.equal('');
167
+ });
168
+
169
+ it('decodes URL-encoded characters', () => {
170
+ expect(new SourceDocument({ location: 'https://example.com/terms%20of%20service' }).id).to.equal('terms of service');
171
+ });
172
+
173
+ it('removes known file extension from URL pathname', () => {
174
+ expect(new SourceDocument({ location: 'https://example.com/en.html' }).id).to.equal('en');
175
+ });
176
+
177
+ it('removes only the last file extension', () => {
178
+ expect(new SourceDocument({ location: 'https://example.com/terms.backup.html' }).id).to.equal('terms.backup');
179
+ });
180
+
181
+ it('keeps unknown extension in URL pathname', () => {
182
+ expect(new SourceDocument({ location: 'https://example.com/terms.of.service' }).id).to.equal('terms.of.service');
183
+ });
184
+ it('decodes URL-encoded characters before replacing illegal ones', () => {
185
+ expect(new SourceDocument({ location: 'https://example.com/terms%3Aof%3Aservice' }).id).to.equal('terms_of_service');
186
+ });
187
+
188
+ context('replaces characters that are illegal in filenames for cross-platform compatibility', () => {
189
+ const ILLEGAL_CHARACTERS_IN_URL_PATHNAME = {
190
+ ':': 'colon',
191
+ '"': 'double quote',
192
+ '<': 'less than',
193
+ '>': 'greater than',
194
+ '|': 'vertical bar',
195
+ '*': 'asterisk',
196
+ };
197
+
198
+ for (const [ character, name ] of Object.entries(ILLEGAL_CHARACTERS_IN_URL_PATHNAME)) {
199
+ it(`replaces ${name} "${character}"`, () => {
200
+ expect(new SourceDocument({ location: `https://example.com/before${character}after` }).id).to.equal('before_after');
201
+ });
202
+ }
203
+
204
+ const ILLEGAL_CHARACTERS_ENCODED_IN_URL = {
205
+ '%5C': 'backslash',
206
+ '%3F': 'question mark',
207
+ };
208
+
209
+ for (const [ encoded, name ] of Object.entries(ILLEGAL_CHARACTERS_ENCODED_IN_URL)) {
210
+ it(`replaces ${name} decoded from "${encoded}"`, () => {
211
+ expect(new SourceDocument({ location: `https://example.com/before${encoded}after` }).id).to.equal('before_after');
212
+ });
213
+ }
214
+ });
215
+ });
216
+
160
217
  describe('#toPersistence', () => {
161
218
  it('converts basic source document declarations into JSON representation', () => {
162
219
  const result = new SourceDocument({