diff options
| -rw-r--r-- | .eslintrc.json | 1 | ||||
| -rw-r--r-- | dev/bin/build.js | 64 | ||||
| -rw-r--r-- | dev/bin/dictionary-validate.js | 1 | ||||
| -rw-r--r-- | dev/bin/schema-validate.js | 5 | ||||
| -rw-r--r-- | dev/build-libs.js | 11 | ||||
| -rw-r--r-- | dev/dictionary-validate.js | 39 | ||||
| -rw-r--r-- | dev/generate-css-json.js | 61 | ||||
| -rw-r--r-- | dev/jsconfig.json | 7 | ||||
| -rw-r--r-- | dev/lint/global-declarations.js | 157 | ||||
| -rw-r--r-- | dev/lint/html-scripts.js | 202 | ||||
| -rw-r--r-- | dev/manifest-util.js | 84 | ||||
| -rw-r--r-- | dev/schema-validate.js | 25 | ||||
| -rw-r--r-- | dev/util.js | 21 | ||||
| -rw-r--r-- | vitest.config.js | 1 | 
14 files changed, 269 insertions, 410 deletions
| diff --git a/.eslintrc.json b/.eslintrc.json index 640a67e2..58262714 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -765,7 +765,6 @@          },          {              "files": [ -                "dev/**/*.js",                  "test/**/*.js"              ],              "rules": { diff --git a/dev/bin/build.js b/dev/bin/build.js index 282f0414..c5814dd3 100644 --- a/dev/bin/build.js +++ b/dev/bin/build.js @@ -21,13 +21,22 @@ import childProcess from 'child_process';  import fs from 'fs';  import path from 'path';  import readline from 'readline'; -import {fileURLToPath} from 'url'; +import JSZip from 'jszip'; +import {fileURLToPath} from 'node:url';  import {buildLibs} from '../build-libs.js';  import {ManifestUtil} from '../manifest-util.js';  import {getAllFiles, getArgs, testMain} from '../util.js';  const dirname = path.dirname(fileURLToPath(import.meta.url)); +/** + * @param {string} directory + * @param {string[]} excludeFiles + * @param {string} outputFileName + * @param {string[]} sevenZipExes + * @param {?import('jszip').OnUpdateCallback} onUpdate + * @param {boolean} dryRun + */  async function createZip(directory, excludeFiles, outputFileName, sevenZipExes, onUpdate, dryRun) {      try {          fs.unlinkSync(outputFileName); @@ -57,11 +66,17 @@ async function createZip(directory, excludeFiles, outputFileName, sevenZipExes,              }          }      } -    return await createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun); +    await createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun);  } +/** + * @param {string} directory + * @param {string[]} excludeFiles + * @param {string} outputFileName + * @param {?import('jszip').OnUpdateCallback} onUpdate + * @param {boolean} dryRun + */  async function createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun) { -    const JSZip = null;      const files = getAllFiles(directory);      removeItemsFromArray(files, excludeFiles);      const zip = new JSZip(); @@ -89,6 +104,10 @@ async function createJSZip(directory, excludeFiles, outputFileName, onUpdate, dr      }  } +/** + * @param {string[]} array + * @param {string[]} removeItems + */  function removeItemsFromArray(array, removeItems) {      for (const item of removeItems) {          const index = getIndexOfFilePath(array, item); @@ -98,6 +117,11 @@ function removeItemsFromArray(array, removeItems) {      }  } +/** + * @param {string[]} array + * @param {string} item + * @returns {number} + */  function getIndexOfFilePath(array, item) {      const pattern = /\\/g;      const separator = '/'; @@ -110,6 +134,16 @@ function getIndexOfFilePath(array, item) {      return -1;  } +/** + * @param {string} buildDir + * @param {string} extDir + * @param {ManifestUtil} manifestUtil + * @param {string[]} variantNames + * @param {string} manifestPath + * @param {boolean} dryRun + * @param {boolean} dryRunBuildZip + * @param {string} yomitanVersion + */  async function build(buildDir, extDir, manifestUtil, variantNames, manifestPath, dryRun, dryRunBuildZip, yomitanVersion) {      const sevenZipExes = ['7za', '7z']; @@ -119,6 +153,7 @@ async function build(buildDir, extDir, manifestUtil, variantNames, manifestPath,      }      const dontLogOnUpdate = !process.stdout.isTTY; +    /** @type {import('jszip').OnUpdateCallback} */      const onUpdate = (metadata) => {          if (dontLogOnUpdate) { return; } @@ -127,7 +162,7 @@ async function build(buildDir, extDir, manifestUtil, variantNames, manifestPath,              message += ` (${metadata.currentFile})`;          } -        readline.clearLine(process.stdout); +        readline.clearLine(process.stdout, 0);          readline.cursorTo(process.stdout, 0);          process.stdout.write(message);      }; @@ -173,6 +208,10 @@ async function build(buildDir, extDir, manifestUtil, variantNames, manifestPath,      }  } +/** + * @param {string} directory + * @param {string[]} files + */  function ensureFilesExist(directory, files) {      for (const file of files) {          assert.ok(fs.existsSync(path.join(directory, file))); @@ -180,8 +219,11 @@ function ensureFilesExist(directory, files) {  } +/** + * @param {string[]} argv + */  export async function main(argv) { -    const args = getArgs(argv, new Map([ +    const args = getArgs(argv, new Map(/** @type {[key: string, value: (boolean|null|number|string|string[])][]} */ ([          ['all', false],          ['default', false],          ['manifest', null], @@ -189,11 +231,11 @@ export async function main(argv) {          ['dry-run-build-zip', false],          ['yomitan-version', '0.0.0.0'],          [null, []] -    ])); +    ]))); -    const dryRun = args.get('dry-run'); -    const dryRunBuildZip = args.get('dry-run-build-zip'); -    const yomitanVersion = args.get('yomitan-version'); +    const dryRun = /** @type {boolean} */ (args.get('dry-run')); +    const dryRunBuildZip = /** @type {boolean} */ (args.get('dry-run-build-zip')); +    const yomitanVersion = /** @type {string} */ (args.get('yomitan-version'));      const manifestUtil = new ManifestUtil(); @@ -204,11 +246,11 @@ export async function main(argv) {      try {          await buildLibs(); -        const variantNames = ( +        const variantNames = /** @type {string[]} */ ((              argv.length === 0 || args.get('all') ?              manifestUtil.getVariants().filter(({buildable}) => buildable !== false).map(({name}) => name) :              args.get(null) -        ); +        ));          await build(buildDir, extDir, manifestUtil, variantNames, manifestPath, dryRun, dryRunBuildZip, yomitanVersion);      } finally {          // Restore manifest diff --git a/dev/bin/dictionary-validate.js b/dev/bin/dictionary-validate.js index 78ad5198..0affb919 100644 --- a/dev/bin/dictionary-validate.js +++ b/dev/bin/dictionary-validate.js @@ -28,6 +28,7 @@ async function main() {          return;      } +    /** @type {import('dev/schema-validate').ValidateMode} */      let mode = null;      if (dictionaryFileNames[0] === '--ajv') {          mode = 'ajv'; diff --git a/dev/bin/schema-validate.js b/dev/bin/schema-validate.js index 86cfebae..319c0d2c 100644 --- a/dev/bin/schema-validate.js +++ b/dev/bin/schema-validate.js @@ -17,8 +17,8 @@   */  import fs from 'fs'; -import performance from 'perf_hooks'; -import {createJsonSchema} from '../util.js'; +import {performance} from 'perf_hooks'; +import {createJsonSchema} from '../schema-validate.js';  function main() {      const args = process.argv.slice(2); @@ -30,6 +30,7 @@ function main() {          return;      } +    /** @type {import('dev/schema-validate').ValidateMode} */      let mode = null;      if (args[0] === '--ajv') {          mode = 'ajv'; diff --git a/dev/build-libs.js b/dev/build-libs.js index d33c1420..eee007f6 100644 --- a/dev/build-libs.js +++ b/dev/build-libs.js @@ -26,15 +26,18 @@ import {fileURLToPath} from 'url';  const dirname = path.dirname(fileURLToPath(import.meta.url));  const extDir = path.join(dirname, '..', 'ext'); -async function buildLib(p) { +/** + * @param {string} scriptPath + */ +async function buildLib(scriptPath) {      await esbuild.build({ -        entryPoints: [p], +        entryPoints: [scriptPath],          bundle: true,          minify: false,          sourcemap: true,          target: 'es2020',          format: 'esm', -        outfile: path.join(extDir, 'lib', path.basename(p)), +        outfile: path.join(extDir, 'lib', path.basename(scriptPath)),          external: ['fs'],          banner: {              js: '// @ts-nocheck' @@ -55,7 +58,7 @@ 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)))); +    const schemas = schemaFileNames.map((schemaFileName) => JSON.parse(fs.readFileSync(path.join(schemaDir, schemaFileName), {encoding: 'utf8'})));      const ajv = new Ajv({schemas: schemas, code: {source: true, esm: true}});      const moduleCode = standaloneCode(ajv); diff --git a/dev/dictionary-validate.js b/dev/dictionary-validate.js index eb40beda..b3654e75 100644 --- a/dev/dictionary-validate.js +++ b/dev/dictionary-validate.js @@ -22,23 +22,34 @@ import path from 'path';  import {performance} from 'perf_hooks';  import {createJsonSchema} from './schema-validate.js'; +/** + * @param {string} relativeFileName + * @returns {import('dev/dictionary-validate').Schema} + */  function readSchema(relativeFileName) {      const fileName = path.join(__dirname, relativeFileName);      const source = fs.readFileSync(fileName, {encoding: 'utf8'});      return JSON.parse(source);  } +/** + * @param {import('dev/schema-validate').ValidateMode} mode + * @param {import('jszip')} zip + * @param {string} fileNameFormat + * @param {import('dev/dictionary-validate').Schema} schema + */  async function validateDictionaryBanks(mode, zip, fileNameFormat, schema) {      let jsonSchema;      try {          jsonSchema = createJsonSchema(mode, schema);      } catch (e) { -        e.message += `\n(in file ${fileNameFormat})}`; -        throw e; +        const e2 = e instanceof Error ? e : new Error(`${e}`); +        e2.message += `\n(in file ${fileNameFormat})}`; +        throw e2;      }      let index = 1;      while (true) { -        const fileName = fileNameFormat.replace(/\?/, index); +        const fileName = fileNameFormat.replace(/\?/, `${index}`);          const file = zip.files[fileName];          if (!file) { break; } @@ -47,14 +58,20 @@ async function validateDictionaryBanks(mode, zip, fileNameFormat, schema) {          try {              jsonSchema.validate(data);          } catch (e) { -            e.message += `\n(in file ${fileName})}`; -            throw e; +            const e2 = e instanceof Error ? e : new Error(`${e}`); +            e2.message += `\n(in file ${fileName})}`; +            throw e2;          }          ++index;      }  } +/** + * @param {import('dev/schema-validate').ValidateMode} mode + * @param {import('jszip')} archive + * @param {import('dev/dictionary-validate').Schemas} schemas + */  export async function validateDictionary(mode, archive, schemas) {      const fileName = 'index.json';      const indexFile = archive.files[fileName]; @@ -69,8 +86,9 @@ export async function validateDictionary(mode, archive, schemas) {          const jsonSchema = createJsonSchema(mode, schemas.index);          jsonSchema.validate(index);      } catch (e) { -        e.message += `\n(in file ${fileName})}`; -        throw e; +        const e2 = e instanceof Error ? e : new Error(`${e}`); +        e2.message += `\n(in file ${fileName})}`; +        throw e2;      }      await validateDictionaryBanks(mode, archive, 'term_bank_?.json', version === 1 ? schemas.termBankV1 : schemas.termBankV3); @@ -80,6 +98,9 @@ export async function validateDictionary(mode, archive, schemas) {      await validateDictionaryBanks(mode, archive, 'tag_bank_?.json', schemas.tagBankV3);  } +/** + * @returns {import('dev/dictionary-validate').Schemas} + */  export function getSchemas() {      return {          index: readSchema('../ext/data/schemas/dictionary-index-schema.json'), @@ -93,6 +114,10 @@ export function getSchemas() {      };  } +/** + * @param {import('dev/schema-validate').ValidateMode} mode + * @param {string[]} dictionaryFileNames + */  export async function testDictionaryFiles(mode, dictionaryFileNames) {      const schemas = getSchemas(); diff --git a/dev/generate-css-json.js b/dev/generate-css-json.js index 914c1452..02e54530 100644 --- a/dev/generate-css-json.js +++ b/dev/generate-css-json.js @@ -16,9 +16,13 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ +import css from 'css';  import fs from 'fs';  import path from 'path'; +/** + * @returns {{cssFile: string, overridesCssFile: string, outputPath: string}[]} + */  export function getTargets() {      return [          { @@ -34,8 +38,11 @@ export function getTargets() {      ];  } -import css from 'css'; - +/** + * @param {import('css-style-applier').RawStyleData} rules + * @param {string[]} selectors + * @returns {number} + */  function indexOfRule(rules, selectors) {      const jj = selectors.length;      for (let i = 0, ii = rules.length; i < ii; ++i) { @@ -53,6 +60,12 @@ function indexOfRule(rules, selectors) {      return -1;  } +/** + * @param {import('css-style-applier').RawStyleDataStyleArray} styles + * @param {string} property + * @param {Map<string, number>} removedProperties + * @returns {number} + */  function removeProperty(styles, property, removedProperties) {      let removeCount = removedProperties.get(property);      if (typeof removeCount !== 'undefined') { return removeCount; } @@ -69,6 +82,10 @@ function removeProperty(styles, property, removedProperties) {      return removeCount;  } +/** + * @param {import('css-style-applier').RawStyleData} rules + * @returns {string} + */  export function formatRulesJson(rules) {      // Manually format JSON, for improved compactness      // return JSON.stringify(rules, null, 4); @@ -102,27 +119,39 @@ export function formatRulesJson(rules) {      return result;  } +/** + * @param {string} cssFile + * @param {string} overridesCssFile + * @returns {import('css-style-applier').RawStyleData} + * @throws {Error} + */  export function generateRules(cssFile, overridesCssFile) {      const content1 = fs.readFileSync(cssFile, {encoding: 'utf8'});      const content2 = fs.readFileSync(overridesCssFile, {encoding: 'utf8'}); -    const stylesheet1 = css.parse(content1, {}).stylesheet; -    const stylesheet2 = css.parse(content2, {}).stylesheet; +    const stylesheet1 = /** @type {css.StyleRules} */ (css.parse(content1, {}).stylesheet); +    const stylesheet2 = /** @type {css.StyleRules} */ (css.parse(content2, {}).stylesheet);      const removePropertyPattern = /^remove-property\s+([\w\W]+)$/;      const removeRulePattern = /^remove-rule$/;      const propertySeparator = /\s+/; +    /** @type {import('css-style-applier').RawStyleData} */      const rules = [];      // Default stylesheet      for (const rule of stylesheet1.rules) {          if (rule.type !== 'rule') { continue; } -        const {selectors, declarations} = rule; +        const {selectors, declarations} = /** @type {css.Rule} */ (rule); +        if (typeof selectors === 'undefined') { continue; } +        /** @type {import('css-style-applier').RawStyleDataStyleArray} */          const styles = []; -        for (const declaration of declarations) { -            if (declaration.type !== 'declaration') { console.log(declaration); continue; } -            const {property, value} = declaration; -            styles.push([property, value]); +        if (typeof declarations !== 'undefined') { +            for (const declaration of declarations) { +                if (declaration.type !== 'declaration') { console.log(declaration); continue; } +                const {property, value} = /** @type {css.Declaration} */ (declaration); +                if (typeof property !== 'string' || typeof value !== 'string') { continue; } +                styles.push([property, value]); +            }          }          if (styles.length > 0) {              rules.push({selectors, styles}); @@ -132,7 +161,9 @@ export function generateRules(cssFile, overridesCssFile) {      // Overrides      for (const rule of stylesheet2.rules) {          if (rule.type !== 'rule') { continue; } -        const {selectors, declarations} = rule; +        const {selectors, declarations} = /** @type {css.Rule} */ (rule); +        if (typeof selectors === 'undefined' || typeof declarations === 'undefined') { continue; } +        /** @type {Map<string, number>} */          const removedProperties = new Map();          for (const declaration of declarations) {              switch (declaration.type) { @@ -146,16 +177,18 @@ export function generateRules(cssFile, overridesCssFile) {                              entry = {selectors, styles: []};                              rules.push(entry);                          } -                        const {property, value} = declaration; -                        removeProperty(entry.styles, property, removedProperties); -                        entry.styles.push([property, value]); +                        const {property, value} = /** @type {css.Declaration} */ (declaration); +                        if (typeof property === 'string' && typeof value === 'string') { +                            removeProperty(entry.styles, property, removedProperties); +                            entry.styles.push([property, value]); +                        }                      }                      break;                  case 'comment':                      {                          const index = indexOfRule(rules, selectors);                          if (index < 0) { throw new Error('Could not find rule with matching selectors'); } -                        const comment = declaration.comment.trim(); +                        const comment = (/** @type {css.Comment} */ (declaration).comment || '').trim();                          let m;                          if ((m = removePropertyPattern.exec(comment)) !== null) {                              for (const property of m[1].split(propertySeparator)) { diff --git a/dev/jsconfig.json b/dev/jsconfig.json index 5b1c450c..e0074980 100644 --- a/dev/jsconfig.json +++ b/dev/jsconfig.json @@ -1,9 +1,9 @@  {      "compilerOptions": { -        "module": "ES2015", +        "module": "ES2022",          "target": "ES2022",          "checkJs": true, -        "moduleResolution": "node", +        "moduleResolution": "bundler",          "strict": true,          "strictNullChecks": true,          "noImplicitAny": true, @@ -73,6 +73,7 @@          "../types/other/globals.d.ts"      ],      "exclude": [ -        "../node_modules" +        "../node_modules", +        "lib"      ]  }
\ No newline at end of file diff --git a/dev/lint/global-declarations.js b/dev/lint/global-declarations.js deleted file mode 100644 index 648ad368..00000000 --- a/dev/lint/global-declarations.js +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan 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/>. - */ - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {getAllFiles} = require('../util'); - - -/** - * @param {string} string - * @returns {string} - */ -function escapeRegExp(string) { -    return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * @param {string} string - * @param {RegExp} pattern - * @returns {number} - */ -function countOccurences(string, pattern) { -    return (string.match(pattern) || []).length; -} - -/** - * @param {string} string - * @returns {'\r'|'\n'|'\r\n'} - */ -function getNewline(string) { -    const count1 = countOccurences(string, /(?:^|[^\r])\n/g); -    const count2 = countOccurences(string, /\r\n/g); -    const count3 = countOccurences(string, /\r(?:[^\n]|$)/g); -    if (count2 > count1) { -        return (count3 > count2) ? '\r' : '\r\n'; -    } else { -        return (count3 > count1) ? '\r' : '\n'; -    } -} - -/** - * @param {string} string - * @param {string} substring - * @returns {number} - */ -function getSubstringCount(string, substring) { -    let count = 0; -    const pattern = new RegExp(`\\b${escapeRegExp(substring)}\\b`, 'g'); -    while (true) { -        const match = pattern.exec(string); -        if (match === null) { break; } -        ++count; -    } -    return count; -} - - -/** - * @param {string} fileName - * @param {boolean} fix - * @returns {boolean} - */ -function validateGlobals(fileName, fix) { -    const pattern = /\/\*\s*global\s+([\w\W]*?)\*\//g; -    const trimPattern = /^[\s,*]+|[\s,*]+$/g; -    const splitPattern = /[\s,*]+/; -    const source = fs.readFileSync(fileName, {encoding: 'utf8'}); -    let match; -    let first = true; -    let endIndex = 0; -    let newSource = ''; -    const allGlobals = []; -    const newline = getNewline(source); -    while ((match = pattern.exec(source)) !== null) { -        if (!first) { -            console.error(`Encountered more than one global declaration in ${fileName}`); -            return false; -        } -        first = false; - -        const parts = match[1].replace(trimPattern, '').split(splitPattern); -        parts.sort(); - -        const actual = match[0]; -        const expected = `/* global${parts.map((v) => `${newline} * ${v}`).join('')}${newline} */`; - -        try { -            assert.strictEqual(actual, expected); -        } catch (e) { -            console.error(`Global declaration error encountered in ${fileName}:`); -            console.error(e instanceof Error ? e.message : `${e}`); -            if (!fix) { -                return false; -            } -        } - -        newSource += source.substring(0, match.index); -        newSource += expected; -        endIndex = match.index + match[0].length; - -        allGlobals.push(...parts); -    } - -    newSource += source.substring(endIndex); - -    // This is an approximate check to see if a global variable is unused. -    // If the global appears in a comment, string, or similar, the check will pass. -    let errorCount = 0; -    for (const global of allGlobals) { -        if (getSubstringCount(newSource, global) <= 1) { -            console.error(`Global variable ${global} appears to be unused in ${fileName}`); -            ++errorCount; -        } -    } - -    if (fix) { -        fs.writeFileSync(fileName, newSource, {encoding: 'utf8'}); -    } - -    return errorCount === 0; -} - - -/** */ -function main() { -    const fix = (process.argv.length >= 2 && process.argv[2] === '--fix'); -    const directory = path.resolve(__dirname, '..', '..', 'ext'); -    const pattern = /\.js$/; -    const ignorePattern = /^lib[\\/]/; -    const fileNames = getAllFiles(directory, (f) => pattern.test(f) && !ignorePattern.test(f)); -    for (const fileName of fileNames) { -        if (!validateGlobals(path.join(directory, fileName), fix)) { -            process.exit(-1); -            return; -        } -    } -    process.exit(0); -} - - -if (require.main === module) { main(); } diff --git a/dev/lint/html-scripts.js b/dev/lint/html-scripts.js deleted file mode 100644 index da8c2c71..00000000 --- a/dev/lint/html-scripts.js +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan 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/>. - */ - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {JSDOM} = require('jsdom'); -const {getAllFiles} = require('../util'); - - -/** - * @param {string} fileName - * @returns {?fs.Stats} - */ -function lstatSyncSafe(fileName) { -    try { -        return fs.lstatSync(fileName); -    } catch (e) { -        return null; -    } -} - -/** - * @param {string} src - * @param {string} fileName - * @param {string} extDir - */ -function validatePath(src, fileName, extDir) { -    assert.ok(typeof src === 'string', `<script> missing src attribute in ${fileName}`); -    assert.ok(src.startsWith('/'), `<script> src attribute is not absolute in ${fileName} (src=${JSON.stringify(src)})`); -    const relativeSrc = src.substring(1); -    assert.ok(!path.isAbsolute(relativeSrc), `<script> src attribute is invalid in ${fileName} (src=${JSON.stringify(src)})`); -    const fullSrc = path.join(extDir, relativeSrc); -    const stats = lstatSyncSafe(fullSrc); -    assert.ok(stats !== null, `<script> src file not found in ${fileName} (src=${JSON.stringify(src)})`); -    assert.ok(stats.isFile(), `<script> src file invalid in ${fileName} (src=${JSON.stringify(src)})`); -} - -/** - * @param {string} string - * @param {RegExp} pattern - * @returns {number} - */ -function getSubstringCount(string, pattern) { -    let count = 0; -    while (true) { -        const match = pattern.exec(string); -        if (match === null) { break; } -        ++count; -    } -    return count; -} - -/** - * @param {string[]} scriptPaths - * @returns {string[]} - */ -function getSortedScriptPaths(scriptPaths) { -    // Sort file names without the extension -    const extensionPattern = /\.[^.]*$/; -    const scriptPaths2 = scriptPaths.map((value) => { -        const match = extensionPattern.exec(value); -        let ext = ''; -        if (match !== null) { -            ext = match[0]; -            value = value.substring(0, value.length - ext.length); -        } -        return {value, ext}; -    }); - -    const stringComparer = new Intl.Collator('en-US'); // Invariant locale -    scriptPaths2.sort((a, b) => stringComparer.compare(a.value, b.value)); - -    return scriptPaths2.map(({value, ext}) => `${value}${ext}`); -} - -/** - * @param {string} fileName - * @param {import('jsdom').DOMWindow} window - * @throws {Error} - */ -function validateScriptOrder(fileName, window) { -    const {document, Node: {ELEMENT_NODE, TEXT_NODE}, NodeFilter} = window; - -    const scriptElements = document.querySelectorAll('script'); -    if (scriptElements.length === 0) { return; } - -    // Assert all scripts are siblings -    const scriptContainerElement = /** @type {Node} */ (scriptElements[0].parentNode); -    for (const element of scriptElements) { -        if (element.parentNode !== scriptContainerElement) { -            assert.fail('All script nodes are not contained within the same element'); -        } -    } - -    // Get script groupings and order -    /** @type {string[][]} */ -    const scriptGroups = []; -    const newlinePattern = /\n/g; -    let separatingText = ''; -    const walker = document.createTreeWalker(scriptContainerElement, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); -    walker.firstChild(); -    for (let node = /** @type {?Node} */ (walker.currentNode); node !== null; node = walker.nextSibling()) { -        switch (node.nodeType) { -            case ELEMENT_NODE: -                if (/** @type {Element} */ (node).tagName.toLowerCase() === 'script') { -                    /** @type {string[]} */ -                    let scriptGroup; -                    if (scriptGroups.length === 0 || getSubstringCount(separatingText, newlinePattern) >= 2) { -                        scriptGroup = []; -                        scriptGroups.push(scriptGroup); -                    } else { -                        scriptGroup = scriptGroups[scriptGroups.length - 1]; -                    } -                    scriptGroup.push(/** @type {HTMLScriptElement} */ (node).src); -                    separatingText = ''; -                } -                break; -            case TEXT_NODE: -                separatingText += node.nodeValue; -                break; -        } -    } - -    // Ensure core.js is first (if it is present) -    const ignorePattern = /^\/lib\//; -    const index = scriptGroups.flat() -        .filter((value) => !ignorePattern.test(value)) -        .findIndex((value) => (value === '/js/core.js')); -    assert.ok(index <= 0, 'core.js is not the first included script'); - -    // Check script order -    for (let i = 0, ii = scriptGroups.length; i < ii; ++i) { -        const scriptGroup = scriptGroups[i]; -        try { -            assert.deepStrictEqual(scriptGroup, getSortedScriptPaths(scriptGroup)); -        } catch (e) { -            console.error(`Script order for group ${i + 1} in file ${fileName} is not correct:`); -            throw e; -        } -    } -} - -/** - * @param {string} fileName - * @param {string} extDir - */ -function validateHtmlScripts(fileName, extDir) { -    const fullFileName = path.join(extDir, fileName); -    const domSource = fs.readFileSync(fullFileName, {encoding: 'utf8'}); -    const dom = new JSDOM(domSource); -    const {window} = dom; -    const {document} = window; -    try { -        for (const {src} of document.querySelectorAll('script')) { -            validatePath(src, fullFileName, extDir); -        } -        for (const {href} of document.querySelectorAll('link')) { -            validatePath(href, fullFileName, extDir); -        } -        validateScriptOrder(fileName, window); -    } finally { -        window.close(); -    } -} - - -/** */ -function main() { -    try { -        const extDir = path.resolve(__dirname, '..', '..', 'ext'); -        const pattern = /\.html$/; -        const ignorePattern = /^lib[\\/]/; -        const fileNames = getAllFiles(extDir, (f) => pattern.test(f) && !ignorePattern.test(f)); -        for (const fileName of fileNames) { -            validateHtmlScripts(fileName, extDir); -        } -    } catch (e) { -        console.error(e); -        process.exit(-1); -        return; -    } -    process.exit(0); -} - - -if (require.main === module) { main(); } diff --git a/dev/manifest-util.js b/dev/manifest-util.js index 15175e7f..1efc8cfc 100644 --- a/dev/manifest-util.js +++ b/dev/manifest-util.js @@ -23,6 +23,11 @@ import path from 'path';  const dirname = path.dirname(fileURLToPath(import.meta.url)); +/** + * @template T + * @param {T} value + * @returns {T} + */  function clone(value) {      return JSON.parse(JSON.stringify(value));  } @@ -31,16 +36,24 @@ function clone(value) {  export class ManifestUtil {      constructor() {          const fileName = path.join(dirname, 'data', 'manifest-variants.json'); -        const {manifest, variants, defaultVariant} = JSON.parse(fs.readFileSync(fileName)); +        const {manifest, variants, defaultVariant} = /** @type {import('dev/manifest').Manifest} */ (JSON.parse(fs.readFileSync(fileName, {encoding: 'utf8'}))); +        /** @type {chrome.runtime.Manifest} */          this._manifest = manifest; +        /** @type {import('dev/manifest').ManifestVariant[]} */          this._variants = variants; +        /** @type {string} */          this._defaultVariant = defaultVariant; +        /** @type {Map<string, import('dev/manifest').ManifestVariant>} */          this._variantMap = new Map();          for (const variant of variants) {              this._variantMap.set(variant.name, variant);          }      } +    /** +     * @param {?string} [variantName] +     * @returns {chrome.runtime.Manifest} +     */      getManifest(variantName) {          if (typeof variantName === 'string') {              const variant = this._variantMap.get(variantName); @@ -59,20 +72,36 @@ export class ManifestUtil {          return clone(this._manifest);      } +    /** +     * @returns {import('dev/manifest').ManifestVariant[]} +     */      getVariants() {          return [...this._variants];      } +    /** +     * @param {string} name +     * @returns {import('dev/manifest').ManifestVariant|undefined} +     */      getVariant(name) {          return this._variantMap.get(name);      } +    /** +     * @param {chrome.runtime.Manifest} manifest +     * @returns {string} +     */      static createManifestString(manifest) {          return JSON.stringify(manifest, null, 4) + '\n';      }      // Private +    /** +     * @param {import('dev/manifest').Command} data +     * @returns {string} +     * @throws {Error} +     */      _evaluateModificationCommand(data) {          const {command, args, trim} = data;          const {stdout, stderr, status} = childProcess.spawnSync(command, args, { @@ -89,6 +118,11 @@ export class ManifestUtil {          return result;      } +    /** +     * @param {chrome.runtime.Manifest} manifest +     * @param {import('dev/manifest').Modification[]} modifications +     * @returns {chrome.runtime.Manifest} +     */      _applyModifications(manifest, modifications) {          if (Array.isArray(modifications)) {              for (const modification of modifications) { @@ -97,6 +131,7 @@ export class ManifestUtil {                      case 'set':                          {                              let {value, before, after, command} = modification; +                            /** @type {import('core').UnknownObject} */                              const object = this._getObjectProperties(manifest, path2, path2.length - 1);                              const key = path2[path2.length - 1]; @@ -121,6 +156,7 @@ export class ManifestUtil {                      case 'replace':                          {                              const {pattern, patternFlags, replacement} = modification; +                            /** @type {import('core').UnknownObject} */                              const value = this._getObjectProperties(manifest, path2, path2.length - 1);                              const regex = new RegExp(pattern, patternFlags);                              const last = path2[path2.length - 1]; @@ -131,6 +167,7 @@ export class ManifestUtil {                          break;                      case 'delete':                          { +                            /** @type {import('core').UnknownObject} */                              const value = this._getObjectProperties(manifest, path2, path2.length - 1);                              const last = path2[path2.length - 1];                              delete value[last]; @@ -139,6 +176,7 @@ export class ManifestUtil {                      case 'remove':                          {                              const {item} = modification; +                            /** @type {unknown[]} */                              const value = this._getObjectProperties(manifest, path2, path2.length);                              const index = value.indexOf(item);                              if (index >= 0) { value.splice(index, 1); } @@ -147,6 +185,7 @@ export class ManifestUtil {                      case 'splice':                          {                              const {start, deleteCount, items} = modification; +                            /** @type {unknown[]} */                              const value = this._getObjectProperties(manifest, path2, path2.length);                              const itemsNew = items.map((v) => clone(v));                              value.splice(start, deleteCount, ...itemsNew); @@ -158,7 +197,9 @@ export class ManifestUtil {                              const {newPath, before, after} = modification;                              const oldKey = path2[path2.length - 1];                              const newKey = newPath[newPath.length - 1]; +                            /** @type {import('core').UnknownObject} */                              const oldObject = this._getObjectProperties(manifest, path2, path2.length - 1); +                            /** @type {import('core').UnknownObject} */                              const newObject = this._getObjectProperties(manifest, newPath, newPath.length - 1);                              const oldObjectIsNewObject = this._arraysAreSame(path2, newPath, -1);                              const value = oldObject[oldKey]; @@ -184,6 +225,7 @@ export class ManifestUtil {                      case 'add':                          {                              const {items} = modification; +                            /** @type {unknown[]} */                              const value = this._getObjectProperties(manifest, path2, path2.length);                              const itemsNew = items.map((v) => clone(v));                              value.push(...itemsNew); @@ -196,6 +238,13 @@ export class ManifestUtil {          return manifest;      } +    /** +     * @template T +     * @param {T[]} array1 +     * @param {T[]} array2 +     * @param {number} lengthOffset +     * @returns {boolean} +     */      _arraysAreSame(array1, array2, lengthOffset) {          let ii = array1.length;          if (ii !== array2.length) { return false; } @@ -206,10 +255,21 @@ export class ManifestUtil {          return true;      } +    /** +     * @param {import('core').UnknownObject} object +     * @param {string|number} key +     * @returns {number} +     */      _getObjectKeyIndex(object, key) { -        return Object.keys(object).indexOf(key); +        return Object.keys(object).indexOf(typeof key === 'string' ? key : `${key}`);      } +    /** +     * @param {import('core').UnknownObject} object +     * @param {string|number} key +     * @param {unknown} value +     * @param {number} index +     */      _setObjectKeyAtIndex(object, key, value, index) {          if (index < 0 || typeof key === 'number' || Object.prototype.hasOwnProperty.call(object, key)) {              object[key] = value; @@ -229,13 +289,24 @@ export class ManifestUtil {          }      } +    /** +     * @template [TReturn=unknown] +     * @param {unknown} object +     * @param {import('dev/manifest').PropertyPath} path2 +     * @param {number} count +     * @returns {TReturn} +     */      _getObjectProperties(object, path2, count) {          for (let i = 0; i < count; ++i) { -            object = object[path2[i]]; +            object = /** @type {import('core').UnknownObject} */ (object)[path2[i]];          } -        return object; +        return /** @type {TReturn} */ (object);      } +    /** +     * @param {import('dev/manifest').ManifestVariant} variant +     * @returns {import('dev/manifest').ManifestVariant[]} +     */      _getInheritanceChain(variant) {          const visited = new Set();          const inheritance = []; @@ -256,6 +327,11 @@ export class ManifestUtil {          return inheritance;      } +    /** +     * @param {chrome.runtime.Manifest} manifest +     * @param {import('dev/manifest').ManifestVariant} variant +     * @returns {chrome.runtime.Manifest} +     */      _createVariantManifest(manifest, variant) {          let modifiedManifest = clone(manifest);          for (const {modifications} of this._getInheritanceChain(variant)) { diff --git a/dev/schema-validate.js b/dev/schema-validate.js index fbd6b06a..a1fe8455 100644 --- a/dev/schema-validate.js +++ b/dev/schema-validate.js @@ -17,31 +17,48 @@   */  import Ajv from 'ajv'; +import {readFileSync} from 'fs';  import {JsonSchema} from '../ext/js/data/json-schema.js'; +import {DataError} from './data-error.js';  class JsonSchemaAjv { +    /** +     * @param {import('dev/schema-validate').Schema} schema +     */      constructor(schema) {          const ajv = new Ajv({              meta: false,              strictTuples: false,              allowUnionTypes: true          }); -        ajv.addMetaSchema(require('ajv/dist/refs/json-schema-draft-07.json')); -        this._validate = ajv.compile(schema); +        const metaSchemaPath = require.resolve('ajv/dist/refs/json-schema-draft-07.json'); +        const metaSchema = JSON.parse(readFileSync(metaSchemaPath, {encoding: 'utf8'})); +        ajv.addMetaSchema(metaSchema); +        /** @type {import('ajv').ValidateFunction} */ +        this._validate = ajv.compile(/** @type {import('ajv').Schema} */ (schema));      } +    /** +     * @param {unknown} data +     * @throws {Error} +     */      validate(data) {          if (this._validate(data)) { return; }          const {errors} = this._validate; -        const error = new Error('Schema validation failed'); +        const error = new DataError('Schema validation failed');          error.data = JSON.parse(JSON.stringify(errors));          throw error;      }  } +/** + * @param {import('dev/schema-validate').ValidateMode} mode + * @param {import('dev/schema-validate').Schema} schema + * @returns {JsonSchema|JsonSchemaAjv} + */  export function createJsonSchema(mode, schema) {      switch (mode) {          case 'ajv': return new JsonSchemaAjv(schema); -        default: return new JsonSchema(schema); +        default: return new JsonSchema(/** @type {import('json-schema').Schema} */ (schema));      }  } diff --git a/dev/util.js b/dev/util.js index cabc40aa..3299dec4 100644 --- a/dev/util.js +++ b/dev/util.js @@ -20,6 +20,11 @@ import fs from 'fs';  import JSZip from 'jszip';  import path from 'path'; +/** + * @param {string[]} args + * @param {Map<?string, (boolean|null|number|string|string[])>} argMap + * @returns {Map<?string, (boolean|null|number|string|string[])>} + */  export function getArgs(args, argMap) {      let key = null;      let canKey = true; @@ -64,11 +69,16 @@ export function getArgs(args, argMap) {      return argMap;  } +/** + * @param {string} baseDirectory + * @param {?(fileName: string) => boolean} predicate + * @returns {string[]} + */  export function getAllFiles(baseDirectory, predicate=null) {      const results = [];      const directories = [baseDirectory];      while (directories.length > 0) { -        const directory = directories.shift(); +        const directory = /** @type {string} */ (directories.shift());          const fileNames = fs.readdirSync(directory);          for (const fileName of fileNames) {              const fullFileName = path.join(directory, fileName); @@ -86,6 +96,11 @@ export function getAllFiles(baseDirectory, predicate=null) {      return results;  } +/** + * @param {string} dictionaryDirectory + * @param {string} [dictionaryName] + * @returns {import('jszip')} + */  export function createDictionaryArchive(dictionaryDirectory, dictionaryName) {      const fileNames = fs.readdirSync(dictionaryDirectory); @@ -125,6 +140,10 @@ export function createDictionaryArchive(dictionaryDirectory, dictionaryName) {      // return zipFileBlob;  } +/** + * @param {(...args: import('core').SafeAny[]) => (unknown|Promise<unknown>)} func + * @param {...import('core').SafeAny} args + */  export async function testMain(func, ...args) {      try {          await func(...args); diff --git a/vitest.config.js b/vitest.config.js index 9e1c54a5..3b6cdde0 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -24,6 +24,7 @@ export default defineConfig({              'test/playwright/**'          ],          environment: 'jsdom', +        // @ts-ignore - Appears to not be defined in the type definitions (https://vitest.dev/advanced/pool)          poolOptions: {              threads: {                  useAtomics: true |