@govtechsg/oobee 0.10.89 → 0.10.90

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.89",
4
+ "version": "0.10.90",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
@@ -0,0 +1,73 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import type { ItemsInfo } from './types.js';
5
+
6
+ export interface ItemsStoreEntry {
7
+ url: string;
8
+ pageTitle: string;
9
+ items: ItemsInfo[];
10
+ filePath?: string;
11
+ pageIndex?: number;
12
+ pageImagePath?: string;
13
+ metadata?: string;
14
+ }
15
+
16
+ export class ItemsStore {
17
+ private basePath: string;
18
+ private ensuredDirs = new Set<string>();
19
+
20
+ constructor(storagePath: string) {
21
+ this.basePath = path.join(storagePath, 'tmp-items');
22
+ }
23
+
24
+ private sanitizeRuleId(ruleId: string): string {
25
+ return ruleId.replace(/[^a-zA-Z0-9_-]/g, '_');
26
+ }
27
+
28
+ private getRuleFilePath(category: string, ruleId: string): string {
29
+ return path.join(this.basePath, category, `${this.sanitizeRuleId(ruleId)}.jsonl`);
30
+ }
31
+
32
+ private async ensureDir(category: string): Promise<void> {
33
+ const dirPath = path.join(this.basePath, category);
34
+ if (!this.ensuredDirs.has(dirPath)) {
35
+ await fs.ensureDir(dirPath);
36
+ this.ensuredDirs.add(dirPath);
37
+ }
38
+ }
39
+
40
+ async appendPageItems(category: string, ruleId: string, entry: ItemsStoreEntry): Promise<void> {
41
+ await this.ensureDir(category);
42
+ const filePath = this.getRuleFilePath(category, ruleId);
43
+ const line = JSON.stringify(entry) + '\n';
44
+ await fs.appendFile(filePath, line, 'utf8');
45
+ }
46
+
47
+ async *readRuleItems(category: string, ruleId: string): AsyncGenerator<ItemsStoreEntry> {
48
+ const filePath = this.getRuleFilePath(category, ruleId);
49
+ if (!fs.existsSync(filePath)) return;
50
+
51
+ const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
52
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
53
+
54
+ for await (const line of rl) {
55
+ if (line.trim()) {
56
+ yield JSON.parse(line) as ItemsStoreEntry;
57
+ }
58
+ }
59
+ }
60
+
61
+ async readRuleItemsMap(category: string, ruleId: string): Promise<Map<string, ItemsStoreEntry>> {
62
+ const map = new Map<string, ItemsStoreEntry>();
63
+ for await (const entry of this.readRuleItems(category, ruleId)) {
64
+ const key = entry.pageIndex != null ? String(entry.pageIndex) : entry.url;
65
+ map.set(key, entry);
66
+ }
67
+ return map;
68
+ }
69
+
70
+ async cleanup(): Promise<void> {
71
+ await fs.rm(this.basePath, { recursive: true, force: true });
72
+ }
73
+ }
@@ -6,6 +6,7 @@ import { pipeline } from 'stream/promises';
6
6
  import { a11yRuleShortDescriptionMap } from '../constants/constants.js';
7
7
  import { consoleLogger } from '../logs.js';
8
8
  import type { AllIssues } from './types.js';
9
+ import type { ItemsStore } from './itemsStore.js';
9
10
 
10
11
  function* serializeObject(obj: any, depth = 0, indent = ' ') {
11
12
  const currentIndent = indent.repeat(depth);
@@ -79,158 +80,151 @@ function writeLargeJsonToFile(obj: object, filePath: string) {
79
80
  });
80
81
  }
81
82
 
82
- const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
83
- return new Promise((resolve, reject) => {
84
- const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
85
- const writeQueue: string[] = [];
86
- let isWriting = false;
87
-
88
- const processNextWrite = async () => {
89
- if (isWriting || writeQueue.length === 0) return;
90
-
91
- isWriting = true;
92
- const data = writeQueue.shift()!;
93
-
94
- try {
95
- if (!writeStream.write(data)) {
96
- await new Promise<void>(resolve => {
97
- writeStream.once('drain', () => {
98
- resolve();
99
- });
100
- });
101
- }
102
- } catch (error) {
103
- writeStream.destroy(error as Error);
104
- return;
105
- }
83
+ const writeLargeScanItemsJsonToFile = async (
84
+ obj: object,
85
+ filePath: string,
86
+ itemsStore?: ItemsStore,
87
+ ) => {
88
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
106
89
 
107
- isWriting = false;
108
- processNextWrite();
109
- };
110
-
111
- const queueWrite = (data: string) => {
112
- writeQueue.push(data);
113
- processNextWrite();
114
- };
90
+ const write = (data: string): Promise<void> => {
91
+ if (!writeStream.write(data)) {
92
+ return new Promise<void>(resolve => writeStream.once('drain', resolve));
93
+ }
94
+ return Promise.resolve();
95
+ };
115
96
 
116
- writeStream.on('error', error => {
117
- consoleLogger.error(`Error writing object to JSON file: ${error}`);
118
- reject(error);
119
- });
97
+ try {
98
+ await write('{\n');
99
+ const keys = Object.keys(obj);
120
100
 
121
- writeStream.on('finish', () => {
122
- consoleLogger.info(`JSON file written successfully: ${filePath}`);
123
- resolve(true);
124
- });
101
+ for (let i = 0; i < keys.length; i++) {
102
+ const key = keys[i];
103
+ const value = obj[key];
104
+
105
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
106
+ await write(` "${key}": ${JSON.stringify(value)}`);
107
+ } else {
108
+ await write(` "${key}": {\n`);
109
+
110
+ const { rules, ...otherProperties } = value;
111
+ const otherKeys = Object.keys(otherProperties);
112
+
113
+ for (let j = 0; j < otherKeys.length; j++) {
114
+ const propKey = otherKeys[j];
115
+ const propValue = otherProperties[propKey];
116
+ const propValueString =
117
+ propValue === null ||
118
+ typeof propValue === 'function' ||
119
+ typeof propValue === 'undefined'
120
+ ? 'null'
121
+ : JSON.stringify(propValue);
122
+ await write(` "${propKey}": ${propValueString}`);
123
+ if (j < otherKeys.length - 1 || (rules && rules.length >= 0)) {
124
+ await write(',\n');
125
+ } else {
126
+ await write('\n');
127
+ }
128
+ }
125
129
 
126
- try {
127
- queueWrite('{\n');
128
- const keys = Object.keys(obj);
129
-
130
- keys.forEach((key, i) => {
131
- const value = obj[key];
132
-
133
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
134
- queueWrite(` "${key}": ${JSON.stringify(value)}`);
135
- } else {
136
- queueWrite(` "${key}": {\n`);
137
-
138
- const { rules, ...otherProperties } = value;
139
-
140
- Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
141
- const propValueString =
142
- propValue === null ||
143
- typeof propValue === 'function' ||
144
- typeof propValue === 'undefined'
145
- ? 'null'
146
- : JSON.stringify(propValue);
147
- queueWrite(` "${propKey}": ${propValueString}`);
148
- if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
149
- queueWrite(',\n');
150
- } else {
151
- queueWrite('\n');
130
+ if (rules && Array.isArray(rules)) {
131
+ await write(' "rules": [\n');
132
+
133
+ for (let j = 0; j < rules.length; j++) {
134
+ const rule = rules[j];
135
+ await write(' {\n');
136
+ const { pagesAffected, ...otherRuleProperties } = rule;
137
+ const ruleKeys = Object.keys(otherRuleProperties);
138
+
139
+ for (let k = 0; k < ruleKeys.length; k++) {
140
+ const ruleKey = ruleKeys[k];
141
+ const ruleValue = otherRuleProperties[ruleKey];
142
+ const ruleValueString =
143
+ ruleValue === null ||
144
+ typeof ruleValue === 'function' ||
145
+ typeof ruleValue === 'undefined'
146
+ ? 'null'
147
+ : JSON.stringify(ruleValue);
148
+ await write(` "${ruleKey}": ${ruleValueString}`);
149
+ if (k < ruleKeys.length - 1 || pagesAffected) {
150
+ await write(',\n');
151
+ } else {
152
+ await write('\n');
153
+ }
152
154
  }
153
- });
154
-
155
- if (rules && Array.isArray(rules)) {
156
- queueWrite(' "rules": [\n');
157
-
158
- rules.forEach((rule, j) => {
159
- queueWrite(' {\n');
160
- const { pagesAffected, ...otherRuleProperties } = rule;
161
-
162
- Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
163
- const ruleValueString =
164
- ruleValue === null ||
165
- typeof ruleValue === 'function' ||
166
- typeof ruleValue === 'undefined'
167
- ? 'null'
168
- : JSON.stringify(ruleValue);
169
- queueWrite(` "${ruleKey}": ${ruleValueString}`);
170
- if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
171
- queueWrite(',\n');
172
- } else {
173
- queueWrite('\n');
174
- }
175
- });
176
155
 
177
- if (pagesAffected && Array.isArray(pagesAffected)) {
178
- queueWrite(' "pagesAffected": [\n');
156
+ if (pagesAffected && Array.isArray(pagesAffected)) {
157
+ // Load items from disk for this rule if itemsStore is available
158
+ let itemsMap: Map<string, any> | null = null;
159
+ if (itemsStore && rule.rule) {
160
+ itemsMap = await itemsStore.readRuleItemsMap(key, rule.rule);
161
+ }
179
162
 
180
- pagesAffected.forEach((page, p) => {
181
- const pageJson = JSON.stringify(page, null, 2)
182
- .split('\n')
183
- .map(line => ` ${line}`)
184
- .join('\n');
163
+ await write(' "pagesAffected": [\n');
185
164
 
186
- queueWrite(pageJson);
165
+ for (let p = 0; p < pagesAffected.length; p++) {
166
+ const page = pagesAffected[p];
167
+ let fullPage = page;
187
168
 
188
- if (p < pagesAffected.length - 1) {
189
- queueWrite(',\n');
190
- } else {
191
- queueWrite('\n');
169
+ if (itemsMap) {
170
+ const lookupKey =
171
+ page.pageIndex != null ? String(page.pageIndex) : page.url;
172
+ const entry = itemsMap.get(lookupKey);
173
+ if (entry) {
174
+ // Strip itemsCount to match original scanItems.json format
175
+ const { itemsCount: _ic, ...pageWithoutCount } = page;
176
+ fullPage = { ...pageWithoutCount, items: entry.items };
192
177
  }
193
- });
178
+ }
194
179
 
195
- queueWrite(' ]');
196
- }
180
+ const pageJson = JSON.stringify(fullPage, null, 2)
181
+ .split('\n')
182
+ .map(line => ` ${line}`)
183
+ .join('\n');
197
184
 
198
- queueWrite('\n }');
199
- if (j < rules.length - 1) {
200
- queueWrite(',\n');
201
- } else {
202
- queueWrite('\n');
203
- }
204
- });
185
+ await write(pageJson);
205
186
 
206
- queueWrite(' ]');
207
- }
208
- queueWrite('\n }');
209
- }
187
+ if (p < pagesAffected.length - 1) {
188
+ await write(',\n');
189
+ } else {
190
+ await write('\n');
191
+ }
192
+ }
210
193
 
211
- if (i < keys.length - 1) {
212
- queueWrite(',\n');
213
- } else {
214
- queueWrite('\n');
215
- }
216
- });
194
+ await write(' ]');
195
+ }
217
196
 
218
- queueWrite('}\n');
197
+ await write('\n }');
198
+ if (j < rules.length - 1) {
199
+ await write(',\n');
200
+ } else {
201
+ await write('\n');
202
+ }
203
+ }
219
204
 
220
- const checkQueueAndEnd = () => {
221
- if (writeQueue.length === 0 && !isWriting) {
222
- writeStream.end();
223
- } else {
224
- setTimeout(checkQueueAndEnd, 100);
205
+ await write(' ]');
225
206
  }
226
- };
207
+ await write('\n }');
208
+ }
227
209
 
228
- checkQueueAndEnd();
229
- } catch (err) {
230
- writeStream.destroy(err as Error);
231
- reject(err);
210
+ if (i < keys.length - 1) {
211
+ await write(',\n');
212
+ } else {
213
+ await write('\n');
214
+ }
232
215
  }
233
- });
216
+
217
+ await write('}\n');
218
+ } finally {
219
+ writeStream.end();
220
+ await new Promise<void>((resolve, reject) => {
221
+ writeStream.on('finish', () => {
222
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
223
+ resolve();
224
+ });
225
+ writeStream.on('error', reject);
226
+ });
227
+ }
234
228
  };
235
229
 
236
230
  async function compressJsonFileStreaming(inputPath: string, outputPath: string) {
@@ -247,12 +241,13 @@ const writeJsonFileAndCompressedJsonFile = async (
247
241
  data: object,
248
242
  storagePath: string,
249
243
  filename: string,
244
+ itemsStore?: ItemsStore,
250
245
  ): Promise<{ jsonFilePath: string; base64FilePath: string }> => {
251
246
  try {
252
247
  consoleLogger.info(`Writing JSON to ${filename}.json`);
253
248
  const jsonFilePath = path.join(storagePath, `${filename}.json`);
254
249
  if (filename === 'scanItems') {
255
- await writeLargeScanItemsJsonToFile(data, jsonFilePath);
250
+ await writeLargeScanItemsJsonToFile(data, jsonFilePath, itemsStore);
256
251
  } else {
257
252
  await writeLargeJsonToFile(data, jsonFilePath);
258
253
  }
@@ -277,6 +272,7 @@ const writeJsonFileAndCompressedJsonFile = async (
277
272
  const writeJsonAndBase64Files = async (
278
273
  allIssues: AllIssues,
279
274
  storagePath: string,
275
+ itemsStore?: ItemsStore,
280
276
  ): Promise<{
281
277
  scanDataJsonFilePath: string;
282
278
  scanDataBase64FilePath: string;
@@ -301,6 +297,7 @@ const writeJsonAndBase64Files = async (
301
297
  { oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
302
298
  storagePath,
303
299
  'scanItems',
300
+ itemsStore,
304
301
  );
305
302
 
306
303
  ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
@@ -345,22 +342,22 @@ const writeJsonAndBase64Files = async (
345
342
 
346
343
  items.mustFix.rules.forEach(rule => {
347
344
  rule.pagesAffected.forEach(page => {
348
- page.itemsCount = page.items.length;
345
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
349
346
  });
350
347
  });
351
348
  items.goodToFix.rules.forEach(rule => {
352
349
  rule.pagesAffected.forEach(page => {
353
- page.itemsCount = page.items.length;
350
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
354
351
  });
355
352
  });
356
353
  items.needsReview.rules.forEach(rule => {
357
354
  rule.pagesAffected.forEach(page => {
358
- page.itemsCount = page.items.length;
355
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
359
356
  });
360
357
  });
361
358
  items.passed.rules.forEach(rule => {
362
359
  rule.pagesAffected.forEach(page => {
363
- page.itemsCount = page.items.length;
360
+ page.itemsCount = page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0);
364
361
  });
365
362
  });
366
363
 
@@ -40,8 +40,8 @@ export default function populateScanPagesDetail(allIssues: AllIssues): void {
40
40
  const { rule: ruleId, conformance = [] } = rule;
41
41
 
42
42
  rule.pagesAffected.forEach(p => {
43
- const { url, pageTitle, items = [] } = p;
44
- const itemsCount = items.length;
43
+ const { url, pageTitle, itemsCount: storedCount, items } = p as any;
44
+ const itemsCount: number = storedCount ?? (Array.isArray(items) ? items.length : 0);
45
45
 
46
46
  if (!pagesMap[url]) {
47
47
  pagesMap[url] = {