modernize frontend to target the same ES and TS standards as the rest of the app

This commit is contained in:
Hazelnoot 2025-10-06 00:01:01 -04:00
parent 0a5c9f79e5
commit 22f49db21f
21 changed files with 249 additions and 170 deletions

View file

@ -12,7 +12,7 @@ export const firstNameDict = [
'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella',
'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan',
'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily',
]
];
/**
* AIで生成した無作為なラストネーム
@ -21,7 +21,7 @@ export const lastNameDict = [
'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown',
'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson',
'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper',
]
];
/**
* AIで生成した無作為な国名
@ -30,7 +30,7 @@ export const countryDict = [
'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India',
'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand',
'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru',
]
];
export function text(length: number = 10, seed?: string): string {
let result = "";
@ -140,7 +140,7 @@ export function imageDataUrl(options?: {
throw new Error('Failed to get 2d context');
}
ctx.beginPath()
ctx.beginPath();
const red = options?.color?.red ?? integer(0, 255, seed);
const green = options?.color?.green ?? integer(0, 255, seed);

View file

@ -4,7 +4,7 @@
*/
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import type { entities } from 'misskey-js'
import type { entities } from 'misskey-js';
import { date, imageDataUrl, text } from "./fake-utils.js";
export function abuseUserReport() {
@ -124,7 +124,7 @@ export function galleryPost(isSensitive = false) {
isSensitive,
likedCount: 0,
isLiked: false,
}
};
}
export function file(isSensitive = false) {
@ -318,13 +318,13 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host: enti
export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
const date = new Date();
const createdAt = new Date();
createdAt.setDate(date.getDate() - 1)
createdAt.setDate(date.getDate() - 1);
const expiresAt = new Date();
if (isExpired) {
expiresAt.setHours(date.getHours() - 1)
expiresAt.setHours(date.getHours() - 1);
} else {
expiresAt.setHours(date.getHours() + 1)
expiresAt.setHours(date.getHours() + 1);
}
return {
@ -336,7 +336,7 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa
usedBy: isUsed ? userDetailed('3i3r2znx1v') : null,
usedAt: isUsed ? date.toISOString() : null,
used: isUsed,
}
};
}
export function role(params: {
@ -385,7 +385,7 @@ export function role(params: {
values: []
},
policies: {},
}
};
}
export function emoji(params?: {
@ -401,7 +401,7 @@ export function emoji(params?: {
license?: string,
isSensitive?: boolean,
localOnly?: boolean,
roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[],
roleIdsThatCanBeUsedThisEmojiAsReaction?: { id: string, name: string }[],
updatedAt?: string,
}, seed?: string): entities.EmojiDetailedAdmin {
const _seed = seed ?? (params?.id ?? "DEFAULT_SEED");
@ -409,7 +409,7 @@ export function emoji(params?: {
const name = params?.name ?? text(8, _seed);
const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString();
const image = imageDataUrl({}, _seed)
const image = imageDataUrl({}, _seed);
return {
id: id,
@ -426,5 +426,5 @@ export function emoji(params?: {
localOnly: params?.localOnly ?? false,
roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
updatedAt: updatedAt,
}
};
}

View file

@ -72,7 +72,7 @@ const generator = {
break;
}
default: {
// @ts-ignore
// @ts-expect-error ???
this[node.expression.type](node.expression, state);
break;
}
@ -94,7 +94,7 @@ type SplitCamel<
: SplitCamel<XR, `${YC}${XH}`, YN>
: YN;
// @ts-ignore
// @ts-expect-error ???
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
? [XH, ...SplitKebab<XR>]
: [T];
@ -110,7 +110,7 @@ type ToKebab<T extends readonly string[]> = T extends readonly [
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
: '';
// @ts-ignore
// @ts-expect-error ???
type ToPascal<T extends readonly string[]> = T extends readonly [
infer XH extends string,
...infer XR extends readonly string[]
@ -474,5 +474,5 @@ function toStories(component: string): Promise<string> {
await Promise.all(components.map(async (component) => {
const stories = component.replace(/\.vue$/, '.stories.ts');
await writeFile(stories, await toStories(component));
}))
}));
})();

View file

@ -8,9 +8,9 @@ import { type SharedOptions, http, HttpResponse } from 'msw';
export const onUnhandledRequest = ((req, print) => {
const url = new URL(req.url);
if (url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(url.pathname)) {
return
return;
}
print.warning()
print.warning();
}) satisfies SharedOptions['onUnhandledRequest'];
export const commonHandlers = [

View file

@ -10,4 +10,4 @@ await writeFile(
new URL('locale.ts', import.meta.url),
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
'utf8',
)
);

View file

@ -30,7 +30,7 @@ const keys = [
'd-u0',
'rosepine',
'rosepine-dawn',
]
];
await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
writeFile(

View file

@ -99,13 +99,13 @@ const preview = {
const channel = addons.getChannel();
const resetIndexedDBPromise = globalThis.indexedDB?.databases
? indexedDB.databases().then((r) => {
for (var i = 0; i < r.length; i++) {
for (let i = 0; i < r.length; i++) {
indexedDB.deleteDatabase(r[i].name!);
}
}).catch(() => {})
: Promise.resolve();
const resetDefaultStorePromise = import('../src/store').then(({ store }) => {
// @ts-expect-error
// @ts-expect-error ???
store.init();
}).catch(() => {});
Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => {

View file

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../tsconfig.vue.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"forceConsistentCasingInFileNames": true,
"jsx": "react",
"jsxFactory": "h"
},
"files": [
"./changes.ts",
"./generate.tsx",
"./preload-locale.ts",
"./preload-theme.ts"
]
}

View file

@ -1,31 +1,9 @@
{
"compilerOptions": {
"target": "es2022",
"module": "Node16",
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": true,
"noEmitOnError": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"incremental": true,
"jsx": "react",
"jsxFactory": "h"
},
"files": [
"./changes.ts",
"./generate.tsx",
"./preload-locale.ts",
"./preload-theme.ts"
"$schema": "https://json.schemastore.org/tsconfig",
"files": [],
// WebStorm only reads one tsconfig per directory, so this tricks it into loading both.
"references": [
{ "path": "./tsconfig.gen.json" },
{ "path": "./tsconfig.vue.json" }
]
}

View file

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../tsconfig.vue.json",
"compilerOptions": {
"jsx": "preserve"
},
"include": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"./changes.ts",
"./generate.tsx",
"./preload-locale.ts",
"./preload-theme.ts"
]
}

View file

@ -16,6 +16,9 @@ export default [
...pluginVue.configs['flat/recommended'],
{
files: ['{src,test,js,@types}/**/*.{ts,vue}'],
ignores: [
'*.*',
],
plugins: { sharkey: { rules: { locale: localeRule } } },
languageOptions: {
globals: {
@ -43,7 +46,7 @@ export default [
parserOptions: {
extraFileExtensions: ['.vue'],
parser: tsParser,
project: ['./tsconfig.json'],
project: ['./tsconfig.vue.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
@ -168,18 +171,77 @@ export default [
files: ['src/**/*.stories.ts'],
rules: {
'no-restricted-globals': 'off',
}
},
},
{
files: [
".storybook/changes.ts",
".storybook/generate.tsx",
".storybook/preload-locale.ts",
".storybook/preload-theme.ts"
],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['.storybook/tsconfig.gen.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['.storybook/**/*.ts', '.storybook/**/*.tsx', '.storybook/**/*.js', '.storybook/**/*.jsx'],
ignores: [
".storybook/changes.ts",
".storybook/generate.tsx",
".storybook/preload-locale.ts",
".storybook/preload-theme.ts"
],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['.storybook/tsconfig.vue.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['test/**/*.ts', 'test/**/*.js'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['test/tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['*.js', '*.ts', 'lib/**/*.ts', 'lib/**/*.js'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.scripts.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'import/no-default-export': 'off',
},
},
{
ignores: [
"**/lib/",
"**/temp/",
"**/built/",
"**/coverage/",
"**/node_modules/",
"**/libopenmpt/",
"**/storybook-static/",
"*.*",
]
'**/lib/',
'**/temp/',
'**/built/',
'**/coverage/',
'**/node_modules/',
'**/libopenmpt/',
'**/storybook-static/',
'vue-shims.d.ts',
'assets/'
],
},
];

View file

@ -8,6 +8,7 @@ import { walk } from '../node_modules/estree-walker/src/index.js';
import type * as estree from 'estree';
import type * as estreeWalker from 'estree-walker';
import type { Plugin } from 'vite';
import type { Identifier } from 'estree';
function isFalsyIdentifier(identifier: estree.Identifier): boolean {
return identifier.name === 'undefined' || identifier.name === 'NaN';
@ -382,7 +383,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
if (childNode.name !== ident) return;
this.replace({
type: 'Identifier',
name: node.declarations[0].id.name,
name: (node.declarations[0].id as Identifier).name,
});
},
});
@ -432,6 +433,7 @@ export function unwindCssModuleClassName(ast: estree.Node): void {
type: 'ArrayExpression',
elements: node.declarations[0].init.arguments[1].elements.slice(0, __cssModulesIndex).concat(node.declarations[0].init.arguments[1].elements.slice(__cssModulesIndex + 1)),
}],
optional: false,
},
}],
kind: 'const',

View file

@ -3,12 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/// <reference lib="esnext" />
import { parse as vueSfcParse } from 'vue/compiler-sfc';
import {
createLogger,
EnvironmentModuleGraph,
type EnvironmentModuleGraph,
type LogErrorOptions,
type LogOptions,
normalizePath,
@ -20,7 +18,7 @@ import { glob } from 'glob';
import JSON5 from 'json5';
import MagicString, { SourceMap } from 'magic-string';
import path from 'node:path'
import { hash, toBase62 } from '../vite.config';
import { hash, toBase62 } from '../vite.config.js';
import { minimatch } from 'minimatch';
import {
type AttributeNode,
@ -63,7 +61,7 @@ interface MarkerRelation {
let logger = {
info: (msg: string, options?: LogOptions) => { },
warn: (msg: string, options?: LogOptions) => { },
error: (msg: string, options?: LogErrorOptions | unknown) => { },
error: (msg: string, options?: LogErrorOptions) => { },
};
let loggerInitialized = false;
@ -470,7 +468,11 @@ export function collectFileMarkers(id: string, code: string): SearchIndexItem[]
return extractUsageInfoFromTemplateAst(descriptor.template?.ast, id);
} catch (error) {
logger.error(`Error analyzing file ${id}:`, error);
logger.error(`Error analyzing file ${id}:`, {
error: error instanceof Error
? error
: new Error(`Unknown error of type ${typeof(error)}`, { cause: error }),
});
}
return [];

View file

@ -11,8 +11,12 @@
"chromatic": "chromatic",
"test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"{src,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
"typecheck-all": "pnpm run --no-bail typecheck:vue && pnpm run --no-bail typecheck:test && pnpm run --no-bail typecheck:scripts",
"typecheck": "pnpm run typecheck:vue && pnpm run typecheck:test && pnpm run typecheck:scripts",
"typecheck:vue": "vue-tsc -p tsconfig.vue.json --noEmit",
"typecheck:test": "vue-tsc -p test/tsconfig.json --noEmit",
"typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit",
"eslint": "eslint --quiet --cache -c eslint.config.js .",
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
@ -58,7 +62,6 @@
"textarea-caret": "3.1.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"typescript": "5.9.2",
"uuid": "13.0.0",
"v-code-diff": "1.13.1",
"vue": "3.5.21",
@ -69,6 +72,7 @@
"cypress": "15.3.0"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "2.1.0",
"@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.3",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
@ -138,6 +142,7 @@
"three": "0.180.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.9.2",
"vite": "7.1.7",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4",

View file

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["esnext", "webworker"],
"lib": ["ES2022", "WebWorker", "Webworker.Iterable"],
"incremental": true
}
}

View file

@ -1,29 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../tsconfig.vue.json",
"compilerOptions": {
"allowJs": true,
"noEmitOnError": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"sourceMap": true,
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true,
"removeComments": false,
"noLib": false,
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"incremental": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
@ -31,13 +11,8 @@
"typeRoots": [
"../node_modules/@types"
],
"lib": [
"esnext",
"dom"
],
"types": ["node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts",
"../src/**/*.vue"

View file

@ -1,62 +1,9 @@
{
"compilerOptions": {
"allowJs": true,
"noEmitOnError": false,
"noImplicitAny": false,
"noImplicitReturns": true,
"noUnusedParameters": false,
"noUnusedLocals": false,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"sourceMap": false,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"removeComments": false,
"noLib": false,
"strict": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@@/*": ["../frontend-shared/*"]
},
"typeRoots": [
"./@types",
"./node_modules/@types",
"./node_modules/@vue-macros",
"./node_modules"
],
"types": [
"vite/client",
"vitest/importMeta",
],
"lib": [
"esnext",
"dom",
"dom.iterable"
],
"jsx": "preserve"
},
"compileOnSave": false,
"include": [
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",
"./test/**/*.ts",
"./test/**/*.vue",
"./@types/**/*.ts"
],
"exclude": [
"node_modules",
".storybook/**/*"
"$schema": "https://json.schemastore.org/tsconfig",
"files": [],
// WebStorm only reads one tsconfig per directory, so this tricks it into loading both.
"references": [
{ "path": "./tsconfig.scripts.json" },
{ "path": "./tsconfig.vue.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../shared/tsconfig.node.json",
"compilerOptions": {
"noImplicitAny": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules"
],
"types": [
"vite/client",
"vitest/importMeta",
]
},
"include": [
"*.js",
"*.ts",
"lib/**/*.ts",
"lib/**/*.js"
],
"exclude": [
"node_modules",
".storybook/**/*",
"vue-shims.d.ts"
]
}

View file

@ -0,0 +1,35 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../shared/tsconfig.web.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@@/*": ["../frontend-shared/*"]
},
"typeRoots": [
"./@types",
"./node_modules/@types",
"./node_modules/@vue-macros",
"./node_modules"
],
"types": [
"vite/client",
"vitest/importMeta"
],
"jsx": "preserve"
},
"include": [
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",
"./test/**/*.ts",
"./test/**/*.vue",
"./@types/**/*.ts",
"./vue-shims.d.ts"
],
"exclude": [
"node_modules",
".storybook/**/*",
"*.*"
]
}

View file

@ -2,20 +2,24 @@ import path from 'path';
import pluginReplace from '@rollup/plugin-replace';
import pluginVue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import type { UserConfig } from 'vite';
import { pluginReplaceIcons } from 'frontend-shared/util/vite.replaceIcons.js';
import locales from '../../locales/index.js';
import { localesVersion } from '../../locales/version.js';
import meta from '../../package.json';
import meta from '../../package.json' with { type: 'json' };
import packageInfo from './package.json' with { type: 'json' };
import tsconfigVue from './tsconfig.vue.json' with { type: 'json' };
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
import pluginJson5 from './vite.json5.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import type { TsconfigRaw } from 'esbuild';
import type { UserConfig } from 'vite';
import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js';
import { pluginReplaceIcons } from './vite.replaceIcons.js';
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue', '.wasm'];
// https://github.com/rollup/plugins/issues/1541#issuecomment-3114729017
const fix = <T>(f: { default: T }): T => f as unknown as T;
/**
*
*/
@ -110,7 +114,7 @@ export function getConfig(): UserConfig {
...pluginReplaceIcons(),
...process.env.NODE_ENV === 'production'
? [
pluginReplace({
fix(pluginReplace)({
preventAssignment: true,
values: {
'isChromatic()': JSON.stringify(false),
@ -143,6 +147,7 @@ export function getConfig(): UserConfig {
},
preprocessorOptions: {
scss: {
// @ts-expect-error This produces an error, but all example code has it
api: 'modern-compiler',
},
},
@ -160,7 +165,13 @@ export function getConfig(): UserConfig {
_DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'),
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
_RUFFLE_VERSION_: JSON.stringify(packageInfo.dependencies['@ruffle-rs/ruffle'])
_RUFFLE_VERSION_: JSON.stringify(packageInfo.dependencies['@ruffle-rs/ruffle']),
},
esbuild: {
// https://github.com/vitejs/vite/discussions/8483#discussioncomment-14485974
// https://esbuild.github.io/api/#tsconfig-raw
tsconfigRaw: tsconfigVue as TsconfigRaw,
},
build: {

View file

@ -1,9 +1,9 @@
// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json
import JSON5 from 'json5';
import { Plugin } from 'rollup';
import type { Plugin } from 'rollup';
import { createFilter, dataToEsm } from '@rollup/pluginutils';
import { RollupJsonOptions } from '@rollup/plugin-json';
import type { RollupJsonOptions } from '@rollup/plugin-json';
// json5 extends SyntaxError with additional fields (without subclassing)
// https://github.com/json5/json5/blob/de344f0619bda1465a6e25c76f1c0c3dda8108d9/lib/parse.js#L1111-L1112