├── .npmrc ├── examples ├── wp-bundler-theme │ ├── index.php │ ├── src │ │ ├── admin.ts │ │ ├── variables.css │ │ ├── main.css │ │ ├── declarations.d.ts │ │ └── main.ts │ ├── languages │ │ ├── sv_SE.mo │ │ ├── sv_SE.po │ │ └── theme.pot │ ├── style.css │ ├── functions.php │ ├── package.json │ └── package-lock.json ├── .wp-env.json ├── wp-bundler-plugin │ ├── src │ │ ├── log.ts │ │ ├── admin.ts │ │ ├── declarations.d.ts │ │ ├── main.tsx │ │ └── main.module.css │ ├── wp-bundler-plugin.php │ ├── package.json │ └── package-lock.json └── README.md ├── .gitignore ├── assets ├── wp-element │ ├── package.json │ └── wp-element.ts └── AssetLoader.php ├── cli.js ├── src ├── utils │ ├── figures.ts │ ├── read-json.ts │ ├── extract-translations │ │ ├── index.ts │ │ ├── theme.ts │ │ ├── utils.ts │ │ ├── types.ts │ │ ├── twig.ts │ │ ├── php.test.ts │ │ ├── javascript.test.ts │ │ ├── php.ts │ │ ├── twig.test.ts │ │ └── javascript.ts │ ├── exists.ts │ ├── rimraf.ts │ ├── dirname.ts │ ├── assert.ts │ ├── assert.test.ts │ ├── asset-loader.test.ts │ ├── externals.ts │ ├── handle-bundled-file.ts │ ├── resolve-config.ts │ ├── bundle-output.ts │ ├── read-pkg.ts │ ├── resolve-config.test.ts │ ├── asset-loader.ts │ ├── po.test.ts │ └── po.ts ├── index.ts ├── test-utils │ ├── utils.ts │ └── extensions.ts ├── plugins │ ├── index.ts │ ├── react-factory.ts │ ├── watch.ts │ ├── asset-loader.ts │ ├── define.ts │ ├── log.ts │ ├── postcss.ts │ ├── externals.ts │ ├── nomodule.ts │ ├── define.test.ts │ └── translations.ts ├── types.ts ├── schema.ts ├── declarations.d.ts ├── context.ts ├── dev-client.ts ├── cli.ts ├── logger.test.ts ├── logger.ts └── context.test.ts ├── tsconfig.build.json ├── .changeset └── config.json ├── vitest.config.ts ├── tsconfig.json ├── .github └── workflows │ ├── pr.yml │ └── release.yml ├── eslint.config.js ├── LICENSE ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md └── CHANGELOG.md /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/index.php: -------------------------------------------------------------------------------- 1 | { 2 | console.log(...messages); 3 | }; 4 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/src/main.css: -------------------------------------------------------------------------------- 1 | @import './variables.css'; 2 | 3 | ::placeholder { 4 | color: var(--color-brand); 5 | } 6 | -------------------------------------------------------------------------------- /assets/wp-element/wp-element.ts: -------------------------------------------------------------------------------- 1 | export { Fragment as __Fragment__, createElement as __createElement__ } from '@wordpress/element'; 2 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | 4 | import { cli } from './dist/index.js'; 5 | 6 | cli(); 7 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/src/admin.ts: -------------------------------------------------------------------------------- 1 | import { log } from './log.js'; 2 | 3 | let messages = ['Hello', 'World']; 4 | log(...messages); 5 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/languages/sv_SE.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adambrgmn/wp-bundler/HEAD/examples/wp-bundler-theme/languages/sv_SE.mo -------------------------------------------------------------------------------- /examples/wp-bundler-theme/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@wordpress/i18n' { 2 | export function __(text: string, domain?: string): string; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/figures.ts: -------------------------------------------------------------------------------- 1 | export const figures = { 2 | tick: '✔', 3 | circle: '◯', 4 | cross: '✖', 5 | lineVertical: '│', 6 | triangleUp: '▲', 7 | triangleRight: '▶', 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli.js'; 2 | export { createContext } from './context.js'; 3 | export { Logger } from './logger.js'; 4 | export type { BundlerOptions, Mode, ProjectPaths, ProjectInfo } from './types.js'; 5 | -------------------------------------------------------------------------------- /src/utils/read-json.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | 3 | export function readJson(path: string) { 4 | let raw = fs.readFileSync(path, 'utf-8'); 5 | let json = JSON.parse(raw) as unknown; 6 | return json; 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/extract-translations/index.ts: -------------------------------------------------------------------------------- 1 | export * as js from './javascript.js'; 2 | export * as php from './php.js'; 3 | export * as theme from './theme.js'; 4 | export * as twig from './twig.js'; 5 | export * from './types.js'; 6 | -------------------------------------------------------------------------------- /src/utils/exists.ts: -------------------------------------------------------------------------------- 1 | import { accessSync } from 'node:fs'; 2 | 3 | export function existsSync(p: string): boolean { 4 | try { 5 | accessSync(p); 6 | return true; 7 | } catch { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/src/main.ts: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | import { bug } from '@wordpress/icons'; 3 | 4 | import './main.css'; 5 | 6 | console.log(__('Hello world! (theme)', 'wp-bundler-theme')); 7 | console.log(bug); 8 | -------------------------------------------------------------------------------- /src/utils/rimraf.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | 3 | import { existsSync } from './exists.js'; 4 | 5 | export function rimraf(path: string) { 6 | if (existsSync(path)) { 7 | fs.rmSync(path, { recursive: true }); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/dirname.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'node:url'; 2 | 3 | export function dirname(metaUrl: string) { 4 | const __filename = url.fileURLToPath(metaUrl); 5 | const __dirname = url.fileURLToPath(new URL('.', metaUrl)); 6 | 7 | return { __dirname, __filename }; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "noEmit": false, 6 | "declaration": true 7 | }, 8 | "include": ["src"], 9 | "exclude": ["**/*.test.ts", "src/test-utils", "src/dev-client.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/test-utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PartialDeep } from 'type-fest'; 2 | 3 | /** 4 | * Lets you pass a deep partial to a slot expecting a type. 5 | * 6 | * @returns whatever you pass in 7 | */ 8 | export const fromPartial = (mock: PartialDeep>): T => { 9 | return mock as T; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@wordpress/i18n' { 2 | export function __(text: string, domain?: string): string; 3 | } 4 | 5 | declare module '*.module.css' { 6 | interface IClassNames { 7 | [className: string]: string; 8 | } 9 | const classNames: IClassNames; 10 | export = classNames; 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json", 3 | "changelog": ["@fransvilhelm/changeset-changelog", { "repo": "adambrgmn/wp-bundler" }], 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/wp-bundler-plugin.php: -------------------------------------------------------------------------------- 1 | ({ 4 | name: 'wp-bundler-react-factory', 5 | setup(build) { 6 | build.initialOptions.jsx = 'transform'; 7 | build.initialOptions.jsxFactory = '__createElement__'; 8 | build.initialOptions.jsxFragment = '__Fragment__'; 9 | build.initialOptions.inject = [ 10 | ...(build.initialOptions.inject || []), 11 | bundler.paths.absolute('./assets/wp-element/wp-element.ts'), 12 | ]; 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fransvilhelm/wp-bundler-plugin", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "build": "../../cli.js build", 8 | "dev": "../../cli.js dev" 9 | }, 10 | "devDependencies": { 11 | "@fransvilhelm/wp-bundler": "file:../..", 12 | "typescript": "^4.9.4" 13 | }, 14 | "wp-bundler": { 15 | "entryPoints": { 16 | "main": "./src/main.tsx", 17 | "admin": "./src/admin.ts" 18 | }, 19 | "assetLoader": { 20 | "path": "dist/AssetLoader.php", 21 | "namespace": "WPBundlerPlugin" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test-utils/extensions.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'node:stream'; 2 | 3 | import { Chalk } from 'chalk'; 4 | 5 | import { Logger } from '../logger.js'; 6 | 7 | export class TestLogger extends Logger { 8 | #logs = new Set(); 9 | 10 | constructor(prefix: string) { 11 | process.env.FORCE_COLOR = '0'; 12 | let stream = new Writable({ 13 | write: (chunk: string | Buffer, _, callback) => { 14 | this.#logs.add(chunk.toString()); 15 | callback(); 16 | }, 17 | }); 18 | 19 | let chalk = new Chalk({ level: 0 }); 20 | super(prefix, stream, chalk); 21 | } 22 | 23 | getOutput() { 24 | return [...this.#logs].join(''); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'esbuild'; 2 | import type { PackageJson } from 'type-fest'; 3 | 4 | import type { BundlerConfig } from './schema.js'; 5 | 6 | export type BundlerOptions = { 7 | config: BundlerConfig; 8 | project: ProjectInfo; 9 | bundler: ProjectInfo; 10 | mode: Mode; 11 | watch: boolean; 12 | host: string; 13 | port: number; 14 | }; 15 | 16 | export type Mode = 'dev' | 'prod'; 17 | 18 | export type ProjectPaths = { 19 | root: string; 20 | absolute: (...to: string[]) => string; 21 | relative: (to: string) => string; 22 | }; 23 | 24 | export type ProjectInfo = { 25 | packageJson: PackageJson & { 'wp-bundler'?: unknown } & Record; 26 | path: string; 27 | paths: ProjectPaths; 28 | }; 29 | 30 | export type BundlerPlugin = (options: BundlerOptions) => Plugin; 31 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fransvilhelm/wp-bundler-theme", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "build": "../../cli.js build", 8 | "dev": "../../cli.js dev" 9 | }, 10 | "devDependencies": { 11 | "@fransvilhelm/wp-bundler": "file:../..", 12 | "typescript": "^4.9.4" 13 | }, 14 | "wp-bundler": { 15 | "entryPoints": { 16 | "main": "./src/main.ts", 17 | "admin": "./src/admin.ts" 18 | }, 19 | "assetLoader": { 20 | "path": "dist/AssetLoader.php", 21 | "namespace": "WPBundlerTheme" 22 | }, 23 | "translations": { 24 | "domain": "wp-bundler-theme", 25 | "pot": "languages/theme.pot", 26 | "pos": [ 27 | "languages/sv_SE.po" 28 | ] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/plugins/watch.ts: -------------------------------------------------------------------------------- 1 | import { globbySync } from 'globby'; 2 | 3 | import type { BundlerPlugin } from '../types.js'; 4 | 5 | export const PLUGIN_NAME = 'wp-bundler-watch'; 6 | 7 | export const watch: BundlerPlugin = (options) => ({ 8 | name: PLUGIN_NAME, 9 | setup(build) { 10 | let watchFiles = globbySync(['**/*.php', '**/*.twig', '!vendor', '!node_modules'], { 11 | cwd: options.project.paths.root, 12 | }).map((p) => options.project.paths.absolute(p)); 13 | 14 | let entry = Object.values(options.config.entryPoints)[0]; 15 | let filter = new RegExp(entry?.replaceAll('.', '\\.').replaceAll('/', '\\/') ?? ''); 16 | 17 | build.onResolve({ filter }, (args) => { 18 | return { 19 | path: options.project.paths.absolute(args.path), 20 | watchFiles, 21 | }; 22 | }); 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | validate: 7 | name: '🧪 Validate (Node.js v${{ matrix.node }})' 8 | 9 | strategy: 10 | matrix: 11 | node: [20, 22, 24] 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v5 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version: ${{ matrix.node }} 23 | cache: npm 24 | 25 | - name: Install 26 | run: npm ci 27 | 28 | - name: Lint 29 | run: npm run lint 30 | 31 | - name: Build 32 | run: npm run build 33 | 34 | - name: Test 35 | run: npm run test:coverage 36 | 37 | - name: Publint 38 | run: npx publint run 39 | 40 | - name: Examples 41 | run: npm run examples:build 42 | -------------------------------------------------------------------------------- /src/plugins/asset-loader.ts: -------------------------------------------------------------------------------- 1 | import type { BundlerPlugin } from '../types.js'; 2 | import { assert } from '../utils/assert.js'; 3 | import { createAssetLoaderTemplate } from '../utils/asset-loader.js'; 4 | import { createFileHandler } from '../utils/handle-bundled-file.js'; 5 | 6 | export const PLUGIN_NAME = 'wp-bundler-asset-loader'; 7 | 8 | export const assetLoader: BundlerPlugin = (options) => ({ 9 | name: PLUGIN_NAME, 10 | setup(build) { 11 | build.initialOptions.metafile = true; 12 | build.onEnd((result) => { 13 | assert(result.metafile, 'No metafile generated'); 14 | 15 | let compileAssetLoader = createAssetLoaderTemplate(options); 16 | let contents = compileAssetLoader({ metafile: result.metafile }); 17 | 18 | let files = createFileHandler(result, options.project); 19 | files.append({ path: options.config.assetLoader.path, contents }); 20 | }); 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import * as style from './main.module.css'; 5 | 6 | const App = () => { 7 | const [count, setCount] = useState(0); 8 | return ( 9 | <> 10 | 13 | 16 |

Count: {count}

17 | 18 | ); 19 | }; 20 | 21 | let wrapper = document.querySelector('.wp-site-blocks'); 22 | let sibling = document.getElementById('wp--skip-link--target'); 23 | 24 | if (wrapper != null && sibling != null) { 25 | let root = document.createElement('div'); 26 | wrapper.insertBefore(root, sibling); 27 | 28 | createRoot(root).render(); 29 | } 30 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const BundlerConfigSchema = z 4 | .object({ 5 | entryPoints: z.record(z.string(), z.string()), 6 | outdir: z.string().default('./dist'), 7 | sourcemap: z.boolean().optional(), 8 | externals: z.record(z.string(), z.string()).optional(), 9 | assetLoader: z 10 | .object({ 11 | path: z.string().default('./AssetLoader.php'), 12 | namespace: z.string().default('WPBundler'), 13 | }) 14 | .strict() 15 | .default({ path: './AssetLoader.php', namespace: 'WPBundler' }), 16 | translations: z 17 | .object({ 18 | domain: z.string(), 19 | pot: z.string(), 20 | pos: z.array(z.string()).optional(), 21 | ignore: z.array(z.string()).optional(), 22 | }) 23 | .strict() 24 | .optional(), 25 | }) 26 | .strict(); 27 | 28 | export type BundlerConfig = z.infer; 29 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import globals from 'globals'; 3 | import tseslint from 'typescript-eslint'; 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | const config = [ 7 | /** 8 | * For some unknown reason `ignores` needs to be in its own config object. As soon as I put any 9 | * other configuration together with `ignores` it stops working and eslint start lint files that 10 | * it shouldn't check. 11 | */ 12 | { ignores: ['**/dist', '**/coverage', 'cli.js'] }, 13 | pluginJs.configs.recommended, 14 | ...tseslint.configs.recommendedTypeChecked, 15 | { 16 | languageOptions: { 17 | globals: globals.node, 18 | parserOptions: { 19 | projectService: true, 20 | tsconfigRootDir: import.meta.dirname, 21 | }, 22 | }, 23 | rules: { 24 | 'prefer-const': 'off', 25 | 'no-empty': ['error', { allowEmptyCatch: true }], 26 | '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], 27 | }, 28 | }, 29 | ]; 30 | 31 | export default config; 32 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | 3 | const assertPrefix = 'Assertion failed'; 4 | 5 | export function assert(value: boolean, message?: string): asserts value; 6 | export function assert(value: T | null | undefined, message?: string): asserts value is T; 7 | export function assert(value: unknown, message?: string) { 8 | if (value === false || value === null || typeof value === 'undefined') { 9 | if (process.env.NODE_ENV === 'production') { 10 | throw new Error(assertPrefix); 11 | } 12 | 13 | throw new Error(`${assertPrefix}: ${message ?? ''}`); 14 | } 15 | } 16 | 17 | export function ensure(value: T | null | undefined, message?: string): T { 18 | if (value === null || typeof value === 'undefined') { 19 | if (process.env.NODE_ENV === 'production') { 20 | throw new Error(assertPrefix); 21 | } 22 | 23 | throw new Error(`${assertPrefix}: ${message ?? ''}`); 24 | } 25 | 26 | return value; 27 | } 28 | 29 | export function isNotNullable(value: T | null | undefined): value is T { 30 | return value != null; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2025 Adam Bergman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v5 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node 20 | uses: actions/setup-node@v6 21 | with: 22 | node-version: 20 23 | cache: npm 24 | 25 | - name: Install Dependencies 26 | run: npm ci 27 | 28 | - name: Create Release Pull Request or Publish to npm 29 | id: changesets 30 | uses: changesets/action@v1 31 | with: 32 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 33 | publish: npm run release 34 | title: Release 35 | commit: Create release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/languages/sv_SE.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: ExampleLast-Translator: FULL NAME \n" 4 | "Language-Team: LANGUAGE \n" 5 | "MIME-Version: 1.0\n" 6 | "Content-Type: text/plain; charset=utf-8\n" 7 | "Content-Transfer-Encoding: 8bit\n" 8 | "POT-Creation-Date: 2021-09-25T15:57:11+00:00\n" 9 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 10 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 11 | "Language: sv_SE\n" 12 | "X-Domain: wp-bundler-theme\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "Last-Translator: \n" 15 | 16 | #: style.css:5 17 | #. translators: Author of the theme 18 | msgid "Adam Bergman" 19 | msgstr "" 20 | 21 | #: style.css:7 22 | #. translators: Description of the theme 23 | msgid "Example theme" 24 | msgstr "" 25 | 26 | #: src/main.ts:6 27 | msgid "Hello world! (theme)" 28 | msgstr "Hej världen! (tema)" 29 | 30 | #: style.css:4 31 | #: style.css:6 32 | #. translators: Author URI of the theme 33 | #. translators: Theme URI of the theme 34 | msgid "https://github.com/adambrgmn/wp-bundler" 35 | msgstr "" 36 | 37 | #: style.css:3 38 | #. translators: Theme Name of the theme 39 | msgid "WP Bundler Example theme" 40 | msgstr "" 41 | -------------------------------------------------------------------------------- /src/plugins/define.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import * as process from 'node:process'; 3 | import * as util from 'node:util'; 4 | 5 | import type { BundlerPlugin } from '../types.js'; 6 | 7 | export const define: BundlerPlugin = ({ mode, project }) => ({ 8 | name: 'wp-bundler-define', 9 | setup(build) { 10 | build.initialOptions.define = build.initialOptions.define ?? {}; 11 | 12 | let NODE_ENV = mode === 'dev' ? 'development' : 'production'; 13 | build.initialOptions.define['process.env.NODE_ENV'] = JSON.stringify(NODE_ENV); 14 | 15 | let envFiles = ['.env', `.env.${NODE_ENV}`, '.env.local', `.env.${NODE_ENV}.local`]; 16 | const env: Record = {}; 17 | 18 | for (let file of envFiles) { 19 | const filePath = project.paths.absolute(file); 20 | if (fs.existsSync(filePath)) { 21 | const envContent = fs.readFileSync(filePath, 'utf-8'); 22 | Object.assign(env, util.parseEnv(envContent)); 23 | } 24 | } 25 | 26 | Object.assign(env, structuredClone(process.env)); 27 | 28 | let WP_ = /^WP_/i; 29 | for (let key of Object.keys(env)) { 30 | if (WP_.test(key)) { 31 | build.initialOptions.define[`process.env.${key}`] = JSON.stringify(env[key]); 32 | } 33 | } 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/languages/theme.pot: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 Automattic 2 | # This file is distributed under the GNU General Public License v2 or later. 3 | msgid "" 4 | msgstr "" 5 | "Project-Id-Version: _s 1.0.0\n" 6 | "Report-Msgid-Bugs-To: https://wordpress.org/support/theme/_s\n" 7 | "Last-Translator: FULL NAME \n" 8 | "Language-Team: LANGUAGE \n" 9 | "MIME-Version: 1.0\n" 10 | "Content-Type: text/plain; charset=utf-8\n" 11 | "Content-Transfer-Encoding: 8bit\n" 12 | "POT-Creation-Date: 2020-04-17T21:03:15+00:00\n" 13 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 | "X-Generator: WP-CLI 2.4.0\n" 15 | "X-Domain: wp-bundler-theme\n" 16 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 17 | 18 | #: style.css:5 19 | #. translators: Author of the theme 20 | msgid "Adam Bergman" 21 | msgstr "" 22 | 23 | #: style.css:7 24 | #. translators: Description of the theme 25 | msgid "Example theme" 26 | msgstr "" 27 | 28 | #: src/main.ts:6 29 | msgid "Hello world! (theme)" 30 | msgstr "" 31 | 32 | #: style.css:4 33 | #: style.css:6 34 | #. translators: Author URI of the theme 35 | #. translators: Theme URI of the theme 36 | msgid "https://github.com/adambrgmn/wp-bundler" 37 | msgstr "" 38 | 39 | #: style.css:3 40 | #. translators: Theme Name of the theme 41 | msgid "WP Bundler Example theme" 42 | msgstr "" 43 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/src/main.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | padding: 0.6em 2em; 3 | border: none; 4 | outline: none; 5 | color: rgb(255, 255, 255); 6 | background: #111; 7 | cursor: pointer; 8 | position: relative; 9 | z-index: 0; 10 | border-radius: 10px; 11 | user-select: none; 12 | -webkit-user-select: none; 13 | touch-action: manipulation; 14 | } 15 | 16 | .button::before { 17 | content: ''; 18 | background: linear-gradient(45deg, #ff0000, #ff7300, #fffb00, #48ff00, #00ffd5, #002bff, #7a00ff, #ff00c8, #ff0000); 19 | position: absolute; 20 | top: -2px; 21 | left: -2px; 22 | background-size: 400%; 23 | z-index: -1; 24 | filter: blur(5px); 25 | -webkit-filter: blur(5px); 26 | width: calc(100% + 4px); 27 | height: calc(100% + 4px); 28 | animation: glowing-button 20s linear infinite; 29 | transition: opacity 0.3s ease-in-out; 30 | border-radius: 10px; 31 | } 32 | 33 | .button::after { 34 | z-index: -1; 35 | content: ''; 36 | position: absolute; 37 | width: 100%; 38 | height: 100%; 39 | background: #222; 40 | left: 0; 41 | top: 0; 42 | border-radius: 10px; 43 | } 44 | 45 | @keyframes glowing-button { 46 | 0% { 47 | background-position: 0 0; 48 | } 49 | 50% { 50 | background-position: 400% 0; 51 | } 52 | 100% { 53 | background-position: 0 0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/assert.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { assert, ensure, isNotNullable } from './assert.js'; 4 | 5 | describe('assert', () => { 6 | it("throws an error if check doesn't pass", () => { 7 | expect(() => assert(false)).toThrow(Error); 8 | expect(() => assert(null)).toThrow(Error); 9 | expect(() => assert(undefined)).toThrow(Error); 10 | expect(() => assert('')).not.toThrow(Error); 11 | expect(() => assert({})).not.toThrow(Error); 12 | expect(() => assert([])).not.toThrow(Error); 13 | }); 14 | 15 | it('can pass along an optional message', () => { 16 | expect(() => assert(false, 'the message')).toThrow('Assertion failed: the message'); 17 | }); 18 | }); 19 | 20 | describe('ensure', () => { 21 | it('makes sure that a variable is not null|undefined and passes it. Otherwise it throws.', () => { 22 | expect(() => ensure(null)).toThrow(Error); 23 | expect(() => ensure(undefined)).toThrow(Error); 24 | expect(ensure('')).toEqual(''); 25 | expect(ensure(0)).toEqual(0); 26 | }); 27 | }); 28 | 29 | describe('isNotNullable', () => { 30 | it('validates that a value is not null or undefined', () => { 31 | expect(isNotNullable(null)).toBeFalsy(); 32 | expect(isNotNullable(undefined)).toBeFalsy(); 33 | expect(isNotNullable(0)).toBeTruthy(); 34 | expect(isNotNullable('')).toBeTruthy(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'po2json' { 2 | export interface ParseOptions { 3 | // Whether to include fuzzy translation in JSON or not. Should be either true or false. Defaults to false. 4 | fuzzy?: boolean; 5 | // If true, returns a JSON string. Otherwise returns a plain Javascript object. Defaults to false. 6 | stringify?: boolean; 7 | // If true, the resulting JSON string will be pretty-printed. Has no effect when stringify is false. Defaults to false 8 | pretty?: boolean; 9 | // Defaults to raw. 10 | format?: 'raw' | 'jed' | 'jedold' | 'mf'; 11 | // The domain the messages will be wrapped inside. Only has effect if format: 'jed'. 12 | domain?: Domain; 13 | // If true, f 14 | 'fallback-to-msgid'?: boolean; 15 | } 16 | 17 | export interface LocaleDataDefault { 18 | domain: Domain; 19 | lang: string; 20 | 'plural-forms': string; 21 | } 22 | 23 | export interface ParseResult { 24 | domain: Domain; 25 | locale_data: Record< 26 | Domain, 27 | { 28 | '': LocaleDataDefault; 29 | [key: string]: string[]; 30 | } 31 | >; 32 | } 33 | 34 | export function parse( 35 | buffer: Buffer | string, 36 | options?: ParseOptions, 37 | ): ParseResult; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/asset-loader.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { toPhpArray } from './asset-loader.js'; 4 | 5 | describe('toPhpArray', () => { 6 | it('formats an empty object', () => { 7 | expect(toPhpArray({})).toMatchInlineSnapshot('"[]"'); 8 | }); 9 | 10 | it('formats an empty array', () => { 11 | expect(toPhpArray([])).toMatchInlineSnapshot('"[]"'); 12 | }); 13 | 14 | it('formats an object with primitive values', () => { 15 | expect(toPhpArray({ a: 'a', b: 1, c: true })).toMatchInlineSnapshot(`"["a"=>"a","b"=>1,"c"=>true,]"`); 16 | }); 17 | 18 | it('formats an array of primitive values', () => { 19 | expect(toPhpArray([1, '2', true])).toMatchInlineSnapshot(`"[1,"2",true,]"`); 20 | }); 21 | 22 | it('formats an object with more complex values within', () => { 23 | expect(toPhpArray({ a: ['a'], b: { foo: true } })).toMatchInlineSnapshot(`"["a"=>["a",],"b"=>["foo"=>true,],]"`); 24 | }); 25 | 26 | it('formats an array with more complex values within', () => { 27 | expect(toPhpArray([{ a: 'a' }, [1, 2]])).toMatchInlineSnapshot(`"[["a"=>"a",],[1,2,],]"`); 28 | }); 29 | 30 | it('ignores undefined values in objects', () => { 31 | expect(toPhpArray({ value: undefined })).toMatchInlineSnapshot('"[]"'); 32 | }); 33 | 34 | it('references undefined values to an undefined variable in arrays.', () => { 35 | expect(toPhpArray([undefined])).toMatchInlineSnapshot('"[$__undefined,]"'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/utils/externals.ts: -------------------------------------------------------------------------------- 1 | import type { Metafile } from 'esbuild'; 2 | 3 | interface Dependencies { 4 | [key: string]: { wpId: string; global: string } | undefined; 5 | } 6 | 7 | const DEPENDENCIES: Dependencies = { 8 | react: { wpId: 'wp-element', global: 'React' }, 9 | 'react-dom': { wpId: 'wp-element', global: 'ReactDOM' }, 10 | jquery: { wpId: 'jquery', global: '$' }, 11 | lodash: { wpId: 'lodash', global: '_' }, 12 | }; 13 | 14 | export const DEFAULT_EXTERNALS = Object.entries(DEPENDENCIES).reduce>((acc, [key, dep]) => { 15 | if (dep) acc[key] = dep.global; 16 | return acc; 17 | }, {}); 18 | 19 | export function findBuiltinDependencies(inputs: Metafile['outputs'][string]['inputs']): string[] { 20 | let dependencies: string[] = []; 21 | 22 | for (let key of Object.keys(inputs)) { 23 | /** 24 | * External packages handled by the `externals` plugin prefix all external 25 | * dependecies with something similar to `_wp-bundler-externals:{pkg}`. By 26 | * trimming that prefix we can find the expected built in dependency 27 | */ 28 | let importedPkg = key.split(':').slice(-1).at(0) ?? ''; 29 | let dep = DEPENDENCIES[importedPkg]?.wpId; 30 | if (dep == null) dep = getWPHandle(importedPkg); 31 | if (dep != null) dependencies.push(dep); 32 | } 33 | 34 | return dependencies; 35 | } 36 | 37 | function getWPHandle(pkg: string): string | undefined { 38 | if (!pkg.startsWith('@wordpress/')) return undefined; 39 | return `wp-${pkg.replace('@wordpress/', '')}`; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/handle-bundled-file.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import * as path from 'node:path'; 3 | 4 | import type { BuildOptions, BuildResult } from 'esbuild'; 5 | import { stringToUint8Array, uint8ArrayToString } from 'uint8array-extras'; 6 | 7 | import type { ProjectInfo } from '../types.js'; 8 | 9 | export function createFileHandler(result: BuildResult, project: ProjectInfo) { 10 | function append(output: { path: string; contents: Uint8Array | string }) { 11 | let absolute = project.paths.absolute(output.path); 12 | let relative = project.paths.relative(output.path); 13 | 14 | let contents = typeof output.contents === 'string' ? stringToUint8Array(output.contents) : output.contents; 15 | let text = typeof output.contents === 'string' ? output.contents : uint8ArrayToString(output.contents, 'utf-8'); 16 | 17 | if (result.outputFiles != null) { 18 | result.outputFiles.push({ path: absolute, contents, text, hash: '' }); 19 | } else { 20 | ensureDir(absolute); 21 | fs.writeFileSync(absolute, contents, 'utf-8'); 22 | } 23 | 24 | if (result.metafile != null) { 25 | result.metafile.outputs[relative] = { 26 | bytes: contents.byteLength, 27 | exports: [], 28 | imports: [], 29 | inputs: {}, 30 | }; 31 | } 32 | } 33 | 34 | return { append } as const; 35 | } 36 | 37 | function ensureDir(file: string) { 38 | try { 39 | let dirname = path.dirname(file); 40 | fs.mkdirSync(dirname, { recursive: true }); 41 | } catch {} 42 | } 43 | -------------------------------------------------------------------------------- /src/plugins/log.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'node:perf_hooks'; 2 | 3 | import type { Plugin } from 'esbuild'; 4 | 5 | import { Logger } from '../logger.js'; 6 | import type { BundlerOptions } from '../types.js'; 7 | 8 | export const PLUGIN_NAME = 'wp-bundler-logger'; 9 | 10 | export function log(options: BundlerOptions, logger: Logger): Plugin { 11 | return { 12 | name: PLUGIN_NAME, 13 | setup(build) { 14 | let start: number; 15 | 16 | logger.info(`Running bundler in ${logger.chalk.blue(options.mode)} mode.`); 17 | 18 | build.onStart(() => { 19 | start = performance.now(); 20 | logger.info('Building...'); 21 | }); 22 | 23 | build.onEnd((result) => { 24 | let errors = result?.errors.length ?? 0; 25 | let warnings = result?.warnings.length ?? 0; 26 | 27 | logger.buildOutput({ 28 | metafile: result.metafile ?? { inputs: {}, outputs: {} }, 29 | outputFiles: result.outputFiles ?? [], 30 | root: options.project.paths.root, 31 | entryPoints: options.config.entryPoints, 32 | }); 33 | 34 | if (errors + warnings > 0 && result != null) { 35 | logger.buildResult(result); 36 | logger.warn(`Build ended, but with ${errors} error(s) and ${warnings} warning(s).`); 37 | } else { 38 | let diff = Math.round(performance.now() - start); 39 | logger.success(`Build succeeded in ${diff} ms.`); 40 | } 41 | 42 | if (options.watch) logger.info('Watching files...'); 43 | }); 44 | }, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/extract-translations/theme.ts: -------------------------------------------------------------------------------- 1 | import type { Location } from 'esbuild'; 2 | 3 | import type { TranslationMessage } from './index.js'; 4 | 5 | let find = [ 6 | { comment: 'translators: Theme Name of the theme', re: /theme name:\s*(?.+)/i }, 7 | { comment: 'translators: Description of the theme', re: /description:\s*(?.+)/i }, 8 | { comment: 'translators: Theme URI of the theme', re: /theme uri:\s*(?.+)/i }, 9 | { comment: 'translators: Author of the theme', re: /author:\s*(?.+)/i }, 10 | { comment: 'translators: Author URI of the theme', re: /author uri:\s*(?.+)/i }, 11 | ]; 12 | 13 | export function extractTranslations(source: string, filename: string, domain: string): TranslationMessage[] { 14 | let translations: TranslationMessage[] = []; 15 | 16 | for (let { comment, re } of find) { 17 | let match = source.match(re); 18 | if (match == null || match.groups?.value == null) continue; 19 | translations.push({ 20 | text: match.groups.value, 21 | domain, 22 | translators: comment, 23 | location: posToLocation(match.index ?? 0, source, filename), 24 | }); 25 | } 26 | 27 | return translations; 28 | } 29 | 30 | function posToLocation(pos: number, source: string, file: string): Location { 31 | let substring = source.substr(0, pos); 32 | let lines = substring.split('\n'); 33 | let line = lines.length; 34 | let column = lines.at(line - 1)?.length ?? 0; 35 | 36 | return { 37 | file, 38 | namespace: '', 39 | line, 40 | column, 41 | length: 0, 42 | lineText: '', 43 | suggestion: '', 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/extract-translations/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Location } from 'esbuild'; 2 | import type { Node as PhpNode } from 'php-parser'; 3 | import type { Node as TsNode } from 'typescript'; 4 | 5 | export function phpNodeToLocation(node: PhpNode, file: string): Location { 6 | return { 7 | file, 8 | namespace: '', 9 | line: node.loc?.start.line ?? 1, 10 | column: node.loc?.start.column ?? 0, 11 | length: 0, 12 | lineText: '', 13 | suggestion: '', 14 | }; 15 | } 16 | 17 | export function tsNodeToLocation(node: TsNode, fnName: string, source: string, file: string): Location { 18 | let pos = source.indexOf(fnName, node.pos); 19 | let substring = source.substring(0, pos); 20 | let lines = substring.split('\n'); 21 | let line = lines.length; 22 | let column = lines.at(line - 1)?.length ?? 0; 23 | 24 | return { 25 | file, 26 | namespace: '', 27 | line, 28 | column, 29 | length: 0, 30 | lineText: '', 31 | suggestion: '', 32 | }; 33 | } 34 | 35 | export function trimComment(value: string): string { 36 | let lines = value.split('\n').map((line) => { 37 | return ( 38 | line 39 | .trim() 40 | // // 41 | .replace(/^\/\//, '') 42 | // /* 43 | .replace(/^\/\*+/, '') 44 | // * 45 | .replace(/\*+\/$/, '') 46 | // */ 47 | .replace(/^\*+/, '') 48 | .trim() 49 | ); 50 | }); 51 | 52 | return lines.filter(Boolean).join('\n'); 53 | } 54 | 55 | export function isTranslatorsComment(comment: string): comment is `translators:${string}` { 56 | return comment.toLowerCase().startsWith('translators:'); 57 | } 58 | -------------------------------------------------------------------------------- /src/plugins/postcss.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | 4 | import type { PartialMessage } from 'esbuild'; 5 | import postcss, { type Warning } from 'postcss'; 6 | import postcssPresetEnv from 'postcss-preset-env'; 7 | 8 | import type { BundlerPlugin } from '../types.js'; 9 | 10 | export const PLUGIN_NAME = 'wp-bundler-postcss'; 11 | 12 | const postcssPlugin: BundlerPlugin = () => ({ 13 | name: PLUGIN_NAME, 14 | setup(build) { 15 | let plugins = [postcssPresetEnv()]; 16 | let processor = postcss(plugins); 17 | 18 | build.onLoad({ filter: /\.css$/, namespace: 'file' }, async (args) => { 19 | let contents = await fs.readFile(args.path, 'utf-8'); 20 | let result = await processor.process(contents, { from: args.path, to: args.path }); 21 | let warnings = transformPostcssWarnings(args.path, result.warnings()); 22 | 23 | return { 24 | contents: result.content, 25 | loader: args.path.endsWith('.module.css') ? 'local-css' : 'css', 26 | pluginName: PLUGIN_NAME, 27 | warnings, 28 | resolveDir: path.dirname(args.path), 29 | }; 30 | }); 31 | }, 32 | }); 33 | 34 | export { postcssPlugin as postcss }; 35 | 36 | function transformPostcssWarnings(file: string, warnings: Warning[]): PartialMessage[] { 37 | return warnings.map((warn) => { 38 | return { 39 | text: warn.text, 40 | location: { 41 | file, 42 | namespace: '', 43 | line: warn.line, // 1-based 44 | column: warn.column, // 0-based, in bytes 45 | length: 0, // in bytes 46 | lineText: warn.node.toString(), 47 | suggestion: '', 48 | }, 49 | }; 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/plugins/externals.ts: -------------------------------------------------------------------------------- 1 | import type { PluginBuild } from 'esbuild'; 2 | import { toCamelCase } from 'strman'; 3 | 4 | import type { BundlerPlugin } from '../types.js'; 5 | import { DEFAULT_EXTERNALS } from '../utils/externals.js'; 6 | 7 | export const externals: BundlerPlugin = ({ config }) => ({ 8 | name: 'wp-bundler-externals', 9 | setup(build) { 10 | setupProjectExternals(build, config.externals); 11 | setupWpExternals(build); 12 | }, 13 | }); 14 | 15 | function setupProjectExternals(build: PluginBuild, providedExternals: Record = {}) { 16 | let namespace = '_wp-bundler-externals'; 17 | let externals: Record = { 18 | ...DEFAULT_EXTERNALS, 19 | ...providedExternals, 20 | }; 21 | 22 | for (let key of Object.keys(externals)) { 23 | build.onResolve({ filter: new RegExp(`^${key}$`) }, (args) => { 24 | return { path: args.path, namespace, sideEffects: false }; 25 | }); 26 | } 27 | 28 | build.onLoad({ filter: /.*/, namespace }, (args) => { 29 | return { 30 | contents: `module.exports = window.${externals[args.path]}`, 31 | loader: 'js', 32 | }; 33 | }); 34 | } 35 | 36 | function setupWpExternals(build: PluginBuild) { 37 | let namespace = '_wp-bundler-wp-externals'; 38 | 39 | /** 40 | * The following packages are treated as internal packages by Gutenberg. If used the content should 41 | * be bundled with the projects source files instead of read from `window.wp`. 42 | */ 43 | let internal = ['@wordpress/icons']; 44 | 45 | build.onResolve({ filter: /@wordpress\/.+/ }, (args) => { 46 | if (internal.includes(args.path)) return undefined; 47 | return { path: args.path, namespace, sideEffects: false }; 48 | }); 49 | 50 | build.onLoad({ filter: /.*/, namespace }, (args) => { 51 | return { 52 | contents: `module.exports = window.wp.${toCamelCase(args.path.replace(/^@wordpress\//, ''))}`, 53 | loader: 'js', 54 | }; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | 3 | import esbuild from 'esbuild'; 4 | 5 | import { Logger } from './logger.js'; 6 | import * as plugin from './plugins/index.js'; 7 | import type { Mode } from './types.js'; 8 | import type { Metadata } from './utils/read-pkg.js'; 9 | 10 | type ContextOptions = Metadata & { 11 | watch?: boolean; 12 | write?: boolean; 13 | mode?: Mode; 14 | cwd?: string; 15 | host?: string; 16 | port?: number; 17 | logger: Logger; 18 | }; 19 | 20 | export function createContext(options: ContextOptions) { 21 | let pluginOptions = { 22 | watch: false, 23 | mode: 'prod' as const, 24 | cwd: process.cwd(), 25 | host: 'localhost', 26 | port: 3000, 27 | ...options, 28 | }; 29 | 30 | let plugins = [ 31 | plugin.assetLoader(pluginOptions), 32 | plugin.define(pluginOptions), 33 | plugin.externals(pluginOptions), 34 | plugin.postcss(pluginOptions), 35 | plugin.reactFactory(pluginOptions), 36 | plugin.translations(pluginOptions), 37 | plugin.watch(pluginOptions), 38 | plugin.log(pluginOptions, options.logger), 39 | ]; 40 | 41 | if (options.mode === 'prod') { 42 | plugins.unshift(plugin.nomodule(pluginOptions)); 43 | } 44 | 45 | let entryNames = options.mode === 'prod' ? '[dir]/[name].[hash]' : '[dir]/[name]'; 46 | 47 | return esbuild.context({ 48 | entryPoints: options.config.entryPoints, 49 | outdir: options.project.paths.absolute(options.config.outdir), 50 | entryNames, 51 | bundle: true, 52 | format: 'esm', 53 | platform: 'browser', 54 | target: 'es2020', 55 | write: options.write ?? true, 56 | metafile: true, 57 | 58 | loader: { 59 | '.ttf': 'file', 60 | '.eot': 'file', 61 | '.woff': 'file', 62 | '.woff2': 'file', 63 | }, 64 | 65 | sourcemap: options.config.sourcemap || options.mode === 'dev', 66 | minify: options.mode === 'prod', 67 | 68 | absWorkingDir: options.project.paths.root, 69 | logLevel: 'silent', 70 | 71 | plugins, 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /src/dev-client.ts: -------------------------------------------------------------------------------- 1 | const url = new URL('/esbuild', `http://${window.WP_BUNDLER_HOST}:${window.WP_BUNDLER_PORT}`); 2 | 3 | let eventSource = new EventSource(url); 4 | let retry = { count: 0 }; 5 | 6 | eventSource.addEventListener('open', () => { 7 | log.info('Dev server connection established'); 8 | retry.count = 0; 9 | }); 10 | 11 | eventSource.addEventListener('error', () => { 12 | retry.count += 1; 13 | 14 | if (retry.count > 5) { 15 | log.error(new Error(`Dev server connection failed. Closing connection.`)); 16 | eventSource.close(); 17 | } else { 18 | log.error(new Error(`Dev server errored`)); 19 | } 20 | }); 21 | 22 | eventSource.addEventListener('change', (e: MessageEvent) => { 23 | const { added, removed, updated } = parseEventPayload(e.data); 24 | 25 | if (!added.length && !removed.length && updated.length === 1) { 26 | for (const link of document.getElementsByTagName('link')) { 27 | const url = new URL(link.href); 28 | 29 | if (url.host === window.location.host && url.pathname === updated[0]) { 30 | const next = link.cloneNode() as HTMLLinkElement; 31 | next.href = updated[0] + '?' + Math.random().toString(36).slice(2); 32 | next.onload = () => link.remove(); 33 | link.parentNode?.insertBefore(next, link.nextSibling); 34 | return; 35 | } 36 | } 37 | } 38 | 39 | window.location.reload(); 40 | }); 41 | 42 | type Payload = { added: string[]; removed: string[]; updated: string[] }; 43 | 44 | function parseEventPayload(payload: string) { 45 | try { 46 | let value = JSON.parse(payload) as Payload; 47 | if (typeof value === 'object' && value != null) return value; 48 | 49 | throw new Error('Bad data format'); 50 | } catch (error) { 51 | throw new Error('Could not parse event payload', { cause: error }); 52 | } 53 | } 54 | 55 | const log = { 56 | info(message: string) { 57 | console.log(`[wp-bundler]: ${message}`); 58 | }, 59 | error(error: unknown) { 60 | console.error(error); 61 | }, 62 | }; 63 | 64 | declare global { 65 | interface Window { 66 | WP_BUNDLER_HOST: string; 67 | WP_BUNDLER_PORT: number; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/resolve-config.ts: -------------------------------------------------------------------------------- 1 | import { type BundlerConfig, BundlerConfigSchema } from '../schema.js'; 2 | import type { ProjectInfo } from '../types.js'; 3 | import { readJson } from './read-json.js'; 4 | 5 | type ConfigKey = 'package.json' | '.wp-bundlerrc' | 'wp-bundler.config.json'; 6 | 7 | export function resolveConfig(project: ProjectInfo): BundlerConfig { 8 | let config = _resolveConfig(project, readJson); 9 | return config; 10 | } 11 | 12 | export function _resolveConfig(project: ProjectInfo, read: (path: string) => unknown) { 13 | let configs: Record = { 14 | 'package.json': project.packageJson['wp-bundler'], 15 | '.wp-bundlerrc': readConfigFile(project.paths.absolute('.wp-bundlerrc'), read), 16 | 'wp-bundler.config.json': readConfigFile(project.paths.absolute('wp-bundler.config.json'), read), 17 | }; 18 | 19 | let foundKeys = Object.keys(configs).filter((key) => configs[key as ConfigKey] != null); 20 | 21 | if (foundKeys.length > 1) { 22 | console.warn( 23 | `Found more than one wp-bundler configuration (${foundKeys.join(', ')}). ` + 24 | 'It is recommended to only stick with one of the options: ' + 25 | 'package.json["wp-bundler"], .wp-bundlerrc or wp-bundler.config.json.', 26 | ); 27 | } 28 | 29 | let config = configs['package.json'] ?? configs['.wp-bundlerrc'] ?? configs['wp-bundler.config.json']; 30 | if (config == null) { 31 | throw new Error( 32 | 'Could not resolve a configuration file. ' + 33 | 'Either configure wp-bundler in your package.json, in .wp-bundlerrc or in wp-bundler.config.json.', 34 | ); 35 | } 36 | 37 | let parsedConfig = BundlerConfigSchema.safeParse(config); 38 | if (parsedConfig.success) { 39 | return parsedConfig.data; 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 43 | console.error(parsedConfig.error.toString()); 44 | throw new Error('Something is wrong in your configuration file.'); 45 | } 46 | 47 | function readConfigFile(path: string, read: (path: string) => unknown): unknown { 48 | try { 49 | let json = read(path); 50 | return json; 51 | } catch { 52 | return undefined; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/extract-translations/types.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | // Based on `Location` from esbuild 4 | const LocationSchema = z.object({ 5 | file: z.string(), 6 | namespace: z.string(), 7 | line: z.number(), 8 | column: z.number(), 9 | length: z.number(), 10 | lineText: z.string(), 11 | suggestion: z.string(), 12 | }); 13 | 14 | const MessageBaseSchema = z.object({ 15 | location: LocationSchema, 16 | domain: z.string().optional(), 17 | translators: z.string().optional(), 18 | }); 19 | 20 | const PluralMessageWithoutContextSchema = MessageBaseSchema.extend({ single: z.string(), plural: z.string() }); 21 | const PluralMessageWithContextSchema = PluralMessageWithoutContextSchema.extend({ context: z.string() }); 22 | const PluralMessageSchema = z.union([PluralMessageWithoutContextSchema, PluralMessageWithContextSchema]); 23 | 24 | type PluralMessageWithContext = z.infer; 25 | type PluralMessage = z.infer; 26 | 27 | export const isPluralMessage = (v: unknown): v is PluralMessage => { 28 | let res = PluralMessageSchema.safeParse(v); 29 | return res.success; 30 | }; 31 | 32 | const SingleMessageWithoutContextSchema = MessageBaseSchema.extend({ text: z.string() }); 33 | const SingleMessageWithContextSchema = SingleMessageWithoutContextSchema.extend({ context: z.string() }); 34 | const SingleMessageSchema = z.union([SingleMessageWithoutContextSchema, SingleMessageWithContextSchema]); 35 | 36 | type SingleMessageWithContext = z.infer; 37 | 38 | const TranslationMessageSchema = z.union([PluralMessageSchema, SingleMessageSchema]); 39 | export type TranslationMessage = z.infer; 40 | export const isTranslationMessage = (v: unknown): v is TranslationMessage => { 41 | let res = TranslationMessageSchema.safeParse(v); 42 | return res.success; 43 | }; 44 | 45 | export const isContextMessage = (value: unknown): value is PluralMessageWithContext | SingleMessageWithContext => { 46 | if (PluralMessageWithContextSchema.safeParse(value).success) return true; 47 | if (SingleMessageWithContextSchema.safeParse(value).success) return true; 48 | return false; 49 | }; 50 | -------------------------------------------------------------------------------- /src/utils/bundle-output.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import type { Metafile, OutputFile } from 'esbuild'; 4 | 5 | type BundlerOutput = Record; 6 | 7 | export type BundleOutputOptions = { 8 | metafile: Metafile; 9 | outputFiles: OutputFile[]; 10 | root: string; 11 | entryPoints: Record; 12 | }; 13 | 14 | export function constructBundleOutput({ metafile, outputFiles, root, entryPoints }: BundleOutputOptions) { 15 | let bundles: BundlerOutput = {}; 16 | 17 | for (let [key, output] of Object.entries(metafile.outputs)) { 18 | let entrypoint = getEntrypoint(output, root, entryPoints); 19 | if (entrypoint != null) { 20 | bundles[entrypoint] ??= []; 21 | bundles[entrypoint]?.push({ file: key, size: getSize(key, outputFiles) }); 22 | 23 | if (output.cssBundle != null) { 24 | bundles[entrypoint]?.push({ file: output.cssBundle, size: getSize(output.cssBundle, outputFiles) }); 25 | } 26 | continue; 27 | } 28 | 29 | let extension = path.extname(key); 30 | if (['.mo', '.po', '.pot'].includes(extension) || (extension === '.json' && key.includes('/languages/'))) { 31 | bundles.translations ??= []; 32 | bundles.translations.push({ file: key, size: getSize(key, outputFiles) }); 33 | continue; 34 | } 35 | 36 | if (extension === '.php') { 37 | bundles['asset-loader'] = []; 38 | bundles['asset-loader'].push({ file: key, size: getSize(key, outputFiles) }); 39 | } 40 | } 41 | 42 | return bundles; 43 | } 44 | 45 | function getEntrypoint(output: Metafile['outputs'][string], root: string, entryPoints: Record) { 46 | if (output.entryPoint != null) { 47 | let base = new URL(`file:${root}${root.endsWith('/') ? '' : '/'}`); 48 | let url = new URL(output.entryPoint, base); 49 | let relative = path.relative(root, url.pathname); 50 | 51 | let name = Object.keys(entryPoints).find((entry) => entryPoints[entry]?.endsWith(relative)); 52 | return name ?? null; 53 | } 54 | 55 | return null; 56 | } 57 | 58 | function getSize(key: string, outputFiles: OutputFile[]) { 59 | let file = outputFiles.find(({ path }) => path === key); 60 | return file?.contents.length ?? null; 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/read-pkg.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import * as path from 'node:path'; 3 | import * as process from 'node:process'; 4 | 5 | import type { PackageJson } from 'type-fest'; 6 | 7 | import type { BundlerConfig } from '../schema.js'; 8 | import type { ProjectInfo, ProjectPaths } from '../types.js'; 9 | import { readJson } from './read-json.js'; 10 | import { resolveConfig } from './resolve-config.js'; 11 | 12 | export interface Metadata { 13 | bundler: ProjectInfo; 14 | project: ProjectInfo; 15 | config: BundlerConfig; 16 | } 17 | 18 | const metadataCache = new Map(); 19 | 20 | export function getMetadata(projectPath: string, bundlerPath: string): Metadata { 21 | let cached = metadataCache.get(projectPath); 22 | if (cached != null) return cached; 23 | 24 | let [project, bundler] = [readPkg(projectPath), readPkg(bundlerPath)]; 25 | let config = resolveConfig(project); 26 | 27 | let metadata = { bundler, project, config }; 28 | metadataCache.set(projectPath, metadata); 29 | return metadata; 30 | } 31 | 32 | function readPkg(cwd: string): ProjectInfo { 33 | let pkg = readPkgUp(cwd); 34 | if (pkg == null) { 35 | throw new Error(`Could not read package.json related to ${cwd}.`); 36 | } 37 | 38 | return { ...pkg, paths: createPaths(pkg.path) }; 39 | } 40 | 41 | interface ReadResult { 42 | path: string; 43 | packageJson: PackageJson & Record; 44 | } 45 | 46 | function readPkgUp(cwd: string = process.cwd()): ReadResult | null { 47 | if (cwd === '/') return null; 48 | let items = fs.readdirSync(cwd); 49 | 50 | for (let item of items) { 51 | if (item === 'package.json') { 52 | let pkgPath = path.join(cwd, item); 53 | let packageJson = readJson(pkgPath) as ProjectInfo['packageJson']; 54 | 55 | return { path: pkgPath, packageJson }; 56 | } 57 | } 58 | 59 | return readPkgUp(path.dirname(cwd)); 60 | } 61 | 62 | export function createPaths(pkgPath: string): ProjectPaths { 63 | let root = path.dirname(pkgPath); 64 | return { 65 | root, 66 | absolute: (to: string, ...rest: string[]) => (path.isAbsolute(to) ? to : path.join(root, to, ...rest)), 67 | relative: (to: string) => path.relative(root, path.isAbsolute(to) ? to : path.join(root, to)), 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are more than welcome! Some questions I ask you to consider before contributing: 4 | 5 | - **Is the changes you want to implement major or do you want to introduce a new feature?** In that case I would appreciate if you open an issue first to describe what you would like to change/add. That way we can discuss if the changes fit into this project and how we can approach them. By doing this you can avoid doing extra work that might not be accepted by the project in the end. 6 | - **Have you found a bug?** Great, feel free to open an issue, or even better open a PR directly! 7 | 8 | ## Setting up the repository for contributions 9 | 10 | If you would like to submit a PR for the project I would appreciate if you made a fork of the project and implemented the changes on a branch on that fork, and then submitting a PR onto `main`. 11 | 12 | 1. Fork repository and `cd` into the project folder 13 | 2. Install dependencies with `npm install` 14 | 3. Run `yarn test:watch` to keep tests running 15 | 4. Run `yarn dev` to rebuild the cli on changes, this will also check types 16 | 17 | You can test your changes in the example directory. There you'll find both a WordPress plugin and theme. To start a local WordPress server with the theme and plugin installed run `yarn example:start`. 18 | 19 | ## Making sure changes are tracked 20 | 21 | Both if you fix a bug or introduce a new feature we need to track that change to make sure we include the changes in the correct release and that the changelog is properly updated when releasing a new version. Do that by following these steps: 22 | 23 | 1. `yarn changeset` will start an interactive cli 24 | 2. Select the type of change you're making 25 | 26 | - `patch` Select this if it is a bug fix that will benefit the current release line 27 | - `minor` Select this if you're introducing a new feature that is backwards compatible 28 | - `major` Select this if you're introducing a breaking change 29 | 30 | 3. Make sure you include a description of the changes that will later be included in the changelog 31 | 32 | ## Releases 33 | 34 | Releases are managed by the repository admins. When a PR is merged into main your changes will be automatically included in a "release" PR. This PR will then be merged by administrators. If we know that more changes are incoming we might hold off on releasing until we have more changes to include in the release. 35 | -------------------------------------------------------------------------------- /src/plugins/nomodule.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | 4 | import { transform } from '@swc/core'; 5 | import esbuild from 'esbuild'; 6 | 7 | import type { BundlerPlugin } from '../types.js'; 8 | import { PLUGIN_NAME as ASSET_LOADER } from './asset-loader.js'; 9 | import { PLUGIN_NAME as LOG } from './log.js'; 10 | import { PLUGIN_NAME as POSTCSS } from './postcss.js'; 11 | import { PLUGIN_NAME as TRANSLATIONS } from './translations.js'; 12 | import { PLUGIN_NAME as WATCH } from './watch.js'; 13 | 14 | const NAMESPACE = 'wp-bundler-nomodule'; 15 | const PLUGIN_NAME = 'wp-bundler-nomodule'; 16 | 17 | const IGNORED_PLUGINS = [PLUGIN_NAME, TRANSLATIONS, POSTCSS, ASSET_LOADER, LOG, WATCH]; 18 | 19 | export const nomodule: BundlerPlugin = ({ project }) => ({ 20 | name: PLUGIN_NAME, 21 | setup(build) { 22 | if (build.initialOptions.entryPoints == null) { 23 | throw new Error('You must configure entrypoints for this plugin to work'); 24 | } 25 | 26 | let entryPoints: Record = {}; 27 | for (let [key, entry] of Object.entries(build.initialOptions.entryPoints as Record)) { 28 | entryPoints[key] = entry; 29 | let ext = path.extname(entry); 30 | if (ext !== '.css') { 31 | entryPoints[`${key}.nomodule`] = entry.replace(ext, `.nomodule${ext}`); 32 | } 33 | } 34 | 35 | build.initialOptions.entryPoints = entryPoints; 36 | 37 | build.onResolve({ filter: /\.nomodule/ }, (args) => { 38 | if (args.kind === 'entry-point') { 39 | return { 40 | path: project.paths.absolute(args.path.replace('.nomodule', '')), 41 | namespace: NAMESPACE, 42 | }; 43 | } 44 | 45 | return undefined; 46 | }); 47 | 48 | build.onLoad({ filter: /.+/, namespace: NAMESPACE }, async (args) => { 49 | let plugins = (build.initialOptions.plugins ?? []).filter((plugin) => !IGNORED_PLUGINS.includes(plugin.name)); 50 | plugins.push(swc()); 51 | 52 | let result = await esbuild.build({ 53 | ...build.initialOptions, 54 | entryPoints: [args.path], 55 | write: false, 56 | format: 'iife', 57 | target: 'es5', 58 | loader: { 59 | ...build.initialOptions.loader, 60 | '.css': 'empty', 61 | }, 62 | plugins, 63 | }); 64 | 65 | let output = result.outputFiles[0]; 66 | if (output) return { contents: output.text }; 67 | return undefined; 68 | }); 69 | }, 70 | }); 71 | 72 | const swc = (): esbuild.Plugin => ({ 73 | name: 'wp-bundler-swc', 74 | setup(build) { 75 | build.onLoad({ filter: /.(js|ts|tsx|jsx)$/, namespace: '' }, async (args) => { 76 | const contents = await fs.readFile(args.path, 'utf-8'); 77 | let { code } = await transform(contents, { 78 | filename: args.path, 79 | sourceMaps: false, 80 | isModule: true, 81 | env: { 82 | targets: { 83 | chrome: '58', 84 | ie: '11', 85 | }, 86 | }, 87 | jsc: { 88 | parser: { syntax: 'typescript', tsx: true }, 89 | }, 90 | }); 91 | 92 | return { contents: code }; 93 | }); 94 | }, 95 | }); 96 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { hideBin } from 'yargs/helpers'; 2 | import yargs from 'yargs/yargs'; 3 | 4 | import { createContext } from './context.js'; 5 | import { Logger } from './logger.js'; 6 | import type { Mode } from './types.js'; 7 | import { dirname } from './utils/dirname.js'; 8 | import { type Metadata, getMetadata } from './utils/read-pkg.js'; 9 | import { rimraf } from './utils/rimraf.js'; 10 | 11 | const { __dirname } = dirname(import.meta.url); 12 | 13 | export function cli() { 14 | return yargs(hideBin(process.argv)) 15 | .command( 16 | 'build', 17 | 'Create production ready version of your project', 18 | { 19 | mode: { 20 | alias: 'm', 21 | default: 'prod' as Mode, 22 | choices: ['dev', 'prod'], 23 | description: 'Version of your source to output', 24 | } as const, 25 | cwd: { 26 | description: 'Optional path to your project', 27 | type: 'string', 28 | } as const, 29 | }, 30 | async function build(argv) { 31 | let cwd = argv.cwd ?? process.cwd(); 32 | let metadata = getMetadata(cwd, __dirname); 33 | prerun(argv.mode, metadata); 34 | 35 | let context = await createContext({ ...argv, cwd, ...metadata, logger: new Logger('wp-bundler') }); 36 | 37 | try { 38 | await context.rebuild(); 39 | process.exitCode = 0; 40 | } catch { 41 | process.exitCode = 1; 42 | } finally { 43 | await context.dispose(); 44 | } 45 | }, 46 | ) 47 | .command( 48 | 'dev', 49 | 'Run a development server', 50 | { 51 | host: { 52 | alias: 'h', 53 | default: 'localhost', 54 | description: 'Host to bind the dev server to', 55 | }, 56 | port: { 57 | alias: 'p', 58 | default: 3000, 59 | description: 'Port to bind the dev server to', 60 | }, 61 | mode: { 62 | alias: 'm', 63 | default: 'dev', 64 | choices: ['dev', 'prod'], 65 | description: 'Version of your source to output', 66 | } as const, 67 | cwd: { 68 | description: 'Optional path to your project', 69 | type: 'string', 70 | } as const, 71 | }, 72 | async function dev(argv) { 73 | let cwd = argv.cwd ?? process.cwd(); 74 | let metadata = getMetadata(cwd, __dirname); 75 | prerun(argv.mode, metadata); 76 | 77 | let context = await createContext({ ...argv, cwd, ...metadata, watch: true, logger: new Logger('wp-bundler') }); 78 | 79 | try { 80 | await context.watch(); 81 | await context.serve({ 82 | servedir: metadata.project.paths.absolute(metadata.config.outdir), 83 | host: argv.host, 84 | port: argv.port, 85 | }); 86 | } catch (error) { 87 | console.error(error); 88 | process.exitCode = 1; 89 | } 90 | }, 91 | ) 92 | .parse(); 93 | } 94 | 95 | function prerun(mode: Mode, metadata: Metadata) { 96 | process.env['NODE_ENV'] = process.env['NODE_ENV'] || mode === 'dev' ? 'development' : 'production'; 97 | rimraf(metadata.project.paths.absolute(metadata.config.outdir)); 98 | } 99 | -------------------------------------------------------------------------------- /src/plugins/define.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import type { PluginBuild } from 'esbuild'; 4 | import { fs, vol } from 'memfs'; 5 | import { afterEach, describe, expect, it, vi } from 'vitest'; 6 | 7 | import { fromPartial } from '../test-utils/utils.js'; 8 | import { define } from './define.js'; 9 | 10 | // @ts-expect-error ... 11 | vi.mock(import('node:fs'), () => fs); 12 | 13 | afterEach(() => { 14 | const contents = vol.toJSON(); 15 | for (const key of Object.keys(contents)) { 16 | vol.rmSync(key); 17 | } 18 | }); 19 | 20 | describe('plugin: define', () => { 21 | it('defines environment variables that can be used in the bundled assets', async () => { 22 | fs.writeFileSync('/.env', ['WP_ENV=test', 'NOT_INCLUDED=nope'].join('\n')); 23 | 24 | const build = await runPluginSetup('dev'); 25 | expect(build.initialOptions.define).toHaveProperty('process.env.NODE_ENV', JSON.stringify('development')); 26 | expect(build.initialOptions.define).toHaveProperty('process.env.WP_ENV', JSON.stringify('test')); 27 | expect(build.initialOptions.define).not.toHaveProperty('process.env.NOT_INCLUDED'); 28 | }); 29 | 30 | it('will not overwrite variables set on process.env', async () => { 31 | process.env['WP_ENV'] = 'test'; 32 | fs.writeFileSync('/.env', ['WP_ENV=production'].join('\n')); 33 | 34 | const build = await runPluginSetup('dev'); 35 | expect(build.initialOptions.define).toHaveProperty('process.env.WP_ENV', JSON.stringify('test')); 36 | delete process.env['WP_ENV']; 37 | }); 38 | 39 | it('will let different env files take precedence (development)', async () => { 40 | fs.writeFileSync('/.env', ['WP_FILE=.env'].join('\n')); 41 | fs.writeFileSync('/.env.development', ['WP_FILE=.env.development'].join('\n')); 42 | fs.writeFileSync('/.env.local', ['WP_FILE=.env.local'].join('\n')); 43 | fs.writeFileSync('/.env.development.local', ['WP_FILE=.env.development.local'].join('\n')); 44 | 45 | const build = await runPluginSetup('dev'); 46 | expect(build.initialOptions.define).toHaveProperty('process.env.WP_FILE', JSON.stringify('.env.development.local')); 47 | }); 48 | 49 | it('will let different env files take precedence (production)', async () => { 50 | fs.writeFileSync('/.env', ['WP_FILE=.env'].join('\n')); 51 | fs.writeFileSync('/.env.production', ['WP_FILE=.env.production'].join('\n')); 52 | fs.writeFileSync('/.env.local', ['WP_FILE=.env.local'].join('\n')); 53 | fs.writeFileSync('/.env.production.local', ['WP_FILE=.env.production.local'].join('\n')); 54 | 55 | const build = await runPluginSetup('prod'); 56 | expect(build.initialOptions.define).toHaveProperty('process.env.WP_FILE', JSON.stringify('.env.production.local')); 57 | }); 58 | 59 | it('will set initialOptions.define if it is not already set', async () => { 60 | const root = '/'; 61 | const plugin = define( 62 | fromPartial({ 63 | mode: 'dev', 64 | project: { 65 | paths: { 66 | absolute: (to: string, ...rest: string[]) => (path.isAbsolute(to) ? to : path.join(root, to, ...rest)), 67 | }, 68 | }, 69 | }), 70 | ); 71 | 72 | const build = fromPartial({ initialOptions: {} }); 73 | await plugin.setup(build); 74 | 75 | expect(build.initialOptions.define).toMatchInlineSnapshot(` 76 | { 77 | "process.env.NODE_ENV": ""development"", 78 | } 79 | `); 80 | }); 81 | }); 82 | 83 | async function runPluginSetup(mode: 'dev' | 'prod') { 84 | const root = '/'; 85 | const plugin = define( 86 | fromPartial({ 87 | mode, 88 | project: { 89 | paths: { 90 | absolute: (to: string, ...rest: string[]) => (path.isAbsolute(to) ? to : path.join(root, to, ...rest)), 91 | }, 92 | }, 93 | }), 94 | ); 95 | 96 | const build = fromPartial({ initialOptions: { define: {} } }); 97 | await plugin.setup(build); 98 | 99 | return build; 100 | } 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fransvilhelm/wp-bundler", 3 | "version": "5.0.0", 4 | "description": "A fast bundler tailored for WordPress based on esbuild", 5 | "type": "module", 6 | "exports": { 7 | ".": "./dist/index.js", 8 | "./package.json": "./package.json" 9 | }, 10 | "bin": { 11 | "wp-bundler": "./cli.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com/adambrgmn/wp-bundler.git" 16 | }, 17 | "author": { 18 | "name": "Adam Bergman", 19 | "email": "adam@fransvilhelm.com" 20 | }, 21 | "homepage": "https://github.com/adambrgmn/wp-bundler#readme", 22 | "bugs": { 23 | "url": "https://github.com/adambrgmn/wp-bundler/issues" 24 | }, 25 | "license": "MIT", 26 | "files": [ 27 | "dist", 28 | "assets", 29 | "cli.js" 30 | ], 31 | "keywords": [ 32 | "wordpress", 33 | "esbuild", 34 | "bundler" 35 | ], 36 | "engines": { 37 | "node": ">=20" 38 | }, 39 | "scripts": { 40 | "prebuild": "rm -rf dist", 41 | "build": "tsc -p ./tsconfig.build.json && npm run build:dev-client", 42 | "build:dev-client": "esbuild --bundle ./src/dev-client.ts --outfile=dist/dev-client.js --sourcemap=inline --format=esm", 43 | "predev": "rm -rf dist", 44 | "dev": "tsc -p ./tsconfig.build.json --watch", 45 | "dev:dev-client": "npm run build:dev-client -- --watch", 46 | "lint": "eslint", 47 | "format": "prettier --write .", 48 | "test": "vitest --ui --coverage", 49 | "test:coverage": "vitest run --coverage", 50 | "type-check": "tsc --noEmit", 51 | "release": "npm run build && npx publint run && changeset publish", 52 | "examples:start": "cd examples && npx -p @wordpress/env wp-env start", 53 | "examples:destroy": "cd examples && npx -p @wordpress/env wp-env destroy", 54 | "examples:stop": "cd examples && npx -p @wordpress/env wp-env stop", 55 | "examples:build": "npm run --prefix ./examples/wp-bundler-theme build && npm run --prefix ./examples/wp-bundler-plugin build", 56 | "examples:build-dev": "npm run --prefix ./examples/wp-bundler-theme build -- --mode dev && npm run --prefix ./examples/wp-bundler-plugin build -- --mode dev" 57 | }, 58 | "dependencies": { 59 | "@swc/core": "^1.15.2", 60 | "@wordpress/icons": "^11.2.0", 61 | "chalk": "^5.6.2", 62 | "esbuild": "^0.27.0", 63 | "filesize": "^11.0.13", 64 | "gettext-parser": "^8.0.0", 65 | "globby": "^16.0.0", 66 | "lodash.mergewith": "^4.6.2", 67 | "php-parser": "^3.2.5", 68 | "postcss": "^8.5.6", 69 | "postcss-preset-env": "^10.4.0", 70 | "strman": "^2.0.1", 71 | "twing": "^7.2.2", 72 | "type-fest": "^5.2.0", 73 | "uint8array-extras": "^1.5.0", 74 | "yargs": "^18.0.0", 75 | "zod": "^4.1.12" 76 | }, 77 | "devDependencies": { 78 | "@changesets/cli": "^2.29.7", 79 | "@eslint/js": "^9.39.1", 80 | "@fransvilhelm/changeset-changelog": "^1.1.1", 81 | "@prettier/plugin-php": "^0.24.0", 82 | "@trivago/prettier-plugin-sort-imports": "^6.0.0", 83 | "@tsconfig/node20": "^20.1.8", 84 | "@tsconfig/strictest": "^2.0.8", 85 | "@types/gettext-parser": "^8.0.0", 86 | "@types/lodash.merge": "^4.6.9", 87 | "@types/lodash.mergewith": "^4.6.9", 88 | "@types/node": "^24.10.1", 89 | "@types/strman": "^2.0.3", 90 | "@types/yargs": "^17.0.35", 91 | "@vitest/coverage-v8": "^4.0.10", 92 | "@vitest/ui": "^4.0.10", 93 | "eslint": "^9.39.1", 94 | "globals": "^16.5.0", 95 | "lodash.merge": "^4.6.2", 96 | "memfs": "^4.51.0", 97 | "prettier": "^3.6.2", 98 | "typescript": "^5.9.3", 99 | "typescript-eslint": "^8.47.0", 100 | "vitest": "^4.0.10" 101 | }, 102 | "prettier": { 103 | "singleQuote": true, 104 | "trailingComma": "all", 105 | "proseWrap": "never", 106 | "printWidth": 120, 107 | "importOrder": [ 108 | "^node:(.*)$", 109 | "", 110 | "^[./]" 111 | ], 112 | "importOrderSeparation": true, 113 | "importOrderSortSpecifiers": true, 114 | "plugins": [ 115 | "@trivago/prettier-plugin-sort-imports" 116 | ] 117 | }, 118 | "packageManager": "npm@9.5.1" 119 | } 120 | -------------------------------------------------------------------------------- /src/utils/resolve-config.test.ts: -------------------------------------------------------------------------------- 1 | import merge from 'lodash.merge'; 2 | import type { JsonValue } from 'type-fest'; 3 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 4 | 5 | import type { ProjectInfo } from '../types.js'; 6 | import { createPaths } from './read-pkg.js'; 7 | import { _resolveConfig } from './resolve-config.js'; 8 | 9 | const readJson = vi.fn<(path: string) => JsonValue | undefined>(); 10 | const resolveConfig = (project: ProjectInfo) => _resolveConfig(project, readJson); 11 | 12 | beforeEach(() => { 13 | vi.resetAllMocks(); 14 | }); 15 | 16 | describe('resolveConfig()', () => { 17 | it('resolves configuration from package.json', () => { 18 | let project = createPackageInfo({ packageJson: { 'wp-bundler': { entryPoints: { entry: './src/index.ts' } } } }); 19 | let configuration = resolveConfig(project); 20 | expect(configuration).toHaveProperty('entryPoints', { entry: './src/index.ts' }); 21 | }); 22 | 23 | it('resolves configuration from .wp-bundlerrc', () => { 24 | readJson.mockImplementation((path) => { 25 | if (path === '/project/.wp-bundlerrc') { 26 | return { entryPoints: { entry: './src/index.ts' } }; 27 | } else { 28 | return undefined; 29 | } 30 | }); 31 | 32 | let project = createPackageInfo({ packageJson: {} }); 33 | let configuration = resolveConfig(project); 34 | expect(configuration).toHaveProperty('entryPoints', { entry: './src/index.ts' }); 35 | }); 36 | 37 | it('resolves configuration from wp-bundler.config.json', () => { 38 | readJson.mockImplementation((path) => { 39 | if (path === '/project/wp-bundler.config.json') { 40 | return { entryPoints: { entry: './src/index.ts' } }; 41 | } else { 42 | return undefined; 43 | } 44 | }); 45 | 46 | let project = createPackageInfo({ packageJson: {} }); 47 | let configuration = resolveConfig(project); 48 | expect(configuration).toHaveProperty('entryPoints', { entry: './src/index.ts' }); 49 | }); 50 | 51 | it('will log a warning if more than one way of config is found', () => { 52 | readJson.mockImplementation((path) => { 53 | if (path === '/project/.wp-bundlerrc') { 54 | return { entryPoints: { entry: './src/index.ts' } }; 55 | } else { 56 | return undefined; 57 | } 58 | }); 59 | 60 | let spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 61 | 62 | let project = createPackageInfo({ packageJson: { 'wp-bundler': { entryPoints: { entry: './src/index.ts' } } } }); 63 | resolveConfig(project); 64 | 65 | expect(spy).toHaveBeenCalled(); 66 | expect(spy.mock.calls.at(0)?.at(0)).toMatchInlineSnapshot( 67 | `"Found more than one wp-bundler configuration (package.json, .wp-bundlerrc). It is recommended to only stick with one of the options: package.json["wp-bundler"], .wp-bundlerrc or wp-bundler.config.json."`, 68 | ); 69 | }); 70 | 71 | it('will throw an error if no proper configuration was found', () => { 72 | let project = createPackageInfo({ packageJson: {} }); 73 | expect(() => resolveConfig(project)).toThrowErrorMatchingInlineSnapshot( 74 | `[Error: Could not resolve a configuration file. Either configure wp-bundler in your package.json, in .wp-bundlerrc or in wp-bundler.config.json.]`, 75 | ); 76 | }); 77 | 78 | it('will throw an error if the resolved configuration does not match the expected schema', () => { 79 | let spy = vi.spyOn(console, 'error').mockImplementation(() => {}); 80 | 81 | let project = createPackageInfo({ packageJson: { 'wp-bundler': { entryPoints: { entry: 1 } } } }); 82 | expect(() => resolveConfig(project)).toThrowErrorMatchingInlineSnapshot( 83 | `[Error: Something is wrong in your configuration file.]`, 84 | ); 85 | 86 | expect(spy).toHaveBeenCalled(); 87 | expect(spy.mock.calls.at(0)?.at(0)).toMatchInlineSnapshot(` 88 | "[ 89 | { 90 | "expected": "string", 91 | "code": "invalid_type", 92 | "path": [ 93 | "entryPoints", 94 | "entry" 95 | ], 96 | "message": "Invalid input: expected string, received number" 97 | } 98 | ]" 99 | `); 100 | }); 101 | }); 102 | 103 | function createPackageInfo(override: Partial = {}): ProjectInfo { 104 | return merge( 105 | { 106 | packageJson: {}, 107 | path: '/project/package.json', 108 | paths: createPaths('/project/package.json'), 109 | }, 110 | override, 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'node:stream'; 2 | 3 | import { Chalk } from 'chalk'; 4 | import type { Note } from 'esbuild'; 5 | import { describe, expect, it } from 'vitest'; 6 | 7 | import { Logger } from './logger.js'; 8 | 9 | const chalk = new Chalk({ level: 0 }); 10 | 11 | describe('Logger', () => { 12 | it('should log depending on level', () => { 13 | const writer = new TestWriter(); 14 | let logger = new Logger('prefix', writer, chalk); 15 | 16 | logger.info('info'); 17 | logger.success('success'); 18 | logger.warn('warn'); 19 | logger.error('error'); 20 | logger.raw('raw'); 21 | 22 | expect(writer.output()).toMatchInlineSnapshot(` 23 | "▶ prefix info 24 | ✔ prefix success 25 | ▲ WARNING warn 26 | ✖ ERROR error 27 | raw 28 | " 29 | `); 30 | }); 31 | 32 | it('can format esbuild messages', () => { 33 | const writer = new TestWriter(); 34 | let logger = new Logger('prefix', writer, chalk); 35 | 36 | logger.buildResult({ 37 | errors: [ 38 | { 39 | id: '1', 40 | pluginName: 'test', 41 | detail: {}, 42 | text: 'something went wrong', 43 | location: { 44 | file: 'test.ts', 45 | line: 1, 46 | column: 3, 47 | lineText: 'console.log("hello world");', 48 | namespace: 'file', 49 | length: 20, 50 | suggestion: 'Write a better message', 51 | }, 52 | notes: [ 53 | { text: 'A note', location: {} as Note['location'] }, 54 | { 55 | text: 'The plugin "something" failed to load (this note should be ignored)', 56 | location: {} as Note['location'], 57 | }, 58 | ], 59 | }, 60 | ], 61 | warnings: [ 62 | { 63 | id: '2', 64 | pluginName: 'test', 65 | detail: {}, 66 | text: 'you have been warned', 67 | location: { 68 | file: 'warn.ts', 69 | line: 1, 70 | column: 3, 71 | lineText: 'with {', 72 | namespace: 'file', 73 | length: 20, 74 | suggestion: 'Do not use `with`', 75 | }, 76 | notes: [], 77 | }, 78 | ], 79 | }); 80 | 81 | expect(writer.output()).toMatchInlineSnapshot(` 82 | "▲ WARNING you have been warned 83 | 84 | warn.ts:1:3: 85 | 1 │ with { 86 | 87 | ✖ ERROR something went wrong 88 | 89 | test.ts:1:3: 90 | 1 │ console.log("hello world"); 91 | 92 | A note 93 | 94 | " 95 | `); 96 | }); 97 | 98 | it('can format bundle output', () => { 99 | const writer = new TestWriter(); 100 | let logger = new Logger('prefix', writer, chalk); 101 | 102 | function output(path: string, text: string) { 103 | const encoder = new TextEncoder(); 104 | return { path, contents: encoder.encode(text), text, hash: '' }; 105 | } 106 | 107 | logger.buildOutput({ 108 | root: '/', 109 | metafile: { 110 | inputs: {}, 111 | outputs: { 112 | 'dist/entry.js': { bytes: 100, inputs: {}, imports: [], exports: [], entryPoint: 'src/entry.ts' }, 113 | 'dist/entry.css': { bytes: 100, inputs: {}, imports: [], exports: [], entryPoint: 'src/entry.ts' }, 114 | 'dist/AssetLoader.php': { bytes: 100, inputs: {}, imports: [], exports: [] }, 115 | }, 116 | }, 117 | entryPoints: { 118 | entry: 'src/entry.ts', 119 | }, 120 | outputFiles: [ 121 | output('dist/entry.js', 'console.log("hello world");\n'), 122 | output('dist/entry.css', '.button { color: rebeccapurple; }\n'), 123 | output('dist/AssetLoader.php', ' void) | undefined): boolean; 145 | override write( 146 | chunk: string | Buffer, 147 | encoding: BufferEncoding, 148 | callback: ((error: Error | null | undefined) => void) | undefined, 149 | ): boolean; 150 | override write(chunk: unknown): boolean { 151 | if (typeof chunk === 'string') { 152 | this.#lines.push(chunk); 153 | return true; 154 | } 155 | 156 | return false; 157 | } 158 | 159 | output() { 160 | return this.#lines.join(''); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/utils/extract-translations/twig.ts: -------------------------------------------------------------------------------- 1 | import type { Location } from 'esbuild'; 2 | import { 3 | type TwingArrayNode, 4 | type TwingBaseNode, 5 | type TwingCommentNode, 6 | type TwingFunctionNode, 7 | type TwingNode, 8 | createArrayLoader, 9 | createEnvironment, 10 | createSource, 11 | } from 'twing'; 12 | 13 | import { ensure } from '../assert.js'; 14 | import type { TranslationMessage } from './types.js'; 15 | import { isTranslatorsComment } from './utils.js'; 16 | 17 | export { mightHaveTranslations } from './php.js'; 18 | 19 | export function extractTranslations(source: string, filename: string): TranslationMessage[] { 20 | let messages: TranslationMessage[] = []; 21 | 22 | let lastTranslators: string | undefined = undefined; 23 | 24 | visitAll(getAst(source, filename), (node) => { 25 | if (node.type === 'function') { 26 | let translation = extractTranslationFromCall(node, filename); 27 | if (translation) { 28 | translation.translators = lastTranslators; 29 | lastTranslators = undefined; 30 | messages.push(translation); 31 | } 32 | } 33 | 34 | if (node.type === 'comment') { 35 | let translators = getTranslatorComment(node); 36 | if (translators) lastTranslators = translators; 37 | } 38 | }); 39 | 40 | return messages; 41 | } 42 | 43 | const env = createEnvironment(createArrayLoader({}), {}); 44 | 45 | function getAst(code: string, filename: string) { 46 | let source = createSource(filename, code); 47 | return env.parse(env.tokenize(source), { strict: false }); 48 | } 49 | 50 | function visitAll(node: TwingNode, callback: (node: TwingNode) => void) { 51 | for (let child of getChildren(node)) { 52 | callback(child); 53 | visitAll(child, callback); 54 | } 55 | } 56 | 57 | function getChildren(node: TwingBaseNode): TwingNode[] { 58 | return Object.values(node.children); 59 | } 60 | 61 | function extractTranslationFromCall(call: TwingFunctionNode, file: string): TranslationMessage | null { 62 | let location: Location = { 63 | file, 64 | namespace: '', 65 | line: call.line, 66 | column: call.column - 1, 67 | length: 0, 68 | lineText: '', 69 | suggestion: '', 70 | }; 71 | 72 | let args = call.children.arguments; 73 | 74 | switch (call.attributes.operatorName) { 75 | case '__': 76 | case '_e': 77 | case 'esc_attr__': 78 | case 'esc_attr_e': 79 | case 'esc_html__': 80 | case 'esc_html_e': 81 | return { 82 | text: ensure(getArgumentStringValue(args, 0)), 83 | domain: getArgumentStringValue(args, 1), 84 | location, 85 | }; 86 | 87 | case '_x': 88 | case '_ex': 89 | case 'esc_attr_x': 90 | case 'esc_html_x': 91 | return { 92 | text: ensure(getArgumentStringValue(args, 0)), 93 | context: ensure(getArgumentStringValue(args, 1)), 94 | domain: getArgumentStringValue(args, 2), 95 | location, 96 | }; 97 | 98 | case '_n': 99 | return { 100 | single: ensure(getArgumentStringValue(args, 0)), 101 | plural: ensure(getArgumentStringValue(args, 1)), 102 | domain: getArgumentStringValue(args, 3), 103 | location, 104 | }; 105 | 106 | case '_n_noop': 107 | return { 108 | single: ensure(getArgumentStringValue(args, 0)), 109 | plural: ensure(getArgumentStringValue(args, 1)), 110 | domain: getArgumentStringValue(args, 2), 111 | location, 112 | }; 113 | 114 | case '_nx': 115 | return { 116 | single: ensure(getArgumentStringValue(args, 0)), 117 | plural: ensure(getArgumentStringValue(args, 1)), 118 | context: ensure(getArgumentStringValue(args, 3)), 119 | domain: getArgumentStringValue(args, 4), 120 | location, 121 | }; 122 | 123 | case '_nx_noop': 124 | return { 125 | single: ensure(getArgumentStringValue(args, 0)), 126 | plural: ensure(getArgumentStringValue(args, 1)), 127 | context: ensure(getArgumentStringValue(args, 2)), 128 | domain: getArgumentStringValue(args, 3), 129 | location, 130 | }; 131 | 132 | default: 133 | return null; 134 | } 135 | } 136 | 137 | function getArgumentStringValue(args: TwingArrayNode, index: number) { 138 | /** 139 | * When parsing the ast twing inserts indices as part of the arguments for some unknown reason. 140 | * That's why we have to cater for the fact that the "array" looks like this: 141 | * `[{value:0}, {value: 'Translation'}, {value: 1}, {value: 'domain'}]` 142 | */ 143 | let clean_index = index * 2 + 1; 144 | let argument = args.children[`${clean_index}`]; 145 | if (argument == null) return undefined; 146 | 147 | let attr = 'value' in argument.attributes ? argument.attributes.value : undefined; 148 | return typeof attr === 'string' ? attr : undefined; 149 | } 150 | 151 | function getTranslatorComment(node: TwingCommentNode) { 152 | let comment = node.attributes.data; 153 | if (isTranslatorsComment(comment)) return comment; 154 | return null; 155 | } 156 | -------------------------------------------------------------------------------- /src/utils/extract-translations/php.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest'; 2 | 3 | import type { TranslationMessage } from './index.js'; 4 | import { extractTranslations } from './php.js'; 5 | 6 | it('extracts translations from php files', () => { 7 | let source = ` 8 | { 30 | let source = ` 31 | { 42 | let source = ` 43 | { 57 | let source = ` 58 | { 70 | let source = ` 71 | { 80 | let source = ` 81 | __('Translation 1', 'wp-bundler') 84 | ]; 85 | 86 | $var2 = [__('Translation 2', 'wp-bundler')]; 87 | `.trim(); 88 | 89 | let result = extractTranslations(source, 'test.php'); 90 | expect(removeLocation(result)).toEqual([ 91 | { text: 'Translation 1', domain: 'wp-bundler' }, 92 | { text: 'Translation 2', domain: 'wp-bundler' }, 93 | ]); 94 | }); 95 | 96 | it('extracts translations with leading slashes (e.g. \\__(...))', () => { 97 | let source = ` 98 | { 107 | let source = ` 108 | { 128 | let source = ` 129 | t); 156 | } 157 | -------------------------------------------------------------------------------- /src/utils/asset-loader.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import * as path from 'node:path'; 3 | 4 | import type { Metafile } from 'esbuild'; 5 | import { slugify } from 'strman'; 6 | 7 | import type { BundlerConfig } from '../schema.js'; 8 | import type { BundlerOptions, Mode, ProjectInfo } from '../types.js'; 9 | import { findBuiltinDependencies } from './externals.js'; 10 | 11 | interface Asset { 12 | js?: string | undefined; 13 | nomodule?: string | undefined; 14 | css?: string | undefined; 15 | deps: string[]; 16 | } 17 | 18 | type AssetsRecord = Record; 19 | 20 | interface TemplateCompileOptions { 21 | metafile: Pick; 22 | config: BundlerConfig; 23 | bundler: ProjectInfo; 24 | project: ProjectInfo; 25 | mode: Mode; 26 | client: string; 27 | host: string; 28 | port: number; 29 | } 30 | 31 | export function createAssetLoaderTemplate({ config, bundler, project, mode, host, port }: BundlerOptions) { 32 | let templatePath = bundler.paths.absolute('./assets/AssetLoader.php'); 33 | let compile = createTemplate(readFileSync(templatePath, 'utf-8')); 34 | let client = readFileSync(bundler.paths.absolute('dist/dev-client.js'), 'utf-8'); 35 | 36 | return ({ metafile }: Pick) => { 37 | return compile({ metafile, config, bundler, project, mode, client, host, port }); 38 | }; 39 | } 40 | 41 | function createTemplate(content: string) { 42 | return function compile({ metafile, config, bundler, project, mode, client, host, port }: TemplateCompileOptions) { 43 | let assetsArray = toPhpArray(metafileToAssets(metafile, config.entryPoints)); 44 | 45 | content = content.replace('* @version v0.0.0', `* @version v${bundler.packageJson.version}`); 46 | 47 | content = content.replace('namespace WPBundler;', `namespace ${config.assetLoader.namespace};`); 48 | 49 | content = content.replace("private static $mode = 'prod'", `protected static $mode = '${mode}'`); 50 | content = content.replace("private static $host = 'localhost'", `protected static $host = '${host}'`); 51 | content = content.replace('private static $port = 3000', `protected static $port = ${port}`); 52 | content = content.replace("private static $dev_client = ''", `private static $dev_client = '${client}'`); 53 | 54 | content = content.replace( 55 | "private static $domain = 'domain';", 56 | `private static $domain = '${config.translations?.domain ?? ''}';`, 57 | ); 58 | 59 | content = content.replace( 60 | "private static $outdir = '/build/';", 61 | `private static $outdir = '/${trimSlashes(config.outdir)}/';`, 62 | ); 63 | 64 | content = content.replace( 65 | "private static $prefix = 'wp-bundler.';", 66 | `private static $prefix = '${slugify(project.packageJson.name ?? 'wp-bundler')}.';`, 67 | ); 68 | 69 | content = content.replace('private static $assets = [];', `private static $assets = ${assetsArray};`); 70 | 71 | return content; 72 | }; 73 | } 74 | 75 | function metafileToAssets({ outputs }: Pick, entryPoints: BundlerConfig['entryPoints']) { 76 | let assets: AssetsRecord = {}; 77 | let names = Object.keys(entryPoints); 78 | 79 | for (let name of names) { 80 | let keys = Object.keys(outputs); 81 | let js = keys.find((key) => key.includes(name) && key.endsWith('.js') && !key.endsWith('.nomodule.js')); 82 | 83 | let nomodule = keys.find((key) => key.includes(name) && /\.nomodule\.[a-zA-Z0-9]+\.js$/g.test(key)); 84 | 85 | let css = keys.find((key) => key.includes(name) && key.endsWith('.css')); 86 | 87 | assets[name] = { 88 | js: js ? path.basename(js) : undefined, 89 | nomodule: nomodule ? path.basename(nomodule) : undefined, 90 | css: css ? path.basename(css) : undefined, 91 | deps: js != null ? findBuiltinDependencies(outputs[js]?.inputs ?? {}) : [], 92 | }; 93 | } 94 | 95 | return assets; 96 | } 97 | 98 | export function toPhpArray(obj: Record | Array) { 99 | let arr = '['; 100 | 101 | function inner(value: unknown): string | null { 102 | if (typeof value === 'string') { 103 | return `"${value}"`; 104 | } 105 | 106 | if (typeof value === 'number') { 107 | return `${value}`; 108 | } 109 | 110 | if (typeof value === 'boolean') { 111 | return value ? 'true' : 'false'; 112 | } 113 | 114 | if (value === null) { 115 | return 'null'; 116 | } 117 | 118 | if (typeof value === 'undefined') { 119 | return null; 120 | } 121 | 122 | if (Array.isArray(value)) { 123 | return toPhpArray(value); 124 | } 125 | 126 | if (typeof value === 'object' && value != null) { 127 | return toPhpArray(value as Record); 128 | } 129 | 130 | return null; 131 | } 132 | 133 | if (Array.isArray(obj)) { 134 | for (let value of obj) { 135 | let val = inner(value); 136 | arr += `${val ?? '$__undefined'},`; 137 | } 138 | } else { 139 | for (let key of Object.keys(obj)) { 140 | let val = inner(obj[key]); 141 | if (val != null) arr += `"${key}"=>${val},`; 142 | } 143 | } 144 | 145 | arr += ']'; 146 | return arr; 147 | } 148 | 149 | function trimSlashes(str: string) { 150 | return str.replace(/^\./, '').replace(/^\//, '').replace(/\/$/, ''); 151 | } 152 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at @adambrgmn. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 80 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | import { Writable } from 'node:stream'; 3 | import * as util from 'node:util'; 4 | 5 | import { type ChalkInstance, default as chalkDefault } from 'chalk'; 6 | import type { BuildResult, PartialMessage } from 'esbuild'; 7 | import { filesize } from 'filesize'; 8 | 9 | import { type BundleOutputOptions, constructBundleOutput } from './utils/bundle-output.js'; 10 | import { figures } from './utils/figures.js'; 11 | 12 | export class Logger { 13 | #prefixValue: string; 14 | #target: Writable; 15 | 16 | chalk: ChalkInstance; 17 | 18 | #prefixColors: { 19 | warning: [icon: typeof chalkDefault, prefix: typeof chalkDefault]; 20 | error: [icon: typeof chalkDefault, prefix: typeof chalkDefault]; 21 | success: [icon: typeof chalkDefault, prefix: typeof chalkDefault]; 22 | default: [icon: typeof chalkDefault, prefix: typeof chalkDefault]; 23 | }; 24 | 25 | constructor(prefix: string, target: Writable = process.stdout, chalk: ChalkInstance = chalkDefault) { 26 | this.#prefixValue = prefix; 27 | this.#target = target; 28 | this.chalk = chalk; 29 | 30 | this.#prefixColors = { 31 | warning: [chalk.yellowBright, chalk.black.bgYellowBright], 32 | error: [chalk.redBright, chalk.white.bgRedBright], 33 | success: [chalk.greenBright, chalk.black.bgGreenBright], 34 | default: [chalk.blue, chalk.black.bgBlue], 35 | }; 36 | } 37 | 38 | info(message: unknown) { 39 | this.#write(message); 40 | } 41 | 42 | success(message: unknown) { 43 | this.#write(this.chalk.green(message), { state: 'success' }); 44 | } 45 | 46 | warn(message: unknown) { 47 | this.#write(this.chalk.yellow(message), { prefix: 'WARNING' }); 48 | } 49 | 50 | error(message: unknown) { 51 | this.#write(this.chalk.red(message), { prefix: 'ERROR' }); 52 | } 53 | 54 | formattedMessage(kind: 'error' | 'warning', message: PartialMessage) { 55 | let lines = []; 56 | lines.push(`${this.#prefix({ prefix: kind.toUpperCase() })}${this.chalk.bold(message.text)}`); 57 | 58 | if (message.location?.file != null) { 59 | lines.push(''); 60 | lines.push(` ${[message.location.file, message.location.line, message.location.column].join(':')}:`); 61 | } 62 | 63 | if (message.location?.lineText != null) { 64 | let mark = this.chalk.green; 65 | let { column = 0, length = 0, line = 0, lineText } = message.location; 66 | let init = ` ${line} ${figures.lineVertical} `; 67 | 68 | let parts = [ 69 | lineText.slice(0, column), 70 | mark.underline(lineText.slice(column, column + length)), 71 | lineText.slice(column + length), 72 | ]; 73 | lines.push(`${init}${parts.join('')}`); 74 | } 75 | 76 | if (message.notes != null) { 77 | for (let note of message.notes) { 78 | // esbuild plugin notes 79 | if (note.text?.startsWith('The plugin "')) continue; 80 | lines.push(''); 81 | lines.push(` ${note.text}`); 82 | } 83 | } 84 | 85 | for (let line of lines) { 86 | this.raw(line); 87 | } 88 | this.raw(''); 89 | } 90 | 91 | buildResult(result: Pick) { 92 | for (let warning of result?.warnings ?? []) { 93 | this.formattedMessage('warning', warning); 94 | } 95 | 96 | for (let error of result?.errors ?? []) { 97 | this.formattedMessage('error', error); 98 | } 99 | } 100 | 101 | buildOutput(options: BundleOutputOptions) { 102 | let output = constructBundleOutput(options); 103 | for (let [name, part] of Object.entries(output)) { 104 | this.raw('\n' + this.chalk.blue(name)); 105 | for (let { file, size } of part) { 106 | let sizeStr = size == null ? '' : `(${filesize(size)})`; 107 | this.raw(` ${file} ${sizeStr}`); 108 | } 109 | } 110 | 111 | this.raw(''); 112 | } 113 | 114 | raw(message: unknown) { 115 | if (typeof message !== 'string') { 116 | message = util.inspect(message, { depth: null, colors: true }); 117 | } 118 | 119 | this.#write(message, { withPrefix: false }); 120 | } 121 | 122 | #write(message: unknown, { withPrefix = true, ...prefix }: WriteOptions = {}) { 123 | let parsedMessage = isStringifiable(message) ? message.toString() : JSON.stringify(message); 124 | this.#target.write(`${withPrefix ? this.#prefix(prefix) : ''}${parsedMessage}\n`); 125 | } 126 | 127 | #prefix({ prefix = this.#prefixValue, state = prefix }: PrefixOptions = {}) { 128 | let key = (state?.toLowerCase() ?? 'default') as keyof typeof PREFIX_ICONS; 129 | let [iconColor, prefixColor] = this.#prefixColors[key] ?? this.#prefixColors.default; 130 | let icon = PREFIX_ICONS[key] ?? PREFIX_ICONS.default; 131 | 132 | return `${iconColor(icon)} ${prefixColor(` ${prefix} `)} `; 133 | } 134 | } 135 | 136 | function isStringifiable(value: unknown): value is { toString(): string } { 137 | return value != null && typeof value.toString === 'function'; 138 | } 139 | 140 | const PREFIX_ICONS = { 141 | warning: figures.triangleUp, 142 | error: figures.cross, 143 | success: figures.tick, 144 | default: figures.triangleRight, 145 | } as const; 146 | 147 | interface PrefixOptions { 148 | prefix?: string | null; 149 | state?: string | null; 150 | } 151 | 152 | interface WriteOptions extends PrefixOptions { 153 | withPrefix?: boolean; 154 | } 155 | -------------------------------------------------------------------------------- /examples/wp-bundler-theme/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fransvilhelm/wp-bundler-theme", 3 | "version": "0.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@fransvilhelm/wp-bundler-theme", 9 | "version": "0.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@fransvilhelm/wp-bundler": "file:../..", 13 | "typescript": "^4.9.4" 14 | } 15 | }, 16 | "../..": { 17 | "name": "@fransvilhelm/wp-bundler", 18 | "version": "3.0.1", 19 | "dev": true, 20 | "license": "MIT", 21 | "dependencies": { 22 | "@swc/core": "^1.2.239", 23 | "@wordpress/icons": "^9.6.0", 24 | "chalk": "^5.0.1", 25 | "chokidar": "^3.5.3", 26 | "dotenv": "^16.0.1", 27 | "esbuild": "^0.15.5", 28 | "execa": "^6.1.0", 29 | "filesize": "^9.0.11", 30 | "gettext-parser": "^6.0.0", 31 | "globby": "^13.1.2", 32 | "lodash.debounce": "^4.0.8", 33 | "lodash.merge": "^4.6.2", 34 | "lodash.mergewith": "^4.6.2", 35 | "md5": "^2.3.0", 36 | "php-parser": "^3.1.0", 37 | "postcss": "^8.4.16", 38 | "postcss-preset-env": "^7.8.0", 39 | "strman": "^2.0.1", 40 | "twing": "^5.1.2", 41 | "type-fest": "^2.18.1", 42 | "ws": "^8.8.1", 43 | "xstate": "^4.33.2", 44 | "yargs": "^17.5.1", 45 | "zod": "^3.18.0" 46 | }, 47 | "bin": { 48 | "wp-bundler": "cli.js" 49 | }, 50 | "devDependencies": { 51 | "@changesets/cli": "^2.24.3", 52 | "@fransvilhelm/changeset-changelog": "^1.1.1", 53 | "@prettier/plugin-php": "^0.18.9", 54 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 55 | "@types/gettext-parser": "^4.0.1", 56 | "@types/lodash.debounce": "^4.0.7", 57 | "@types/lodash.merge": "^4.6.7", 58 | "@types/lodash.mergewith": "^4.6.7", 59 | "@types/md5": "^2.3.2", 60 | "@types/node": "^18.7.6", 61 | "@types/strman": "^2.0.0", 62 | "@types/ws": "^8.5.3", 63 | "@types/yargs": "^17.0.11", 64 | "@vitest/coverage-c8": "^0.22.1", 65 | "eslint": "^8.22.0", 66 | "eslint-config-react-app": "^7.0.1", 67 | "husky": ">=8.0.1", 68 | "lint-staged": ">=13.0.3", 69 | "prettier": "^2.7.1", 70 | "typescript": "^4.7.4", 71 | "vitest": "^0.22.1" 72 | }, 73 | "engines": { 74 | "node": ">=14.18.0" 75 | }, 76 | "peerDependencies": { 77 | "typescript": ">4.0.0" 78 | } 79 | }, 80 | "node_modules/@fransvilhelm/wp-bundler": { 81 | "resolved": "../..", 82 | "link": true 83 | }, 84 | "node_modules/typescript": { 85 | "version": "4.9.4", 86 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", 87 | "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", 88 | "dev": true, 89 | "bin": { 90 | "tsc": "bin/tsc", 91 | "tsserver": "bin/tsserver" 92 | }, 93 | "engines": { 94 | "node": ">=4.2.0" 95 | } 96 | } 97 | }, 98 | "dependencies": { 99 | "@fransvilhelm/wp-bundler": { 100 | "version": "file:../..", 101 | "requires": { 102 | "@changesets/cli": "^2.24.3", 103 | "@fransvilhelm/changeset-changelog": "^1.1.1", 104 | "@prettier/plugin-php": "^0.18.9", 105 | "@swc/core": "^1.2.239", 106 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 107 | "@types/gettext-parser": "^4.0.1", 108 | "@types/lodash.debounce": "^4.0.7", 109 | "@types/lodash.merge": "^4.6.7", 110 | "@types/lodash.mergewith": "^4.6.7", 111 | "@types/md5": "^2.3.2", 112 | "@types/node": "^18.7.6", 113 | "@types/strman": "^2.0.0", 114 | "@types/ws": "^8.5.3", 115 | "@types/yargs": "^17.0.11", 116 | "@vitest/coverage-c8": "^0.22.1", 117 | "@wordpress/icons": "^9.6.0", 118 | "chalk": "^5.0.1", 119 | "chokidar": "^3.5.3", 120 | "dotenv": "^16.0.1", 121 | "esbuild": "^0.15.5", 122 | "eslint": "^8.22.0", 123 | "eslint-config-react-app": "^7.0.1", 124 | "execa": "^6.1.0", 125 | "filesize": "^9.0.11", 126 | "gettext-parser": "^6.0.0", 127 | "globby": "^13.1.2", 128 | "husky": ">=8.0.1", 129 | "lint-staged": ">=13.0.3", 130 | "lodash.debounce": "^4.0.8", 131 | "lodash.merge": "^4.6.2", 132 | "lodash.mergewith": "^4.6.2", 133 | "md5": "^2.3.0", 134 | "php-parser": "^3.1.0", 135 | "postcss": "^8.4.16", 136 | "postcss-preset-env": "^7.8.0", 137 | "prettier": "^2.7.1", 138 | "strman": "^2.0.1", 139 | "twing": "^5.1.2", 140 | "type-fest": "^2.18.1", 141 | "typescript": "^4.7.4", 142 | "vitest": "^0.22.1", 143 | "ws": "^8.8.1", 144 | "xstate": "^4.33.2", 145 | "yargs": "^17.5.1", 146 | "zod": "^3.18.0" 147 | } 148 | }, 149 | "typescript": { 150 | "version": "4.9.4", 151 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", 152 | "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", 153 | "dev": true 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /examples/wp-bundler-plugin/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fransvilhelm/wp-bundler-plugin", 3 | "version": "0.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@fransvilhelm/wp-bundler-plugin", 9 | "version": "0.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@fransvilhelm/wp-bundler": "file:../..", 13 | "typescript": "^4.9.4" 14 | } 15 | }, 16 | "../..": { 17 | "name": "@fransvilhelm/wp-bundler", 18 | "version": "3.0.1", 19 | "dev": true, 20 | "license": "MIT", 21 | "dependencies": { 22 | "@swc/core": "^1.2.239", 23 | "@wordpress/icons": "^9.6.0", 24 | "chalk": "^5.0.1", 25 | "chokidar": "^3.5.3", 26 | "dotenv": "^16.0.1", 27 | "esbuild": "^0.15.5", 28 | "execa": "^6.1.0", 29 | "filesize": "^9.0.11", 30 | "gettext-parser": "^6.0.0", 31 | "globby": "^13.1.2", 32 | "lodash.debounce": "^4.0.8", 33 | "lodash.merge": "^4.6.2", 34 | "lodash.mergewith": "^4.6.2", 35 | "md5": "^2.3.0", 36 | "php-parser": "^3.1.0", 37 | "postcss": "^8.4.16", 38 | "postcss-preset-env": "^7.8.0", 39 | "strman": "^2.0.1", 40 | "twing": "^5.1.2", 41 | "type-fest": "^2.18.1", 42 | "ws": "^8.8.1", 43 | "xstate": "^4.33.2", 44 | "yargs": "^17.5.1", 45 | "zod": "^3.18.0" 46 | }, 47 | "bin": { 48 | "wp-bundler": "cli.js" 49 | }, 50 | "devDependencies": { 51 | "@changesets/cli": "^2.24.3", 52 | "@fransvilhelm/changeset-changelog": "^1.1.1", 53 | "@prettier/plugin-php": "^0.18.9", 54 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 55 | "@types/gettext-parser": "^4.0.1", 56 | "@types/lodash.debounce": "^4.0.7", 57 | "@types/lodash.merge": "^4.6.7", 58 | "@types/lodash.mergewith": "^4.6.7", 59 | "@types/md5": "^2.3.2", 60 | "@types/node": "^18.7.6", 61 | "@types/strman": "^2.0.0", 62 | "@types/ws": "^8.5.3", 63 | "@types/yargs": "^17.0.11", 64 | "@vitest/coverage-c8": "^0.22.1", 65 | "eslint": "^8.22.0", 66 | "eslint-config-react-app": "^7.0.1", 67 | "husky": ">=8.0.1", 68 | "lint-staged": ">=13.0.3", 69 | "prettier": "^2.7.1", 70 | "typescript": "^4.7.4", 71 | "vitest": "^0.22.1" 72 | }, 73 | "engines": { 74 | "node": ">=14.18.0" 75 | }, 76 | "peerDependencies": { 77 | "typescript": ">4.0.0" 78 | } 79 | }, 80 | "node_modules/@fransvilhelm/wp-bundler": { 81 | "resolved": "../..", 82 | "link": true 83 | }, 84 | "node_modules/typescript": { 85 | "version": "4.9.4", 86 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", 87 | "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", 88 | "dev": true, 89 | "bin": { 90 | "tsc": "bin/tsc", 91 | "tsserver": "bin/tsserver" 92 | }, 93 | "engines": { 94 | "node": ">=4.2.0" 95 | } 96 | } 97 | }, 98 | "dependencies": { 99 | "@fransvilhelm/wp-bundler": { 100 | "version": "file:../..", 101 | "requires": { 102 | "@changesets/cli": "^2.24.3", 103 | "@fransvilhelm/changeset-changelog": "^1.1.1", 104 | "@prettier/plugin-php": "^0.18.9", 105 | "@swc/core": "^1.2.239", 106 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 107 | "@types/gettext-parser": "^4.0.1", 108 | "@types/lodash.debounce": "^4.0.7", 109 | "@types/lodash.merge": "^4.6.7", 110 | "@types/lodash.mergewith": "^4.6.7", 111 | "@types/md5": "^2.3.2", 112 | "@types/node": "^18.7.6", 113 | "@types/strman": "^2.0.0", 114 | "@types/ws": "^8.5.3", 115 | "@types/yargs": "^17.0.11", 116 | "@vitest/coverage-c8": "^0.22.1", 117 | "@wordpress/icons": "^9.6.0", 118 | "chalk": "^5.0.1", 119 | "chokidar": "^3.5.3", 120 | "dotenv": "^16.0.1", 121 | "esbuild": "^0.15.5", 122 | "eslint": "^8.22.0", 123 | "eslint-config-react-app": "^7.0.1", 124 | "execa": "^6.1.0", 125 | "filesize": "^9.0.11", 126 | "gettext-parser": "^6.0.0", 127 | "globby": "^13.1.2", 128 | "husky": ">=8.0.1", 129 | "lint-staged": ">=13.0.3", 130 | "lodash.debounce": "^4.0.8", 131 | "lodash.merge": "^4.6.2", 132 | "lodash.mergewith": "^4.6.2", 133 | "md5": "^2.3.0", 134 | "php-parser": "^3.1.0", 135 | "postcss": "^8.4.16", 136 | "postcss-preset-env": "^7.8.0", 137 | "prettier": "^2.7.1", 138 | "strman": "^2.0.1", 139 | "twing": "^5.1.2", 140 | "type-fest": "^2.18.1", 141 | "typescript": "^4.7.4", 142 | "vitest": "^0.22.1", 143 | "ws": "^8.8.1", 144 | "xstate": "^4.33.2", 145 | "yargs": "^17.5.1", 146 | "zod": "^3.18.0" 147 | } 148 | }, 149 | "typescript": { 150 | "version": "4.9.4", 151 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", 152 | "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", 153 | "dev": true 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/utils/extract-translations/javascript.test.ts: -------------------------------------------------------------------------------- 1 | import type { Location } from 'esbuild'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { extractTranslations } from './javascript.js'; 5 | 6 | it('extract translations from regular ts files', () => { 7 | let source = ` 8 | import { __ } from '@wordpress/i18n'; 9 | let translated: string = __('Translate this', 'wp-bundler'); 10 | `; 11 | 12 | let result = extractTranslations(source, 'test.ts'); 13 | expect(result).toEqual([{ text: 'Translate this', domain: 'wp-bundler', location: expect.anything() as Location }]); 14 | }); 15 | 16 | it('extract translations from tsx files', () => { 17 | let source = ` 18 | import { _x } from '@wordpress/i18n'; 19 | const Comp: React.FC = () =>

{_x('Translate this', 'context', 'wp-bundler')}

; 20 | `; 21 | 22 | let result = extractTranslations(source, 'test.ts'); 23 | expect(result).toEqual([ 24 | { 25 | text: 'Translate this', 26 | context: 'context', 27 | domain: 'wp-bundler', 28 | location: expect.anything() as Location, 29 | }, 30 | ]); 31 | }); 32 | 33 | it('extract translations from js files', () => { 34 | let source = ` 35 | import { __ } from '@wordpress/i18n'; 36 | let translated = __('Translate this', 'wp-bundler'); 37 | `; 38 | 39 | let result = extractTranslations(source, 'test.ts'); 40 | expect(result).toEqual([ 41 | { 42 | text: 'Translate this', 43 | domain: 'wp-bundler', 44 | location: expect.anything() as Location, 45 | }, 46 | ]); 47 | }); 48 | 49 | it('extract translations from jsx files', () => { 50 | let source = ` 51 | import { _x } from '@wordpress/i18n'; 52 | const Comp = () =>

{_x('Translate this', 'context', 'wp-bundler')}

; 53 | `; 54 | 55 | let result = extractTranslations(source, 'test.ts'); 56 | expect(result).toEqual([ 57 | { 58 | text: 'Translate this', 59 | context: 'context', 60 | domain: 'wp-bundler', 61 | location: expect.anything() as Location, 62 | }, 63 | ]); 64 | }); 65 | 66 | it('extract translations from named imports', () => { 67 | let source = ` 68 | import { __ } from '@wordpress/i18n'; 69 | __('Translate this', 'wp-bundler'); 70 | `; 71 | 72 | let result = extractTranslations(source, 'test.ts'); 73 | expect(result).toEqual([{ text: 'Translate this', domain: 'wp-bundler', location: expect.anything() as Location }]); 74 | }); 75 | 76 | it('extract translations from default imports', () => { 77 | let source = ` 78 | import i18n from '@wordpress/i18n'; 79 | i18n.__('Translate this', 'wp-bundler'); 80 | `; 81 | 82 | let result = extractTranslations(source, 'test.ts'); 83 | expect(result).toEqual([{ text: 'Translate this', domain: 'wp-bundler', location: expect.anything() as Location }]); 84 | }); 85 | 86 | it('extract translations from namespace imports', () => { 87 | let source = ` 88 | import * as i18n from '@wordpress/i18n'; 89 | i18n.__('Translate this', 'wp-bundler'); 90 | `; 91 | 92 | let result = extractTranslations(source, 'test.ts'); 93 | expect(result).toEqual([{ text: 'Translate this', domain: 'wp-bundler', location: expect.anything() as Location }]); 94 | }); 95 | 96 | it('extract translations from calls to window.wp.i18n', () => { 97 | let source = ` 98 | let translated = window.wp.i18n.__('Translate this', 'wp-bundler'); 99 | `; 100 | 101 | let result = extractTranslations(source, 'test.ts'); 102 | expect(result).toEqual([ 103 | { 104 | text: 'Translate this', 105 | domain: 'wp-bundler', 106 | location: expect.anything() as Location, 107 | }, 108 | ]); 109 | }); 110 | 111 | it('extract translations from calls to wp.i18n', () => { 112 | let source = ` 113 | let translated = wp.i18n.__('Translate this', 'wp-bundler'); 114 | `; 115 | 116 | let result = extractTranslations(source, 'test.ts'); 117 | expect(result).toEqual([ 118 | { 119 | text: 'Translate this', 120 | domain: 'wp-bundler', 121 | location: expect.anything() as Location, 122 | }, 123 | ]); 124 | }); 125 | 126 | it('can extract translations if the named import is renamed on import', () => { 127 | let source = ` 128 | import { __ as translate, _x as translateX } from '@wordpress/i18n'; 129 | let translated = translate('Translate this', 'wp-bundler'); 130 | let translated2 = translateX('Translate this', 'context', 'wp-bundler'); 131 | `; 132 | 133 | let result = extractTranslations(source, 'test.ts'); 134 | expect(result).toEqual([ 135 | { 136 | text: 'Translate this', 137 | domain: 'wp-bundler', 138 | location: expect.anything() as Location, 139 | }, 140 | { 141 | text: 'Translate this', 142 | context: 'context', 143 | domain: 'wp-bundler', 144 | location: expect.anything() as Location, 145 | }, 146 | ]); 147 | }); 148 | 149 | it('outputs correct location for translations', () => { 150 | let source = ` 151 | import { __ } from '@wordpress/i18n'; 152 | __('Translate this', 'wp-bundler'); 153 | `.trim(); 154 | 155 | let result = extractTranslations(source, 'test.ts'); 156 | expect(result.at(0)?.location).toEqual({ 157 | file: 'test.ts', 158 | namespace: '', 159 | line: 2, // 1-based 160 | column: 4, // 0-based, in bytes 161 | length: 0, // in bytes 162 | lineText: '', 163 | suggestion: '', 164 | }); 165 | }); 166 | 167 | it('extracts translator comments', () => { 168 | let source = ` 169 | import { __ } from '@wordpress/i18n'; 170 | 171 | // translators: a comment 1 172 | const variable = __('Translation 1', 'wp-bundler'); 173 | 174 | /* translators: a comment 2 */ 175 | console.log(__('Translation 3', 'wp-bundler')); 176 | 177 | function translate() { 178 | /** 179 | * translators: a comment 3 180 | */ 181 | __('Translation 3', 'wp-bundler'); 182 | } 183 | `; 184 | 185 | let result = extractTranslations(source, 'test.php'); 186 | expect(result.at(0)?.translators).toEqual('translators: a comment 1'); 187 | expect(result.at(1)?.translators).toEqual('translators: a comment 2'); 188 | expect(result.at(2)?.translators).toEqual('translators: a comment 3'); 189 | }); 190 | -------------------------------------------------------------------------------- /src/utils/extract-translations/php.ts: -------------------------------------------------------------------------------- 1 | import { type Call, type Comment, type CommentBlock, Engine, type Node, type String } from 'php-parser'; 2 | 3 | import type { TranslationMessage } from './types.js'; 4 | import { isTranslatorsComment, phpNodeToLocation, trimComment } from './utils.js'; 5 | 6 | export const WP_TRANSLATION_FUNCTIONS = [ 7 | '__', 8 | '_e', 9 | 'esc_attr__', 10 | 'esc_attr_e', 11 | 'esc_html__', 12 | 'esc_html_e', 13 | '_x', 14 | '_ex', 15 | 'esc_attr_x', 16 | 'esc_html_x', 17 | '_n', 18 | '_n_noop', 19 | '_nx', 20 | '_nx_noop', 21 | ]; 22 | 23 | export function mightHaveTranslations(source: string): boolean { 24 | return WP_TRANSLATION_FUNCTIONS.some((fn) => source.includes(fn)); 25 | } 26 | 27 | export function extractTranslations(source: string, filename: string): TranslationMessage[] { 28 | let parser = new Engine({ parser: { php7: true, extractDoc: true }, ast: { withPositions: true } }); 29 | let program = parser.parseCode(source, filename); 30 | let translations: TranslationMessage[] = []; 31 | 32 | let lastTranslators: string | undefined = undefined; 33 | visitAll(program, (node) => { 34 | let translators = getTranslatorComment(node); 35 | if (translators) lastTranslators = translators; 36 | 37 | if (isCallNode(node)) { 38 | let translation = extractTranslationFromCall(node, filename); 39 | if (translation != null) { 40 | translation.translators = lastTranslators; 41 | lastTranslators = undefined; 42 | translations.push(translation); 43 | } 44 | } 45 | }); 46 | 47 | return translations; 48 | } 49 | 50 | function visitAll(nodes: Node[] | Node, callback: (node: Node, parent?: Node) => boolean | undefined | null | void) { 51 | for (let node of Array.isArray(nodes) ? nodes : [nodes]) { 52 | let shouldContinue = callback(node); 53 | if (shouldContinue === false) return; 54 | 55 | for (let key of childrenKeys) { 56 | if (hasChildArray(node, key)) { 57 | let children = node[key]; 58 | visitAll(children, callback); 59 | } 60 | } 61 | } 62 | } 63 | 64 | function hasChildArray( 65 | node: unknown, 66 | key: Key, 67 | ): node is { [K in typeof key]: Node[] } { 68 | return typeof node === 'object' && node != null && (node as Record)[key] != null; 69 | } 70 | 71 | const childrenKeys = [ 72 | 'arguments', 73 | 'alternate', 74 | 'body', 75 | 'catches', 76 | 'children', 77 | 'expr', 78 | 'expression', 79 | 'expressions', 80 | 'trueExpr', 81 | 'falseExpr', 82 | 'items', 83 | 'key', 84 | 'left', 85 | 'right', 86 | 'value', 87 | 'what', 88 | ] as const; 89 | 90 | function isCallNode(node?: Node | null): node is Call { 91 | return node != null && node.kind === 'call'; 92 | } 93 | 94 | function isStringNode(node?: Node | null): node is String { 95 | return node != null && node.kind === 'string'; 96 | } 97 | 98 | function hasLeadingComments( 99 | node?: Node | null, 100 | ): node is Omit & { leadingComments: Comment[] | CommentBlock[] } { 101 | return node != null && Array.isArray(node.leadingComments); 102 | } 103 | 104 | function getCalledFunction(call: Call): string | null { 105 | if (call.kind === 'call' && typeof call.what.name === 'string') { 106 | return call.what.name.replace(/^\\/g, ''); 107 | } 108 | 109 | return null; 110 | } 111 | 112 | function extractTranslationFromCall(call: Call, filename: string): TranslationMessage | null { 113 | let name = getCalledFunction(call); 114 | const getStringVal = (val: Node | undefined) => { 115 | return isStringNode(val) && typeof val.value === 'string' ? val.value : null; 116 | }; 117 | 118 | let location = phpNodeToLocation(call, filename); 119 | 120 | switch (name) { 121 | case '__': 122 | case '_e': 123 | case 'esc_attr__': 124 | case 'esc_attr_e': 125 | case 'esc_html__': 126 | case 'esc_html_e': 127 | return { 128 | text: getStringVal(call.arguments.at(0)) ?? '', 129 | domain: getStringVal(call.arguments.at(1)) ?? undefined, 130 | location, 131 | }; 132 | 133 | case '_x': 134 | case '_ex': 135 | case 'esc_attr_x': 136 | case 'esc_html_x': 137 | return { 138 | text: getStringVal(call.arguments.at(0)) ?? '', 139 | context: getStringVal(call.arguments.at(1)) ?? '', 140 | domain: getStringVal(call.arguments.at(2)) ?? undefined, 141 | location, 142 | }; 143 | 144 | case '_n': 145 | return { 146 | single: getStringVal(call.arguments.at(0)) ?? '', 147 | plural: getStringVal(call.arguments.at(1)) ?? '', 148 | domain: getStringVal(call.arguments.at(3)) ?? undefined, 149 | location, 150 | }; 151 | 152 | case '_n_noop': 153 | return { 154 | single: getStringVal(call.arguments.at(0)) ?? '', 155 | plural: getStringVal(call.arguments.at(1)) ?? '', 156 | domain: getStringVal(call.arguments.at(2)) ?? undefined, 157 | location, 158 | }; 159 | 160 | case '_nx': 161 | return { 162 | single: getStringVal(call.arguments.at(0)) ?? '', 163 | plural: getStringVal(call.arguments.at(1)) ?? '', 164 | context: getStringVal(call.arguments.at(3)) ?? '', 165 | domain: getStringVal(call.arguments.at(4)) ?? undefined, 166 | location, 167 | }; 168 | 169 | case '_nx_noop': 170 | return { 171 | single: getStringVal(call.arguments.at(0)) ?? '', 172 | plural: getStringVal(call.arguments.at(1)) ?? '', 173 | context: getStringVal(call.arguments.at(2)) ?? '', 174 | domain: getStringVal(call.arguments.at(3)) ?? undefined, 175 | location, 176 | }; 177 | 178 | default: 179 | return null; 180 | } 181 | } 182 | 183 | function getTranslatorComment(node?: Node): string | undefined { 184 | if (hasLeadingComments(node)) { 185 | let comments = node.leadingComments.map((comment) => trimComment(comment.value)); 186 | 187 | for (let comment of comments) { 188 | if (isTranslatorsComment(comment)) return comment; 189 | } 190 | 191 | return undefined; 192 | } 193 | 194 | return undefined; 195 | } 196 | -------------------------------------------------------------------------------- /src/utils/extract-translations/twig.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest'; 2 | 3 | import type { TranslationMessage } from './index.js'; 4 | import { extractTranslations } from './twig.js'; 5 | 6 | it('extracts translations from php files', () => { 7 | let source = ` 8 | {{ __('Translation 0', 'wp-bundler') }} 9 | {{ _e('Translation 1', 'wp-bundler') }} 10 | {{ esc_attr__('Translation 2', 'wp-bundler') }} 11 | {{ esc_attr_e('Translation 3', 'wp-bundler') }} 12 | {{ esc_html__('Translation 4', 'wp-bundler') }} 13 | {{ esc_html_e('Translation 5', 'wp-bundler') }} 14 | 15 | {{ _x('Translation 6', 'context', 'wp-bundler') }} 16 | {{ _ex('Translation 7', 'context', 'wp-bundler') }} 17 | {{ esc_attr_x('Translation 8', 'context', 'wp-bundler') }} 18 | {{ esc_html_x('Translation 9', 'context', 'wp-bundler') }} 19 | 20 | {{ _n('Translation 10', 'Translations 10', 1, 'wp-bundler') }} 21 | {{ _n_noop('Translation 11', 'Translations 11', 'wp-bundler') }} 22 | {{ _nx('Translation 12', 'Translations 12', 1, 'context', 'wp-bundler') }} 23 | {{ _nx_noop('Translation 13', 'Translations 13', 'context', 'wp-bundler') }} 24 | `; 25 | 26 | let result = removeLocation(extractTranslations(source, 'test.twig')); 27 | 28 | expect(result).toHaveLength(14); 29 | 30 | expect(result[0]).toEqual({ text: 'Translation 0', domain: 'wp-bundler' }); 31 | expect(result[1]).toEqual({ text: 'Translation 1', domain: 'wp-bundler' }); 32 | expect(result[2]).toEqual({ text: 'Translation 2', domain: 'wp-bundler' }); 33 | expect(result[3]).toEqual({ text: 'Translation 3', domain: 'wp-bundler' }); 34 | expect(result[4]).toEqual({ text: 'Translation 4', domain: 'wp-bundler' }); 35 | expect(result[5]).toEqual({ text: 'Translation 5', domain: 'wp-bundler' }); 36 | 37 | expect(result[6]).toEqual({ text: 'Translation 6', context: 'context', domain: 'wp-bundler' }); 38 | expect(result[7]).toEqual({ text: 'Translation 7', context: 'context', domain: 'wp-bundler' }); 39 | expect(result[8]).toEqual({ text: 'Translation 8', context: 'context', domain: 'wp-bundler' }); 40 | expect(result[9]).toEqual({ text: 'Translation 9', context: 'context', domain: 'wp-bundler' }); 41 | 42 | expect(result[10]).toEqual({ single: 'Translation 10', plural: 'Translations 10', domain: 'wp-bundler' }); 43 | expect(result[11]).toEqual({ single: 'Translation 11', plural: 'Translations 11', domain: 'wp-bundler' }); 44 | expect(result[12]).toEqual({ 45 | single: 'Translation 12', 46 | plural: 'Translations 12', 47 | context: 'context', 48 | domain: 'wp-bundler', 49 | }); 50 | expect(result[13]).toEqual({ 51 | single: 'Translation 13', 52 | plural: 'Translations 13', 53 | context: 'context', 54 | domain: 'wp-bundler', 55 | }); 56 | }); 57 | 58 | it('extracts translations that is part of set calls', () => { 59 | let source = ` 60 | {% set form_id = __('Translation', 'wp-bundler') %} 61 | `; 62 | 63 | let result = extractTranslations(source, 'test.twig'); 64 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 65 | }); 66 | 67 | it('extracts translations from special cases', () => { 68 | let source = ` 69 | {{ otherFunction(__('Translation 1', 'wp-bundler')) }} 70 | {{ other.function(__('Translation 2', 'wp-bundler')) }} 71 | 72 | {% for day in week %} 73 | {{ _x('Translation 3', 'context', 'wp-bundler') }} 74 | {% endfor %} 75 | 76 | {% set test = sprintf(_n('Translation 4 single', 'Translation 4 plural', getNum('2'), 'wp-bundler')) %} 77 | `; 78 | 79 | let result = extractTranslations(source, 'test.twig'); 80 | expect(removeLocation(result)).toEqual([ 81 | { text: 'Translation 1', domain: 'wp-bundler' }, 82 | { text: 'Translation 2', domain: 'wp-bundler' }, 83 | { text: 'Translation 3', context: 'context', domain: 'wp-bundler' }, 84 | { single: 'Translation 4 single', plural: 'Translation 4 plural', domain: 'wp-bundler' }, 85 | ]); 86 | }); 87 | 88 | it('extracts translations within blocks', () => { 89 | let source = ` 90 | {% block title %} 91 | {{ __('Translation', 'wp-bundler') }}

92 | {% endblock %} 93 | `; 94 | 95 | let result = extractTranslations(source, 'test.twig'); 96 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 97 | }); 98 | 99 | it('extracts translations within extends', () => { 100 | let source = ` 101 | {% block title %} 102 | {{ __('Translation', 'wp-bundler') }}

103 | {% endblock %} 104 | `; 105 | 106 | let result = extractTranslations(source, 'test.twig'); 107 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 108 | }); 109 | 110 | it('extracts translations within macros', () => { 111 | let source = ` 112 | {% macro title() %} 113 | {{ __('Translation', 'wp-bundler') }}

114 | {% endmacro %} 115 | `; 116 | 117 | let result = extractTranslations(source, 'test.twig'); 118 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 119 | }); 120 | 121 | it('extracts translations from multiline definitions', () => { 122 | let source = ` 123 | {{ __( 124 | 'Translation', 125 | 'wp-bundler' 126 | ) }}

127 | `; 128 | 129 | let result = extractTranslations(source, 'test.twig'); 130 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 131 | }); 132 | 133 | it.skip('extracts translations from language extensions', () => { 134 | let source = ` 135 | {% switch input.type %} 136 | {% case "checkbox" %} 137 | {{ __('Translation', 'wp-bundler') }}

138 | {% endswitch %} 139 | `; 140 | 141 | let result = extractTranslations(source, 'test.twig'); 142 | expect(removeLocation(result)).toEqual([{ text: 'Translation', domain: 'wp-bundler' }]); 143 | }); 144 | 145 | it('extracts the correct location for translations', () => { 146 | let source = ` 147 |

{{ __('Translation', 'wp-bundler') }}

148 | `; 149 | 150 | let result = extractTranslations(source, 'test.twig'); 151 | expect(result.at(0)?.location).toEqual({ 152 | file: 'test.twig', 153 | namespace: '', 154 | line: 2, // 1-based 155 | column: 10, // 0-based, in bytes 156 | length: 0, // in bytes 157 | lineText: '', 158 | suggestion: '', 159 | }); 160 | }); 161 | 162 | it('can extract translator comments from twig', () => { 163 | let source = ` 164 | {# translators: a translation #} 165 |

{{ __('Translation 0', 'wp-bundler') }}

166 | {# not a translators string #} 167 |

{{ __('Translation 1', 'wp-bundler') }}

168 | `; 169 | 170 | let result = extractTranslations(source, 'test.twig'); 171 | expect(removeLocation(result)).toEqual([ 172 | { text: 'Translation 0', domain: 'wp-bundler', translators: 'translators: a translation' }, 173 | { text: 'Translation 1', domain: 'wp-bundler', translators: undefined }, 174 | ]); 175 | }); 176 | 177 | it('will not break if domain is not defined', () => { 178 | let source = ` 179 |

{{ __('Translation without domain') }}

180 | `; 181 | 182 | let result = extractTranslations(source, 'test.twig'); 183 | expect(removeLocation(result)).toEqual([{ text: 'Translation without domain', domain: undefined }]); 184 | }); 185 | 186 | function removeLocation(messages: TranslationMessage[]): Omit[] { 187 | return messages.map(({ location, ...t }) => t); 188 | } 189 | -------------------------------------------------------------------------------- /src/plugins/translations.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | import * as fs from 'node:fs/promises'; 3 | import * as path from 'node:path'; 4 | 5 | import type { Message, Plugin } from 'esbuild'; 6 | import { globby } from 'globby'; 7 | import { stringToUint8Array } from 'uint8array-extras'; 8 | 9 | import type { BundlerPlugin } from '../types.js'; 10 | import { type TranslationMessage, js, php, theme, twig } from '../utils/extract-translations/index.js'; 11 | import { createFileHandler } from '../utils/handle-bundled-file.js'; 12 | import { Po } from '../utils/po.js'; 13 | 14 | export const PLUGIN_NAME = 'wp-bundler-translations'; 15 | 16 | export const translations: BundlerPlugin = ({ project, config }): Plugin => ({ 17 | name: PLUGIN_NAME, 18 | setup(build) { 19 | if (config.translations == null) return; 20 | 21 | let translationsConfig = config.translations; 22 | let translations: TranslationMessage[] = []; 23 | 24 | build.onStart(() => { 25 | translations = []; 26 | }); 27 | 28 | /** 29 | * Parse each source file and extract all translations. 30 | */ 31 | build.onLoad({ filter: /.(js|ts|tsx|jsx)$/, namespace: '' }, async (args) => { 32 | let relativePath = project.paths.relative(args.path); 33 | let source = await fs.readFile(args.path, 'utf-8'); 34 | 35 | if (js.mightHaveTranslations(source)) { 36 | let fileTranslations = js.extractTranslations(source, relativePath); 37 | translations.push(...fileTranslations); 38 | } 39 | 40 | return { contents: source, loader: 'default' }; 41 | }); 42 | 43 | /** 44 | * Write all po- and pot-files to disk. 45 | */ 46 | build.onEnd(async (result) => { 47 | let warnings: Message[] = []; 48 | if (result.metafile == null) return; 49 | 50 | let files = createFileHandler(result, project); 51 | 52 | translations.unshift(...(await findThemeTranslations(project.paths.root, translationsConfig.domain))); 53 | translations.push( 54 | ...(await findPhpTranslations(project.paths.root, translationsConfig.ignore)), 55 | ...(await findTwigTranslations(project.paths.root, translationsConfig.ignore)), 56 | ); 57 | 58 | let template = await Po.load(project.paths.absolute(translationsConfig.pot)); 59 | let pos = await Promise.all(translationsConfig.pos?.map((po) => Po.load(project.paths.absolute(po))) ?? []); 60 | 61 | template.clear(); 62 | for (let t of translations) { 63 | if (t.domain === translationsConfig.domain) template.set(t); 64 | } 65 | 66 | pos.forEach((po) => po.updateFromTemplate(template)); 67 | let foldLength = getFoldLength(project.packageJson); 68 | files.append(template.toOutputFile(undefined, foldLength)); 69 | for (let po of pos) { 70 | files.append(po.toOutputFile(undefined, foldLength)); 71 | } 72 | 73 | let langDir = project.paths.absolute(config.outdir, 'languages'); 74 | let missingLangWarnings: Po[] = []; 75 | 76 | for (let po of pos) { 77 | let language = po.header('Language'); 78 | if (language == null) { 79 | missingLangWarnings.push(po); 80 | continue; 81 | } 82 | 83 | let contents = po.toMo(); 84 | files.append({ path: po.filename.replace(/\.po$/, '.mo'), contents }); 85 | 86 | for (let distFile of Object.keys(result.metafile.outputs)) { 87 | let meta = result.metafile.outputs[distFile]; 88 | let srcFiles = Object.keys(meta?.inputs ?? {}); 89 | 90 | let jed = po.toJed(translationsConfig.domain, ({ comments }) => { 91 | return comments != null && srcFiles.some((file) => comments.reference?.includes(file)); 92 | }); 93 | 94 | if (jed == null) continue; 95 | let filename = generateTranslationFilename(translationsConfig.domain, language, distFile); 96 | let text = JSON.stringify(jed); 97 | files.append({ path: path.join(langDir, filename), contents: stringToUint8Array(text) }); 98 | } 99 | } 100 | 101 | warnings.push( 102 | ...validateTranslations(translations), 103 | ...missingLangWarnings.map((po) => { 104 | return { 105 | id: crypto.randomUUID(), 106 | pluginName: PLUGIN_NAME, 107 | text: 'Missing language header in po file. No translations will be emitted.', 108 | location: { 109 | file: project.paths.relative(po.filename), 110 | line: 1, 111 | column: 1, 112 | namespace: '', 113 | lineText: '', 114 | length: 0, 115 | suggestion: '', 116 | }, 117 | detail: {}, 118 | notes: [], 119 | }; 120 | }), 121 | ); 122 | 123 | return { warnings }; 124 | }); 125 | }, 126 | }); 127 | 128 | function validateTranslations(translations: TranslationMessage[]): Message[] { 129 | let warnings: Message[] = []; 130 | 131 | for (let translation of translations) { 132 | if (translation.domain == null) { 133 | warnings.push({ 134 | id: crypto.randomUUID(), 135 | pluginName: PLUGIN_NAME, 136 | text: 'Missing domain.', 137 | location: translation.location, 138 | detail: [], 139 | notes: [], 140 | }); 141 | } 142 | } 143 | 144 | return warnings; 145 | } 146 | 147 | async function findPhpTranslations(cwd: string, ignore: string[] = []): Promise { 148 | let files = await globby(['**/*.php', '!vendor', '!node_modules', ...ignore.map((i) => '!' + i)], { cwd }); 149 | 150 | let translations: Array = await Promise.all( 151 | files.map(async (file) => { 152 | let source = await fs.readFile(path.join(cwd, file), 'utf-8'); 153 | if (!php.mightHaveTranslations(source)) return []; 154 | return php.extractTranslations(source, file); 155 | }), 156 | ); 157 | 158 | return translations.flat(); 159 | } 160 | 161 | async function findTwigTranslations(cwd: string, ignore: string[] = []): Promise { 162 | let files = await globby(['**/*.twig', '!vendor', '!node_modules', ...ignore.map((i) => '!' + i)], { cwd }); 163 | 164 | let translations: Array = await Promise.all( 165 | files.map(async (file) => { 166 | let source = await fs.readFile(path.join(cwd, file), 'utf-8'); 167 | if (!twig.mightHaveTranslations(source)) return []; 168 | return twig.extractTranslations(source, file); 169 | }), 170 | ); 171 | 172 | return translations.flat(); 173 | } 174 | 175 | async function findThemeTranslations(cwd: string, domain: string) { 176 | try { 177 | let source = await fs.readFile(path.join(cwd, 'style.css'), 'utf-8'); 178 | return theme.extractTranslations(source, 'style.css', domain); 179 | } catch { 180 | return []; 181 | } 182 | } 183 | 184 | function generateTranslationFilename(domain: string, language: string, file: string): string { 185 | let hash = crypto.createHash('md5').update(file).digest('hex'); 186 | return `${domain}-${language}-${hash}.json`; 187 | } 188 | 189 | function getFoldLength(pkgJson: Record) { 190 | let prettier = pkgJson.prettier; 191 | if (typeof prettier === 'object' && prettier != null && 'printWidth' in prettier) { 192 | return typeof prettier.printWidth === 'number' ? prettier.printWidth : undefined; 193 | } 194 | 195 | return undefined; 196 | } 197 | -------------------------------------------------------------------------------- /src/utils/extract-translations/javascript.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | import { ensure } from '../assert.js'; 4 | import type { TranslationMessage } from './types.js'; 5 | import { isTranslatorsComment, trimComment, tsNodeToLocation } from './utils.js'; 6 | 7 | export function mightHaveTranslations(source: string): boolean { 8 | return source.includes('wp.i18n') || source.includes('@wordpress/i18n'); 9 | } 10 | 11 | export function extractTranslations(source: string, filename: string): TranslationMessage[] { 12 | let sourceFile = ts.createSourceFile('admin.ts', source, ts.ScriptTarget.ES2015, true, ts.ScriptKind.TSX); 13 | 14 | let relevantImports = findRelevantImports(sourceFile); 15 | let messages = findTranslatableMessages(sourceFile, relevantImports, { source, filename }); 16 | 17 | return messages; 18 | } 19 | 20 | const translatableMethods = ['_n', '_nx', '_x', '__'] as const; 21 | type TranslatableMethod = (typeof translatableMethods)[number]; 22 | const isTranslatableMethod = (name: string): name is TranslatableMethod => { 23 | return translatableMethods.includes(name as TranslatableMethod); 24 | }; 25 | 26 | /** 27 | * Extract imported identifiers from a ts file. It will find default imports, 28 | * namespaced imports and named imports from '@wordpress/i18n'. 29 | * 30 | * @param sourceFile Source file to check for imports 31 | * @returns Array of imported identifiers 32 | */ 33 | function findRelevantImports(sourceFile: ts.SourceFile): ts.Identifier[] { 34 | let identifiers: ts.Identifier[] = []; 35 | 36 | visitAll(sourceFile, (node) => { 37 | if (ts.isImportDeclaration(node)) { 38 | if (node.moduleSpecifier.getText(sourceFile).includes('@wordpress/i18n')) { 39 | let clause = node.importClause?.name ?? node.importClause?.namedBindings; 40 | if (clause != null) { 41 | switch (clause.kind) { 42 | // Default import 43 | case ts.SyntaxKind.Identifier: 44 | identifiers.push(clause); 45 | break; 46 | 47 | // Namespace import (* as i18n) 48 | case ts.SyntaxKind.NamespaceImport: 49 | identifiers.push(clause.name); 50 | break; 51 | 52 | // Named import ({ __, _x }) 53 | case ts.SyntaxKind.NamedImports: { 54 | let relevant = getRelevantNamedImports(clause); 55 | identifiers.push(...relevant); 56 | break; 57 | } 58 | } 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | return true; 66 | }); 67 | 68 | return identifiers; 69 | } 70 | 71 | /** 72 | * Extract all translatable data from within a file. Based on the imported 73 | * variables. It will also find calls to `[window.]wp.i18n`. 74 | * 75 | * @param sourceFile Source file to traverse and search witin 76 | * @param imports Relevant imported variables to look for 77 | * @returns An array of translation messages 78 | */ 79 | function findTranslatableMessages( 80 | sourceFile: ts.SourceFile, 81 | imports: ts.Identifier[], 82 | locationMeta: { source: string; filename: string }, 83 | ) { 84 | let messages: TranslationMessage[] = []; 85 | 86 | let referencesImport = (expression: { getText(): string }) => { 87 | return imports.findIndex((imported) => imported.getText() === expression.getText()) > -1; 88 | }; 89 | 90 | let lastTranslators: string | undefined = undefined; 91 | 92 | visitAll(sourceFile, (node) => { 93 | let message: TranslationMessage | null = null; 94 | let translators = getTranslatorComment(node, locationMeta.source); 95 | if (translators) lastTranslators = translators; 96 | 97 | if (!ts.isCallExpression(node)) return; 98 | 99 | // __(...) 100 | if (ts.isIdentifier(node.expression) && referencesImport(node.expression)) { 101 | message = extractMessage(node.expression, node.arguments, imports, locationMeta); 102 | } 103 | 104 | // i18n.__(...) 105 | if (ts.isPropertyAccessExpression(node.expression) && referencesImport(node.expression.expression)) { 106 | message = extractMessage(node.expression.name, node.arguments, null, locationMeta); 107 | } 108 | 109 | // wp.i18n.__(...) || window.wp.i18n.__(...) 110 | if ( 111 | ts.isPropertyAccessExpression(node.expression) && 112 | (node.expression.expression.getText() === 'window.wp.i18n' || node.expression.expression.getText() === 'wp.i18n') 113 | ) { 114 | message = extractMessage(node.expression.name, node.arguments, null, locationMeta); 115 | } 116 | 117 | if (message != null) { 118 | message.translators = lastTranslators; 119 | lastTranslators = undefined; 120 | messages.push(message); 121 | return false; 122 | } 123 | 124 | return true; 125 | }); 126 | 127 | return messages; 128 | } 129 | 130 | /** 131 | * Extract translation data from a caller. 132 | * 133 | * @param caller Caller variable 134 | * @param args Arguments passed to caller 135 | * @returns A compiled message 136 | */ 137 | function extractMessage( 138 | caller: ts.LeftHandSideExpression | ts.MemberName, 139 | args: ts.NodeArray, 140 | imports: ts.Identifier[] | null, 141 | { source, filename }: { source: string; filename: string }, 142 | ): TranslationMessage | null { 143 | let id = caller.getText(); 144 | 145 | /** 146 | * This lookup is looking to see if the caller variable is a reference to a 147 | * named import, an import which was renamed (`import { __ as translate }`). 148 | * We have previously identified that import, that's why it ends up here. 149 | * And by looking at the imported variable we can find the "real" method that 150 | * it references. 151 | */ 152 | if (Array.isArray(imports) && !isTranslatableMethod(id)) { 153 | let importVar = imports.find((imported) => imported.getText() === id); 154 | let parent = importVar?.parent; 155 | if (parent == null || !ts.isImportSpecifier(parent)) return null; 156 | id = parent.propertyName?.getText() ?? parent.name.getText(); 157 | } 158 | 159 | if (!isTranslatableMethod(id)) return null; 160 | 161 | let location = tsNodeToLocation(caller, id, source, filename); 162 | 163 | switch (id) { 164 | case '_n': 165 | return { 166 | location, 167 | single: getStringValue(ensure(args[0])), 168 | plural: getStringValue(ensure(args[1])), 169 | domain: args[3] ? getStringValue(args[3]) : undefined, 170 | }; 171 | 172 | case '_nx': 173 | return { 174 | location, 175 | single: getStringValue(ensure(args[0])), 176 | plural: getStringValue(ensure(args[1])), 177 | context: getStringValue(ensure(args[3])), 178 | domain: args[4] ? getStringValue(args[4]) : undefined, 179 | }; 180 | 181 | case '__': 182 | return { 183 | location, 184 | text: getStringValue(ensure(args[0])), 185 | domain: args[1] ? getStringValue(args[1]) : undefined, 186 | }; 187 | 188 | case '_x': 189 | return { 190 | location, 191 | text: getStringValue(ensure(args[0])), 192 | context: getStringValue(ensure(args[1])), 193 | domain: args[2] ? getStringValue(args[2]) : undefined, 194 | }; 195 | } 196 | } 197 | 198 | /** 199 | * Extract all relevant imports from a named imports declaration. It will also 200 | * look for aliased imports (`import { __ as translate }`) and push those to the 201 | * relevant array. 202 | * 203 | * @param clause Named imports node 204 | */ 205 | function getRelevantNamedImports(clause: ts.NamedImports): ts.Identifier[] { 206 | let relevant: ts.Identifier[] = []; 207 | for (let specifier of clause.elements) { 208 | if (isTranslatableMethod(specifier.propertyName?.getText() ?? specifier.name.getText())) { 209 | relevant.push(specifier.name); 210 | } 211 | } 212 | 213 | return relevant; 214 | } 215 | 216 | /** 217 | * A function to traverse all children within a source file. 218 | * 219 | * @param source The source file to visit node within 220 | * @param callback A callback fired on each node. Return `false` to prevent going deeper. 221 | */ 222 | function visitAll(source: ts.SourceFile, callback: (node: ts.Node) => boolean | undefined | null | void) { 223 | function visit(node: ts.Node) { 224 | let shouldContinue = callback(node); 225 | if (shouldContinue !== false) { 226 | node.getChildren(source).forEach(visit); 227 | } 228 | } 229 | 230 | visit(source); 231 | } 232 | 233 | /** 234 | * Extract the string representation of a string literal (or string like 235 | * literal). 236 | * 237 | * @param expression String literal expression 238 | * @returns String representation of the expression, or throws an error if it is not a StringLiteral 239 | */ 240 | function getStringValue(expression: ts.Expression): string { 241 | if (ts.isStringLiteral(expression) || ts.isStringLiteralLike(expression)) { 242 | return expression.text; 243 | } 244 | 245 | throw new Error('Given expression is not a string literal.'); 246 | } 247 | 248 | function getTranslatorComment(node: ts.Node, source: string): string | undefined { 249 | let comments = ts.getLeadingCommentRanges(source, node.pos); 250 | if (Array.isArray(comments)) { 251 | for (let commentNode of comments) { 252 | let comment = trimComment(source.substring(commentNode.pos, commentNode.end)); 253 | if (isTranslatorsComment(comment)) return comment; 254 | } 255 | } 256 | 257 | return undefined; 258 | } 259 | -------------------------------------------------------------------------------- /src/utils/po.test.ts: -------------------------------------------------------------------------------- 1 | import type { Location } from 'esbuild'; 2 | import { expect, it } from 'vitest'; 3 | 4 | import { Po } from './po.js'; 5 | 6 | it('appends a new translation if a similar one does not exist', async () => { 7 | let po = await Po.load('test.po'); 8 | 9 | po.set({ text: 'A', domain: 'wp', location: createLocation() }); 10 | expect(po.translations).toHaveLength(1); 11 | po.set({ text: 'B', domain: 'wp', location: createLocation() }); 12 | expect(po.translations).toHaveLength(2); 13 | }); 14 | 15 | it('updates already existing translations', async () => { 16 | let po = await Po.load('test.po'); 17 | 18 | po.set({ text: 'A', domain: 'wp', location: createLocation({ file: 'test1.ts' }) }); 19 | expect(po.translations).toHaveLength(1); 20 | po.set({ text: 'A', domain: 'wp', location: createLocation({ file: 'test2.ts' }) }); 21 | expect(po.translations).toHaveLength(1); 22 | 23 | expect(po.get('A')?.comments?.reference).toMatchInlineSnapshot(` 24 | "test1.ts:1 25 | test2.ts:1" 26 | `); 27 | }); 28 | 29 | it('can update a po file with data from a pot', async () => { 30 | let po = await Po.load('test.po'); 31 | let pot = await Po.load('test.pot'); 32 | 33 | po.set({ 34 | msgid: 'A', 35 | msgstr: ['A'], 36 | comments: { 37 | reference: 'old.ts:1', 38 | extracted: 'translators: old stuff', 39 | translator: 'A comment to keep', 40 | flag: '', 41 | previous: '', 42 | }, 43 | }); 44 | 45 | pot.set({ text: 'A', domain: 'wp', location: createLocation('test.ts'), translators: 'translators: New translator' }); 46 | 47 | po.updateFromTemplate(pot); 48 | expect(po.toString()).toMatchInlineSnapshot(` 49 | "msgid "" 50 | msgstr "" 51 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 52 | "Content-Type: text/plain; charset=utf-8\\n" 53 | "Content-Transfer-Encoding: 8bit\\n" 54 | "MIME-Version: 1.0\\n" 55 | 56 | # A comment to keep 57 | #: test.ts:1 58 | #. translators: New translator 59 | msgid "A" 60 | msgstr "A" 61 | " 62 | `); 63 | }); 64 | 65 | it('keeps unreferenced translations in the bottom', async () => { 66 | let po = await Po.load('test.po'); 67 | let pot = await Po.load('test.pot'); 68 | 69 | po.set({ msgid: 'A unused 1', msgstr: ['Unused'] }); 70 | po.set({ msgid: 'A unused 2', msgstr: ['Unused'] }); 71 | po.set({ msgid: 'A', msgstr: ['A'] }); 72 | 73 | pot.set({ text: 'A', domain: 'wp', location: createLocation({ file: 'test1.ts' }) }); 74 | pot.set({ text: 'B', domain: 'wp', location: createLocation({ file: 'test2.ts' }) }); 75 | 76 | po.updateFromTemplate(pot); 77 | expect(po.toString()).toMatchInlineSnapshot(` 78 | "msgid "" 79 | msgstr "" 80 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 81 | "Content-Type: text/plain; charset=utf-8\\n" 82 | "Content-Transfer-Encoding: 8bit\\n" 83 | "MIME-Version: 1.0\\n" 84 | 85 | #: test1.ts:1 86 | msgid "A" 87 | msgstr "A" 88 | 89 | #: test2.ts:1 90 | msgid "B" 91 | msgstr "" 92 | 93 | # THIS TRANSLATION IS NO LONGER REFERENCED INSIDE YOUR PROJECT 94 | msgid "A unused 1" 95 | msgstr "Unused" 96 | 97 | # THIS TRANSLATION IS NO LONGER REFERENCED INSIDE YOUR PROJECT 98 | msgid "A unused 2" 99 | msgstr "Unused" 100 | " 101 | `); 102 | }); 103 | 104 | it('outputs proper JED with `.toJed`', async () => { 105 | let po = await Po.load('test.po'); 106 | 107 | po.set({ msgid: 'Hello', msgstr: ['Hej'] }); 108 | po.set({ msgid: 'World', msgstr: ['Världen'] }); 109 | po.set({ msgid: 'Hello', msgstr: ['Hejsan'], msgctxt: 'relaxed' }); 110 | 111 | expect(po.toJed('wp')).toMatchInlineSnapshot(` 112 | { 113 | "domain": "wp", 114 | "locale_data": { 115 | "wp": { 116 | "": { 117 | "domain": "wp", 118 | "lang": "", 119 | "plural-forms": "nplurals=2; plural=(n != 1);", 120 | }, 121 | "Hello": [ 122 | "Hej", 123 | ], 124 | "World": [ 125 | "Världen", 126 | ], 127 | "relaxedHello": [ 128 | "Hejsan", 129 | ], 130 | }, 131 | }, 132 | } 133 | `); 134 | }); 135 | 136 | it('outputs proper JED with `.toJed` with filtered translations', async () => { 137 | let po = await Po.load('test.po'); 138 | 139 | po.set({ msgid: 'Hello', msgstr: ['Hej'] }); 140 | po.set({ msgid: 'World', msgstr: ['Världen'] }); 141 | po.set({ msgid: 'Hello', msgstr: ['Hejsan'], msgctxt: 'relaxed' }); 142 | 143 | expect(po.toJed('wp', ({ msgctxt }) => msgctxt === 'relaxed')).toMatchInlineSnapshot(` 144 | { 145 | "domain": "wp", 146 | "locale_data": { 147 | "wp": { 148 | "": { 149 | "domain": "wp", 150 | "lang": "", 151 | "plural-forms": "nplurals=2; plural=(n != 1);", 152 | }, 153 | "relaxedHello": [ 154 | "Hejsan", 155 | ], 156 | }, 157 | }, 158 | } 159 | `); 160 | }); 161 | 162 | it('will output translations sorted by their msgid then their msgctxt', async () => { 163 | let po1 = await Po.load('test.po'); 164 | po1.set({ text: 'A', context: 'context', domain: 'wp', location: createLocation() }); 165 | po1.set({ text: 'A', domain: 'wp', location: createLocation() }); 166 | po1.set({ text: 'B', domain: 'wp', location: createLocation() }); 167 | 168 | let po2 = await Po.load('test.po'); 169 | po2.set({ text: 'B', domain: 'wp', location: createLocation() }); 170 | po2.set({ text: 'A', domain: 'wp', location: createLocation() }); 171 | po2.set({ text: 'A', context: 'context', domain: 'wp', location: createLocation() }); 172 | 173 | expect(po1.toString()).toEqual(po2.toString()); 174 | expect(po1.toString()).toMatchInlineSnapshot(` 175 | "msgid "" 176 | msgstr "" 177 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 178 | "Content-Type: text/plain; charset=utf-8\\n" 179 | "Content-Transfer-Encoding: 8bit\\n" 180 | "MIME-Version: 1.0\\n" 181 | 182 | #: test.ts:1 183 | msgid "A" 184 | msgstr "" 185 | 186 | #: test.ts:1 187 | msgctxt "context" 188 | msgid "A" 189 | msgstr "" 190 | 191 | #: test.ts:1 192 | msgid "B" 193 | msgstr "" 194 | " 195 | `); 196 | }); 197 | 198 | it('should not overwrite headers in po file when updating from pot', async () => { 199 | let po = new Po( 200 | ` 201 | msgid "" 202 | msgstr "" 203 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 204 | "Content-Type: text/plain; charset=UTF-8\n" 205 | "Content-Transfer-Encoding: 8bit\n" 206 | "MIME-Version: 1.0\n" 207 | "Language: en_US\n" 208 | `, 209 | 'test.po', 210 | ); 211 | let pot = await Po.load('test.pot'); 212 | 213 | pot.set({ text: 'a', domain: 'wp', location: createLocation() }); 214 | 215 | po.updateFromTemplate(pot); 216 | 217 | expect(po.toString()).toMatchInlineSnapshot(` 218 | "msgid "" 219 | msgstr "" 220 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 221 | "Content-Type: text/plain; charset=utf-8\\n" 222 | "Content-Transfer-Encoding: 8bit\\n" 223 | "MIME-Version: 1.0\\n" 224 | "Language: en_US\\n" 225 | 226 | #: test.ts:1 227 | msgid "a" 228 | msgstr "" 229 | " 230 | `); 231 | }); 232 | 233 | it('creates correct plural templates translations', async () => { 234 | let po = await Po.load('test.po'); 235 | 236 | po.set({ single: 'test', plural: 'tester', domain: 'wp', location: createLocation() }); 237 | expect(po.toString()).toMatchInlineSnapshot(` 238 | "msgid "" 239 | msgstr "" 240 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 241 | "Content-Type: text/plain; charset=utf-8\\n" 242 | "Content-Transfer-Encoding: 8bit\\n" 243 | "MIME-Version: 1.0\\n" 244 | 245 | #: test.ts:1 246 | msgid "test" 247 | msgid_plural "tester" 248 | msgstr[0] "" 249 | msgstr[1] "" 250 | " 251 | `); 252 | }); 253 | 254 | it('handles translations that exists as single but get appended as plural', () => { 255 | let po = new Po( 256 | ` 257 | msgid "" 258 | msgstr "" 259 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 260 | "Content-Type: text/plain; charset=UTF-8\n" 261 | "Content-Transfer-Encoding: 8bit\n" 262 | "MIME-Version: 1.0\n" 263 | "Language: en_US\n" 264 | 265 | msgid "test" 266 | msgstr "test" 267 | `, 268 | 'test.po', 269 | ); 270 | 271 | po.set({ single: 'test', plural: 'tester', location: createLocation() }); 272 | expect(po.toString()).toMatchInlineSnapshot(` 273 | "msgid "" 274 | msgstr "" 275 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 276 | "Content-Type: text/plain; charset=utf-8\\n" 277 | "Content-Transfer-Encoding: 8bit\\n" 278 | "MIME-Version: 1.0\\n" 279 | "Language: en_US\\n" 280 | 281 | #: test.ts:1 282 | msgid "test" 283 | msgid_plural "tester" 284 | msgstr[0] "test" 285 | msgstr[1] "" 286 | " 287 | `); 288 | }); 289 | 290 | it('removes removed comment if translation is brought back', async () => { 291 | let po = new Po( 292 | ` 293 | msgid "" 294 | msgstr "" 295 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 296 | "Content-Type: text/plain; charset=UTF-8\n" 297 | "Content-Transfer-Encoding: 8bit\n" 298 | "MIME-Version: 1.0\n" 299 | "Language: en_US\n" 300 | 301 | # THIS TRANSLATION IS NO LONGER REFERENCED INSIDE YOUR PROJECT 302 | msgid "test" 303 | msgstr "test" 304 | `, 305 | 'test.po', 306 | ); 307 | 308 | let pot = await Po.load('test.pot'); 309 | 310 | pot.set({ text: 'test', location: createLocation() }); 311 | po.updateFromTemplate(pot); 312 | 313 | expect(po.toString()).toMatchInlineSnapshot(` 314 | "msgid "" 315 | msgstr "" 316 | "Plural-Forms: nplurals=2; plural=(n != 1);\\n" 317 | "Content-Type: text/plain; charset=utf-8\\n" 318 | "Content-Transfer-Encoding: 8bit\\n" 319 | "MIME-Version: 1.0\\n" 320 | "Language: en_US\\n" 321 | 322 | #: test.ts:1 323 | msgid "test" 324 | msgstr "test" 325 | " 326 | `); 327 | }); 328 | 329 | function createLocation(replace: Partial = {}): Location { 330 | return { 331 | file: 'test.ts', 332 | namespace: '', 333 | line: 1, 334 | column: 0, 335 | length: 1, 336 | lineText: '', 337 | suggestion: '', 338 | ...replace, 339 | }; 340 | } 341 | -------------------------------------------------------------------------------- /src/context.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import type { BuildContext } from 'esbuild'; 4 | import { afterEach, describe, expect, it } from 'vitest'; 5 | 6 | import { createContext } from './context.js'; 7 | import { TestLogger } from './test-utils/extensions.js'; 8 | import type { Mode } from './types.js'; 9 | import { dirname } from './utils/dirname.js'; 10 | import { getMetadata } from './utils/read-pkg.js'; 11 | 12 | describe('theme', () => { 13 | it.skip('generates the expected output in prod mode', async () => { 14 | let [context, logger] = await createTestContext('theme', 'prod'); 15 | let result = await context.rebuild(); 16 | let output = cleanOutput(logger); 17 | 18 | expect(output).toMatchInlineSnapshot(` 19 | "▶ WP-BUNDLER Running bundler in prod mode. 20 | ▶ WP-BUNDLER Building... 21 | 22 | main 23 | dist/main.NMV57XSA.js 24 | dist/main.TGLZWPB2.css 25 | dist/main.nomodule.BAHQUTN7.js 26 | 27 | admin 28 | dist/admin.KRT7TWFX.js 29 | dist/admin.nomodule.K3AVKCJ4.js 30 | 31 | asset-loader 32 | dist/AssetLoader.php 33 | 34 | translations 35 | languages/theme.pot 36 | languages/sv_SE.po 37 | languages/sv_SE.mo 38 | dist/languages/wp-bundler-theme-sv_SE-b3d4ea03d549de3b657a18b46bf56e02.json 39 | 40 | ✔ WP-BUNDLER Build succeeded in XX ms." 41 | `); 42 | 43 | expect(Object.keys(result.metafile?.outputs ?? {})).toMatchInlineSnapshot(` 44 | [ 45 | "dist/main.NMV57XSA.js", 46 | "dist/main.TGLZWPB2.css", 47 | "dist/main.nomodule.BAHQUTN7.js", 48 | "dist/admin.KRT7TWFX.js", 49 | "dist/admin.nomodule.K3AVKCJ4.js", 50 | "dist/AssetLoader.php", 51 | "languages/theme.pot", 52 | "languages/sv_SE.po", 53 | "languages/sv_SE.mo", 54 | "dist/languages/wp-bundler-theme-sv_SE-b3d4ea03d549de3b657a18b46bf56e02.json", 55 | ] 56 | `); 57 | }); 58 | it('generates the expected output in dev mode', async () => { 59 | let [context] = await createTestContext('theme', 'dev'); 60 | let result = await context.rebuild(); 61 | expect(Object.keys(result.metafile?.outputs ?? {})).toMatchInlineSnapshot(` 62 | [ 63 | "dist/main.js.map", 64 | "dist/main.js", 65 | "dist/main.css.map", 66 | "dist/main.css", 67 | "dist/admin.js.map", 68 | "dist/admin.js", 69 | "dist/AssetLoader.php", 70 | "languages/theme.pot", 71 | "languages/sv_SE.po", 72 | "languages/sv_SE.mo", 73 | "dist/languages/wp-bundler-theme-sv_SE-2770833218bd08d0b5d0c0157cfef742.json", 74 | ] 75 | `); 76 | }); 77 | }); 78 | 79 | describe('plugin', () => { 80 | it.skip('generates the expected output in prod mode', async () => { 81 | let [context, logger] = await createTestContext('plugin', 'prod'); 82 | let result = await context.rebuild(); 83 | let output = cleanOutput(logger); 84 | 85 | expect(output).toMatchInlineSnapshot(` 86 | "▶ WP-BUNDLER Running bundler in prod mode. 87 | ▶ WP-BUNDLER Building... 88 | 89 | main 90 | dist/main.UGCVKHPS.js 91 | dist/main.nomodule.NXTWLLGJ.js 92 | 93 | admin 94 | dist/admin.EYKEWSYL.js 95 | dist/admin.nomodule.5UYSHB2K.js 96 | 97 | asset-loader 98 | dist/AssetLoader.php 99 | 100 | ✔ WP-BUNDLER Build succeeded in XX ms." 101 | `); 102 | 103 | expect(Object.keys(result.metafile?.outputs ?? {})).toMatchInlineSnapshot(` 104 | [ 105 | "dist/main.UGCVKHPS.js", 106 | "dist/main.nomodule.NXTWLLGJ.js", 107 | "dist/admin.EYKEWSYL.js", 108 | "dist/admin.nomodule.5UYSHB2K.js", 109 | "dist/AssetLoader.php", 110 | ] 111 | `); 112 | }); 113 | 114 | it('generates the expected output in dev mode', async () => { 115 | let [context] = await createTestContext('plugin', 'dev'); 116 | let result = await context.rebuild(); 117 | expect(Object.keys(result.metafile?.outputs ?? {})).toMatchInlineSnapshot(` 118 | [ 119 | "dist/main.js.map", 120 | "dist/main.js", 121 | "dist/main.css.map", 122 | "dist/main.css", 123 | "dist/admin.js.map", 124 | "dist/admin.js", 125 | "dist/AssetLoader.php", 126 | ] 127 | `); 128 | }); 129 | }); 130 | 131 | describe('output', () => { 132 | it('bundles js/ts in dev mode without transpilation', async () => { 133 | let [context] = await createTestContext('plugin', 'dev'); 134 | let files = await bundle(context); 135 | 136 | expect( 137 | files 138 | .content('admin') 139 | .replace(/\/\/# sourceMappingURL=.+/, '') 140 | .trim(), 141 | ).toMatchInlineSnapshot(` 142 | "var __getOwnPropNames = Object.getOwnPropertyNames; 143 | var __esm = (fn, res) => function __init() { 144 | return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; 145 | }; 146 | 147 | // ../../assets/wp-element/wp-element.ts 148 | var init_wp_element = __esm({ 149 | "../../assets/wp-element/wp-element.ts"() { 150 | "use strict"; 151 | } 152 | }); 153 | 154 | // src/admin.ts 155 | init_wp_element(); 156 | 157 | // src/log.ts 158 | init_wp_element(); 159 | var log = (...messages2) => { 160 | console.log(...messages2); 161 | }; 162 | 163 | // src/admin.ts 164 | var messages = ["Hello", "World"]; 165 | log(...messages);" 166 | `); 167 | }); 168 | 169 | it('outputs minified js/ts in production mode', async () => { 170 | let [context] = await createTestContext('plugin', 'prod'); 171 | let files = await bundle(context); 172 | expect(files.content('admin')).toMatchInlineSnapshot(` 173 | "var m=(e,r)=>()=>(e&&(r=e(e=0)),r);var o=m(()=>{"use strict"});o();o();var l=(...e)=>{console.log(...e)};var s=["Hello","World"];l(...s); 174 | " 175 | `); 176 | }); 177 | 178 | it('outputs transpiled nomodule version of javascript in production mode', async () => { 179 | let [context] = await createTestContext('plugin', 'prod'); 180 | let files = await bundle(context); 181 | expect(files.content('admin.nomodule')).toMatchInlineSnapshot(` 182 | "var S=(o,a)=>()=>(o&&(a=o(o=0)),a);var l=S(()=>{"use strict"});l();(function(){var o=function(n,t){return function(){return n&&(t=n(n=0)),t}},a=o(function(){"use strict"});a(),a();function i(n,t){(t==null||t>n.length)&&(t=n.length);for(var r=0,e=new Array(t);rn.length)&&(t=n.length);for(var r=0,e=new Array(t);r { 188 | let [context] = await createTestContext('theme', 'dev'); 189 | let files = await bundle(context); 190 | expect( 191 | files 192 | .content('main', 'css') 193 | .replace(/\/\*# sourceMappingURL=.+/, '') 194 | .trim(), 195 | ).toMatchInlineSnapshot(` 196 | "/* src/variables.css */ 197 | :root { 198 | --color-brand: rgba(0, 0, 255, 0.9); 199 | } 200 | 201 | /* src/main.css */ 202 | ::-moz-placeholder { 203 | color: var(--color-brand); 204 | } 205 | ::placeholder { 206 | color: var(--color-brand); 207 | }" 208 | `); 209 | }); 210 | 211 | it('outputs minified css in production mode', async () => { 212 | let [context] = await createTestContext('theme', 'prod'); 213 | let files = await bundle(context); 214 | expect(files.content('main', 'css')).toMatchInlineSnapshot(` 215 | ":root{--color-brand: rgba(0, 0, 255, .9)}::-moz-placeholder{color:var(--color-brand)}::placeholder{color:var(--color-brand)} 216 | " 217 | `); 218 | }); 219 | 220 | it('should include @wordpress/icons in bundle', async () => { 221 | let [context] = await createTestContext('theme', 'dev'); 222 | let files = await bundle(context); 223 | let content = files.content('main'); 224 | 225 | expect(content).toContain('var bug_default = '); 226 | expect(content).toContain('viewBox: "0 0 24 24"'); 227 | // expect(content).toMatchInlineSnapshot(); 228 | }); 229 | }); 230 | 231 | const createdContexts = new Set(); 232 | afterEach(async () => { 233 | for (let ctx of createdContexts) { 234 | await ctx.dispose(); 235 | createdContexts.delete(ctx); 236 | } 237 | }); 238 | 239 | const { __dirname } = dirname(import.meta.url); 240 | 241 | const paths = { 242 | plugin: path.join(__dirname, '../examples/wp-bundler-plugin'), 243 | theme: path.join(__dirname, '../examples/wp-bundler-theme'), 244 | } as const; 245 | 246 | async function createTestContext(kind: keyof typeof paths, mode: Mode) { 247 | let meta = getMetadata(paths[kind], __dirname); 248 | let logger = new TestLogger('WP-BUNDLER'); 249 | let options = { 250 | write: false, 251 | mode, 252 | watch: false, 253 | host: 'localhost', 254 | port: 3000, 255 | cwd: paths[kind], 256 | logger, 257 | ...meta, 258 | } as const; 259 | 260 | let ctx = await createContext(options); 261 | createdContexts.add(ctx); 262 | 263 | return [ctx, logger] as const; 264 | } 265 | 266 | async function bundle(context: BuildContext) { 267 | let result = await context.rebuild(); 268 | return { 269 | content: (name: string, extension = 'js') => { 270 | let file = result.outputFiles?.find((output) => { 271 | return output.path.includes(name) && output.path.endsWith(extension); 272 | }); 273 | return file?.text ?? ''; 274 | }, 275 | }; 276 | } 277 | 278 | function cleanOutput(logger: TestLogger) { 279 | return logger 280 | .getOutput() 281 | .replace(/\d+ ms/, 'XX ms') 282 | .split('\n') 283 | .map((l) => l.trim()) 284 | .join('\n') 285 | .trim(); 286 | } 287 | -------------------------------------------------------------------------------- /src/utils/po.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import type { OutputFile } from 'esbuild'; 4 | import { type GetTextTranslation, type GetTextTranslations, mo, po } from 'gettext-parser'; 5 | import mergeWith from 'lodash.mergewith'; 6 | import { stringToUint8Array } from 'uint8array-extras'; 7 | import * as z from 'zod'; 8 | 9 | import { ensure } from './assert.js'; 10 | import { 11 | type TranslationMessage, 12 | isContextMessage, 13 | isPluralMessage, 14 | isTranslationMessage, 15 | } from './extract-translations/index.js'; 16 | 17 | const GetTextTranslationSchema = z.object({ 18 | msgctxt: z.string().optional(), 19 | msgid: z.string().min(1), 20 | msgid_plural: z.string().min(1).optional(), 21 | msgstr: z.array(z.string()).default([]), 22 | comments: z 23 | .object({ 24 | translator: z.string().optional().default(''), 25 | reference: z.string().optional().default(''), 26 | extracted: z.string().optional().default(''), 27 | flag: z.string().optional().default(''), 28 | previous: z.string().optional().default(''), 29 | }) 30 | .optional() 31 | .default({ translator: '', reference: '', extracted: '', flag: '', previous: '' }), 32 | }); 33 | 34 | function parse(source: string | Uint8Array) { 35 | let result = po.parse(Buffer.from(source)); 36 | for (let key of Object.keys(result.translations)) { 37 | let context = ensure(result.translations[key]); 38 | result.translations[key] = Object.entries(context).reduce( 39 | (acc, [key, translation]) => { 40 | if (key === '') { 41 | acc[key] = translation; 42 | } else { 43 | let parsed = GetTextTranslationSchema.safeParse(translation); 44 | if (parsed.success) acc[key] = parsed.data; 45 | } 46 | 47 | return acc; 48 | }, 49 | {}, 50 | ); 51 | } 52 | 53 | return result; 54 | } 55 | 56 | export class Po { 57 | private parsedTranslations: GetTextTranslations; 58 | public filename: string; 59 | 60 | constructor(source: string | Uint8Array, filename: string) { 61 | this.parsedTranslations = parse(source); 62 | this.filename = filename; 63 | this.parsedTranslations.headers['Plural-Forms'] = 'nplurals=2; plural=(n != 1);'; 64 | } 65 | 66 | static UNUSED_COMMENT = 'THIS TRANSLATION IS NO LONGER REFERENCED INSIDE YOUR PROJECT'; 67 | 68 | static isUnused(translation: GetTextTranslation) { 69 | return translation.comments?.translator === Po.UNUSED_COMMENT; 70 | } 71 | 72 | static async load(filename: string): Promise { 73 | try { 74 | let source = await fs.readFile(filename); 75 | return new Po(source as Uint8Array, filename); 76 | } catch { 77 | return new Po( 78 | ` 79 | msgid "" 80 | msgstr "" 81 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 82 | "Content-Type: text/plain; charset=UTF-8\n" 83 | "Content-Transfer-Encoding: 8bit\n" 84 | "MIME-Version: 1.0\n" 85 | `, 86 | filename, 87 | ); 88 | } 89 | } 90 | 91 | async write(filename = this.filename, foldLength?: number) { 92 | await fs.writeFile(filename, this.toString(foldLength)); 93 | } 94 | 95 | toOutputFile(filename = this.filename, foldLength?: number) { 96 | let text = this.toString(foldLength); 97 | return { 98 | path: filename, 99 | contents: stringToUint8Array(text), 100 | text: text, 101 | hash: '', 102 | } satisfies OutputFile; 103 | } 104 | 105 | has(id: string, context: string = '') { 106 | return this.get(id, context) != null; 107 | } 108 | 109 | hasContext(context: string) { 110 | return this.parsedTranslations.translations[context] != null; 111 | } 112 | 113 | get(id: string, context: string = ''): GetTextTranslation | undefined { 114 | let ctx = this.getContext(context); 115 | return ctx?.[id] ?? undefined; 116 | } 117 | 118 | getContext(context: string): GetTextTranslations['translations'][string] | undefined { 119 | return this.parsedTranslations.translations[context] ?? undefined; 120 | } 121 | 122 | set(message: TranslationMessage | GetTextTranslation, mergeComments: boolean = true) { 123 | let next = isTranslationMessage(message) 124 | ? messageToTranslationItem(message) 125 | : GetTextTranslationSchema.parse(message); 126 | 127 | let context = this.createContext(next.msgctxt ?? ''); 128 | let current = this.get(next.msgid, next.msgctxt) ?? next; 129 | 130 | let final = mergeWith( 131 | current, 132 | next, 133 | (value: unknown, srcValue: unknown, key: string, obj: GetTextTranslation, source: GetTextTranslation) => { 134 | let keysToMerge = mergeComments ? ['reference', 'extracted', 'translator', 'flag', 'previous'] : ['translator']; 135 | 136 | if (keysToMerge.includes(key) && typeof value === 'string' && typeof srcValue === 'string') { 137 | let lines = [...value.trim().split('\n'), ...srcValue.trim().split('\n')]; 138 | 139 | let out = lines 140 | // Keep only lines with content, and uniq 141 | .filter((line, i, self) => !!line && self.indexOf(line) === i) 142 | // Remove unused comment if translation is back in the game 143 | .filter((line) => { 144 | if (srcValue.includes(Po.UNUSED_COMMENT)) return true; 145 | return !line.includes(Po.UNUSED_COMMENT); 146 | }) 147 | .sort() 148 | .join('\n') 149 | .replace(/translators:/gi, 'translators:'); 150 | 151 | return out; 152 | } 153 | 154 | if (key === 'msgstr' && isStringArray(value) && isStringArray(srcValue)) { 155 | return minLengthMsgstr( 156 | value.map((prev, i) => srcValue[i] || prev || ''), 157 | obj.msgid_plural != null || source.msgid_plural != null, 158 | ); 159 | } 160 | 161 | return undefined; 162 | }, 163 | ); 164 | 165 | context[next.msgid] = final; 166 | } 167 | 168 | createContext(context: string) { 169 | let existing = this.getContext(context); 170 | if (existing != null) return existing; 171 | 172 | this.parsedTranslations.translations[context] = {}; 173 | return ensure(this.parsedTranslations.translations[context]); 174 | } 175 | 176 | remove(id: string, context: string = '') { 177 | let ctx = this.parsedTranslations.translations[context]; 178 | if (ctx != null) { 179 | delete ctx[id]; 180 | } 181 | } 182 | 183 | clear() { 184 | for (let ctx of Object.values(this.parsedTranslations.translations)) { 185 | for (let key of Object.keys(ctx)) { 186 | if (key !== '') { 187 | delete ctx[key]; 188 | } 189 | } 190 | } 191 | } 192 | 193 | updateFromTemplate(pot: Po) { 194 | let removed: GetTextTranslation[] = []; 195 | 196 | // Remove all unused translations 197 | for (let translation of this.translations) { 198 | if (!pot.has(translation.msgid, translation.msgctxt)) { 199 | removed.push(translation); 200 | this.remove(translation.msgid, translation.msgctxt); 201 | } 202 | } 203 | 204 | // Set or update existing ones 205 | for (let translation of pot.translations) { 206 | this.set(translation, false); 207 | } 208 | 209 | // Append removed translations at the end 210 | for (let translation of removed) { 211 | translation.comments = { 212 | translator: Po.UNUSED_COMMENT, 213 | reference: '', 214 | extracted: '', 215 | flag: '', 216 | previous: '', 217 | }; 218 | 219 | this.set(translation); 220 | } 221 | } 222 | 223 | toString(foldLength: number = 120 - 9) { 224 | let buffer = po.compile(this.parsedTranslations, { sort: compareTranslations, foldLength }); 225 | return buffer.toString('utf-8'); 226 | } 227 | 228 | toMo(filterTranslation?: (t: GetTextTranslation) => boolean) { 229 | let translations: GetTextTranslations['translations'] = {}; 230 | for (let [contextKey, context] of Object.entries(this.parsedTranslations.translations)) { 231 | let nextContext: Record = {}; 232 | translations[contextKey] = nextContext; 233 | 234 | for (let [msgid, translation] of Object.entries(context)) { 235 | if (filterTranslation == null || filterTranslation(translation)) { 236 | nextContext[msgid] = translation; 237 | } 238 | } 239 | } 240 | 241 | let data: GetTextTranslations = { 242 | ...this.parsedTranslations, 243 | translations, 244 | }; 245 | 246 | return mo.compile(data) as Uint8Array; 247 | } 248 | 249 | toJed( 250 | domain: Domain, 251 | filterTranslation?: (t: GetTextTranslation) => boolean, 252 | ): JedFormat | null { 253 | let DELIMITER = '\u0004'; 254 | let translations: Record = {}; 255 | 256 | for (let translation of this.translations) { 257 | if (translation.msgid === '') continue; 258 | if (filterTranslation != null && !filterTranslation(translation)) continue; 259 | 260 | let key = [translation.msgctxt, translation.msgid].filter(Boolean).join(DELIMITER); 261 | translations[key] = translation.msgstr; 262 | } 263 | 264 | if (Object.keys(translations).length < 1) return null; 265 | 266 | let lang = this.header('Language') ?? ''; 267 | let pluralForms = this.header('Plural-Forms') ?? ''; 268 | 269 | return { 270 | domain, 271 | locale_data: { 272 | [domain]: { 273 | '': { domain, lang, 'plural-forms': pluralForms }, 274 | ...translations, 275 | }, 276 | } as LocaleData, 277 | }; 278 | } 279 | 280 | header(header: string): string | null { 281 | return this.headers[header] ?? null; 282 | } 283 | 284 | get headers() { 285 | return this.parsedTranslations.headers; 286 | } 287 | 288 | get translations() { 289 | return Object.values(this.parsedTranslations.translations) 290 | .flatMap((ctx) => Object.values(ctx)) 291 | .filter(({ msgid }) => msgid !== '') 292 | .sort(compareTranslations); 293 | } 294 | } 295 | 296 | interface LocaleDataDefault { 297 | domain: Domain; 298 | lang: string; 299 | 'plural-forms': string; 300 | } 301 | 302 | type LocaleData = { 303 | [key in Domain]: Record & { '': LocaleDataDefault }; 304 | }; 305 | 306 | interface JedFormat { 307 | domain: Domain; 308 | locale_data: LocaleData; 309 | } 310 | 311 | function messageToTranslationItem(message: TranslationMessage) { 312 | return GetTextTranslationSchema.parse({ 313 | msgctxt: isContextMessage(message) ? message.context : undefined, 314 | msgid: isPluralMessage(message) ? message.single : message.text, 315 | msgid_plural: isPluralMessage(message) ? message.plural : undefined, 316 | msgstr: isPluralMessage(message) ? ['', ''] : [''], 317 | comments: { 318 | reference: `${message.location.file}:${message.location.line}`, 319 | extracted: message.translators ?? '', 320 | }, 321 | }); 322 | } 323 | 324 | function compareTranslations(a: GetTextTranslation, b: GetTextTranslation) { 325 | if (Po.isUnused(a) && !Po.isUnused(b)) return 1; 326 | if (!Po.isUnused(a) && Po.isUnused(b)) return -1; 327 | 328 | let sort = a.msgid.localeCompare(b.msgid); 329 | if (sort === 0) sort = (a.msgctxt ?? '').localeCompare(b.msgctxt ?? ''); 330 | 331 | return sort; 332 | } 333 | 334 | function minLengthMsgstr(msgstr: string[], plural: boolean) { 335 | return Array.from({ length: plural ? 2 : 1 }, (_, i) => msgstr[i] ?? ''); 336 | } 337 | 338 | function isStringArray(value: unknown): value is string[] { 339 | return Array.isArray(value) && value.every((v) => typeof v === 'string'); 340 | } 341 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @fransvilhelm/wp-bundler 2 | 3 | ## 5.0.0 4 | 5 | ### Major Changes 6 | 7 | - Drop support for node 18 (by [@adambrgmn](https://github.com/adambrgmn) in [#88](https://github.com/adambrgmn/wp-bundler/pull/88)) 8 | 9 | ### Minor Changes 10 | 11 | - Upgrade package dependencies (by [@adambrgmn](https://github.com/adambrgmn) in [#88](https://github.com/adambrgmn/wp-bundler/pull/88)) 12 | - Add support for css modules (by [@adambrgmn](https://github.com/adambrgmn) in [#81](https://github.com/adambrgmn/wp-bundler/pull/81)) 13 | 14 | Esbuild has had support for css modules for a while. But it has not been possible to use them with wp-bundler due to an implementation detail of the wp-bundler setup. 15 | 16 | But from now on css modules are supported. Read more about how they [work in the esbuild context](https://esbuild.github.io/content-types/#local-css). 17 | 18 | ### Patch Changes 19 | 20 | - Stop retrying event source connection after 5 tries (by [@adambrgmn](https://github.com/adambrgmn) in [#81](https://github.com/adambrgmn/wp-bundler/pull/81)) 21 | 22 | ## 4.0.3 23 | 24 | ### Patch Changes 25 | 26 | - Skip using wp.element globals (by [@adambrgmn](https://github.com/adambrgmn) in [#78](https://github.com/adambrgmn/wp-bundler/pull/78)) 27 | 28 | ## 4.0.2 29 | 30 | ### Patch Changes 31 | 32 | - Use correct ReactDOM global (by [@adambrgmn](https://github.com/adambrgmn) in [#75](https://github.com/adambrgmn/wp-bundler/pull/75)) 33 | 34 | ## 4.0.1 35 | 36 | ### Patch Changes 37 | 38 | - Remove postinstall script (by [@adambrgmn](https://github.com/adambrgmn) in [#73](https://github.com/adambrgmn/wp-bundler/pull/73)) 39 | 40 | The postinstall script was a stupid idea from the beginning, causing issues for `yarn` projects. 41 | 42 | We're better off without it. 43 | 44 | ## 4.0.0 45 | 46 | ### Major Changes 47 | 48 | - Make project esm only (by [@adambrgmn](https://github.com/adambrgmn) in [#66](https://github.com/adambrgmn/wp-bundler/pull/66)) 49 | 50 | This project is now esm only. Generally it shouldn't affect you that much. But if you plan on building something on top of wp-bundler, using the exposed interfaces you need to be aware of this fact. 51 | 52 | - Remove ability to call wp-bundler without sub commands (by [@adambrgmn](https://github.com/adambrgmn) in [#69](https://github.com/adambrgmn/wp-bundler/pull/69)) 53 | 54 | Previously we allowed calling `wp-bundler` without `dev` or `build` sub commands, like it was from v1. This release removes that ability. From now on you must call `wp-bundler dev` or `wp-bundler build`. 55 | 56 | ### Minor Changes 57 | 58 | - Move away from multibundler setup (by [@adambrgmn](https://github.com/adambrgmn) in [#69](https://github.com/adambrgmn/wp-bundler/pull/69)) 59 | 60 | Previously we initiated two separate esbuild process to build the modern and legacy outputs. This meant we had no way to output a good asset loader witouth waiting for both of the outputs to be done and merge them. 61 | 62 | With this approach the legacy output is moving into the main process again. Something that will speed up and make out lives much easier in the future. 63 | 64 | ## 3.0.1 65 | 66 | ### Patch Changes 67 | 68 | - Update documentation for v3 (by [@adambrgmn](https://github.com/adambrgmn) in [#58](https://github.com/adambrgmn/wp-bundler/pull/58)) 69 | 70 | ## 3.0.0 71 | 72 | ### Major Changes 73 | 74 | - Require minimum node version 14 (by [@adambrgmn](https://github.com/adambrgmn) in [#45](https://github.com/adambrgmn/wp-bundler/pull/45)) 75 | 76 | This was the requirement before as well, though it was not clearly stated. With this release it is more clearly stated that 14.8 is the least requirement. 77 | 78 | - Rework cli output (by [@adambrgmn](https://github.com/adambrgmn) in [#41](https://github.com/adambrgmn/wp-bundler/pull/41)) 79 | 80 | Previoulsy the cli output was rendered with `ink` and `react`. That was effective and made it easy to make an interactive cli. But I've realized that this is not an interactive cli. I want `wp-bundler` to stay out of your way. 81 | 82 | This rework means that the cli is no longer rendered with ink. Instead the output is regular `console.log`'s. This also has the benefit of working in non interactive cli environments as well. 83 | 84 | - Introduce build & dev sub commands (by [@adambrgmn](https://github.com/adambrgmn) in [#35](https://github.com/adambrgmn/wp-bundler/pull/35)) 85 | 86 | Previously `wp-bundler` worked as a single command, without nothing but flags as the arguments. 87 | 88 | But to cater for future improvements I've choosen to split the command into sub commands – for now `wp-bundler build` for production and `wp-bundler dev` for development. 89 | 90 | The old behaviour is still around, but is marked as deprecated and is not recommended for new projects. It will be removed in the next major release. 91 | 92 | ### Minor Changes 93 | 94 | - Improve handling of react (by [@adambrgmn](https://github.com/adambrgmn) in [#56](https://github.com/adambrgmn/wp-bundler/pull/56)) 95 | 96 | Previously we relied on React being imported every time you wanted to use jsx (`import React from 'react';`). But with this change the jsx factory is injected when needed and you no longer have to import react. 97 | 98 | It also improves how the jsx factory is handled. Previously it used `React.createElement`. But now it instead using `createElement` from `'@wordpress/element'`. 99 | 100 | - Add support for plugins (by [@adambrgmn](https://github.com/adambrgmn) in [#48](https://github.com/adambrgmn/wp-bundler/pull/48)) 101 | 102 | This builder wasn't supported by plugins before since the generated `AssetLoader` included some functions tied to specific themes. But with this relase the bundler also supports plugins. 103 | 104 | The only thing you need to do is pass root directory and url to the `AssetLoader::prepare` call in you main entry file: 105 | 106 | ```php 107 | require_once __DIR__ . '/dist/AssetLoader.php'; 108 | 109 | WPBundler\AssetLoader::prepare(\plugin_dir_path(__FILE__), \plugin_dir_url(__FILE__)); 110 | WPBundler\AssetLoader::enqueueAssets('main'); 111 | ``` 112 | 113 | ### Patch Changes 114 | 115 | - Fix bug where css transforms are not applied (by [@adambrgmn](https://github.com/adambrgmn) in [#45](https://github.com/adambrgmn/wp-bundler/pull/45)) 116 | - Skip emitting confusing nomodule css (by [@adambrgmn](https://github.com/adambrgmn) in [#46](https://github.com/adambrgmn/wp-bundler/pull/46)) 117 | - Add improved examples (by [@adambrgmn](https://github.com/adambrgmn) in [#49](https://github.com/adambrgmn/wp-bundler/pull/49)) 118 | 119 | This release also adds improved examples. The previous example ran in a custom environment without actually using WordPress. These new examples makes use of `@wordpress/env` to spin up a quick WordPress environment. 120 | 121 | - Bundle @wordpress/icons instead of adding it as dependency (by [@adambrgmn](https://github.com/adambrgmn) in [#55](https://github.com/adambrgmn/wp-bundler/pull/55)) 122 | 123 | @wordpress/icons is treated as an internal packages and is not exposed on `window.wp` as the others. Instead this package should be bundled with the projects source. See #54 for context. 124 | 125 | - Surface errors outside esbuild pipeline (by [@adambrgmn](https://github.com/adambrgmn) in [#47](https://github.com/adambrgmn/wp-bundler/pull/47)) 126 | 127 | ## 2.1.1 128 | 129 | ### Patch Changes 130 | 131 | - Remove unused comment when translation is used again (by [@adambrgmn](https://github.com/adambrgmn) in [#34](https://github.com/adambrgmn/wp-bundler/pull/34)) 132 | - Fix default empty string for plural translations (by [@adambrgmn](https://github.com/adambrgmn) in [#31](https://github.com/adambrgmn/wp-bundler/pull/31)) 133 | 134 | ## 2.1.0 135 | 136 | ### Minor Changes 137 | 138 | - Load dev script in admin area (by [@adambrgmn](https://github.com/adambrgmn) in [#27](https://github.com/adambrgmn/wp-bundler/pull/27)) 139 | - Add ability to configure wp-bundler with .wp-bundlerrc or wp-bundler.config.json (by [@adambrgmn](https://github.com/adambrgmn) in [#24](https://github.com/adambrgmn/wp-bundler/pull/24)) 140 | 141 | ## 2.0.0 142 | 143 | ### Major Changes 144 | 145 | - Drop tailwind support (by [@adambrgmn](https://github.com/adambrgmn) in [#18](https://github.com/adambrgmn/wp-bundler/pull/18)) 146 | 147 | Running tailwind as part of the dev flow took to long. Tailwind needs to run outside of the wp-bundler context. 148 | 149 | - Add proper dev server with reload on change (by [@adambrgmn](https://github.com/adambrgmn) in [#21](https://github.com/adambrgmn/wp-bundler/pull/21)) 150 | 151 | This version includes a new dev server. The server is automatically started when running `wp-bundler --watch`. 152 | 153 | The server will listen for changes to your source files, including `.php` and `.twig` files. If a change is detected the page will be reloaded and the changes applied. 154 | 155 | If a change only affects `.css`-files the page will not be reloaded. Instead all your css will be "hot-reladed" on the page without requiring a refresh. 156 | 157 | ### Minor Changes 158 | 159 | - Add env variable support similar to CRA (by [@adambrgmn](https://github.com/adambrgmn) in [#22](https://github.com/adambrgmn/wp-bundler/pull/22)) 160 | - Rewrite postcss plugin (by [@adambrgmn](https://github.com/adambrgmn) in [#17](https://github.com/adambrgmn/wp-bundler/pull/17)) 161 | 162 | ### Patch Changes 163 | 164 | - Fix broken translations (by [@adambrgmn](https://github.com/adambrgmn) in [#17](https://github.com/adambrgmn/wp-bundler/pull/17)) 165 | - Improve twig message extraction (by [@adambrgmn](https://github.com/adambrgmn) in [#20](https://github.com/adambrgmn/wp-bundler/pull/20)) 166 | - Fix translations extraction inconsistencies (by [@adambrgmn](https://github.com/adambrgmn) in [#15](https://github.com/adambrgmn/wp-bundler/pull/15)) 167 | - Remove metafile plugin (by [@adambrgmn](https://github.com/adambrgmn) in [#17](https://github.com/adambrgmn/wp-bundler/pull/17)) 168 | - Only run translations plugin on build (by [@adambrgmn](https://github.com/adambrgmn) in [#15](https://github.com/adambrgmn/wp-bundler/pull/15)) 169 | - Fix issue with extracting domains from \_n_noop (by [@adambrgmn](https://github.com/adambrgmn) in [#21](https://github.com/adambrgmn/wp-bundler/pull/21)) 170 | - Fix issues in error output (by [@adambrgmn](https://github.com/adambrgmn) in [#17](https://github.com/adambrgmn/wp-bundler/pull/17)) 171 | 172 | ## 1.2.0 173 | 174 | ### Minor Changes 175 | 176 | - Extract translations from style.css (by [@adambrgmn](https://github.com/adambrgmn) in [#14](https://github.com/adambrgmn/wp-bundler/pull/14)) 177 | - Extract translations from twig files (by [@adambrgmn](https://github.com/adambrgmn) in [#11](https://github.com/adambrgmn/wp-bundler/pull/11)) 178 | - Extract translator comments when extracting translations (by [@adambrgmn](https://github.com/adambrgmn) in [#10](https://github.com/adambrgmn/wp-bundler/pull/10)) 179 | - Extract translations from PHP files as part of the build step (by [@adambrgmn](https://github.com/adambrgmn) in [#10](https://github.com/adambrgmn/wp-bundler/pull/10)) 180 | 181 | ### Patch Changes 182 | 183 | - Ensure uniq references in po(t) files (by [@adambrgmn](https://github.com/adambrgmn) in [#8](https://github.com/adambrgmn/wp-bundler/pull/8)) 184 | - Fix writing out proper po file (by [@adambrgmn](https://github.com/adambrgmn) in [#14](https://github.com/adambrgmn/wp-bundler/pull/14)) 185 | - Emit proper translator comments (by [@adambrgmn](https://github.com/adambrgmn) in [#14](https://github.com/adambrgmn/wp-bundler/pull/14)) 186 | - Properly minify css after postcss (by [@adambrgmn](https://github.com/adambrgmn) in [#13](https://github.com/adambrgmn/wp-bundler/pull/13)) 187 | - Enable ignoring folders for message extraction (by [@adambrgmn](https://github.com/adambrgmn) in [#14](https://github.com/adambrgmn/wp-bundler/pull/14)) 188 | - Fix merging po and pot files (by [@adambrgmn](https://github.com/adambrgmn) in [#13](https://github.com/adambrgmn/wp-bundler/pull/13)) 189 | 190 | ## 1.1.1 191 | 192 | ### Patch Changes 193 | 194 | - Update po files with translations as well (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 195 | - Fix issue where scripts loaded in the block editor weren't loaded as modules (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 196 | - allow defining css dependencies on scripts (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 197 | - Add lodash as as we built-in global (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 198 | - Fix purging tailwind classes (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 199 | - Skip marking node globals as external (by [@adambrgmn](https://github.com/adambrgmn) in [#6](https://github.com/adambrgmn/wp-bundler/pull/6)) 200 | 201 | Doing this hides errors that should otherwise be surfaced. Because marking them as just "external" forces the browser to try and import these libraries (`import fs from 'node:fs'`) in the browser. Which of course blows up. Now we instead rely on esbuild to report errors when our scripts (or their dependencies) tries to import any built-in node modules. 202 | 203 | ## 1.1.0 204 | 205 | ### Minor Changes 206 | 207 | - Fix dependency issues (by [@adambrgmn](https://github.com/adambrgmn) in [#4](https://github.com/adambrgmn/wp-bundler/pull/4)) 208 | 209 | ## 1.0.0 210 | 211 | ### Major Changes 212 | 213 | - Initial implementation (by [@adambrgmn](https://github.com/adambrgmn) in [#1](https://github.com/adambrgmn/wp-bundler/pull/1)) 214 | 215 | This is the initial release of the `wp-bundler` cli. 216 | -------------------------------------------------------------------------------- /assets/AssetLoader.php: -------------------------------------------------------------------------------- 1 | 10 | * @license MIT https://opensource.org/licenses/MIT 11 | * @version v0.0.0 12 | * @link https://github.com/adambrgmn/wp-bundler 13 | * @since 1.0.0 14 | */ 15 | 16 | namespace WPBundler; 17 | 18 | /** 19 | * Core class to register and enqueue assets generated by the wp-bundler cli. 20 | * 21 | * @package WPBundler 22 | * @author Adam Bergman 23 | * @license MIT https://opensource.org/licenses/MIT 24 | * @link https://github.com/adambrgmn/wp-bundler 25 | * @since 1.0.0 26 | */ 27 | class AssetLoader 28 | { 29 | /** 30 | * Indicates if the loader is prepared yet. To avoid doubles. 31 | * 32 | * @since 1.0.0 33 | * @var bool 34 | */ 35 | private static $prepared = false; 36 | 37 | /** 38 | * Current mode of wp-bundler, dev or prod 39 | * 40 | * @since 2.0.0 41 | * @var bool 42 | */ 43 | private static $mode = 'prod'; 44 | 45 | /** 46 | * Current mode of wp-bundler, dev or prod 47 | * 48 | * @since 2.0.0 49 | * @var bool 50 | */ 51 | private static $host = 'localhost'; 52 | 53 | /** 54 | * Current mode of wp-bundler, dev or prod 55 | * 56 | * @since 2.0.0 57 | * @var bool 58 | */ 59 | private static $port = 3000; 60 | 61 | /** 62 | * Domain used for translations. 63 | * 64 | * @since 1.0.0 65 | * @var string 66 | */ 67 | private static $domain = 'domain'; 68 | 69 | /** 70 | * Prefix used to set a more unique namespace for the assets. 71 | * 72 | * @since 3.0.0 73 | * @var string 74 | */ 75 | private static $prefix = 'wp-bundler.'; 76 | 77 | /** 78 | * Build directory, relative to template root directory. 79 | * 80 | * @since 1.0.0 81 | * @var string 82 | */ 83 | private static $outdir = '/build/'; 84 | 85 | /** 86 | * Assets generated by the cli. 87 | * 88 | * @since 1.0.0 89 | * @var array 90 | */ 91 | private static $assets = []; 92 | 93 | /** 94 | * Root directory of the theme or plugin. This is automatically handled for themes, but needs to 95 | * be passed as an argument for plugins. 96 | * 97 | * @since 3.0.0 98 | * @var string 99 | */ 100 | private static $rootdir = ''; 101 | 102 | /** 103 | * Root uri of the theme or plugin. This is automatically handled for themes, but needs to be 104 | * passed as an argument for plugins. 105 | * 106 | * @since 3.0.0 107 | * @var string 108 | */ 109 | private static $rooturi = ''; 110 | 111 | /** 112 | * Prepare the asset loader by setting up required actions and filters. This 113 | * method should be called as early as possible. 114 | * 115 | * @since 1.0.0 116 | * @param string $rootdir Root directory of the theme or plugin. This is automatically handled for themes, but needs to be passed as an argument for plugins. 117 | * @param string $rooturi Root uri of the theme or plugin. This is automatically handled for themes, but needs to be passed as an argument for plugins. 118 | * @return void 119 | */ 120 | public static function prepare(string $rootdir = '', string $rooturi = ''): void 121 | { 122 | if (self::$prepared) { 123 | return; 124 | } 125 | 126 | if ($rootdir === '') { 127 | $rootdir = \get_stylesheet_directory(); 128 | } 129 | 130 | if ($rooturi === '') { 131 | $rooturi = \get_stylesheet_directory_uri(); 132 | } 133 | 134 | self::$rootdir = $rootdir; 135 | self::$rooturi = $rooturi; 136 | 137 | if (self::$mode === 'dev') { 138 | \add_action('wp_enqueue_scripts', [get_called_class(), 'enqueueDevScript']); 139 | \add_action('admin_enqueue_scripts', [get_called_class(), 'enqueueDevScript']); 140 | } 141 | 142 | \add_filter('script_loader_tag', [get_called_class(), 'filterModuleScripts'], 10, 2); 143 | 144 | self::$prepared = true; 145 | } 146 | 147 | /** 148 | * Setup wp action and enqueue the scripts related to the provided handle. 149 | * 150 | * @since 1.0.0 151 | * 152 | * @param string $name Name of asset to enqueue. 153 | * @param array $deps Optional. Dependecy array (e.g. jquery, wp-i18n etc.). 154 | * @param bool $inFooter Optional. Wether to enqueue scripts in the footer (defaults to `true`). 155 | * @param string $action Action to hook into (defaults to `'wp_enqueue_scripts'`). 156 | * @return void 157 | */ 158 | public static function enqueueAssets( 159 | string $name, 160 | array $deps = [], 161 | bool $inFooter = true, 162 | string $action = 'wp_enqueue_scripts' 163 | ): void { 164 | self::prepare(); 165 | \add_action($action, function () use ($name, $deps, $inFooter) { 166 | self::enqueue($name, $deps, $inFooter); 167 | }); 168 | } 169 | 170 | /** 171 | * Enqueue assets with the `'enqueue_block_editor_assets'` wp action. 172 | * 173 | * @since 1.0.0 174 | * 175 | * @param string $name Name of asset to enqueue. 176 | * @param array $deps Optional. Dependency array (e.g. jquery, wp-i18n etc.). 177 | * @return void 178 | */ 179 | public static function enqueueEditorAssets(string $name, array $deps = []): void 180 | { 181 | self::prepare(); 182 | self::enqueueAssets($name, $deps, true, 'enqueue_block_editor_assets'); 183 | } 184 | 185 | /** 186 | * Enqueue assets with the `'enqueue_block_editor_assets'` wp action. 187 | * 188 | * @since 1.0.0 189 | * 190 | * @param string $name Name of asset to enqueue. 191 | * @param array $deps Optional. Dependency array (e.g. jquery, wp-i18n etc.). 192 | * @return void 193 | */ 194 | public static function enqueueAdminAssets(string $name, array $deps = []): void 195 | { 196 | self::prepare(); 197 | self::enqueueAssets($name, $deps, true, 'admin_enqueue_scripts'); 198 | } 199 | 200 | /** 201 | * Enqueue a block type with its related assets. 202 | * 203 | * @since 1.0.0 204 | * 205 | * @param string $name Name of assets to register as part of the block 206 | * @param string $blockName Name of the block to register 207 | * @param array $blockConfig Optional. Array of block type arguments. Accepts any public property of `WP_Block_Type`. See WP_Block_Type::__construct() for information on accepted arguments. Default empty array. 208 | * @param array $deps Optional. Dependency array (e.g. jquery, wp-i18n etc.). 209 | * @return void 210 | */ 211 | public static function enqueueBlockType( 212 | string $name, 213 | string $blockName, 214 | array $blockConfig = [], 215 | array $deps = [] 216 | ): void { 217 | self::prepare(); 218 | \add_action('init', function () use ($name, $blockName, $blockConfig, $deps) { 219 | self::registerBlockType($name, $blockName, $blockConfig, $deps); 220 | }); 221 | } 222 | 223 | /** 224 | * Register assets. 225 | * 226 | * @since 1.0.0 227 | * 228 | * @param string $name Name of asset to register. 229 | * @param array $deps Optional. Array with two keys, js and css containing each types dependencies. Note that js dependencies are often automatically detected. 230 | * @param bool $inFooter Optional. Render script tag in footer (defaults to `true`). 231 | * @return array Returns array of registered handles by type (js, css, nomodule). 232 | */ 233 | public static function register(string $name, array $deps = [], bool $inFooter = true): array 234 | { 235 | $handles = []; 236 | 237 | $jsDeps = []; 238 | $cssDeps = []; 239 | if (key_exists('js', $deps)) { 240 | $jsDeps = $deps['js']; 241 | } 242 | if (key_exists('css', $deps)) { 243 | $cssDeps = $deps['css']; 244 | } 245 | 246 | if (!key_exists($name, self::$assets)) { 247 | return $handles; 248 | } 249 | 250 | $asset = self::$assets[$name]; 251 | 252 | if (key_exists('js', $asset)) { 253 | $handle = self::$prefix . $name; 254 | $handles['js'] = $handle; 255 | 256 | \wp_register_script( 257 | $handle, 258 | self::outDirUri($asset['js']), 259 | array_merge($asset['deps'], $jsDeps), 260 | false, 261 | $inFooter 262 | ); 263 | 264 | \wp_set_script_translations($handle, self::$domain, self::outDirPath('languages')); 265 | } 266 | 267 | if (key_exists('nomodule', $asset)) { 268 | $handle = self::$prefix . $name . '.nomodule'; 269 | $handles['nomodule'] = $handle; 270 | 271 | \wp_register_script( 272 | $handle, 273 | self::outDirUri($asset['nomodule']), 274 | array_merge($asset['deps'], $jsDeps), 275 | false, 276 | $inFooter 277 | ); 278 | } 279 | 280 | if (key_exists('css', $asset)) { 281 | $handle = self::$prefix . $name; 282 | $handles['css'] = $handle; 283 | 284 | \wp_register_style($handle, self::outDirUri($asset['css']), $cssDeps, false, 'all'); 285 | } 286 | 287 | return $handles; 288 | } 289 | 290 | /** 291 | * Enqueue assets. 292 | * 293 | * @since 1.0.0 294 | * 295 | * @param string $name Name of asset to enqueue. 296 | * @param array $deps Optional. Dependency array (e.g. jquery, wp-i18n etc.). 297 | * @param bool $inFooter Optional. Render script tag in footer (defaults to `true`). 298 | * @return array Returns array of registered handles by type (js, css, nomodule). 299 | */ 300 | public static function enqueue(string $name, array $deps = [], bool $inFooter = true): array 301 | { 302 | $handles = self::register($name, $deps, $inFooter); 303 | 304 | foreach ($handles as $key => $handle) { 305 | if ($key === 'css') { 306 | \wp_enqueue_style($handle); 307 | } else { 308 | \wp_enqueue_script($handle); 309 | } 310 | } 311 | 312 | return $handles; 313 | } 314 | 315 | /** 316 | * Register assets as related to a specific block type. 317 | * 318 | * @param string $name Name of assets to register as part of the block 319 | * @param string $blockName Name of the block to register 320 | * @param array $blockConfig Optional. Array of block type arguments. Accepts any public property of `WP_Block_Type`. See WP_Block_Type::__construct() for information on accepted arguments. Default empty array. 321 | * @param array $deps Optional. Dependency array (e.g. jquery, wp-i18n etc.). 322 | * @return \WP_Block_Type|false The registered block type on success, or false on failure. 323 | */ 324 | public static function registerBlockType(string $name, string $blockName, array $blockConfig = [], array $deps = []) 325 | { 326 | $handles = self::register($name, $deps); 327 | 328 | if (key_exists('js', $handles)) { 329 | $blockConfig['editor_script'] = $handles['js']; 330 | } 331 | 332 | if (key_exists('css', $handles)) { 333 | $blockConfig['editor_style'] = $handles['css']; 334 | } 335 | 336 | return \register_block_type($blockName, $blockConfig); 337 | } 338 | 339 | /** 340 | * Get full uri path to theme directory. 341 | * 342 | * @param string $path Path to append to theme directory uri 343 | * @return string 344 | */ 345 | private static function outDirUri(string $path): string 346 | { 347 | return self::$rooturi . self::$outdir . $path; 348 | } 349 | 350 | /** 351 | * Get full path to theme directory. 352 | * 353 | * @param string $path Path to append to theme directory 354 | * @return string 355 | */ 356 | private static function outDirPath(string $path): string 357 | { 358 | return self::$rootdir . self::$outdir . $path; 359 | } 360 | 361 | /** 362 | * Enqueue the dev script which enables automatic reload on changes 363 | * during development. 364 | */ 365 | public static function enqueueDevScript() 366 | { 367 | \wp_register_script('wp-bundler-dev-client', '', [], false, false); 368 | 369 | \wp_add_inline_script( 370 | 'wp-bundler-dev-client', 371 | 'window.WP_BUNDLER_HOST = "' . self::$host . '"; window.WP_BUNDLER_PORT = ' . self::$port . ';' 372 | ); 373 | \wp_add_inline_script('wp-bundler-dev-client', self::$dev_client); 374 | 375 | \wp_enqueue_script('wp-bundler-dev-client'); 376 | } 377 | 378 | /** 379 | * Filter the script tags enqueued by WordPress and properly set type 380 | * module on the scripts that should be. And nomodule on the scripts that 381 | * should have it. 382 | * 383 | * @param string $tag The full tag 384 | * @param string $handle The handle used to enqueue the tag 385 | * @return string 386 | */ 387 | public static function filterModuleScripts(string $tag, string $handle): string 388 | { 389 | if (!str_contains($handle, self::$prefix)) { 390 | return $tag; 391 | } 392 | 393 | if (str_contains($handle, '.nomodule')) { 394 | return str_replace(' src', ' nomodule src', $tag); 395 | } 396 | 397 | $tag = str_replace('text/javascript', 'module', $tag); 398 | if (!str_contains('module', $tag)) { 399 | /** 400 | * When loaded as part of the block editor the script tag, 401 | * for some reason doesn't include `type="text/javascript"`. 402 | * In that case we need to do one more str_replace. 403 | */ 404 | $tag = str_replace('