Migrate tools and configs to typescript, require node.js >= 22.18.0 (#35421)

Migrate all JS config and tools to TS and fix a number of type issues.
This required Node.js 22.18.0 or greater where [type-stripping was
enabled](https://nodejs.org/en/blog/release/v22.18.0) by default.

Given that Node 22 is the current LTS, I think it's ok to assume that
the user has a recent version of it.

Webpack currently requires the `--disable-interpret` flag to work,
should be fixed eventually with
https://github.com/webpack/webpack-cli/issues/4525.
`fast-glob` is replaced by `fs.globSync`, available in Node 22.0.0 or
greater.
This commit is contained in:
silverwind
2025-09-06 14:58:25 +02:00
committed by GitHub
parent 1640e9a490
commit b8f1c9f048
14 changed files with 89 additions and 99 deletions

View File

@@ -18,6 +18,9 @@ RUN apk --no-cache add \
&& npm install -g pnpm@10 \
&& rm -rf /var/cache/apk/*
# workaround for node >= 22.18.0 on alpine 3.22. Remove when upgrading to alpine 3.23
COPY --from=docker.io/node:22-alpine3.22 /usr/local/bin/node /usr/local/bin/node
# Setup repo
COPY . ${GOPATH}/src/code.gitea.io/gitea
WORKDIR ${GOPATH}/src/code.gitea.io/gitea

View File

@@ -18,6 +18,9 @@ RUN apk --no-cache add \
&& npm install -g pnpm@10 \
&& rm -rf /var/cache/apk/*
# workaround for node >= 22.18.0 on alpine 3.22. Remove when upgrading to alpine 3.23
COPY --from=docker.io/node:22-alpine3.22 /usr/local/bin/node /usr/local/bin/node
# Setup repo
COPY . ${GOPATH}/src/code.gitea.io/gitea
WORKDIR ${GOPATH}/src/code.gitea.io/gitea

View File

@@ -127,7 +127,7 @@ GO_TEST_PACKAGES ?= $(filter-out $(shell $(GO) list code.gitea.io/gitea/models/m
MIGRATE_TEST_PACKAGES ?= $(shell $(GO) list code.gitea.io/gitea/models/migrations/...)
WEBPACK_SOURCES := $(shell find web_src/js web_src/css -type f)
WEBPACK_CONFIGS := webpack.config.js tailwind.config.js
WEBPACK_CONFIGS := webpack.config.ts tailwind.config.ts
WEBPACK_DEST := public/assets/js/index.js public/assets/css/index.css
WEBPACK_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts
@@ -153,9 +153,9 @@ TAR_EXCLUDES := .git data indexers queues log node_modules $(EXECUTABLE) $(DIST)
GO_DIRS := build cmd models modules routers services tests
WEB_DIRS := web_src/js web_src/css
ESLINT_FILES := web_src/js tools *.js *.ts *.cjs tests/e2e
ESLINT_FILES := web_src/js tools *.ts *.cjs tests/e2e
STYLELINT_FILES := web_src/css web_src/js/components/*.vue
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.ini .github $(filter-out CHANGELOG.md, $(wildcard *.go *.js *.md *.yml *.yaml *.toml)) $(filter-out tools/misspellings.csv, $(wildcard tools/*))
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.ini .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml)) $(filter-out tools/misspellings.csv, $(wildcard tools/*))
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.ini
GO_SOURCES := $(wildcard *.go)
@@ -407,7 +407,7 @@ lint-actions: ## lint action workflow files
.PHONY: lint-templates
lint-templates: .venv node_modules ## lint template files
@node tools/lint-templates-svg.js
@node tools/lint-templates-svg.ts
@uv run --frozen djlint $(shell find templates -type f -iname '*.tmpl')
.PHONY: lint-yaml
@@ -421,7 +421,7 @@ watch: ## watch everything and continuously rebuild
.PHONY: watch-frontend
watch-frontend: node-check node_modules ## watch frontend files and continuously rebuild
@rm -rf $(WEBPACK_DEST_ENTRIES)
NODE_ENV=development pnpm exec webpack --watch --progress
NODE_ENV=development pnpm exec webpack --watch --progress --disable-interpret
.PHONY: watch-backend
watch-backend: go-check ## watch backend files and continuously rebuild
@@ -877,13 +877,13 @@ $(WEBPACK_DEST): $(WEBPACK_SOURCES) $(WEBPACK_CONFIGS) pnpm-lock.yaml
@$(MAKE) -s node-check node_modules
@rm -rf $(WEBPACK_DEST_ENTRIES)
@echo "Running webpack..."
@BROWSERSLIST_IGNORE_OLD_DATA=true pnpm exec webpack
@BROWSERSLIST_IGNORE_OLD_DATA=true pnpm exec webpack --disable-interpret
@touch $(WEBPACK_DEST)
.PHONY: svg
svg: node-check | node_modules ## build svg files
rm -rf $(SVG_DEST_DIR)
node tools/generate-svg.js
node tools/generate-svg.ts
.PHONY: svg-check
svg-check: svg
@@ -922,7 +922,7 @@ generate-gitignore: ## update gitignore files
.PHONY: generate-images
generate-images: | node_modules ## generate images
cd tools && node generate-images.js $(TAGS)
cd tools && node generate-images.ts $(TAGS)
.PHONY: generate-manpage
generate-manpage: ## generate manpage

View File

@@ -2,8 +2,8 @@
"type": "module",
"packageManager": "pnpm@10.0.0",
"engines": {
"node": ">= 20.0.0",
"pnpm": ">=10.0.0"
"node": ">= 22.18.0",
"pnpm": ">= 10.0.0"
},
"dependencies": {
"@citation-js/core": "0.7.18",
@@ -31,7 +31,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild-loader": "4.3.0",
"fast-glob": "3.3.3",
"htmx.org": "2.0.6",
"idiomorph": "0.7.3",
"jquery": "3.7.1",
@@ -110,7 +109,6 @@
"stylelint-config-recommended": "17.0.0",
"stylelint-declaration-block-no-ignored-properties": "2.8.0",
"stylelint-declaration-strict-value": "1.10.11",
"stylelint-define-config": "16.22.0",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "4.0.0",
"type-fest": "4.41.0",

17
pnpm-lock.yaml generated
View File

@@ -83,9 +83,6 @@ importers:
esbuild-loader:
specifier: 4.3.0
version: 4.3.0(webpack@5.101.0)
fast-glob:
specifier: 3.3.3
version: 3.3.3
htmx.org:
specifier: 2.0.6
version: 2.0.6
@@ -315,9 +312,6 @@ importers:
stylelint-declaration-strict-value:
specifier: 1.10.11
version: 1.10.11(stylelint@16.23.1(typescript@5.8.3))
stylelint-define-config:
specifier: 16.22.0
version: 16.22.0(stylelint@16.23.1(typescript@5.8.3))
stylelint-value-no-unknown-custom-properties:
specifier: 6.0.1
version: 6.0.1(stylelint@16.23.1(typescript@5.8.3))
@@ -4518,12 +4512,6 @@ packages:
peerDependencies:
stylelint: '>=7 <=16'
stylelint-define-config@16.22.0:
resolution: {integrity: sha512-EEgHRugsryKo7LpenYyd4yLoZon3lHvRAi7WsMaZoRX9GPOkeDXrMga+N4VA4nK4Zus02EQwyYkndNQ64jaB2A==}
engines: {node: '>=18.0.0', npm: '>=9.0.0', pnpm: '>=8.6.0'}
peerDependencies:
stylelint: '>=16.0.0'
stylelint-value-no-unknown-custom-properties@6.0.1:
resolution: {integrity: sha512-N60PTdaTknB35j6D4FhW0GL2LlBRV++bRpXMMldWMQZ240yFQaoltzlLY4lXXs7Z0J5mNUYZQ/gjyVtU2DhCMA==}
engines: {node: '>=18.12.0'}
@@ -9732,11 +9720,6 @@ snapshots:
dependencies:
stylelint: 16.23.1(typescript@5.8.3)
stylelint-define-config@16.22.0(stylelint@16.23.1(typescript@5.8.3)):
dependencies:
csstype: 3.1.3
stylelint: 16.23.1(typescript@5.8.3)
stylelint-value-no-unknown-custom-properties@6.0.1(stylelint@16.23.1(typescript@5.8.3)):
dependencies:
postcss-value-parser: 4.2.0

View File

@@ -1,6 +1,5 @@
// @ts-check
import {defineConfig} from 'stylelint-define-config';
import {fileURLToPath} from 'node:url';
import type {Config} from 'stylelint';
const cssVarFiles = [
fileURLToPath(new URL('web_src/css/base.css', import.meta.url)),
@@ -8,7 +7,7 @@ const cssVarFiles = [
fileURLToPath(new URL('web_src/css/themes/theme-gitea-dark.css', import.meta.url)),
];
export default defineConfig({
export default {
extends: 'stylelint-config-recommended',
reportUnscopedDisables: true,
reportNeedlessDisables: true,
@@ -124,7 +123,6 @@ export default defineConfig({
'csstools/value-no-unknown-custom-properties': [true, {importFrom: cssVarFiles}],
'declaration-block-no-duplicate-properties': [true, {ignore: ['consecutive-duplicates-with-different-values']}],
'declaration-block-no-redundant-longhand-properties': [true, {ignoreShorthands: ['flex-flow', 'overflow', 'grid-template']}],
// @ts-expect-error - https://github.com/stylelint-types/stylelint-define-config/issues/1
'declaration-property-unit-disallowed-list': {'line-height': ['em']},
'declaration-property-value-disallowed-list': {'word-break': ['break-word']},
'font-family-name-quotes': 'always-where-recommended',
@@ -148,4 +146,4 @@ export default defineConfig({
'shorthand-property-no-redundant-values': true,
'value-no-vendor-prefix': [true, {ignoreValues: ['box', 'inline-box']}],
},
});
} satisfies Config;

View File

@@ -2,17 +2,18 @@ import {readFileSync} from 'node:fs';
import {env} from 'node:process';
import {parse} from 'postcss';
import plugin from 'tailwindcss/plugin.js';
import type {Config} from 'tailwindcss';
const isProduction = env.NODE_ENV !== 'development';
function extractRootVars(css) {
function extractRootVars(css: string) {
const root = parse(css);
const vars = new Set();
const vars = new Set<string>();
root.walkRules((rule) => {
if (rule.selector !== ':root') return;
rule.each((decl) => {
if (decl.value && decl.prop.startsWith('--')) {
vars.add(decl.prop.substring(2));
rule.each((node) => {
if (node.type === 'decl' && node.value && node.prop.startsWith('--')) {
vars.add(node.prop.substring(2));
}
});
});
@@ -120,4 +121,4 @@ export default {
});
}),
],
};
} satisfies Config;

View File

@@ -4,12 +4,7 @@ import {optimize} from 'svgo';
import {readFile, writeFile} from 'node:fs/promises';
import {argv, exit} from 'node:process';
function doExit(err) {
if (err) console.error(err);
exit(err ? 1 : 0);
}
async function generate(svg, path, {size, bg}) {
async function generate(svg: string, path: string, {size, bg}: {size: number, bg?: boolean}) {
const outputFile = new URL(path, import.meta.url);
if (String(outputFile).endsWith('.svg')) {
@@ -19,7 +14,9 @@ async function generate(svg, path, {size, bg}) {
'removeDimensions',
{
name: 'addAttributesToSVGElement',
params: {attributes: [{width: size}, {height: size}]},
params: {
attributes: [{width: String(size)}, {height: String(size)}],
},
},
],
});
@@ -57,7 +54,8 @@ async function main() {
}
try {
doExit(await main());
await main();
} catch (err) {
doExit(err);
console.error(err);
exit(1);
}

View File

@@ -1,27 +1,29 @@
#!/usr/bin/env node
import fastGlob from 'fast-glob';
import {optimize} from 'svgo';
import {parse} from 'node:path';
import {dirname, parse} from 'node:path';
import {globSync, writeFileSync} from 'node:fs';
import {readFile, writeFile, mkdir} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {exit} from 'node:process';
import * as fs from 'node:fs';
import type {Manifest} from 'material-icon-theme';
const glob = (pattern) => fastGlob.sync(pattern, {
cwd: fileURLToPath(new URL('..', import.meta.url)),
absolute: true,
});
const glob = (pattern: string) => globSync(pattern, {cwd: dirname(import.meta.dirname)});
async function processAssetsSvgFile(file, {prefix, fullName} = {}) {
type Opts = {
prefix?: string,
fullName?: string,
};
async function processAssetsSvgFile(path: string, {prefix, fullName}: Opts = {}) {
let name = fullName;
if (!name) {
name = parse(file).name;
name = parse(path).name;
if (prefix) name = `${prefix}-${name}`;
if (prefix === 'octicon') name = name.replace(/-[0-9]+$/, ''); // chop of '-16' on octicons
}
// Set the `xmlns` attribute so that the files are displayable in standalone documents
// The svg backend module will strip the attribute during startup for inline display
const {data} = optimize(await readFile(file, 'utf8'), {
const {data} = optimize(await readFile(path, 'utf8'), {
plugins: [
{name: 'preset-default'},
{name: 'removeDimensions'},
@@ -41,16 +43,16 @@ async function processAssetsSvgFile(file, {prefix, fullName} = {}) {
await writeFile(fileURLToPath(new URL(`../public/assets/img/svg/${name}.svg`, import.meta.url)), data);
}
function processAssetsSvgFiles(pattern, opts) {
return glob(pattern).map((file) => processAssetsSvgFile(file, opts));
function processAssetsSvgFiles(pattern: string, opts: Opts = {}) {
return glob(pattern).map((path) => processAssetsSvgFile(path, opts));
}
async function processMaterialFileIcons() {
const files = glob('node_modules/material-icon-theme/icons/*.svg');
const svgSymbols = {};
for (const file of files) {
const paths = glob('node_modules/material-icon-theme/icons/*.svg');
const svgSymbols: Record<string, string> = {};
for (const path of paths) {
// remove all unnecessary attributes, only keep "viewBox"
const {data} = optimize(await readFile(file, 'utf8'), {
const {data} = optimize(await readFile(path, 'utf8'), {
plugins: [
{name: 'preset-default'},
{name: 'removeDimensions'},
@@ -58,16 +60,16 @@ async function processMaterialFileIcons() {
{name: 'removeAttrs', params: {attrs: 'xml:space', elemSeparator: ','}},
],
});
const svgName = parse(file).name;
const svgName = parse(path).name;
// intentionally use single quote here to avoid escaping
svgSymbols[svgName] = data.replace(/"/g, `'`);
}
fs.writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-svgs.json`, import.meta.url)), JSON.stringify(svgSymbols, null, 2));
writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-svgs.json`, import.meta.url)), JSON.stringify(svgSymbols, null, 2));
const vscodeExtensionsJson = await readFile(fileURLToPath(new URL(`generate-svg-vscode-extensions.json`, import.meta.url)));
const vscodeExtensions = JSON.parse(vscodeExtensionsJson);
const iconRulesJson = await readFile(fileURLToPath(new URL(`../node_modules/material-icon-theme/dist/material-icons.json`, import.meta.url)));
const iconRules = JSON.parse(iconRulesJson);
const vscodeExtensionsJson = await readFile(fileURLToPath(new URL(`generate-svg-vscode-extensions.json`, import.meta.url)), 'utf8');
const vscodeExtensions = JSON.parse(vscodeExtensionsJson) as Record<string, string>;
const iconRulesJson = await readFile(fileURLToPath(new URL(`../node_modules/material-icon-theme/dist/material-icons.json`, import.meta.url)), 'utf8');
const iconRules = JSON.parse(iconRulesJson) as Manifest;
// The rules are from VSCode material-icon-theme, we need to adjust them to our needs
// 1. We only use lowercase filenames to match (it should be good enough for most cases and more efficient)
// 2. We do not have a "Language ID" system:
@@ -91,7 +93,7 @@ async function processMaterialFileIcons() {
}
}
const iconRulesPretty = JSON.stringify(iconRules, null, 2);
fs.writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-rules.json`, import.meta.url)), iconRulesPretty);
writeFileSync(fileURLToPath(new URL(`../options/fileicon/material-icon-rules.json`, import.meta.url)), iconRulesPretty);
}
async function main() {

View File

@@ -1,11 +1,10 @@
#!/usr/bin/env node
import {readdirSync, readFileSync} from 'node:fs';
import {readdirSync, readFileSync, globSync} from 'node:fs';
import {parse, relative} from 'node:path';
import {fileURLToPath} from 'node:url';
import {exit} from 'node:process';
import fastGlob from 'fast-glob';
const knownSvgs = new Set();
const knownSvgs = new Set<string>();
for (const file of readdirSync(new URL('../public/assets/img/svg', import.meta.url))) {
knownSvgs.add(parse(file).name);
}
@@ -13,7 +12,7 @@ for (const file of readdirSync(new URL('../public/assets/img/svg', import.meta.u
const rootPath = fileURLToPath(new URL('..', import.meta.url));
let hadErrors = false;
for (const file of fastGlob.sync(fileURLToPath(new URL('../templates/**/*.tmpl', import.meta.url)))) {
for (const file of globSync(fileURLToPath(new URL('../templates/**/*.tmpl', import.meta.url)))) {
const content = readFileSync(file, 'utf8');
for (const [_, name] of content.matchAll(/svg ["'`]([^"'`]+)["'`]/g)) {
if (!knownSvgs.has(name)) {

View File

@@ -41,6 +41,7 @@
"types": [
"vitest/globals",
"./web_src/js/globals.d.ts",
"./types.d.ts",
],
}
}

9
types.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module 'add-asset-webpack-plugin' {
const plugin: any;
export = plugin
}
declare module '@techknowlogick/license-checker-webpack-plugin' {
const plugin: any;
export = plugin
}

View File

@@ -1,3 +1,5 @@
import type {Config} from 'updates';
export default {
exclude: [
'@mcaptcha/vanilla-glue', // breaking changes in rc versions need to be handled
@@ -11,4 +13,4 @@ export default {
'eslint-plugin-vitest', // need to migrate to eslint flat config first
'tailwindcss', // need to migrate
],
};
} satisfies Config;

View File

@@ -1,4 +1,3 @@
import fastGlob from 'fast-glob';
import wrapAnsi from 'wrap-ansi';
import AddAssetPlugin from 'add-asset-webpack-plugin';
import LicenseCheckerWebpackPlugin from '@techknowlogick/license-checker-webpack-plugin';
@@ -6,28 +5,23 @@ import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
import {VueLoaderPlugin} from 'vue-loader';
import EsBuildLoader from 'esbuild-loader';
import {parse, dirname} from 'node:path';
import webpack from 'webpack';
import {parse} from 'node:path';
import webpack, {type Configuration, type EntryObject} from 'webpack';
import {fileURLToPath} from 'node:url';
import {readFileSync} from 'node:fs';
import {readFileSync, globSync} from 'node:fs';
import {env} from 'node:process';
import tailwindcss from 'tailwindcss';
import tailwindConfig from './tailwind.config.js';
import tailwindConfig from './tailwind.config.ts';
import tailwindcssNesting from 'tailwindcss/nesting/index.js';
import postcssNesting from 'postcss-nesting';
const {EsbuildPlugin} = EsBuildLoader;
const {SourceMapDevToolPlugin, DefinePlugin, EnvironmentPlugin} = webpack;
const formatLicenseText = (licenseText) => wrapAnsi(licenseText || '', 80).trim();
const formatLicenseText = (licenseText: string) => wrapAnsi(licenseText || '', 80).trim();
const glob = (pattern) => fastGlob.sync(pattern, {
cwd: dirname(fileURLToPath(new URL(import.meta.url))),
absolute: true,
});
const themes = {};
for (const path of glob('web_src/css/themes/*.css')) {
themes[parse(path).name] = [path];
const themes: EntryObject = {};
for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirname})) {
themes[parse(path).name] = [`./${path}`];
}
const isProduction = env.NODE_ENV !== 'development';
@@ -55,12 +49,12 @@ const webComponents = new Set([
'text-expander',
]);
const filterCssImport = (url, ...args) => {
const filterCssImport = (url: string, ...args: Array<any>) => {
const cssFile = args[1] || args[0]; // resourcePath is 2nd argument for url and 3rd for import
const importedFile = url.replace(/[?#].+/, '').toLowerCase();
if (cssFile.includes('fomantic')) {
if (/brand-icons/.test(importedFile)) return false;
if (importedFile.includes('brand-icons')) return false;
if (/(eot|ttf|otf|woff|svg)$/i.test(importedFile)) return false;
}
@@ -71,7 +65,6 @@ const filterCssImport = (url, ...args) => {
return true;
};
/** @type {import("webpack").Configuration} */
export default {
mode: isProduction ? 'production' : 'development',
entry: {
@@ -100,7 +93,7 @@ export default {
path: fileURLToPath(new URL('public/assets', import.meta.url)),
filename: () => 'js/[name].js',
chunkFilename: ({chunk}) => {
const language = (/monaco.*languages?_.+?_(.+?)_/.exec(chunk.id) || [])[1];
const language = (/monaco.*languages?_.+?_(.+?)_/.exec(String(chunk.id)) || [])[1];
return `js/${language ? `monaco-language-${language.toLowerCase()}` : `[name]`}.[contenthash:8].js`;
},
},
@@ -129,7 +122,7 @@ export default {
loader: 'vue-loader',
options: {
compilerOptions: {
isCustomElement: (tag) => webComponents.has(tag),
isCustomElement: (tag: string) => webComponents.has(tag),
},
},
},
@@ -225,10 +218,10 @@ export default {
}),
isProduction ? new LicenseCheckerWebpackPlugin({
outputFilename: 'licenses.txt',
outputWriter: ({dependencies}) => {
outputWriter: ({dependencies}: {dependencies: Array<Record<string, string>>}) => {
const line = '-'.repeat(80);
const goJson = readFileSync('assets/go-licenses.json', 'utf8');
const goModules = JSON.parse(goJson).map(({name, licenseText}) => {
const goModules = JSON.parse(goJson).map(({name, licenseText}: Record<string, string>) => {
return {name, body: formatLicenseText(licenseText)};
});
const jsModules = dependencies.map(({name, version, licenseName, licenseText}) => {
@@ -285,4 +278,4 @@ export default {
reasons: false,
runtimeModules: false,
},
};
} satisfies Configuration;