summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2023-12-19 00:33:38 -0500
committerGitHub <noreply@github.com>2023-12-19 05:33:38 +0000
commit1ced9aafc00c10992bab8bd3f1b6b1397f05b7b9 (patch)
tree305bb2b3bfc7fc3b051ee1cd3d1c35f442af0de4
parent5f96276fda93dcad39f2165fd3c8d890aa5f9be5 (diff)
Make JSON.parse usage safer (#373)
* Make JSON.parse usage safer * Fix any type * Add readResponseJson * Use readResponseJson * Additional updates * Rename files * Add types
-rw-r--r--dev/bin/schema-validate.js5
-rw-r--r--dev/build-libs.js7
-rw-r--r--dev/dictionary-validate.js8
-rw-r--r--dev/json.js18
-rw-r--r--dev/manifest-util.js5
-rw-r--r--dev/schema-validate.js6
-rw-r--r--dev/util.js5
-rw-r--r--ext/js/background/backend.js15
-rw-r--r--ext/js/comm/anki-connect.js3
-rw-r--r--ext/js/comm/api.js11
-rw-r--r--ext/js/comm/cross-frame-api.js4
-rw-r--r--ext/js/core.js2
-rw-r--r--ext/js/core/json.js40
-rw-r--r--ext/js/data/options-util.js11
-rw-r--r--ext/js/dom/sandbox/css-style-applier.js7
-rw-r--r--ext/js/input/hotkey-help-controller.js3
-rw-r--r--ext/js/language/deinflector.js2
-rw-r--r--ext/js/language/dictionary-importer.js24
-rw-r--r--ext/js/media/audio-downloader.js6
-rw-r--r--ext/js/pages/settings/backup-controller.js4
-rw-r--r--ext/js/pages/settings/generic-setting-controller.js3
-rw-r--r--ext/js/templates/sandbox/template-renderer-frame-api.js3
-rw-r--r--test/anki-note-builder.test.js8
-rw-r--r--test/database.test.js7
-rw-r--r--test/deinflector.test.js4
-rw-r--r--test/dom-text-scanner.test.js18
-rw-r--r--test/fixtures/translator-test.js6
-rw-r--r--test/json-schema.test.js3
-rw-r--r--test/options-util.test.js3
-rw-r--r--test/translator.test.js11
-rw-r--r--types/ext/api.d.ts2
-rw-r--r--types/ext/audio-downloader.d.ts10
-rw-r--r--types/ext/cross-frame-api.d.ts13
-rw-r--r--types/test/dom-text-scanner.d.ts32
-rw-r--r--types/test/translator.d.ts (renamed from types/test/anki-note-builder.d.ts)25
35 files changed, 257 insertions, 77 deletions
diff --git a/dev/bin/schema-validate.js b/dev/bin/schema-validate.js
index 206f26ca..bbd5ad5f 100644
--- a/dev/bin/schema-validate.js
+++ b/dev/bin/schema-validate.js
@@ -18,6 +18,7 @@
import fs from 'fs';
import {performance} from 'perf_hooks';
+import {parseJson} from '../../ext/js/core/json.js';
import {createJsonSchema} from '../schema-validate.js';
/** */
@@ -39,14 +40,14 @@ function main() {
}
const schemaSource = fs.readFileSync(args[0], {encoding: 'utf8'});
- const schema = JSON.parse(schemaSource);
+ const schema = parseJson(schemaSource);
for (const dataFileName of args.slice(1)) {
const start = performance.now();
try {
console.log(`Validating ${dataFileName}...`);
const dataSource = fs.readFileSync(dataFileName, {encoding: 'utf8'});
- const data = JSON.parse(dataSource);
+ const data = parseJson(dataSource);
createJsonSchema(mode, schema).validate(data);
const end = performance.now();
console.log(`No issues detected (${((end - start) / 1000).toFixed(2)}s)`);
diff --git a/dev/build-libs.js b/dev/build-libs.js
index a992f20a..10720010 100644
--- a/dev/build-libs.js
+++ b/dev/build-libs.js
@@ -22,6 +22,7 @@ import esbuild from 'esbuild';
import fs from 'fs';
import path from 'path';
import {fileURLToPath} from 'url';
+import {parseJson} from './json.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const extDir = path.join(dirname, '..', 'ext');
@@ -61,7 +62,11 @@ export async function buildLibs() {
const schemaDir = path.join(extDir, 'data/schemas/');
const schemaFileNames = fs.readdirSync(schemaDir);
- const schemas = schemaFileNames.map((schemaFileName) => JSON.parse(fs.readFileSync(path.join(schemaDir, schemaFileName), {encoding: 'utf8'})));
+ const schemas = schemaFileNames.map((schemaFileName) => {
+ /** @type {import('ajv').AnySchema} */
+ const result = parseJson(fs.readFileSync(path.join(schemaDir, schemaFileName), {encoding: 'utf8'}));
+ return result;
+ });
const ajv = new Ajv({
schemas,
code: {source: true, esm: true},
diff --git a/dev/dictionary-validate.js b/dev/dictionary-validate.js
index 7842c65e..efc2eb8c 100644
--- a/dev/dictionary-validate.js
+++ b/dev/dictionary-validate.js
@@ -21,6 +21,7 @@ import JSZip from 'jszip';
import path from 'path';
import {performance} from 'perf_hooks';
import {fileURLToPath} from 'url';
+import {parseJson} from './json.js';
import {createJsonSchema} from './schema-validate.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,7 +33,7 @@ const dirname = path.dirname(fileURLToPath(import.meta.url));
function readSchema(relativeFileName) {
const fileName = path.join(dirname, relativeFileName);
const source = fs.readFileSync(fileName, {encoding: 'utf8'});
- return JSON.parse(source);
+ return parseJson(source);
}
/**
@@ -57,7 +58,7 @@ async function validateDictionaryBanks(mode, zip, fileNameFormat, schema) {
const file = zip.files[fileName];
if (!file) { break; }
- const data = JSON.parse(await file.async('string'));
+ const data = parseJson(await file.async('string'));
try {
jsonSchema.validate(data);
} catch (e) {
@@ -83,7 +84,8 @@ export async function validateDictionary(mode, archive, schemas) {
throw new Error('No dictionary index found in archive');
}
- const index = JSON.parse(await indexFile.async('string'));
+ /** @type {import('dictionary-data').Index} */
+ const index = parseJson(await indexFile.async('string'));
const version = index.format || index.version;
try {
diff --git a/dev/json.js b/dev/json.js
new file mode 100644
index 00000000..a76edfcd
--- /dev/null
+++ b/dev/json.js
@@ -0,0 +1,18 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+export {parseJson} from '../ext/js/core/json.js';
diff --git a/dev/manifest-util.js b/dev/manifest-util.js
index 638706d8..ac9b58db 100644
--- a/dev/manifest-util.js
+++ b/dev/manifest-util.js
@@ -20,6 +20,7 @@ import childProcess from 'child_process';
import fs from 'fs';
import {fileURLToPath} from 'node:url';
import path from 'path';
+import {parseJson} from './json.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -29,14 +30,14 @@ const dirname = path.dirname(fileURLToPath(import.meta.url));
* @returns {T}
*/
function clone(value) {
- return JSON.parse(JSON.stringify(value));
+ return parseJson(JSON.stringify(value));
}
export class ManifestUtil {
constructor() {
const fileName = path.join(dirname, 'data', 'manifest-variants.json');
- const {manifest, variants, defaultVariant} = /** @type {import('dev/manifest').ManifestConfig} */ (JSON.parse(fs.readFileSync(fileName, {encoding: 'utf8'})));
+ const {manifest, variants, defaultVariant} = /** @type {import('dev/manifest').ManifestConfig} */ (parseJson(fs.readFileSync(fileName, {encoding: 'utf8'})));
/** @type {import('dev/manifest').Manifest} */
this._manifest = manifest;
/** @type {import('dev/manifest').ManifestVariant[]} */
diff --git a/dev/schema-validate.js b/dev/schema-validate.js
index 81953f49..57faf96c 100644
--- a/dev/schema-validate.js
+++ b/dev/schema-validate.js
@@ -20,6 +20,7 @@ import Ajv from 'ajv';
import {readFileSync} from 'fs';
import {JsonSchema} from '../ext/js/data/json-schema.js';
import {DataError} from './data-error.js';
+import {parseJson} from './json.js';
class JsonSchemaAjv {
/**
@@ -32,7 +33,8 @@ class JsonSchemaAjv {
allowUnionTypes: true
});
const metaSchemaPath = require.resolve('ajv/dist/refs/json-schema-draft-07.json');
- const metaSchema = JSON.parse(readFileSync(metaSchemaPath, {encoding: 'utf8'}));
+ /** @type {import('ajv').AnySchemaObject} */
+ const metaSchema = parseJson(readFileSync(metaSchemaPath, {encoding: 'utf8'}));
ajv.addMetaSchema(metaSchema);
/** @type {import('ajv').ValidateFunction} */
this._validate = ajv.compile(/** @type {import('ajv').Schema} */ (schema));
@@ -46,7 +48,7 @@ class JsonSchemaAjv {
if (this._validate(data)) { return; }
const {errors} = this._validate;
const error = new DataError('Schema validation failed');
- error.data = JSON.parse(JSON.stringify(errors));
+ error.data = parseJson(JSON.stringify(errors));
throw error;
}
}
diff --git a/dev/util.js b/dev/util.js
index f45966c4..6a7fa8f5 100644
--- a/dev/util.js
+++ b/dev/util.js
@@ -19,6 +19,7 @@
import fs from 'fs';
import JSZip from 'jszip';
import path from 'path';
+import {parseJson} from './json.js';
/**
* @param {string[]} args
@@ -112,9 +113,9 @@ export function createDictionaryArchive(dictionaryDirectory, dictionaryName) {
for (const fileName of fileNames) {
if (/\.json$/.test(fileName)) {
const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: 'utf8'});
- const json = JSON.parse(content);
+ const json = parseJson(content);
if (fileName === 'index.json' && typeof dictionaryName === 'string') {
- json.title = dictionaryName;
+ /** @type {import('dictionary-data').Index} */ (json).title = dictionaryName;
}
archive.file(fileName, JSON.stringify(json, null, 0));
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index 20c7a189..09edbd6e 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -24,6 +24,7 @@ import {ClipboardReader} from '../comm/clipboard-reader.js';
import {Mecab} from '../comm/mecab.js';
import {clone, deferPromise, generateId, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
+import {parseJson, readResponseJson} from '../core/json.js';
import {AnkiUtil} from '../data/anki-util.js';
import {OptionsUtil} from '../data/options-util.js';
import {PermissionsUtil} from '../data/permissions-util.js';
@@ -291,7 +292,8 @@ export class Backend {
log.error(e);
}
- const deinflectionReasons = /** @type {import('deinflector').ReasonsRaw} */ (await this._fetchJson('/data/deinflect.json'));
+ /** @type {import('deinflector').ReasonsRaw} */
+ const deinflectionReasons = await this._fetchJson('/data/deinflect.json');
this._translator.prepare(deinflectionReasons);
await this._optionsUtil.prepare();
@@ -764,6 +766,7 @@ export class Backend {
const frameId = sender.frameId;
const id = generateId(16);
+ /** @type {import('cross-frame-api').ActionPortDetails} */
const details = {
name: 'action-port',
id
@@ -908,11 +911,13 @@ export class Backend {
throw new Error('Port does not have an associated frame ID');
}
+ /** @type {import('cross-frame-api').CrossFrameCommunicationPortDetails} */
const sourceDetails = {
name: 'cross-frame-communication-port',
otherTabId: targetTabId,
otherFrameId: targetFrameId
};
+ /** @type {import('cross-frame-api').CrossFrameCommunicationPortDetails} */
const targetDetails = {
name: 'cross-frame-communication-port',
otherTabId: sourceTabId,
@@ -1530,7 +1535,8 @@ export class Backend {
hasStarted = true;
port.onMessage.removeListener(onMessage);
- const messageData = JSON.parse(messageString);
+ /** @type {{action: string, params?: import('core').SerializableObject}} */
+ const messageData = parseJson(messageString);
messageString = null;
onMessageComplete(messageData);
}
@@ -2062,12 +2068,13 @@ export class Backend {
}
/**
+ * @template [T=unknown]
* @param {string} url
- * @returns {Promise<unknown>}
+ * @returns {Promise<T>}
*/
async _fetchJson(url) {
const response = await this._fetchAsset(url);
- return await response.json();
+ return await readResponseJson(response);
}
/**
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js
index bd9a69a2..fa5543d5 100644
--- a/ext/js/comm/anki-connect.js
+++ b/ext/js/comm/anki-connect.js
@@ -17,6 +17,7 @@
*/
import {ExtensionError} from '../core/extension-error.js';
+import {parseJson} from '../core/json.js';
import {AnkiUtil} from '../data/anki-util.js';
/**
@@ -419,7 +420,7 @@ export class AnkiConnect {
let result;
try {
responseText = await response.text();
- result = JSON.parse(responseText);
+ result = parseJson(responseText);
} catch (e) {
const error = new ExtensionError('Invalid Anki response');
error.data = {action, params, status: response.status, responseText, originalError: e};
diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js
index 35a66d9e..43f707e2 100644
--- a/ext/js/comm/api.js
+++ b/ext/js/comm/api.js
@@ -18,6 +18,7 @@
import {deferPromise} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
+import {parseJson} from '../core/json.js';
export class API {
/**
@@ -433,6 +434,7 @@ export class API {
return new Promise((resolve, reject) => {
/** @type {?import('core').Timeout} */
let timer = null;
+ /** @type {import('core').DeferredPromiseDetails<import('api').CreateActionPortResult>} */
const portDetails = deferPromise();
/**
@@ -441,8 +443,9 @@ export class API {
const onConnect = async (port) => {
try {
const {name: expectedName, id: expectedId} = await portDetails.promise;
- const {name, id} = JSON.parse(port.name);
- if (name !== expectedName || id !== expectedId || timer === null) { return; }
+ /** @type {import('cross-frame-api').PortDetails} */
+ const portDetails2 = parseJson(port.name);
+ if (portDetails2.name !== expectedName || portDetails2.id !== expectedId || timer === null) { return; }
} catch (e) {
return;
}
@@ -470,7 +473,9 @@ export class API {
timer = setTimeout(() => onError(new Error('Timeout')), timeout);
chrome.runtime.onConnect.addListener(onConnect);
- this._invoke('createActionPort').then(portDetails.resolve, onError);
+ /** @type {Promise<import('api').CreateActionPortResult>} */
+ const createActionPortResult = this._invoke('createActionPort');
+ createActionPortResult.then(portDetails.resolve, onError);
});
}
diff --git a/ext/js/comm/cross-frame-api.js b/ext/js/comm/cross-frame-api.js
index 3ac38cf2..0d3f3275 100644
--- a/ext/js/comm/cross-frame-api.js
+++ b/ext/js/comm/cross-frame-api.js
@@ -18,6 +18,7 @@
import {EventDispatcher, EventListenerCollection, invokeMessageHandler, log} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
+import {parseJson} from '../core/json.js';
import {yomitan} from '../yomitan.js';
/**
@@ -377,9 +378,10 @@ export class CrossFrameAPI {
*/
_onConnect(port) {
try {
+ /** @type {import('cross-frame-api').PortDetails} */
let details;
try {
- details = JSON.parse(port.name);
+ details = parseJson(port.name);
} catch (e) {
return;
}
diff --git a/ext/js/core.js b/ext/js/core.js
index c95eae01..d16a2099 100644
--- a/ext/js/core.js
+++ b/ext/js/core.js
@@ -47,7 +47,7 @@ export function stringReverse(string) {
}
/**
- * Creates a deep clone of an object or value. This is similar to `JSON.parse(JSON.stringify(value))`.
+ * Creates a deep clone of an object or value. This is similar to `parseJson(JSON.stringify(value))`.
* @template T
* @param {T} value The value to clone.
* @returns {T} A new clone of the value.
diff --git a/ext/js/core/json.js b/ext/js/core/json.js
new file mode 100644
index 00000000..a031f84e
--- /dev/null
+++ b/ext/js/core/json.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * This function is used to ensure more safe usage of `JSON.parse`.
+ * By default, `JSON.parse` returns a value with type `any`, which is easy to misuse.
+ * By changing the default to `unknown` and allowing it to be templatized,
+ * this improves how the return type is used.
+ * @template [T=unknown]
+ * @param {string} value
+ * @returns {T}
+ */
+export function parseJson(value) {
+ return JSON.parse(value);
+}
+
+/**
+ * This function is used to ensure more safe usage of `Response.json`,
+ * which returns the `any` type.
+ * @template [T=unknown]
+ * @param {Response} response
+ * @returns {Promise<T>}
+ */
+export async function readResponseJson(response) {
+ return await response.json();
+}
diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js
index c3c0e685..a17763e9 100644
--- a/ext/js/data/options-util.js
+++ b/ext/js/data/options-util.js
@@ -17,6 +17,7 @@
*/
import {escapeRegExp, isObject} from '../core.js';
+import {parseJson, readResponseJson} from '../core/json.js';
import {TemplatePatcher} from '../templates/template-patcher.js';
import {JsonSchema} from './json-schema.js';
@@ -30,7 +31,8 @@ export class OptionsUtil {
/** */
async prepare() {
- const schema = /** @type {import('json-schema').Schema} */ (await this._fetchJson('/data/schemas/options-schema.json'));
+ /** @type {import('json-schema').Schema} */
+ const schema = await this._fetchJson('/data/schemas/options-schema.json');
this._optionsSchema = new JsonSchema(schema);
}
@@ -115,7 +117,7 @@ export class OptionsUtil {
}
});
});
- options = JSON.parse(optionsStr);
+ options = parseJson(optionsStr);
} catch (e) {
// NOP
}
@@ -477,12 +479,13 @@ export class OptionsUtil {
}
/**
+ * @template [T=unknown]
* @param {string} url
- * @returns {Promise<unknown>}
+ * @returns {Promise<T>}
*/
async _fetchJson(url) {
const response = await this._fetchGeneric(url);
- return await response.json();
+ return await readResponseJson(response);
}
/**
diff --git a/ext/js/dom/sandbox/css-style-applier.js b/ext/js/dom/sandbox/css-style-applier.js
index 200fd05f..6925f263 100644
--- a/ext/js/dom/sandbox/css-style-applier.js
+++ b/ext/js/dom/sandbox/css-style-applier.js
@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+import {readResponseJson} from '../../core/json.js';
+
/**
* This class is used to apply CSS styles to elements using a consistent method
* that is the same across different browsers.
@@ -99,8 +101,9 @@ export class CssStyleApplier {
/**
* Fetches and parses a JSON file.
+ * @template [T=unknown]
* @param {string} url The URL to the file.
- * @returns {Promise<*>} A JSON object.
+ * @returns {Promise<T>} A JSON object.
* @throws {Error} An error is thrown if the fetch fails.
*/
async _fetchJsonAsset(url) {
@@ -115,7 +118,7 @@ export class CssStyleApplier {
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
- return await response.json();
+ return await readResponseJson(response);
}
/**
diff --git a/ext/js/input/hotkey-help-controller.js b/ext/js/input/hotkey-help-controller.js
index 4a3c0264..14063d9a 100644
--- a/ext/js/input/hotkey-help-controller.js
+++ b/ext/js/input/hotkey-help-controller.js
@@ -17,6 +17,7 @@
*/
import {isObject} from '../core.js';
+import {parseJson} from '../core/json.js';
import {yomitan} from '../yomitan.js';
import {HotkeyUtil} from './hotkey-util.js';
@@ -149,7 +150,7 @@ export class HotkeyHelpController {
_getNodeInfo(node) {
const {hotkey} = node.dataset;
if (typeof hotkey !== 'string') { return null; }
- const data = /** @type {unknown} */ (JSON.parse(hotkey));
+ const data = /** @type {unknown} */ (parseJson(hotkey));
if (!Array.isArray(data)) { return null; }
const [action, attributes, values] = data;
if (typeof action !== 'string') { return null; }
diff --git a/ext/js/language/deinflector.js b/ext/js/language/deinflector.js
index 676f45a1..26cc6b18 100644
--- a/ext/js/language/deinflector.js
+++ b/ext/js/language/deinflector.js
@@ -20,7 +20,7 @@ export class Deinflector {
/**
* @param {import('deinflector').ReasonsRaw} reasons
* @example
- * const deinflectionReasons = JSON.parse(
+ * const deinflectionReasons = parseJson(
* readFileSync(path.join('ext/data/deinflect.json')).toString(),
* );
* const deinflector = new Deinflector(deinflectionReasons);
diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js
index dfbd9590..df9c48f1 100644
--- a/ext/js/language/dictionary-importer.js
+++ b/ext/js/language/dictionary-importer.js
@@ -26,6 +26,7 @@ import {
} from '../../lib/zip.js';
import {stringReverse} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
+import {parseJson} from '../core/json.js';
import {MediaUtil} from '../media/media-util.js';
const ajvSchemas = /** @type {import('dictionary-importer').CompiledSchemaValidators} */ (/** @type {unknown} */ (ajvSchemas0));
@@ -89,7 +90,7 @@ export class DictionaryImporter {
const indexFile2 = /** @type {import('@zip.js/zip.js').Entry} */ (indexFile);
const indexContent = await this._getData(indexFile2, new TextWriter());
- const index = /** @type {import('dictionary-data').Index} */ (JSON.parse(indexContent));
+ const index = /** @type {import('dictionary-data').Index} */ (parseJson(indexContent));
if (!ajvSchemas.dictionaryIndex(index)) {
throw this._formatAjvSchemaError(ajvSchemas.dictionaryIndex, indexFileName);
@@ -589,25 +590,6 @@ export class DictionaryImporter {
}
/**
- * @param {string} url
- * @returns {Promise<unknown>}
- */
- async _fetchJsonAsset(url) {
- const response = await fetch(url, {
- method: 'GET',
- mode: 'no-cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer'
- });
- if (!response.ok) {
- throw new Error(`Failed to fetch ${url}: ${response.status}`);
- }
- return await response.json();
- }
-
- /**
* @param {import('dictionary-data').TermV1} entry
* @param {string} dictionary
* @returns {import('dictionary-database').DatabaseTermEntry}
@@ -730,7 +712,7 @@ export class DictionaryImporter {
const results = [];
for (const file of files) {
const content = await this._getData(file, new TextWriter());
- const entries = /** @type {unknown} */ (JSON.parse(content));
+ const entries = /** @type {unknown} */ (parseJson(content));
startIndex = progressData.index;
this._progress();
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index e041cc67..a9b2133b 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -18,6 +18,7 @@
import {RequestBuilder} from '../background/request-builder.js';
import {ExtensionError} from '../core/extension-error.js';
+import {readResponseJson} from '../core/json.js';
import {JsonSchema} from '../data/json-schema.js';
import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';
import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
@@ -272,7 +273,8 @@ export class AudioDownloader {
throw new Error(`Invalid response: ${response.status}`);
}
- const responseJson = await response.json();
+ /** @type {import('audio-downloader').CustomAudioList} */
+ const responseJson = await readResponseJson(response);
if (this._customAudioListSchema === null) {
const schema = await this._getCustomAudioListSchema();
@@ -425,6 +427,6 @@ export class AudioDownloader {
redirect: 'follow',
referrerPolicy: 'no-referrer'
});
- return await response.json();
+ return await readResponseJson(response);
}
}
diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js
index 85803077..c539bdfe 100644
--- a/ext/js/pages/settings/backup-controller.js
+++ b/ext/js/pages/settings/backup-controller.js
@@ -18,6 +18,7 @@
import {Dexie} from '../../../lib/dexie.js';
import {isObject, log} from '../../core.js';
+import {parseJson} from '../../core/json.js';
import {OptionsUtil} from '../../data/options-util.js';
import {ArrayBufferUtil} from '../../data/sandbox/array-buffer-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
@@ -427,7 +428,8 @@ export class BackupController {
if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); }
const dataString = ArrayBufferUtil.arrayBufferUtf8Decode(await this._readFileArrayBuffer(file));
- const data = JSON.parse(dataString);
+ /** @type {import('backup-controller').BackupData} */
+ const data = parseJson(dataString);
// Type check
if (!isObject(data)) {
diff --git a/ext/js/pages/settings/generic-setting-controller.js b/ext/js/pages/settings/generic-setting-controller.js
index 8666614b..8268f563 100644
--- a/ext/js/pages/settings/generic-setting-controller.js
+++ b/ext/js/pages/settings/generic-setting-controller.js
@@ -17,6 +17,7 @@
*/
import {ExtensionError} from '../../core/extension-error.js';
+import {parseJson} from '../../core/json.js';
import {DocumentUtil} from '../../dom/document-util.js';
import {DOMDataBinder} from '../../dom/dom-data-binder.js';
@@ -265,7 +266,7 @@ export class GenericSettingController {
*/
_getTransformDataArray(transformRaw) {
if (typeof transformRaw === 'string') {
- const transforms = JSON.parse(transformRaw);
+ const transforms = parseJson(transformRaw);
return Array.isArray(transforms) ? transforms : [transforms];
}
return [];
diff --git a/ext/js/templates/sandbox/template-renderer-frame-api.js b/ext/js/templates/sandbox/template-renderer-frame-api.js
index 94ebf7fe..388401f2 100644
--- a/ext/js/templates/sandbox/template-renderer-frame-api.js
+++ b/ext/js/templates/sandbox/template-renderer-frame-api.js
@@ -17,6 +17,7 @@
*/
import {ExtensionError} from '../../core/extension-error.js';
+import {parseJson} from '../../core/json.js';
export class TemplateRendererFrameApi {
/**
@@ -108,7 +109,7 @@ export class TemplateRendererFrameApi {
* @returns {T}
*/
_clone(value) {
- return JSON.parse(JSON.stringify(value));
+ return parseJson(JSON.stringify(value));
}
/**
diff --git a/test/anki-note-builder.test.js b/test/anki-note-builder.test.js
index cc136957..bdf3f8e4 100644
--- a/test/anki-note-builder.test.js
+++ b/test/anki-note-builder.test.js
@@ -22,6 +22,7 @@ import {readFileSync} from 'fs';
import {fileURLToPath} from 'node:url';
import path from 'path';
import {describe, vi} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {AnkiNoteBuilder} from '../ext/js/data/anki-note-builder.js';
import {JapaneseUtil} from '../ext/js/language/sandbox/japanese-util.js';
import {createTranslatorTest} from './fixtures/translator-test.js';
@@ -170,11 +171,12 @@ async function getRenderResults(dictionaryEntries, type, mode, template, expect)
const testInputsFilePath = path.join(dirname, 'data/translator-test-inputs.json');
-/** @type {import('test/anki-note-builder').TranslatorTestInputs} */
-const {optionsPresets, tests} = JSON.parse(readFileSync(testInputsFilePath, {encoding: 'utf8'}));
+/** @type {import('test/translator').TranslatorTestInputs} */
+const {optionsPresets, tests} = parseJson(readFileSync(testInputsFilePath, {encoding: 'utf8'}));
const testResults1FilePath = path.join(dirname, 'data/anki-note-builder-test-results.json');
-const expectedResults1 = JSON.parse(readFileSync(testResults1FilePath, {encoding: 'utf8'}));
+/** @type {import('test/translator').AnkiNoteBuilderTestResults} */
+const expectedResults1 = parseJson(readFileSync(testResults1FilePath, {encoding: 'utf8'}));
const template = readFileSync(path.join(dirname, '../ext/data/templates/default-anki-field-templates.handlebars'), {encoding: 'utf8'});
diff --git a/test/database.test.js b/test/database.test.js
index 2fdea99c..702de9f8 100644
--- a/test/database.test.js
+++ b/test/database.test.js
@@ -20,6 +20,7 @@ import {IDBFactory, IDBKeyRange} from 'fake-indexeddb';
import {fileURLToPath} from 'node:url';
import path from 'path';
import {beforeEach, describe, expect, test, vi} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {createDictionaryArchive} from '../dev/util.js';
import {DictionaryDatabase} from '../ext/js/language/dictionary-database.js';
import {DictionaryImporter} from '../ext/js/language/dictionary-importer.js';
@@ -109,7 +110,8 @@ async function testDatabase1() {
// Load dictionary data
const testDictionary = createTestDictionaryArchive('valid-dictionary1');
const testDictionarySource = await testDictionary.generateAsync({type: 'arraybuffer'});
- const testDictionaryIndex = JSON.parse(await testDictionary.files['index.json'].async('string'));
+ /** @type {import('dictionary-data').Index} */
+ const testDictionaryIndex = parseJson(await testDictionary.files['index.json'].async('string'));
const title = testDictionaryIndex.title;
const titles = new Map([
@@ -852,7 +854,8 @@ async function testDatabase2() {
// Load dictionary data
const testDictionary = createTestDictionaryArchive('valid-dictionary1');
const testDictionarySource = await testDictionary.generateAsync({type: 'arraybuffer'});
- const testDictionaryIndex = JSON.parse(await testDictionary.files['index.json'].async('string'));
+ /** @type {import('dictionary-data').Index} */
+ const testDictionaryIndex = parseJson(await testDictionary.files['index.json'].async('string'));
const title = testDictionaryIndex.title;
const titles = new Map([
diff --git a/test/deinflector.test.js b/test/deinflector.test.js
index adb347f1..1d7a39cf 100644
--- a/test/deinflector.test.js
+++ b/test/deinflector.test.js
@@ -22,6 +22,7 @@ import fs from 'fs';
import {fileURLToPath} from 'node:url';
import path from 'path';
import {describe, expect, test} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {Deinflector} from '../ext/js/language/deinflector.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -929,7 +930,8 @@ function testDeinflections() {
}
];
- const deinflectionReasons = JSON.parse(fs.readFileSync(path.join(dirname, '..', 'ext', 'data/deinflect.json'), {encoding: 'utf8'}));
+ /** @type {import('deinflector').ReasonsRaw} */
+ const deinflectionReasons = parseJson(fs.readFileSync(path.join(dirname, '..', 'ext', 'data/deinflect.json'), {encoding: 'utf8'}));
const deinflector = new Deinflector(deinflectionReasons);
describe('deinflections', () => {
diff --git a/test/dom-text-scanner.test.js b/test/dom-text-scanner.test.js
index d62e334d..f53e326d 100644
--- a/test/dom-text-scanner.test.js
+++ b/test/dom-text-scanner.test.js
@@ -19,6 +19,7 @@
import {fileURLToPath} from 'node:url';
import path from 'path';
import {describe, expect} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {DOMTextScanner} from '../ext/js/dom/dom-text-scanner.js';
import {createDomTest} from './fixtures/dom-test.js';
@@ -109,28 +110,33 @@ describe('DOMTextScanner', () => {
window.getComputedStyle = createAbsoluteGetComputedStyle(window);
for (const testElement of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('y-test'))) {
- let testData = JSON.parse(/** @type {string} */ (testElement.dataset.testData));
+ /** @type {import('test/dom-text-scanner').TestData|import('test/dom-text-scanner').TestData[]} */
+ let testData = parseJson(/** @type {string} */ (testElement.dataset.testData));
if (!Array.isArray(testData)) {
testData = [testData];
}
for (const testDataItem of testData) {
- let {
- node,
+ const {
+ node: nodeSelector,
offset,
length,
forcePreserveWhitespace,
generateLayoutContent,
reversible,
expected: {
- node: expectedNode,
+ node: expectedNodeSelector,
offset: expectedOffset,
content: expectedContent,
remainder: expectedRemainder
}
} = testDataItem;
- node = querySelectorTextNode(testElement, node);
- expectedNode = querySelectorTextNode(testElement, expectedNode);
+ const node = querySelectorTextNode(testElement, nodeSelector);
+ const expectedNode = querySelectorTextNode(testElement, expectedNodeSelector);
+
+ expect(node).not.toEqual(null);
+ expect(expectedNode).not.toEqual(null);
+ if (node === null || expectedNode === null) { continue; }
// Standard test
{
diff --git a/test/fixtures/translator-test.js b/test/fixtures/translator-test.js
index b17c37d9..cb1a3ef5 100644
--- a/test/fixtures/translator-test.js
+++ b/test/fixtures/translator-test.js
@@ -21,6 +21,7 @@ import {readFileSync} from 'fs';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {dirname, join, resolve} from 'path';
import {expect, vi} from 'vitest';
+import {parseJson} from '../../dev/json.js';
import {createDictionaryArchive} from '../../dev/util.js';
import {AnkiNoteDataCreator} from '../../ext/js/data/sandbox/anki-note-data-creator.js';
import {DictionaryDatabase} from '../../ext/js/language/dictionary-database.js';
@@ -60,7 +61,7 @@ async function fetch(url) {
status: 200,
statusText: 'OK',
text: async () => content.toString('utf8'),
- json: async () => JSON.parse(content.toString('utf8'))
+ json: async () => parseJson(content.toString('utf8'))
};
}
@@ -96,7 +97,8 @@ async function createTranslatorContext(dictionaryDirectory, dictionaryName) {
// Setup translator
const japaneseUtil = new JapaneseUtil(null);
const translator = new Translator({japaneseUtil, database: dictionaryDatabase});
- const deinflectionReasons = JSON.parse(readFileSync(deinflectionReasonsPath, {encoding: 'utf8'}));
+ /** @type {import('deinflector').ReasonsRaw} */
+ const deinflectionReasons = parseJson(readFileSync(deinflectionReasonsPath, {encoding: 'utf8'}));
translator.prepare(deinflectionReasons);
// Assign properties
diff --git a/test/json-schema.test.js b/test/json-schema.test.js
index a93e5002..e6817d23 100644
--- a/test/json-schema.test.js
+++ b/test/json-schema.test.js
@@ -19,6 +19,7 @@
/* eslint-disable no-multi-spaces */
import {expect, test} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {JsonSchema} from '../ext/js/data/json-schema.js';
/**
@@ -54,7 +55,7 @@ function createProxy(schema, value) {
* @returns {T}
*/
function clone(value) {
- return JSON.parse(JSON.stringify(value));
+ return parseJson(JSON.stringify(value));
}
diff --git a/test/options-util.test.js b/test/options-util.test.js
index f2ffa36c..41185756 100644
--- a/test/options-util.test.js
+++ b/test/options-util.test.js
@@ -22,6 +22,7 @@ import fs from 'fs';
import url, {fileURLToPath} from 'node:url';
import path from 'path';
import {expect, test, vi} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {OptionsUtil} from '../ext/js/data/options-util.js';
import {TemplatePatcher} from '../ext/js/templates/template-patcher.js';
@@ -40,7 +41,7 @@ async function fetch(url2) {
status: 200,
statusText: 'OK',
text: async () => Promise.resolve(content.toString('utf8')),
- json: async () => Promise.resolve(JSON.parse(content.toString('utf8')))
+ json: async () => Promise.resolve(parseJson(content.toString('utf8')))
};
}
/** @type {import('dev/vm').PseudoChrome} */
diff --git a/test/translator.test.js b/test/translator.test.js
index 59887d7e..42a9076e 100644
--- a/test/translator.test.js
+++ b/test/translator.test.js
@@ -20,6 +20,7 @@ import {readFileSync} from 'fs';
import {fileURLToPath} from 'node:url';
import path from 'path';
import {describe} from 'vitest';
+import {parseJson} from '../dev/json.js';
import {createTranslatorTest} from './fixtures/translator-test.js';
import {createTestAnkiNoteData} from './utilities/anki.js';
import {createFindOptions} from './utilities/translator.js';
@@ -27,14 +28,16 @@ import {createFindOptions} from './utilities/translator.js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const testInputsFilePath = path.join(dirname, 'data/translator-test-inputs.json');
-/** @type {import('test/anki-note-builder').TranslatorTestInputs} */
-const {optionsPresets, tests} = JSON.parse(readFileSync(testInputsFilePath, {encoding: 'utf8'}));
+/** @type {import('test/translator').TranslatorTestInputs} */
+const {optionsPresets, tests} = parseJson(readFileSync(testInputsFilePath, {encoding: 'utf8'}));
const testResults1FilePath = path.join(dirname, 'data/translator-test-results.json');
-const expectedResults1 = JSON.parse(readFileSync(testResults1FilePath, {encoding: 'utf8'}));
+/** @type {import('test/translator').TranslatorTestResults} */
+const expectedResults1 = parseJson(readFileSync(testResults1FilePath, {encoding: 'utf8'}));
const testResults2FilePath = path.join(dirname, 'data/translator-test-results-note-data1.json');
-const expectedResults2 = JSON.parse(readFileSync(testResults2FilePath, {encoding: 'utf8'}));
+/** @type {import('test/translator').TranslatorTestNoteDataResults} */
+const expectedResults2 = parseJson(readFileSync(testResults2FilePath, {encoding: 'utf8'}));
const dictionaryName = 'Test Dictionary 2';
const test = await createTranslatorTest(void 0, path.join(dirname, 'data/dictionaries/valid-dictionary1'), dictionaryName);
diff --git a/types/ext/api.d.ts b/types/ext/api.d.ts
index 6b7b4b19..2d78cc06 100644
--- a/types/ext/api.d.ts
+++ b/types/ext/api.d.ts
@@ -467,6 +467,6 @@ export type RequestBackendReadySignalResult = boolean;
export type CreateActionPortDetails = Record<string, never>;
export type CreateActionPortResult = {
- name: string;
+ name: 'action-port';
id: string;
};
diff --git a/types/ext/audio-downloader.d.ts b/types/ext/audio-downloader.d.ts
index b8e812f8..dfda8cb9 100644
--- a/types/ext/audio-downloader.d.ts
+++ b/types/ext/audio-downloader.d.ts
@@ -42,3 +42,13 @@ export type AudioBinaryBase64 = {
data: string;
contentType: string | null;
};
+
+export type CustomAudioList = {
+ type: 'audioSourceList';
+ audioSources: CustomAudioListSource[];
+};
+
+export type CustomAudioListSource = {
+ url: string;
+ name?: string;
+};
diff --git a/types/ext/cross-frame-api.d.ts b/types/ext/cross-frame-api.d.ts
index 88ce59a7..e31079b7 100644
--- a/types/ext/cross-frame-api.d.ts
+++ b/types/ext/cross-frame-api.d.ts
@@ -52,3 +52,16 @@ export type Invocation = {
ack: boolean;
timer: Core.Timeout | null;
};
+
+export type PortDetails = CrossFrameCommunicationPortDetails | ActionPortDetails;
+
+export type CrossFrameCommunicationPortDetails = {
+ name: 'cross-frame-communication-port';
+ otherTabId: number;
+ otherFrameId: number;
+};
+
+export type ActionPortDetails = {
+ name: 'action-port';
+ id: string;
+};
diff --git a/types/test/dom-text-scanner.d.ts b/types/test/dom-text-scanner.d.ts
new file mode 100644
index 00000000..362a7eb5
--- /dev/null
+++ b/types/test/dom-text-scanner.d.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2023 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+export type TestData = {
+ node: string;
+ offset: number;
+ length: number;
+ forcePreserveWhitespace?: boolean;
+ generateLayoutContent?: boolean;
+ reversible?: boolean;
+ expected: {
+ node: string;
+ offset: number;
+ content: string;
+ remainder?: number;
+ };
+};
+
diff --git a/types/test/anki-note-builder.d.ts b/types/test/translator.d.ts
index 0ccb25e9..3e4c8b9d 100644
--- a/types/test/anki-note-builder.d.ts
+++ b/types/test/translator.d.ts
@@ -17,6 +17,9 @@
import type {OptionsPresetObject} from 'dev/vm';
import type {FindTermsMode} from 'translator';
+import type {DictionaryEntry} from 'dictionary';
+import type {NoteData} from 'anki-templates';
+import type {NoteFields} from 'anki';
export type TranslatorTestInputs = {
optionsPresets: OptionsPresetObject;
@@ -39,3 +42,25 @@ export type TestInputFindTerm = {
text: string;
options: string;
};
+
+export type TranslatorTestResults = TranslatorTestResult[];
+
+export type TranslatorTestResult = {
+ name: string;
+ originalTextLength?: number;
+ dictionaryEntries: DictionaryEntry[];
+};
+
+export type TranslatorTestNoteDataResults = TranslatorTestNoteDataResult[];
+
+export type TranslatorTestNoteDataResult = {
+ name: string;
+ noteDataList: NoteData[];
+};
+
+export type AnkiNoteBuilderTestResults = AnkiNoteBuilderTestResult[];
+
+export type AnkiNoteBuilderTestResult = {
+ name: string;
+ results: NoteFields[] | null;
+};