diff --git a/.changeset/fair-yaks-smash.md b/.changeset/fair-yaks-smash.md new file mode 100644 index 000000000..9e26032d3 --- /dev/null +++ b/.changeset/fair-yaks-smash.md @@ -0,0 +1,5 @@ +--- +"@headstartwp/next": patch +--- + +Fix loading of the headless.config.js file to prevent injecting it twice. diff --git a/package-lock.json b/package-lock.json index ef0f8436b..68e522798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3381,6 +3381,7 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.3", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4862,6 +4863,7 @@ }, "node_modules/@types/eslint": { "version": "8.37.0", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4871,6 +4873,7 @@ }, "node_modules/@types/eslint-scope": { "version": "3.7.4", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4880,6 +4883,7 @@ }, "node_modules/@types/estree": { "version": "1.0.1", + "dev": true, "license": "MIT", "peer": true }, @@ -5025,6 +5029,7 @@ }, "node_modules/@types/node": { "version": "12.20.55", + "dev": true, "license": "MIT" }, "node_modules/@types/node-fetch": { @@ -5430,6 +5435,7 @@ }, "node_modules/@webassemblyjs/ast": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5439,16 +5445,19 @@ }, "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true }, @@ -5515,6 +5524,7 @@ }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5525,11 +5535,13 @@ }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5541,6 +5553,7 @@ }, "node_modules/@webassemblyjs/ieee754": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5549,6 +5562,7 @@ }, "node_modules/@webassemblyjs/leb128": { "version": "1.11.5", + "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { @@ -5557,11 +5571,13 @@ }, "node_modules/@webassemblyjs/utf8": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5577,6 +5593,7 @@ }, "node_modules/@webassemblyjs/wasm-gen": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5589,6 +5606,7 @@ }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5600,6 +5618,7 @@ }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5651,6 +5670,7 @@ }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.11.5", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5914,6 +5934,7 @@ }, "node_modules/acorn": { "version": "8.8.2", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5933,6 +5954,7 @@ }, "node_modules/acorn-import-assertions": { "version": "1.8.0", + "dev": true, "license": "MIT", "peer": true, "peerDependencies": { @@ -8929,6 +8951,7 @@ }, "node_modules/es-module-lexer": { "version": "1.2.1", + "dev": true, "license": "MIT", "peer": true }, @@ -9500,6 +9523,7 @@ }, "node_modules/eslint-scope": { "version": "5.1.1", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -9511,6 +9535,7 @@ }, "node_modules/eslint-scope/node_modules/estraverse": { "version": "4.3.0", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -10966,6 +10991,7 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", + "dev": true, "license": "BSD-2-Clause", "peer": true }, @@ -14661,6 +14687,7 @@ }, "node_modules/loader-runner": { "version": "4.3.0", + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -14674,19 +14701,6 @@ "node": ">= 12.13.0" } }, - "node_modules/loader-utils-webpack-v4": { - "name": "loader-utils", - "version": "2.0.4", - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "license": "MIT", @@ -15111,6 +15125,7 @@ }, "node_modules/merge-stream": { "version": "2.0.0", + "dev": true, "license": "MIT" }, "node_modules/merge2": { @@ -15340,17 +15355,6 @@ "node": ">=10" } }, - "node_modules/modify-source-webpack-plugin": { - "version": "4.1.0", - "license": "MIT", - "dependencies": { - "loader-utils-webpack-v4": "npm:loader-utils@^2.0.4", - "schema-utils": "^4.0.0" - }, - "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" - } - }, "node_modules/move-concurrently": { "version": "1.0.1", "license": "ISC", @@ -18073,6 +18077,7 @@ }, "node_modules/serialize-javascript": { "version": "6.0.1", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" @@ -19396,6 +19401,7 @@ }, "node_modules/terser": { "version": "5.17.1", + "dev": true, "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -19413,6 +19419,7 @@ }, "node_modules/terser-webpack-plugin": { "version": "5.3.7", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -19446,6 +19453,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/has-flag": { "version": "4.0.0", + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -19454,6 +19462,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/jest-worker": { "version": "27.5.1", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -19467,6 +19476,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/schema-utils": { "version": "3.1.2", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -19484,6 +19494,7 @@ }, "node_modules/terser-webpack-plugin/node_modules/supports-color": { "version": "8.1.1", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -19498,11 +19509,13 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", + "dev": true, "license": "MIT", "peer": true }, "node_modules/terser/node_modules/source-map-support": { "version": "0.5.21", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -20513,6 +20526,7 @@ }, "node_modules/watchpack": { "version": "2.4.0", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -20817,6 +20831,7 @@ }, "node_modules/webpack": { "version": "5.81.0", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -20977,6 +20992,7 @@ }, "node_modules/webpack-sources": { "version": "3.2.3", + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -20985,6 +21001,7 @@ }, "node_modules/webpack/node_modules/schema-utils": { "version": "3.1.2", + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -21758,7 +21775,6 @@ "@headstartwp/core": "^1.1.2", "deepmerge": "^4.3.1", "loader-utils": "^3.2.0", - "modify-source-webpack-plugin": "^4.1.0", "schema-utils": "^4.0.0" }, "devDependencies": { diff --git a/packages/next/package.json b/packages/next/package.json index a885f0d28..d2ff3058f 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -50,7 +50,6 @@ "dependencies": { "deepmerge": "^4.3.1", "@headstartwp/core": "^1.1.2", - "modify-source-webpack-plugin": "^4.1.0", "loader-utils": "^3.2.0", "schema-utils": "^4.0.0" }, diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/ModifySourcePlugin.ts b/packages/next/src/config/plugins/ModifySourcePlugin/ModifySourcePlugin.ts new file mode 100644 index 000000000..5d5f65694 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/ModifySourcePlugin.ts @@ -0,0 +1,139 @@ +import type { Compiler, NormalModule, Compilation } from 'webpack'; + +import { AbstractOperation, Operation } from './operations'; + +const { validate } = require('schema-utils'); + +export interface Rule { + test: RegExp | ((module: NormalModule, compilation: Compilation) => boolean); + operations?: AbstractOperation[]; +} + +export type Options = { + debug?: boolean; + rules: Rule[]; + constants?: Record; +}; + +const validationSchema = { + type: 'object', + additionalProperties: false, + properties: { + rules: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + test: { + anyOf: [{ instanceof: 'Function' }, { instanceof: 'RegExp' }], + }, + operations: { + type: 'array', + items: { + type: 'object', + }, + }, + }, + }, + }, + constants: { + type: 'object', + }, + debug: { + type: 'boolean', + }, + }, +}; + +const PLUGIN_NAME = 'ModifySourcePlugin'; + +export class ModifySourcePlugin { + constructor(protected readonly options: Options) { + validate(validationSchema, options, { + name: PLUGIN_NAME, + }); + } + + public apply(compiler: Compiler): void { + const { rules, debug, constants = {} } = this.options; + + compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { + const modifiedModules: (string | number)[] = []; + + const tapCallback = (_: any, normalModule: NormalModule) => { + const userRequest = normalModule.userRequest || ''; + + const startIndex = + userRequest.lastIndexOf('!') === -1 ? 0 : userRequest.lastIndexOf('!') + 1; + + const moduleRequest = userRequest.substring(startIndex).replace(/\\/g, '/'); + + if (modifiedModules.includes(moduleRequest)) { + return; + } + + rules.forEach((ruleOptions) => { + const { test } = ruleOptions; + const isMatched = (() => { + if (typeof test === 'function' && test(normalModule, compilation)) { + return true; + } + + return test instanceof RegExp && test.test(moduleRequest); + })(); + + if (isMatched) { + type NormalModuleLoader = { + loader: string; + options: any; + ident?: string; + type?: string; + }; + + const serializableOperations = ruleOptions.operations?.map((op) => + Operation.makeSerializable(op), + ); + + let loader; + + try { + loader = require.resolve('./loader.js'); + } catch (e) { + loader = require.resolve('../build/loader.js'); + } + + (normalModule.loaders as NormalModuleLoader[]).push({ + loader, + options: { + moduleRequest, + operations: serializableOperations, + constants, + }, + }); + + modifiedModules.push(moduleRequest); + + if (debug) { + // eslint-disable-next-line no-console + console.log(`\n[${PLUGIN_NAME}] Use loader for "${moduleRequest}".`); + } + } + }); + }; + + const NormalModule = compiler.webpack?.NormalModule; + const isNormalModuleAvailable = + Boolean(NormalModule) && Boolean(NormalModule.getCompilationHooks); + + if (isNormalModuleAvailable) { + NormalModule.getCompilationHooks(compilation).beforeLoaders.tap( + PLUGIN_NAME, + tapCallback, + ); + } else { + compilation.hooks.normalModuleLoader.tap(PLUGIN_NAME, tapCallback); + } + }); + } +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/index.ts b/packages/next/src/config/plugins/ModifySourcePlugin/index.ts new file mode 100644 index 000000000..7787b76c9 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/index.ts @@ -0,0 +1,2 @@ +export * from './ModifySourcePlugin'; +export * from './operations'; diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/loader.ts b/packages/next/src/config/plugins/ModifySourcePlugin/loader.ts new file mode 100644 index 000000000..db9e40cf3 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/loader.ts @@ -0,0 +1,63 @@ +import path from 'path'; + +import { Operation, SerializableOperation } from './operations'; + +const { validate } = require('schema-utils'); + +const schema = { + type: 'object', + properties: { + operations: { + type: 'array', + items: { + type: 'object', + }, + }, + moduleRequest: { + type: 'string', + }, + constants: { + type: 'object', + }, + }, + additionalProperties: false, +}; + +interface LoaderOptions { + operations: SerializableOperation[]; + moduleRequest: string; + constants: Record; +} + +interface modifyModuleSourceLoader { + getOptions: () => LoaderOptions; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export default function modifyModuleSourceLoader( + this: modifyModuleSourceLoader, + source: string, +): string { + const options: LoaderOptions = this.getOptions(); + + validate(schema, options, { + name: 'ModifySourcePlugin webpack loader', + }); + + const cleanPath = options.moduleRequest.split('?')[0]; + const fileName = path.basename(cleanPath); + + if (source.includes('__setHeadstartWPConfig')) { + return source; + } + + return options.operations.reduce((sourceText, serializableOp) => { + const operation = Operation.fillConstants(Operation.fromSerializable(serializableOp), { + ...options.constants, + FILE_PATH: cleanPath, + FILE_NAME: fileName, + }); + + return Operation.apply(sourceText, operation); + }, source); +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/operations/AbstractOperation.ts b/packages/next/src/config/plugins/ModifySourcePlugin/operations/AbstractOperation.ts new file mode 100644 index 000000000..9cb4bd99c --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/operations/AbstractOperation.ts @@ -0,0 +1,15 @@ +export type SerializableProperties = { + [K in keyof T as T[K] extends string ? K : never]: T[K] extends string ? T[K] : never; +}; + +export type SerializableOperation = { + operationName: string; +} & { + [K in keyof T as T[K] extends string ? K : never]: T[K] extends string ? T[K] : never; +}; + +export abstract class AbstractOperation { + public abstract getSerializableProperties(): string[]; + + public abstract getTextProperties(): string[]; +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/operations/ConcatOperation.ts b/packages/next/src/config/plugins/ModifySourcePlugin/operations/ConcatOperation.ts new file mode 100644 index 000000000..77a5b3a81 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/operations/ConcatOperation.ts @@ -0,0 +1,27 @@ +import { AbstractOperation } from './AbstractOperation'; + +export enum ConcatOperationType { + 'start' = 'start', + 'end' = 'end', +} + +export class ConcatOperation extends AbstractOperation { + constructor( + public readonly type: keyof typeof ConcatOperationType, + public readonly value: string, + ) { + super(); + } + + public getSerializableProperties(): (keyof this & string)[] { + return ['type', 'value']; + } + + public getTextProperties(): (keyof this & string)[] { + return ['value']; + } + + public static getAllowedTypes(): ConcatOperationType[] { + return [ConcatOperationType.start, ConcatOperationType.end]; + } +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/operations/Operation.ts b/packages/next/src/config/plugins/ModifySourcePlugin/operations/Operation.ts new file mode 100644 index 000000000..d488b93fb --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/operations/Operation.ts @@ -0,0 +1,120 @@ +import { + AbstractOperation, + SerializableOperation, + SerializableProperties, +} from './AbstractOperation'; +import { ConcatOperation } from './ConcatOperation'; +import { ReplaceOperation } from './ReplaceOperation'; + +function isSerializableOfOperation( + serializable: SerializableOperation, + operation: { new (...args: any[]): T }, +): serializable is SerializableOperation { + return serializable.operationName === operation.name; +} + +function throwUnknownOperationType(op: AbstractOperation, opType: string): void { + const allowedTypes = + op instanceof ConcatOperation + ? ConcatOperation.getAllowedTypes() + : ReplaceOperation.getAllowedTypes(); + + throw new Error( + `Incorrect operation type '${opType}' for ${ + op.constructor.name + }. Allowed types: '${allowedTypes.join("', '")}'.`, + ); +} + +export class Operation { + public static makeSerializable(op: T): SerializableOperation { + const propertyValues = op.getSerializableProperties().reduce((acc, val) => { + return { + ...acc, + [val]: op[val as keyof T], + }; + }, {} as SerializableProperties); + + return { + operationName: op.constructor.name, + ...propertyValues, + }; + } + + public static fromSerializable(serializable: SerializableOperation): AbstractOperation { + if (isSerializableOfOperation(serializable, ConcatOperation)) { + const { type, value } = serializable; + + return new ConcatOperation(type, value); + } + + if (isSerializableOfOperation(serializable, ReplaceOperation)) { + const { type, searchValue, replaceValue } = serializable; + + return new ReplaceOperation(type, searchValue, replaceValue); + } + + throw new Error(`Incorrect serializable provided: ${JSON.stringify(serializable)}`); + } + + public static fillConstants< + T extends AbstractOperation, + TConstants extends Record, + >(operation: T, constants: TConstants): T { + const filledTextProps = operation.getTextProperties().reduce((acc, propName) => { + let propValue = operation[propName as keyof T] as string; + + Object.keys(constants).forEach((constant) => { + propValue = propValue.replace( + new RegExp(`\\$${constant}`, 'g'), + String(constants[constant]), + ); + }); + + return { + ...acc, + [propName]: propValue, + }; + }, {}); + + const mergedObject = { + ...Operation.makeSerializable(operation), + ...filledTextProps, + }; + + return Operation.fromSerializable(mergedObject) as T; + } + + public static apply(src: string, operation: AbstractOperation): string { + if (operation instanceof ConcatOperation) { + switch (operation.type) { + case 'start': + return operation.value + src; + + case 'end': + return src + operation.value; + + default: + throwUnknownOperationType(operation, operation.type); + } + } + + if (operation instanceof ReplaceOperation) { + switch (operation.type) { + case 'once': + return src.replace(operation.searchValue, operation.replaceValue); + + case 'all': + return src.replace( + new RegExp(operation.searchValue, 'g'), + operation.replaceValue, + ); + + default: + throwUnknownOperationType(operation, operation.type); + } + } + + throw new Error(`Unknown operation instance: ${operation.constructor.name}`); + } +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/operations/ReplaceOperation.ts b/packages/next/src/config/plugins/ModifySourcePlugin/operations/ReplaceOperation.ts new file mode 100644 index 000000000..38a56a793 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/operations/ReplaceOperation.ts @@ -0,0 +1,28 @@ +import { AbstractOperation } from './AbstractOperation'; + +export enum ReplaceOperationType { + 'once' = 'once', + 'all' = 'all', +} + +export class ReplaceOperation extends AbstractOperation { + constructor( + public readonly type: keyof typeof ReplaceOperationType, + public readonly searchValue: string, + public readonly replaceValue: string, + ) { + super(); + } + + public getSerializableProperties(): (keyof this & string)[] { + return ['type', 'searchValue', 'replaceValue']; + } + + public getTextProperties(): (keyof this & string)[] { + return ['searchValue', 'replaceValue']; + } + + public static getAllowedTypes(): ReplaceOperationType[] { + return [ReplaceOperationType.all, ReplaceOperationType.once]; + } +} diff --git a/packages/next/src/config/plugins/ModifySourcePlugin/operations/index.ts b/packages/next/src/config/plugins/ModifySourcePlugin/operations/index.ts new file mode 100644 index 000000000..d5848b6d5 --- /dev/null +++ b/packages/next/src/config/plugins/ModifySourcePlugin/operations/index.ts @@ -0,0 +1,4 @@ +export * from './AbstractOperation'; +export * from './ConcatOperation'; +export * from './ReplaceOperation'; +export * from './Operation'; diff --git a/packages/next/src/config/withHeadlessConfig.ts b/packages/next/src/config/withHeadlessConfig.ts index 0b08d7a5a..44a84a667 100644 --- a/packages/next/src/config/withHeadlessConfig.ts +++ b/packages/next/src/config/withHeadlessConfig.ts @@ -1,8 +1,8 @@ import { ConfigError, HeadlessConfig } from '@headstartwp/core'; import { NextConfig } from 'next'; -import { ModifySourcePlugin, ConcatOperation } from 'modify-source-webpack-plugin'; import path from 'path'; import fs from 'fs'; +import { ModifySourcePlugin, ConcatOperation } from './plugins/ModifySourcePlugin'; const LINARIA_EXTENSION = '.linaria.module.css'; @@ -153,9 +153,17 @@ export function withHeadlessConfig( }, webpack: (config, options) => { + const headlessConfigPath = `${process.cwd()}/headless.config.js`; + const headstartWpConfigPath = `${process.cwd()}/headstartwp.config.js`; + + const configPath = fs.existsSync(headstartWpConfigPath) + ? headstartWpConfigPath + : headlessConfigPath; + const importSetHeadlessConfig = ` - import { setHeadstartWPConfig } from '@headstartwp/core/utils'; - setHeadstartWPConfig(${JSON.stringify(headlessConfig)}); + import { setHeadstartWPConfig as __setHeadstartWPConfig } from '@headstartwp/core/utils'; + import __headlessConfig from '${configPath}'; + __setHeadstartWPConfig(__headlessConfig); `; // clear webpack cache whenever headless.config.js changes or one of the env files