diff --git a/eslint.config.mjs b/eslint.config.mjs index 731dece0..2bf35beb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,12 +8,13 @@ import wordpressPlugin from '@wordpress/eslint-plugin'; import comments from '@eslint-community/eslint-plugin-eslint-comments/configs'; import jestPlugin from 'eslint-plugin-jest'; import globals from 'globals'; +import tseslint from 'typescript-eslint'; const TEST_FILES = [ - '**/__tests__/**/*.js', - '**/test/*.js', - '**/?(*.)test.js', - 'tests/js/**/*.js', + '**/__tests__/**/*.{js,ts,tsx}', + '**/test/*.{js,ts,tsx}', + '**/?(*.)test.{js,ts,tsx}', + 'tests/js/**/*.{js,ts,tsx}', ]; export default [ @@ -28,6 +29,30 @@ export default [ ...wordpressPlugin.configs[ 'recommended-with-formatting' ], + // `recommended-with-formatting` (unlike `recommended`) doesn't register a + // TypeScript config, so `.ts`/`.tsx` would be unmatched and skipped. Add the + // TypeScript parser + the unused-vars handoff, mirroring the TS block in + // @wordpress/eslint-plugin's `recommended` preset. + { + files: [ '**/*.ts', '**/*.tsx' ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + 'no-duplicate-imports': 'off', + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true }, + ], + }, + }, + // import plugin is already registered by the WordPress config above; // add the remaining rules from plugin:import/recommended without re-registering. { @@ -65,7 +90,12 @@ export default [ { settings: { - 'import/resolver': { node: true }, + 'import/resolver': { + typescript: { + extensions: [ '.js', '.jsx', '.ts', '.tsx' ], + }, + node: true, + }, }, }, ]; diff --git a/package-lock.json b/package-lock.json index 23e480be..9e537a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,10 @@ "devDependencies": { "@babel/core": "7.29.7", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2", - "@types/jest": "^29.5.14", - "@types/node": "^22.10.0", + "@types/jest": "29.5.14", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "@types/wordpress__block-editor": "15.0.6", - "@types/wordpress__i18n": "3.11.0", - "@typescript-eslint/eslint-plugin": "^8.20.0", - "@typescript-eslint/parser": "^8.20.0", "@wordpress/babel-preset-default": "8.47.0", "@wordpress/browserslist-config": "6.47.0", "@wordpress/env": "11.7.0", @@ -42,7 +40,7 @@ "npm-run-all": "4.1.5", "rtlcss": "4.3.0", "svgo": "4.0.1", - "typescript": "^5.7.0", + "typescript": "6.0.3", "webpack": "5.107.2", "webpack-remove-empty-scripts": "1.1.1" } @@ -8638,12 +8636,6 @@ "@types/pg": "*" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true - }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -8659,9 +8651,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", - "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -8796,25 +8788,6 @@ "react-autosize-textarea": "^7.1.0" } }, - "node_modules/@types/wordpress__block-editor/node_modules/@types/react": { - "version": "18.3.30", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", - "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/wordpress__block-editor/node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, "node_modules/@types/wordpress__block-editor/node_modules/@wordpress/base-styles": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-6.20.0.tgz", @@ -9020,17 +8993,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@types/wordpress__i18n": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@types/wordpress__i18n/-/wordpress__i18n-3.11.0.tgz", - "integrity": "sha512-l4OXE1LQH7IBqRXY//NGBCsM0jPv8sdUEubPaAzPxKUxYGe+I2UTREjwB0ejuh2+J0p90EhW3mX5FYM8a8lf2w==", - "deprecated": "This is a stub types definition. wordpress__i18n provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "@wordpress/i18n": "*" - } - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -32991,9 +32953,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 87d34c23..738863b0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "devDependencies": { "@babel/core": "7.29.7", "@eslint-community/eslint-plugin-eslint-comments": "4.7.2", + "@types/jest": "29.5.14", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@types/wordpress__block-editor": "15.0.6", "@wordpress/babel-preset-default": "8.47.0", "@wordpress/browserslist-config": "6.47.0", "@wordpress/env": "11.7.0", @@ -34,6 +38,7 @@ "npm-run-all": "4.1.5", "rtlcss": "4.3.0", "svgo": "4.0.1", + "typescript": "6.0.3", "webpack": "5.107.2", "webpack-remove-empty-scripts": "1.1.1" }, @@ -47,7 +52,9 @@ "overrides": { "webpack-dev-server": "5.2.1", "serialize-javascript": "7.0.3", - "minimatch": "3.1.3" + "minimatch": "3.1.3", + "@types/react": "$@types/react", + "@types/react-dom": "$@types/react-dom" }, "private": true, "repository": { @@ -64,11 +71,12 @@ "init": "./bin/init.js && npm run sync-ai", "sync-ai": "node bin/sync-ai.js", "lint:all": "npm-run-all --parallel lint:*", - "lint:css": "wp-scripts lint-style ./src", + "lint:css": "wp-scripts lint-style './src/**/*.{css,scss}'", "lint:css:fix": "npm run lint:css -- --fix", "lint:js": "wp-scripts lint-js ./src", "lint:js:fix": "npm run lint:js -- --fix", "lint:js:report": "npm run lint:js -- --output-file lint-js-report.json --format json .", + "lint:js:types": "tsc --noEmit", "lint:package-json": "wp-scripts lint-pkg-json --ignorePath .gitignore", "lint:php": "php vendor/bin/phpcs", "lint:php:fix": "node ./bin/phpcbf.js", diff --git a/src/components/button/button.js b/src/components/button/button.ts similarity index 100% rename from src/components/button/button.js rename to src/components/button/button.ts diff --git a/src/components/card/card.js b/src/components/card/card.ts similarity index 100% rename from src/components/card/card.js rename to src/components/card/card.ts diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000..34284238 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,2 @@ +declare module '*.scss'; +declare module '*.css'; diff --git a/src/js/frontend/core-navigation.js b/src/js/frontend/core-navigation.ts similarity index 100% rename from src/js/frontend/core-navigation.js rename to src/js/frontend/core-navigation.ts diff --git a/src/js/frontend/modules/media-text.js b/src/js/frontend/modules/media-text.ts similarity index 59% rename from src/js/frontend/modules/media-text.js rename to src/js/frontend/modules/media-text.ts index 4e8d56ca..ac8a5769 100644 --- a/src/js/frontend/modules/media-text.js +++ b/src/js/frontend/modules/media-text.ts @@ -7,29 +7,32 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; +/** + * Shape of the per-element context (set via `data-wp-context` in the markup). + */ +interface MediaTextContext { + isPlaying: boolean; +} + store( 'elementary/media-text', { actions: { /** * Update the video play state. - * - * @return {void} */ - play() { - const context = getContext(); + play(): void { + const context = getContext< MediaTextContext >(); context.isPlaying = true; }, }, callbacks: { /** * Play the video. - * - * @return {void} */ - playVideo() { - const context = getContext(); + playVideo(): void { + const context = getContext< MediaTextContext >(); const { ref } = getElement(); if ( context.isPlaying ) { - ref.querySelector( 'video' )?.play(); + ref?.querySelector( 'video' )?.play(); context.isPlaying = false; } }, diff --git a/tests/js/webpack-config.test.js b/tests/js/webpack-config.test.js index a5b458ca..d763e05c 100644 --- a/tests/js/webpack-config.test.js +++ b/tests/js/webpack-config.test.js @@ -45,4 +45,33 @@ describe( 'webpack component entries', () => { 'components/button': path.join( buttonDir, 'button.js' ), } ); } ); + + it( 'picks up .ts and .tsx component entries with the build pattern', () => { + tmpDir = fs.mkdtempSync( + path.join( os.tmpdir(), 'elementary-webpack-components-ts-' ) + ); + + const cardDir = path.join( tmpDir, 'card' ); + const modalDir = path.join( tmpDir, 'modal' ); + const legacyDir = path.join( tmpDir, 'legacy' ); + + [ cardDir, modalDir, legacyDir ].forEach( ( dir ) => fs.mkdirSync( dir ) ); + + // One entry per supported extension. + fs.writeFileSync( path.join( cardDir, 'card.ts' ), '' ); + fs.writeFileSync( path.join( modalDir, 'modal.tsx' ), '' ); + fs.writeFileSync( path.join( legacyDir, 'legacy.js' ), '' ); + + // Same-folder files that must NOT become entries (basename mismatch), + // even though their extensions match the widened pattern. + fs.writeFileSync( path.join( cardDir, 'card.types.ts' ), '' ); + fs.writeFileSync( path.join( cardDir, 'card.test.tsx' ), '' ); + + // Mirrors the pattern used by componentScripts in webpack.config.js. + expect( getComponentEntries( tmpDir, /\.(jsx?|tsx?)$/ ) ).toEqual( { + 'components/card': path.join( cardDir, 'card.ts' ), + 'components/modal': path.join( modalDir, 'modal.tsx' ), + 'components/legacy': path.join( legacyDir, 'legacy.js' ), + } ); + } ); } ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f49418f8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + + "compilerOptions": { + "allowJs": true, + // Migration override — flip to true once src/ is fully .ts/.tsx. + "checkJs": false, + + // Type checking — full strict. + "strict": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noErrorTruncation": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + + // Modules + "module": "preserve", + "moduleResolution": "bundler", + "resolveJsonModule": true, + + // Interop Constraints + "esModuleInterop": true, + "isolatedModules": true, + // @todo flip to true once package.json declares `"type": "module"`. + "verbatimModuleSyntax": false, + + // Language and Environment + "jsx": "react-jsx", + "lib": [ "ES2022", "DOM.Iterable", "DOM" ], + "moduleDetection": "force", + "target": "ES2022", + + // Completeness + "skipLibCheck": true, + + // Project-specific + "noEmit": true, + "paths": { + "@/*": [ "./src/*" ] + }, + // Jest globals stay out of src/; tests get their own tsconfig once they migrate. + "types": [ "node" ] + }, + "include": [ + "src/**/*", + "tests/**/*", + "webpack.config.js", + "webpack.blocks.config.js", + "babel.config.js" + ], + "exclude": [ + "node_modules", + "vendor", + "assets/build", + "build", + "bin" + ] +} diff --git a/webpack.config.js b/webpack.config.js index 39bcd742..1f9ae6be 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -530,10 +530,10 @@ const scripts = { ], }; -// Component JS entry points from src/components/{component}/{component}.js. +// Component script entry points from src/components/{component}/{component}.{js,jsx,ts,tsx}. const componentScripts = { ...sharedNonHotConfig, - entry: () => getComponentEntries( COMPONENTS_DIR, /\.js$/ ), + entry: () => getComponentEntries( COMPONENTS_DIR, /\.(jsx?|tsx?)$/ ), plugins: [ ...sharedNonHotConfig.plugins.filter( isNotPlugin( 'RtlCssPlugin' ) ), ],