From 5eb1c6de04239a3d376620c8fd9b559e506860a3 Mon Sep 17 00:00:00 2001 From: Hugues Tavernier Date: Mon, 20 Nov 2023 17:24:56 +0100 Subject: [PATCH 1/4] add stimulus hmr --- playground/stimulus/assets/app.js | 9 + .../assets/controllers/welcome_controller.js | 22 +- .../controllers/welcome_controller.scss | 27 ++ .../stimulus/assets/react/controllers/App.jsx | 1 - playground/stimulus/package.json | 2 +- .../stimulus/public/o/.vite/entrypoints.json | 29 ++ .../templates/default/welcome.html.twig | 14 +- src/vite-plugin-symfony/HELP.md | 11 + src/vite-plugin-symfony/package-lock.json | 5 +- src/vite-plugin-symfony/package.json | 5 +- .../{ => entrypoints}/entryPointsHelper.ts | 2 +- .../src/entrypoints/index.ts | 305 ++++++++++++++++ .../src/{ => entrypoints}/utils.ts | 13 +- src/vite-plugin-symfony/src/index.ts | 328 ++---------------- src/vite-plugin-symfony/src/pluginOptions.ts | 25 +- .../src/stimulus/helpers/util.ts | 8 +- src/vite-plugin-symfony/src/stimulus/index.ts | 92 +++++ src/vite-plugin-symfony/src/types.d.ts | 22 +- .../entryPointsHelper.test.ts | 4 +- .../tests/{ => entrypoints}/index.test.ts | 45 ++- .../tests/{ => entrypoints}/utils.test.ts | 10 +- .../{ => entrypoints}/utils.win32.test.ts | 2 +- src/vite-plugin-symfony/tests/mocks.ts | 4 +- .../tests/stimulus/helpers/util.test.ts | 63 +++- 24 files changed, 673 insertions(+), 375 deletions(-) create mode 100644 playground/stimulus/assets/controllers/welcome_controller.scss create mode 100644 playground/stimulus/public/o/.vite/entrypoints.json create mode 100644 src/vite-plugin-symfony/HELP.md rename src/vite-plugin-symfony/src/{ => entrypoints}/entryPointsHelper.ts (99%) create mode 100644 src/vite-plugin-symfony/src/entrypoints/index.ts rename src/vite-plugin-symfony/src/{ => entrypoints}/utils.ts (97%) create mode 100644 src/vite-plugin-symfony/src/stimulus/index.ts rename src/vite-plugin-symfony/tests/{ => entrypoints}/entryPointsHelper.test.ts (87%) rename src/vite-plugin-symfony/tests/{ => entrypoints}/index.test.ts (87%) rename src/vite-plugin-symfony/tests/{ => entrypoints}/utils.test.ts (97%) rename src/vite-plugin-symfony/tests/{ => entrypoints}/utils.win32.test.ts (94%) diff --git a/playground/stimulus/assets/app.js b/playground/stimulus/assets/app.js index d5658327..4caf5b5c 100644 --- a/playground/stimulus/assets/app.js +++ b/playground/stimulus/assets/app.js @@ -11,5 +11,14 @@ let $nav = document.querySelector("#nav"); if ($nav) { window.addEventListener("scroll", refreshStickStatus); refreshStickStatus(); + + + } +// if (import.meta.hot) { +// import.meta.hot.accept((mod, ctx) => { +// import.meta.hot.send('restart') +// console.log(mod, ctx) +// }) +// } \ No newline at end of file diff --git a/playground/stimulus/assets/controllers/welcome_controller.js b/playground/stimulus/assets/controllers/welcome_controller.js index dc089756..b8246b0f 100644 --- a/playground/stimulus/assets/controllers/welcome_controller.js +++ b/playground/stimulus/assets/controllers/welcome_controller.js @@ -1,10 +1,28 @@ import { Controller } from "@hotwired/stimulus" +import "./welcome_controller.scss"; export default class controller extends Controller { + static targets = [ + "title", + "button" + ] static values = { - name: String + name: String, + count: { + type: Number, + default: 0 + } } connect() { - this.element.textContent = `hello ${this.nameValue}`; + this.titleTarget.textContent = `hello ${this.nameValue}`; + } + increment() { + this.countValue++ + } + countValueChanged() { + this.updateButtonText() + } + updateButtonText() { + this.buttonTarget.textContent = `count : ${this.countValue}` } } \ No newline at end of file diff --git a/playground/stimulus/assets/controllers/welcome_controller.scss b/playground/stimulus/assets/controllers/welcome_controller.scss new file mode 100644 index 00000000..5483586a --- /dev/null +++ b/playground/stimulus/assets/controllers/welcome_controller.scss @@ -0,0 +1,27 @@ +[data-controller="welcome"] { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; + + button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #f9f9f9; + cursor: pointer; + transition: border-color 0.25s; + + &:focus, + &:focus-visible { + outline: 4px auto -webkit-focus-ring-color; + } + + &:hover { + border-color: #646cff; + } + } +} \ No newline at end of file diff --git a/playground/stimulus/assets/react/controllers/App.jsx b/playground/stimulus/assets/react/controllers/App.jsx index aeda2d56..79cfbc44 100644 --- a/playground/stimulus/assets/react/controllers/App.jsx +++ b/playground/stimulus/assets/react/controllers/App.jsx @@ -28,7 +28,6 @@ function App(props) {

Edit component and save to test HMR - (we have hmr with js but not with css)

diff --git a/playground/stimulus/package.json b/playground/stimulus/package.json index 1f95c812..b5b764ef 100644 --- a/playground/stimulus/package.json +++ b/playground/stimulus/package.json @@ -52,4 +52,4 @@ "bootstrap": "^5.3.2", "stimulus-color-picker": "^1.1.0" } -} +} \ No newline at end of file diff --git a/playground/stimulus/public/o/.vite/entrypoints.json b/playground/stimulus/public/o/.vite/entrypoints.json new file mode 100644 index 00000000..5e5ed446 --- /dev/null +++ b/playground/stimulus/public/o/.vite/entrypoints.json @@ -0,0 +1,29 @@ +{ + "base": "/o/", + "entryPoints": { + "pageTranslator": { + "js": [ + "http://127.0.0.1:5173/o/assets/page/translator/index.js" + ] + }, + "app": { + "js": [ + "http://127.0.0.1:5173/o/assets/app.js" + ] + }, + "theme": { + "css": [ + "http://127.0.0.1:5173/o/assets/theme.scss" + ] + } + }, + "legacy": false, + "metadatas": {}, + "version": [ + "6.2.0", + 6, + 2, + 0 + ], + "viteServer": "http://127.0.0.1:5173" +} \ No newline at end of file diff --git a/playground/stimulus/templates/default/welcome.html.twig b/playground/stimulus/templates/default/welcome.html.twig index b4c362ae..e156b5ed 100644 --- a/playground/stimulus/templates/default/welcome.html.twig +++ b/playground/stimulus/templates/default/welcome.html.twig @@ -5,6 +5,18 @@ {% block html_class %}page-welcome{% endblock %} {% block content %} -

+
+

 

+
+ +
+

+ assets/controllers/welcome_controller.js +

+

+ Edit component and save to test HMR +

+ +
{% endblock %} diff --git a/src/vite-plugin-symfony/HELP.md b/src/vite-plugin-symfony/HELP.md new file mode 100644 index 00000000..5ffc5c3f --- /dev/null +++ b/src/vite-plugin-symfony/HELP.md @@ -0,0 +1,11 @@ +```js +import util from "node:util"; + +if (pluginOptions.debug) { + setTimeout(() => { + logger.info(`\n${colors.green("➜")} Vite Config \n`); + logger.info(util.inspect(viteConfig, { showHidden: false, depth: null, colors: true })); + logger.info(`\n${colors.green("➜")} End of config \n`); + }, 100); +} +``` \ No newline at end of file diff --git a/src/vite-plugin-symfony/package-lock.json b/src/vite-plugin-symfony/package-lock.json index 69a690d0..a7c9751d 100644 --- a/src/vite-plugin-symfony/package-lock.json +++ b/src/vite-plugin-symfony/package-lock.json @@ -9,6 +9,7 @@ "version": "6.2.0", "license": "MIT", "dependencies": { + "debug": "^4.3.4", "fast-glob": "^3.3.1", "picocolors": "^1.0.0", "sirv": "^2.0.2" @@ -1583,7 +1584,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2842,8 +2842,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mz": { "version": "2.7.0", diff --git a/src/vite-plugin-symfony/package.json b/src/vite-plugin-symfony/package.json index 32239e9c..652a40c2 100644 --- a/src/vite-plugin-symfony/package.json +++ b/src/vite-plugin-symfony/package.json @@ -1,7 +1,7 @@ { "name": "vite-plugin-symfony", "version": "6.2.0", - "description": "", + "description": "A Vite plugin to integrate easily Vite in your Symfony application", "main": "dist/index.js", "exports": { ".": { @@ -84,6 +84,7 @@ "node": "20.9.0" }, "dependencies": { + "debug": "^4.3.4", "fast-glob": "^3.3.1", "picocolors": "^1.0.0", "sirv": "^2.0.2" @@ -106,4 +107,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/vite-plugin-symfony/src/entryPointsHelper.ts b/src/vite-plugin-symfony/src/entrypoints/entryPointsHelper.ts similarity index 99% rename from src/vite-plugin-symfony/src/entryPointsHelper.ts rename to src/vite-plugin-symfony/src/entrypoints/entryPointsHelper.ts index 726e10f4..0eed6108 100644 --- a/src/vite-plugin-symfony/src/entryPointsHelper.ts +++ b/src/vite-plugin-symfony/src/entrypoints/entryPointsHelper.ts @@ -1,7 +1,7 @@ import process from "node:process"; import type { ResolvedConfig } from "vite"; import { getLegacyName, prepareRollupInputs } from "./utils"; -import { EntryPoints, EntryPoint, StringMapping, GeneratedFiles, FileInfos, FilesMetadatas } from "./types"; +import { EntryPoints, EntryPoint, StringMapping, GeneratedFiles, FileInfos, FilesMetadatas } from "../types"; export const getDevEntryPoints = (config: ResolvedConfig, viteDevServerUrl: string): EntryPoints => { const entryPoints: EntryPoints = {}; diff --git a/src/vite-plugin-symfony/src/entrypoints/index.ts b/src/vite-plugin-symfony/src/entrypoints/index.ts new file mode 100644 index 00000000..2748683c --- /dev/null +++ b/src/vite-plugin-symfony/src/entrypoints/index.ts @@ -0,0 +1,305 @@ +import { resolve, join, relative, dirname } from "node:path"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import glob from "fast-glob"; +import process from "node:process"; + +import { Logger, Plugin, UserConfig } from "vite"; +import sirv from "sirv"; + +import colors from "picocolors"; + +import type { RenderedChunk, OutputAsset, NormalizedOutputOptions, OutputChunk } from "rollup"; + +import { getDevEntryPoints, getBuildEntryPoints, getFilesMetadatas } from "./entryPointsHelper"; +import { + normalizePath, + writeJson, + emptyDir, + isImportRequest, + isInternalRequest, + resolveDevServerUrl, + isAddressInfo, + isCssEntryPoint, + getFileInfos, + getInputRelPath, + parseVersionString, + isSubdirectory, +} from "./utils"; +import { resolveBase, resolveOutDir, refreshPaths, resolvePublicDir } from "../pluginOptions"; + +import { + StringMapping, + GeneratedFiles, + ResolvedConfigWithOrderablePlugins, + VitePluginSymfonyEntrypointsOptions, +} from "../types"; + +// src and dist directory are in the same level; +let pluginDir = dirname(dirname(fileURLToPath(import.meta.url))); +let pluginVersion; + +if (process.env.VITEST) { + pluginDir = dirname(pluginDir); + pluginVersion = ["test"]; +} else { + const packageJson = JSON.parse(readFileSync(join(pluginDir, "package.json")).toString()); + pluginVersion = parseVersionString(packageJson?.version); +} + +export default function symfonyEntrypoints(pluginOptions: VitePluginSymfonyEntrypointsOptions, logger: Logger): Plugin { + let viteConfig: ResolvedConfigWithOrderablePlugins; + let viteDevServerUrl: string; + + const entryPointsFileName = ".vite/entrypoints.json"; + + const inputRelPath2outputRelPath: StringMapping = {}; + const generatedFiles: GeneratedFiles = {}; + + let outputCount = 0; + + return { + name: "symfony-entrypoints", + enforce: "post", + config(userConfig) { + const root = userConfig.root ? resolve(userConfig.root) : process.cwd(); + + if (userConfig.build.rollupOptions.input instanceof Array) { + logger.error(colors.red("rollupOptions.input must be an Objet like {app: './assets/app.js'}"), { + timestamp: true, + }); + process.exit(1); + } + + const extraConfig: UserConfig = { + base: userConfig.base ?? resolveBase(pluginOptions), + publicDir: false, + build: { + manifest: true, + outDir: userConfig.build?.outDir ?? resolveOutDir(pluginOptions), + }, + optimizeDeps: { + //Set to true to force dependency pre-bundling. + force: true, + }, + server: { + watch: { + ignored: userConfig.server?.watch?.ignored + ? [] + : ["**/vendor/**", glob.escapePath(root + "/var") + "/**", glob.escapePath(root + "/public") + "/**"], + }, + }, + }; + + return extraConfig; + }, + configResolved(config) { + viteConfig = config as ResolvedConfigWithOrderablePlugins; + + if (pluginOptions.enforcePluginOrderingPosition) { + const pluginPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "symfony"); + const symfonyPlugin = viteConfig.plugins.splice(pluginPos, 1); + + const manifestPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "vite:reporter"); + viteConfig.plugins.splice(manifestPos, 0, symfonyPlugin[0]); + } + }, + configureServer(devServer) { + // vite server is running + + const { watcher, ws } = devServer; + // empty the buildDir and create an entrypoints.json file inside. + devServer.httpServer?.once("listening", () => { + if (viteConfig.env.DEV) { + if (typeof pluginOptions.buildDirectory !== "undefined") { + logger.error( + `"buildDirectory" plugin option is deprecated and will be removed in v5.x use base: "${resolveBase( + pluginOptions, + )}" from vite config instead`, + { + timestamp: true, + }, + ); + } + if (typeof pluginOptions.publicDirectory !== "undefined") { + logger.error( + `${colors.red( + "[vite-plugin-symfony]", + )} "publicDirectory" plugin option is deprecated and will be removed in v5.x use build.outDir: "${resolveOutDir( + pluginOptions, + )}" from vite config instead`, + { + timestamp: true, + }, + ); + } + if (pluginOptions.viteDevServerHostname !== null) { + logger.error( + `${colors.red( + "[vite-plugin-symfony]", + )} "viteDevServerHostname" plugin option is deprecated and will be removed in v5.x use originOverride with protocol and port instead`, + { + timestamp: true, + }, + ); + } + + const buildDir = resolve(viteConfig.root, viteConfig.build.outDir); + const viteDir = resolve(buildDir, ".vite"); + + // buildDir is not a subdirectory of the vite project root -> potentially dangerous + if (!isSubdirectory(viteConfig.root, buildDir) && viteConfig.build.emptyOutDir !== true) { + logger.error( + `outDir ${buildDir} is not a subDirectory of your project root. To prevent recursively deleting files anywhere else set "build.outDir" to true in your vite.config.js to confirm that you did not accidentally specify a wrong directory location.`, + { + timestamp: true, + }, + ); + process.exit(1); + } + + if (!existsSync(buildDir)) { + mkdirSync(buildDir, { recursive: true }); + } + + existsSync(buildDir) && emptyDir(buildDir); + + mkdirSync(viteDir, { recursive: true }); + + const address = devServer.httpServer?.address(); + + if (!isAddressInfo(address)) { + logger.error( + `address is not an object open an issue with your address value to fix the problem : ${address}`, + { + timestamp: true, + }, + ); + process.exit(1); + } + + viteDevServerUrl = resolveDevServerUrl(address, devServer.config, pluginOptions); + if (pluginOptions.enforceServerOriginAfterListening) { + viteConfig.server.origin = viteDevServerUrl; + } + + const entryPoints = getDevEntryPoints(viteConfig, viteDevServerUrl); + + const entryPointsPath = resolve(viteConfig.root, viteConfig.build.outDir, entryPointsFileName); + + writeJson(entryPointsPath, { + base: viteConfig.base, + entryPoints, + legacy: false, + metadatas: {}, + version: pluginVersion, + viteServer: viteDevServerUrl, + }); + } + }); + + // full reload vite dev server if twig files are modified. + if (pluginOptions.refresh !== false) { + const paths = pluginOptions.refresh === true ? refreshPaths : pluginOptions.refresh; + for (const path of paths) { + watcher.add(path); + } + watcher.on("change", function (path) { + if (path.endsWith(".twig")) { + ws.send({ + type: "full-reload", + }); + } + }); + } + + if (pluginOptions.servePublic !== false) { + // inspired by https://github.com/vitejs/vite + // file: packages/vite/src/node/server/middlewares/static.ts + const serve = sirv(resolvePublicDir(pluginOptions), { + dev: true, + etag: true, + extensions: [], + setHeaders(res, pathname) { + // Matches js, jsx, ts, tsx. + // The reason this is done, is that the .ts file extension is reserved + // for the MIME type video/mp2t. In almost all cases, we can expect + // these files to be TypeScript files, and for Vite to serve them with + // this Content-Type. + if (/\.[tj]sx?$/.test(pathname)) { + res.setHeader("Content-Type", "application/javascript"); + } + + res.setHeader("Access-Control-Allow-Origin", "*"); + }, + }); + devServer.middlewares.use(function viteServePublicMiddleware(req, res, next) { + if (req.url === "/" || req.url === "/build/") { + res.statusCode = 404; + res.end(readFileSync(join(pluginDir, "static/dev-server-404.html"))); + return; + } + + // skip import request and internal requests `/@fs/ /@vite-client` etc... + if (isImportRequest(req.url!) || isInternalRequest(req.url!)) { + return next(); + } + serve(req, res, next); + }); + } + }, + async renderChunk(code: string, chunk: RenderedChunk) { + // we need this step because css entrypoints doesn't have a facadeModuleId in `generateBundle` step. + if (!isCssEntryPoint(chunk)) { + return; + } + + // Here we have only css entryPoints + const cssAssetName = chunk.facadeModuleId + ? normalizePath(relative(viteConfig.root, chunk.facadeModuleId)) + : chunk.name; + + // chunk.viteMetadata.importedCss contains a Set of relative file paths of css files + // in our case we have only one file. + // eg: inputRelPath2outputRelPath['assets/theme.scss'] = 'assets/theme-44b5be96.css'; + chunk.viteMetadata.importedCss.forEach((cssBuildFilename) => { + inputRelPath2outputRelPath[cssAssetName] = cssBuildFilename; + }); + }, + generateBundle(options: NormalizedOutputOptions, bundle: { [fileName: string]: OutputAsset | OutputChunk }) { + for (const chunk of Object.values(bundle)) { + const inputRelPath = getInputRelPath(chunk, options, viteConfig); + inputRelPath2outputRelPath[inputRelPath] = chunk.fileName; + generatedFiles[chunk.fileName] = getFileInfos(chunk, inputRelPath, pluginOptions); + } + + outputCount++; + const output = viteConfig.build.rollupOptions?.output; + + // if we have multiple build passes output is an array of each pass. + // else we have an object of this unique pass + const outputLength = Array.isArray(output) ? output.length : 1; + + if (outputCount >= outputLength) { + const entryPoints = getBuildEntryPoints(generatedFiles, viteConfig, inputRelPath2outputRelPath); + + this.emitFile({ + fileName: entryPointsFileName, + source: JSON.stringify( + { + base: viteConfig.base, + entryPoints, + legacy: typeof entryPoints["polyfills-legacy"] !== "undefined", + metadatas: getFilesMetadatas(viteConfig.base, generatedFiles), + version: pluginVersion, + viteServer: null, + }, + null, + 2, + ), + type: "asset", + }); + } + }, + }; +} diff --git a/src/vite-plugin-symfony/src/utils.ts b/src/vite-plugin-symfony/src/entrypoints/utils.ts similarity index 97% rename from src/vite-plugin-symfony/src/utils.ts rename to src/vite-plugin-symfony/src/entrypoints/utils.ts index 18e67a55..e6a8bbff 100644 --- a/src/vite-plugin-symfony/src/utils.ts +++ b/src/vite-plugin-symfony/src/entrypoints/utils.ts @@ -6,7 +6,14 @@ import { writeFileSync, rmSync, readdirSync } from "fs"; import { join } from "path"; import type { RenderedChunk, OutputChunk, OutputAsset, NormalizedOutputOptions } from "rollup"; import { resolve, extname, relative } from "path"; -import { StringMapping, DevServerUrl, VitePluginSymfonyOptions, FileInfos, ParsedInputs, HashAlgorithm } from "./types"; +import { + StringMapping, + DevServerUrl, + FileInfos, + ParsedInputs, + HashAlgorithm, + VitePluginSymfonyEntrypointsOptions, +} from "../types"; import { BinaryLike, createHash } from "node:crypto"; export const isWindows = os.platform() === "win32"; @@ -93,7 +100,7 @@ const polyfillId = "\0vite/legacy-polyfills"; export function resolveDevServerUrl( address: AddressInfo, config: ResolvedConfig, - pluginOptions: VitePluginSymfonyOptions, + pluginOptions: VitePluginSymfonyEntrypointsOptions, ): DevServerUrl { if (pluginOptions.originOverride) { return pluginOptions.originOverride as DevServerUrl; @@ -143,7 +150,7 @@ export const isCssEntryPoint = (chunk: RenderedChunk) => { export const getFileInfos = ( chunk: OutputChunk | OutputAsset, inputRelPath, - pluginOptions: VitePluginSymfonyOptions, + pluginOptions: VitePluginSymfonyEntrypointsOptions, ): FileInfos => { const alg = pluginOptions.sriAlgorithm; if (chunk.type === "asset") { diff --git a/src/vite-plugin-symfony/src/index.ts b/src/vite-plugin-symfony/src/index.ts index 8a070521..c50a18fb 100644 --- a/src/vite-plugin-symfony/src/index.ts +++ b/src/vite-plugin-symfony/src/index.ts @@ -1,305 +1,25 @@ -import { resolve, join, relative, dirname } from "node:path"; -import { existsSync, mkdirSync, readFileSync } from "node:fs"; -import util from "node:util"; -import { fileURLToPath } from "node:url"; -import glob from "fast-glob"; -import process from "node:process"; - -import { Plugin, UserConfig } from "vite"; -import sirv from "sirv"; - -import colors from "picocolors"; - -import type { RenderedChunk, OutputAsset, NormalizedOutputOptions, OutputChunk } from "rollup"; - -import { getDevEntryPoints, getBuildEntryPoints, getFilesMetadatas } from "./entryPointsHelper"; -import { - normalizePath, - writeJson, - emptyDir, - isImportRequest, - isInternalRequest, - resolveDevServerUrl, - isAddressInfo, - isCssEntryPoint, - getFileInfos, - getInputRelPath, - parseVersionString, - isSubdirectory, -} from "./utils"; -import { resolvePluginOptions, resolveBase, resolveOutDir, refreshPaths, resolvePublicDir } from "./pluginOptions"; - -import { VitePluginSymfonyOptions, StringMapping, GeneratedFiles, ResolvedConfigWithOrderablePlugins } from "./types"; -import { createControllersModule, virtualSymfonyControllersModuleId } from "./stimulus/node/bridge"; - -// src and dist directory are in the same level; -const pluginDir = dirname(dirname(fileURLToPath(import.meta.url))); -const packageJson = JSON.parse(readFileSync(join(pluginDir, "package.json")).toString()); -const pluginVersion = process.env.VITEST ? "test" : parseVersionString(packageJson?.version); - -export default function symfony(userOptions: Partial = {}): Plugin { - const pluginOptions = resolvePluginOptions(userOptions); - let viteConfig: ResolvedConfigWithOrderablePlugins; - let viteDevServerUrl: string; - - const entryPointsFileName = ".vite/entrypoints.json"; - - let stimulusControllersContent = null; - - const inputRelPath2outputRelPath: StringMapping = {}; - const generatedFiles: GeneratedFiles = {}; - - let outputCount = 0; - - return { - name: "symfony", - enforce: "post", - config(userConfig) { - const root = userConfig.root ? resolve(userConfig.root) : process.cwd(); - - if (userConfig.build.rollupOptions.input instanceof Array) { - console.error("rollupOptions.input must be an Objet like {app: './assets/app.js'}"); - process.exit(1); - } - - const extraConfig: UserConfig = { - base: userConfig.base ?? resolveBase(pluginOptions), - publicDir: false, - build: { - manifest: true, - outDir: userConfig.build?.outDir ?? resolveOutDir(pluginOptions), - }, - optimizeDeps: { - exclude: [...(userConfig?.optimizeDeps?.exclude ?? []), virtualSymfonyControllersModuleId], - //Set to true to force dependency pre-bundling. - force: true, - }, - server: { - watch: { - ignored: userConfig.server?.watch?.ignored - ? [] - : ["**/vendor/**", glob.escapePath(root + "/var") + "/**", glob.escapePath(root + "/public") + "/**"], - }, - }, - }; - - return extraConfig; - }, - configResolved(config) { - viteConfig = config as ResolvedConfigWithOrderablePlugins; - - if (pluginOptions.enforcePluginOrderingPosition) { - const pluginPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "symfony"); - const symfonyPlugin = viteConfig.plugins.splice(pluginPos, 1); - - const manifestPos = viteConfig.plugins.findIndex((plugin) => plugin.name === "vite:reporter"); - viteConfig.plugins.splice(manifestPos, 0, symfonyPlugin[0]); - } - - if (typeof pluginOptions.stimulus === "string") { - stimulusControllersContent = JSON.parse(readFileSync(resolve(config.root, pluginOptions.stimulus)).toString()); - } - }, - resolveId(id: string) { - if (pluginOptions.stimulus !== false && id === virtualSymfonyControllersModuleId) { - return id; - } - }, - load(id) { - if (id === virtualSymfonyControllersModuleId) { - return createControllersModule(stimulusControllersContent); - } - }, - configureServer(devServer) { - // vite server is running - - const { watcher, ws } = devServer; - - // empty the buildDir and create an entrypoints.json file inside. - devServer.httpServer?.once("listening", () => { - if (viteConfig.env.DEV) { - if (typeof pluginOptions.buildDirectory !== "undefined") { - devServer.config.logger.error( - `${colors.red( - "[vite-plugin-symfony]", - )} "buildDirectory" plugin option is deprecated and will be removed in v5.x use base: "${resolveBase( - pluginOptions, - )}" from vite config instead`, - ); - } - if (typeof pluginOptions.publicDirectory !== "undefined") { - devServer.config.logger.error( - `${colors.red( - "[vite-plugin-symfony]", - )} "publicDirectory" plugin option is deprecated and will be removed in v5.x use build.outDir: "${resolveOutDir( - pluginOptions, - )}" from vite config instead`, - ); - } - if (pluginOptions.viteDevServerHostname !== null) { - devServer.config.logger.error( - `${colors.red( - "[vite-plugin-symfony]", - )} "viteDevServerHostname" plugin option is deprecated and will be removed in v5.x use originOverride with protocol and port instead`, - ); - } - - const buildDir = resolve(viteConfig.root, viteConfig.build.outDir); - const viteDir = resolve(buildDir, ".vite"); - - // buildDir is not a subdirectory of the vite project root -> potentially dangerous - if (!isSubdirectory(viteConfig.root, buildDir) && viteConfig.build.emptyOutDir !== true) { - devServer.config.logger.error( - `outDir ${buildDir} is not a subDirectory of your project root. To prevent recursively deleting files anywhere else set "build.outDir" to true in your vite.config.js to confirm that you did not accidentally specify a wrong directory location.`, - ); - process.exit(1); - } - - if (!existsSync(buildDir)) { - mkdirSync(buildDir, { recursive: true }); - } - - existsSync(buildDir) && emptyDir(buildDir); - - mkdirSync(viteDir, { recursive: true }); - - const address = devServer.httpServer?.address(); - - if (!isAddressInfo(address)) { - console.error("address is not an object open an issue with your address value to fix the problem", address); - process.exit(1); - } - - viteDevServerUrl = resolveDevServerUrl(address, devServer.config, pluginOptions); - if (pluginOptions.enforceServerOriginAfterListening) { - viteConfig.server.origin = viteDevServerUrl; - } - - const entryPoints = getDevEntryPoints(viteConfig, viteDevServerUrl); - - const entryPointsPath = resolve(viteConfig.root, viteConfig.build.outDir, entryPointsFileName); - - writeJson(entryPointsPath, { - base: viteConfig.base, - entryPoints, - legacy: false, - metadatas: {}, - version: pluginVersion, - viteServer: viteDevServerUrl, - }); - } - - if (pluginOptions.debug) { - setTimeout(() => { - devServer.config.logger.info(`\n${colors.green("➜")} Vite Config \n`); - devServer.config.logger.info(util.inspect(viteConfig, { showHidden: false, depth: null, colors: true })); - devServer.config.logger.info(`\n${colors.green("➜")} End of config \n`); - }, 100); - } - }); - - // full reload vite dev server if twig files are modified. - if (pluginOptions.refresh !== false) { - const paths = pluginOptions.refresh === true ? refreshPaths : pluginOptions.refresh; - for (const path of paths) { - watcher.add(path); - } - watcher.on("change", function (path) { - if (path.endsWith(".twig")) { - ws.send({ - type: "full-reload", - }); - } - }); - } - - if (pluginOptions.servePublic !== false) { - // inspired by https://github.com/vitejs/vite - // file: packages/vite/src/node/server/middlewares/static.ts - const serve = sirv(resolvePublicDir(pluginOptions), { - dev: true, - etag: true, - extensions: [], - setHeaders(res, pathname) { - // Matches js, jsx, ts, tsx. - // The reason this is done, is that the .ts file extension is reserved - // for the MIME type video/mp2t. In almost all cases, we can expect - // these files to be TypeScript files, and for Vite to serve them with - // this Content-Type. - if (/\.[tj]sx?$/.test(pathname)) { - res.setHeader("Content-Type", "application/javascript"); - } - - res.setHeader("Access-Control-Allow-Origin", "*"); - }, - }); - devServer.middlewares.use(function viteServePublicMiddleware(req, res, next) { - if (req.url === "/" || req.url === "/build/") { - res.statusCode = 404; - res.end(readFileSync(join(pluginDir, "static/dev-server-404.html"))); - return; - } - - // skip import request and internal requests `/@fs/ /@vite-client` etc... - if (isImportRequest(req.url!) || isInternalRequest(req.url!)) { - return next(); - } - serve(req, res, next); - }); - } - }, - async renderChunk(code: string, chunk: RenderedChunk) { - // we need this step because css entrypoints doesn't have a facadeModuleId in `generateBundle` step. - if (!isCssEntryPoint(chunk)) { - return; - } - - // Here we have only css entryPoints - const cssAssetName = chunk.facadeModuleId - ? normalizePath(relative(viteConfig.root, chunk.facadeModuleId)) - : chunk.name; - - // chunk.viteMetadata.importedCss contains a Set of relative file paths of css files - // in our case we have only one file. - // eg: inputRelPath2outputRelPath['assets/theme.scss'] = 'assets/theme-44b5be96.css'; - chunk.viteMetadata.importedCss.forEach((cssBuildFilename) => { - inputRelPath2outputRelPath[cssAssetName] = cssBuildFilename; - }); - }, - generateBundle(options: NormalizedOutputOptions, bundle: { [fileName: string]: OutputAsset | OutputChunk }) { - for (const chunk of Object.values(bundle)) { - const inputRelPath = getInputRelPath(chunk, options, viteConfig); - inputRelPath2outputRelPath[inputRelPath] = chunk.fileName; - generatedFiles[chunk.fileName] = getFileInfos(chunk, inputRelPath, pluginOptions); - } - - outputCount++; - const output = viteConfig.build.rollupOptions?.output; - - // if we have multiple build passes output is an array of each pass. - // else we have an object of this unique pass - const outputLength = Array.isArray(output) ? output.length : 1; - - if (outputCount >= outputLength) { - const entryPoints = getBuildEntryPoints(generatedFiles, viteConfig, inputRelPath2outputRelPath); - - this.emitFile({ - fileName: entryPointsFileName, - source: JSON.stringify( - { - base: viteConfig.base, - entryPoints, - legacy: typeof entryPoints["polyfills-legacy"] !== "undefined", - metadatas: getFilesMetadatas(viteConfig.base, generatedFiles), - version: pluginVersion, - viteServer: null, - }, - null, - 2, - ), - type: "asset", - }); - } - }, - }; +import { Plugin, createLogger } from "vite"; +import symfonyEntrypoints from "./entrypoints"; +import symfonyStimulus from "./stimulus"; + +import { VitePluginSymfonyOptions } from "./types"; +import { resolvePluginOptions } from "./pluginOptions"; + +export default function symfony(userOptions: Partial = {}): Plugin[] { + const { stimulus: stimulusOptions, ...entrypointsOptions } = resolvePluginOptions(userOptions); + + const plugins: Plugin[] = [ + symfonyEntrypoints( + entrypointsOptions, + createLogger("info", { prefix: "[symfony:entrypoints]", allowClearScreen: true }), + ), + ]; + + if (typeof stimulusOptions === "object") { + plugins.push( + symfonyStimulus(stimulusOptions, createLogger("info", { prefix: "[symfony:stimulus]", allowClearScreen: true })), + ); + } + + return plugins; } diff --git a/src/vite-plugin-symfony/src/pluginOptions.ts b/src/vite-plugin-symfony/src/pluginOptions.ts index c0c63f16..c3655724 100644 --- a/src/vite-plugin-symfony/src/pluginOptions.ts +++ b/src/vite-plugin-symfony/src/pluginOptions.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { VitePluginSymfonyOptions } from "./types"; +import { VitePluginSymfonyEntrypointsOptions, VitePluginSymfonyOptions } from "./types"; export function resolvePluginOptions(userConfig: Partial = {}): VitePluginSymfonyOptions { if (typeof userConfig.publicDirectory === "string") { @@ -30,8 +30,21 @@ export function resolvePluginOptions(userConfig: Partial { + if (!window.${applicationGlobalVarName}) { + console.warn('Simulus app not available. Are you creating app with startStimulusApp() ?'); + import.meta.hot.invalidate(); + } else { + window.${applicationGlobalVarName}.register('${identifier}', newModule.default); + } + }) + } + `; + + return `${code}\n${metaHotFooter}`; + }, + configureServer(devServer) { + const { watcher } = devServer; + watcher.on("change", (path) => { + if (path === controllersFilePath) { + logger.info("✨ controllers.json updated, we restart server.", { timestamp: true }); + devServer.restart(); + } + }); + }, + }; +} diff --git a/src/vite-plugin-symfony/src/types.d.ts b/src/vite-plugin-symfony/src/types.d.ts index c729320e..e282356f 100644 --- a/src/vite-plugin-symfony/src/types.d.ts +++ b/src/vite-plugin-symfony/src/types.d.ts @@ -119,7 +119,15 @@ export type DevServerUrl = `${"http" | "https"}://${string}:${number}`; export type HashAlgorithm = false | "sha256" | "sha384" | "sha512"; -export type VitePluginSymfonyOptions = { +export type VitePluginSymfonyOptions = VitePluginSymfonyEntrypointsOptions & { + /** + * enable controllers.json loader for Symfony UX. + * @default false + */ + stimulus: boolean | string | VitePluginSymfonyStimulusOptions; +}; + +export type VitePluginSymfonyEntrypointsOptions = { /** * Web directory root * Relative file path from project directory root. @@ -202,10 +210,16 @@ export type VitePluginSymfonyOptions = { * @default true */ enforceServerOriginAfterListening: boolean; +}; +export type VitePluginSymfonyStimulusOptions = { /** - * enable controllers.json loader for Symfony UX. - * @default false + * path to controllers.json relative to vite root + */ + controllersFilePath: string; + + /** + * enable hmr for controllers */ - stimulus: boolean | string; + hmr: boolean; }; diff --git a/src/vite-plugin-symfony/tests/entryPointsHelper.test.ts b/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts similarity index 87% rename from src/vite-plugin-symfony/tests/entryPointsHelper.test.ts rename to src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts index 8bc770f1..37c99b94 100644 --- a/src/vite-plugin-symfony/tests/entryPointsHelper.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts @@ -1,8 +1,8 @@ import { describe, it } from "vitest"; -import { getDevEntryPoints } from "../src/entryPointsHelper"; +import { getDevEntryPoints } from "../../src/entrypoints/entryPointsHelper"; import type { ResolvedConfig } from "vite"; -import { viteBaseConfig } from "./mocks"; +import { viteBaseConfig } from "../mocks"; describe("getDevEntryPoints", () => { it("generate correct entrypoints", ({ expect }) => { diff --git a/src/vite-plugin-symfony/tests/index.test.ts b/src/vite-plugin-symfony/tests/entrypoints/index.test.ts similarity index 87% rename from src/vite-plugin-symfony/tests/index.test.ts rename to src/vite-plugin-symfony/tests/entrypoints/index.test.ts index 4848ef5c..861b538b 100644 --- a/src/vite-plugin-symfony/tests/index.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, vi } from "vitest"; -import vitePluginSymfony from "../src/index"; +import vitePluginSymfonyEntrypoints from "../../src/entrypoints/index"; import type { OutputChunk, OutputAsset } from "rollup"; import { @@ -18,7 +18,9 @@ import { circular1Js, circular2Js, viteUserConfigNoRoot, -} from "./mocks"; +} from "../mocks"; +import { VitePluginSymfonyOptions } from "../../src/types"; +import { resolvePluginOptions } from "../../src/pluginOptions"; function createBundleObject(files: (OutputChunk | OutputAsset)[]) { const bundles: { @@ -31,9 +33,15 @@ function createBundleObject(files: (OutputChunk | OutputAsset)[]) { return bundles; } -describe("vitePluginSymfony", () => { +function plugin(userOptions: Partial) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { stimulus, ...entrypointsOptions } = resolvePluginOptions(userOptions); + return vitePluginSymfonyEntrypoints(entrypointsOptions); +} + +describe("vitePluginSymfonyEntrypoints", () => { it("generate correct welcome build entrypoints", ({ expect }) => { - const welcomePluginInstance = vitePluginSymfony({ debug: true }) as any; + const welcomePluginInstance = plugin({ debug: true }) as any; welcomePluginInstance.emitFile = vi.fn(); welcomePluginInstance.configResolved({ @@ -64,7 +72,7 @@ describe("vitePluginSymfony", () => { }, legacy: false, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -75,7 +83,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct integrity hash for build entrypoints", ({ expect }) => { - const hashPluginInstance = vitePluginSymfony({ debug: true, sriAlgorithm: "sha256" }) as any; + const hashPluginInstance = plugin({ debug: true, sriAlgorithm: "sha256" }) as any; hashPluginInstance.emitFile = vi.fn(); hashPluginInstance.configResolved({ @@ -110,7 +118,7 @@ describe("vitePluginSymfony", () => { hash: "sha256-w+Sit18/MC+LC1iX8MrNapOiCQ8wbPX8Rb6ErbfDX1Q=", }, }, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -121,7 +129,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct pageAssets build entrypoints", ({ expect }) => { - const pageAssetsPluginInstance = vitePluginSymfony({ debug: true }) as any; + const pageAssetsPluginInstance = plugin({ debug: true }) as any; pageAssetsPluginInstance.emitFile = vi.fn(); pageAssetsPluginInstance.configResolved({ ...viteBaseConfig, @@ -151,7 +159,7 @@ describe("vitePluginSymfony", () => { }, legacy: false, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -162,7 +170,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct pageImports build entrypoints", ({ expect }) => { - const pageImportsPluginInstance = vitePluginSymfony({ debug: true }) as any; + const pageImportsPluginInstance = plugin({ debug: true }) as any; pageImportsPluginInstance.emitFile = vi.fn(); pageImportsPluginInstance.configResolved({ ...viteBaseConfig, @@ -192,7 +200,7 @@ describe("vitePluginSymfony", () => { }, legacy: false, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -203,7 +211,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct theme build entrypoints", ({ expect }) => { - const themePluginInstance = vitePluginSymfony({ debug: true }) as any; + const themePluginInstance = plugin({ debug: true }) as any; themePluginInstance.emitFile = vi.fn(); themePluginInstance.configResolved({ ...viteBaseConfig, @@ -235,7 +243,7 @@ describe("vitePluginSymfony", () => { }, legacy: false, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -246,7 +254,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct circular build entrypoints", ({ expect }) => { - const circularPluginInstance = vitePluginSymfony({ debug: true }) as any; + const circularPluginInstance = plugin({ debug: true }) as any; circularPluginInstance.emitFile = vi.fn(); circularPluginInstance.configResolved({ ...viteBaseConfig, @@ -277,7 +285,7 @@ describe("vitePluginSymfony", () => { }, legacy: false, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -288,7 +296,7 @@ describe("vitePluginSymfony", () => { }); it("generate correct legacy build entrypoints", ({ expect }) => { - const legacyPluginInstance = vitePluginSymfony({ debug: true }) as any; + const legacyPluginInstance = plugin({ debug: true }) as any; legacyPluginInstance.emitFile = vi.fn(); legacyPluginInstance.configResolved({ ...viteBaseConfig, @@ -335,7 +343,7 @@ describe("vitePluginSymfony", () => { }, legacy: true, metadatas: {}, - version: "test", + version: ["test"], viteServer: null, }, null, @@ -346,7 +354,7 @@ describe("vitePluginSymfony", () => { }); it("loads correctly without root user config option", ({ expect }) => { - const pluginInstance = vitePluginSymfony({ debug: true }) as any; + const pluginInstance = plugin({ debug: true }) as any; const config = pluginInstance.config(viteUserConfigNoRoot); expect(config).toEqual({ @@ -357,7 +365,6 @@ describe("vitePluginSymfony", () => { outDir: "public/build", }, optimizeDeps: { - exclude: ["virtual:symfony/controllers"], force: true, }, server: { diff --git a/src/vite-plugin-symfony/tests/utils.test.ts b/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts similarity index 97% rename from src/vite-plugin-symfony/tests/utils.test.ts rename to src/vite-plugin-symfony/tests/entrypoints/utils.test.ts index 41c88d66..c1061cac 100644 --- a/src/vite-plugin-symfony/tests/utils.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts @@ -8,8 +8,8 @@ import { isSubdirectory, parseVersionString, resolveDevServerUrl, -} from "../src/utils"; -import { resolvePluginOptions } from "../src/pluginOptions"; +} from "../../src/entrypoints/utils"; +import { resolvePluginOptions } from "../../src/pluginOptions"; import { OutputChunk, OutputAsset, NormalizedOutputOptions } from "rollup"; import { asyncDepChunk, @@ -20,9 +20,9 @@ import { themeCss, welcomeJs, welcomeLegacyJs, -} from "./mocks"; -import { defineConfig, InlineConfig, resolveConfig, type ResolvedConfig } from "vite"; -import { VitePluginSymfonyOptions } from "../src/types"; +} from "../mocks"; +import { resolveConfig, type ResolvedConfig } from "vite"; +import { VitePluginSymfonyOptions } from "../../src/types"; const viteBaseConfig = { root: "/home/me/project-dir", diff --git a/src/vite-plugin-symfony/tests/utils.win32.test.ts b/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts similarity index 94% rename from src/vite-plugin-symfony/tests/utils.win32.test.ts rename to src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts index b78d44a5..f7fdd30c 100644 --- a/src/vite-plugin-symfony/tests/utils.win32.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts @@ -1,5 +1,5 @@ import { describe, it, vi } from "vitest"; -import { isSubdirectory, normalizePath } from "../src/utils"; +import { isSubdirectory, normalizePath } from "../../src/entrypoints/utils"; vi.mock("node:path", async () => { const win32Path = await vi.importActual("node:path/win32"); diff --git a/src/vite-plugin-symfony/tests/mocks.ts b/src/vite-plugin-symfony/tests/mocks.ts index 32ef466e..acce4307 100644 --- a/src/vite-plugin-symfony/tests/mocks.ts +++ b/src/vite-plugin-symfony/tests/mocks.ts @@ -5,12 +5,12 @@ import { ChunkMetadata } from "../src/types"; export const viteBaseConfig = { root: "/home/me/project-dir", base: "/build/", - plugins: [{ name: "symfony" }, { name: "vite:reporter" }], + plugins: [{ name: "symfony-entrypoints" }, { name: "symfony-stimulus" }, { name: "vite:reporter" }], } as unknown as ResolvedConfig; export const viteUserConfigNoRoot = { base: "/build/", - plugins: [{ name: "symfony" }, { name: "vite:reporter" }], + plugins: [{ name: "symfony-entrypoints" }, { name: "symfony-stimulus" }, { name: "vite:reporter" }], build: { rollupOptions: {}, }, diff --git a/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts b/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts index 781e10fb..216e6fa5 100644 --- a/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts +++ b/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts @@ -3,27 +3,62 @@ import { getStimulusControllerFileInfos } from "../../../src/stimulus/helpers/ut describe("stimulus", () => { it.each([ - { input: "./controllers/welcome_controller.js", expectedId: "welcome", expectedLazy: false }, - { input: "./controllers/welcome_lazycontroller.js", expectedId: "welcome", expectedLazy: true }, + { + input: "./controllers/welcome_controller.js", + onlyControllersDir: false, + expectedId: "welcome", + expectedLazy: false, + }, + { + input: "./controllers/welcome_lazycontroller.js", + onlyControllersDir: false, + expectedId: "welcome", + expectedLazy: true, + }, { input: "./some-content-before/controllers/welcome_controller.js", + onlyControllersDir: false, expectedId: "welcome", expectedLazy: false, }, // without controllers - { input: "../welcome_controller.js", expectedId: "welcome", expectedLazy: false }, + { input: "../welcome_controller.js", onlyControllersDir: false, expectedId: "welcome", expectedLazy: false }, // bare module - { input: "library/welcome_controller.js", expectedId: "library--welcome", expectedLazy: false }, + { + input: "library/welcome_controller.js", + onlyControllersDir: false, + expectedId: "library--welcome", + expectedLazy: false, + }, // some content after we add -- - { input: "./controllers/foo/bar_controller.js", expectedId: "foo--bar", expectedLazy: false }, + { + input: "./controllers/foo/bar_controller.js", + onlyControllersDir: false, + expectedId: "foo--bar", + expectedLazy: false, + }, // we replace _ -> - - { input: "./controllers/foo_bar_controller.js", expectedId: "foo-bar", expectedLazy: false }, - { input: "./controllers/my_module.js", expectedId: "my-module", expectedLazy: false }, - { input: "./path/to/file.js", expectedId: "path--to--file", expectedLazy: false }, - { input: "not a controller", expectedId: undefined, expectedLazy: false }, - ])("getStimulusControllerFileInfos generate correct infos", ({ input, expectedId, expectedLazy }) => { - const { identifier, lazy } = getStimulusControllerFileInfos(input); - expect(identifier).toBe(expectedId); - expect(lazy).toBe(expectedLazy); - }); + { + input: "./controllers/foo_bar_controller.js", + onlyControllersDir: false, + expectedId: "foo-bar", + expectedLazy: false, + }, + { input: "./controllers/my_module.js", onlyControllersDir: false, expectedId: "my-module", expectedLazy: false }, + { input: "./path/to/file.js", onlyControllersDir: false, expectedId: "path--to--file", expectedLazy: false }, + { input: "not a controller", onlyControllersDir: false, expectedId: undefined, expectedLazy: false }, + { + input: "/home/lhapaipai/projets/symfony-vite-dev/playground/stimulus/assets/app.js", + onlyControllersDir: true, + expectedId: undefined, + expectedLazy: false, + }, + ])( + "getStimulusControllerFileInfos generate correct infos", + ({ input, onlyControllersDir, expectedId, expectedLazy }) => { + const { identifier, lazy } = getStimulusControllerFileInfos(input, onlyControllersDir); + expect(identifier).toBe(expectedId); + expect(lazy).toBe(expectedLazy); + }, + ); }); From 11391db109b2b69d5ecb037766d38087a99e7b5a Mon Sep 17 00:00:00 2001 From: Hugues Tavernier Date: Mon, 20 Nov 2023 18:22:45 +0100 Subject: [PATCH 2/4] stimulus hmr update doc --- CHANGELOG.md | 6 + docs/.vitepress/config.ts | 1 + docs/src/fr/index.md | 20 +- docs/src/fr/reference/vite-plugin-symfony.md | 24 ++- docs/src/index.md | 22 +- docs/src/public/images/stimulus.svg | 57 +++++ docs/src/public/images/twig.svg | 210 +++++++++++++++++++ docs/src/reference/vite-plugin-symfony.md | 23 +- src/vite-plugin-symfony/src/types.d.ts | 2 + 9 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 docs/src/public/images/stimulus.svg create mode 100644 docs/src/public/images/twig.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index aac38e51..f76a0f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v6.3.0 + +- stimulus HMR +- fix bug : stimulus restart vite dev server when controllers.json is updated +- split vite-plugin-symfony into 2 plugins `vite-plugin-symfony-entrypoints` and `vite-plugin-symfony-stimulus`. + ## v6.2.0 - fix #77 support Vite 5.x diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 22a7763a..37936ceb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -4,6 +4,7 @@ import { fileURLToPath, URL } from 'node:url'; // https://vitepress.dev/reference/site-config export default defineConfig({ vite: { + publicDir: "./public", resolve: { alias: [ { diff --git a/docs/src/fr/index.md b/docs/src/fr/index.md index ec876f6b..977533a3 100644 --- a/docs/src/fr/index.md +++ b/docs/src/fr/index.md @@ -25,11 +25,29 @@ features: - icon: ⚡️ title: Configuration facile details: Installation rapide avec la recette Bundle Flex et le plugin Vite préconfiguré. - - icon: 🛠️ + - icon: + src: /images/twig.svg + wrap: true + width: 32 + height: 23 title: Fonctions Twig details: Associez vos points d'entrée dans vos modèles Twig avec des fonctions Twig. - icon: 📦 title: Gestion des ressources details: Intégrez vos ressources dans Symfony avec une stratégie de version d'asset personnalisée. + - icon: + src: /images/stimulus.svg + width: 32 + height: 32 + wrap: true + title: Stimulus / Symfony UX + details: Intégration de vos composants Symfony UX avec HMR. + - icon: 🧩 + title: Fonctionnalités avancées + details: Attributs personnalisés, configuration multiple, injection de dépendances. + - icon: 🚀 + title: Performances + details: Préchargement de vos fichiers, mise en cache de votre configuration. + --- diff --git a/docs/src/fr/reference/vite-plugin-symfony.md b/docs/src/fr/reference/vite-plugin-symfony.md index c69cdf3c..efa17e53 100644 --- a/docs/src/fr/reference/vite-plugin-symfony.md +++ b/docs/src/fr/reference/vite-plugin-symfony.md @@ -74,13 +74,35 @@ Génère des clés de hachage lors de la génération de vos fichiers. À utilis ## stimulus -- **Type :** `boolean | string` +- **Type :** `boolean | string | VitePluginSymfonyStimulusOptions` - **Valeur par défaut :** `false` Active le bridge qui va interpréter le fichier `assets/controllers.json` pour les contrôleurs tiers de Stimulus (incluant Symfony UX). Saisir `true` si votre fichier est situés à l'emplacement par défaut `assets/controllers.json` sinon spécifiez le chemin vers votre fichier de référence. +vous pouvez aussi préciser un objet de configuration. + +```ts +type VitePluginSymfonyStimulusOptions = { + /** + * path to controllers.json relative to vite root + * @default ./assets/controller.json + */ + controllersFilePath: string; + + /** + * enable hmr for controllers + * @default true + */ + hmr: boolean; +} +``` + +:::warning +Par défaut le HMR est activé sur vos controlleurs Stimulus. Si ces derniers ne sont pas idempotents (voir [doc Stimulus](https://turbo.hotwired.dev/handbook/building#making-transformations-idempotent)), vous risquez de rencontrez des problèmes (les HMR ne fonctionnera pas comme attendu et vous devrez rafraîchir manuellement votre page). Dans ce cas il est préférable de désactiver l'option `hmr: false`. Ainsi, toute modification du fichier entrainera quand même un rafraichissement automatique de la page. +::: + ## viteDevServerHostname - **Type :** `null | string` diff --git a/docs/src/index.md b/docs/src/index.md index 01bcb98a..126f3517 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -37,10 +37,28 @@ features: - icon: ⚡️ title: Easy configuration details: Fast installation with Bundle Flex recipe and preconfigured Vite plugin. - - icon: 🛠️ + - icon: + src: /images/twig.svg + wrap: true + width: 32 + height: 23 title: Twig functions - details: Associate your entrypoints in your Twig templates with Twig functions. + details: Provide Twig functions for your Twig templates. - icon: 📦 title: Assets management details: Integrate your assets into Symfony with custom Asset version Strategy. + - icon: + src: /images/stimulus.svg + width: 32 + height: 32 + wrap: true + title: Stimulus / Symfony UX + details: Easy integration with Symfony UX components and HMR. + - icon: 🧩 + title: Advanced features + details: Custom attributes, Multiple configurations, Dependency injection + - icon: 🚀 + title: Performances + details: Preload assets, Cache for config files + --- diff --git a/docs/src/public/images/stimulus.svg b/docs/src/public/images/stimulus.svg new file mode 100644 index 00000000..43241778 --- /dev/null +++ b/docs/src/public/images/stimulus.svg @@ -0,0 +1,57 @@ + + diff --git a/docs/src/public/images/twig.svg b/docs/src/public/images/twig.svg new file mode 100644 index 00000000..e2793c41 --- /dev/null +++ b/docs/src/public/images/twig.svg @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/reference/vite-plugin-symfony.md b/docs/src/reference/vite-plugin-symfony.md index 891420ca..485c78fa 100644 --- a/docs/src/reference/vite-plugin-symfony.md +++ b/docs/src/reference/vite-plugin-symfony.md @@ -75,13 +75,34 @@ Generates hash keys when generating your files. Use if you want to deploy your a ## stimulus -- **Type:** `boolean | string` +- **Type:** `boolean | string | VitePluginSymfonyStimulusOptions` - **Default value:** `false` Enables the bridge that will interpret the `assets/controllers.json` file for third-party Stimulus controllers (including Symfony UX). Enter `true` if your file is located in the default location `assets/controllers.json` otherwise specify the path to your reference file. +You can also specify a configuration object. + +```ts +type VitePluginSymfonyStimulusOptions = { + /** + * path to controllers.json relative to vite root + * @default ./assets/controller.json + */ + controllersFilePath: string; + + /** + * enable hmr for controllers + * @default true + */ + hmr: boolean; +} +``` + +:::warning +By default, HMR is activated on your Stimulus controllers. If these are not idempotent (see [Stimulus doc](https://turbo.hotwired.dev/handbook/building#making-transformations-idempotent)), you may encounter problems (HMRs will not work as expected and you will have to manually refresh your page). In this case it is preferable to deactivate the `hmr: false` option. Therefore, any modification of the file will still result in an automatic refresh of the page. +::: ## viteDevServerHostname diff --git a/src/vite-plugin-symfony/src/types.d.ts b/src/vite-plugin-symfony/src/types.d.ts index e282356f..8d0cf898 100644 --- a/src/vite-plugin-symfony/src/types.d.ts +++ b/src/vite-plugin-symfony/src/types.d.ts @@ -215,11 +215,13 @@ export type VitePluginSymfonyEntrypointsOptions = { export type VitePluginSymfonyStimulusOptions = { /** * path to controllers.json relative to vite root + * @default ./assets/controller.json */ controllersFilePath: string; /** * enable hmr for controllers + * @default true */ hmr: boolean; }; From 4555cb462edd56071f5a72e64d2985fb4e8c98ed Mon Sep 17 00:00:00 2001 From: Hugues Tavernier Date: Mon, 20 Nov 2023 23:27:09 +0100 Subject: [PATCH 3/4] stimulus hmr add tests --- playground/stimulus/assets/app.js | 12 --- src/vite-plugin-symfony/.eslintrc | 3 +- .../src/stimulus/helpers/base.ts | 2 +- src/vite-plugin-symfony/src/stimulus/index.ts | 32 ++++---- .../src/stimulus/node/bridge.ts | 2 +- .../src/stimulus/node/util.ts | 7 -- .../src/stimulus/{helpers => }/util.ts | 8 ++ .../entrypoints/entryPointsHelper.test.ts | 2 +- .../tests/entrypoints/index.test.ts | 9 ++- .../tests/entrypoints/utils.test.ts | 6 +- .../tests/entrypoints/utils.win32.test.ts | 2 +- .../tests/pluginOptions.test.ts | 4 +- .../tests/stimulus/helpers/vue.test.ts | 5 +- .../tests/stimulus/index.test.ts | 76 +++++++++++++++++++ .../tests/stimulus/node/bridge.test.ts | 2 +- .../tests/stimulus/node/util.test.ts | 14 ---- .../tests/stimulus/{helpers => }/util.test.ts | 16 +++- src/vite-plugin-symfony/tsconfig.json | 3 +- 18 files changed, 134 insertions(+), 71 deletions(-) delete mode 100644 src/vite-plugin-symfony/src/stimulus/node/util.ts rename src/vite-plugin-symfony/src/stimulus/{helpers => }/util.ts (71%) create mode 100644 src/vite-plugin-symfony/tests/stimulus/index.test.ts delete mode 100644 src/vite-plugin-symfony/tests/stimulus/node/util.test.ts rename src/vite-plugin-symfony/tests/stimulus/{helpers => }/util.test.ts (77%) diff --git a/playground/stimulus/assets/app.js b/playground/stimulus/assets/app.js index 4caf5b5c..a93dbc25 100644 --- a/playground/stimulus/assets/app.js +++ b/playground/stimulus/assets/app.js @@ -1,6 +1,3 @@ -// import { registerVueControllerComponents } from '@symfony/ux-vue'; -// import { registerSvelteControllerComponents } from '@symfony/ux-svelte'; -// import { registerReactControllerComponents } from '@symfony/ux-react'; import './bootstrap.js'; function refreshStickStatus() { @@ -11,14 +8,5 @@ let $nav = document.querySelector("#nav"); if ($nav) { window.addEventListener("scroll", refreshStickStatus); refreshStickStatus(); - - - } -// if (import.meta.hot) { -// import.meta.hot.accept((mod, ctx) => { -// import.meta.hot.send('restart') -// console.log(mod, ctx) -// }) -// } \ No newline at end of file diff --git a/src/vite-plugin-symfony/.eslintrc b/src/vite-plugin-symfony/.eslintrc index 76e19121..5c593734 100644 --- a/src/vite-plugin-symfony/.eslintrc +++ b/src/vite-plugin-symfony/.eslintrc @@ -20,7 +20,8 @@ }, "rules": { "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off" }, "ignorePatterns": [ "dist/*.js" diff --git a/src/vite-plugin-symfony/src/stimulus/helpers/base.ts b/src/vite-plugin-symfony/src/stimulus/helpers/base.ts index 8a006c9c..64c7e1cb 100644 --- a/src/vite-plugin-symfony/src/stimulus/helpers/base.ts +++ b/src/vite-plugin-symfony/src/stimulus/helpers/base.ts @@ -1,6 +1,6 @@ import { Application, Controller } from "@hotwired/stimulus"; import thirdPartyControllers from "virtual:symfony/controllers"; -import { getStimulusControllerFileInfos } from "./util"; +import { getStimulusControllerFileInfos } from "~/stimulus/util"; import { ControllerModule, ImportedModules, LazyModule } from "./types"; declare module "@hotwired/stimulus" { diff --git a/src/vite-plugin-symfony/src/stimulus/index.ts b/src/vite-plugin-symfony/src/stimulus/index.ts index 6882b060..f84f8bbd 100644 --- a/src/vite-plugin-symfony/src/stimulus/index.ts +++ b/src/vite-plugin-symfony/src/stimulus/index.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { Logger, Plugin, ResolvedConfig, UserConfig } from "vite"; import { VitePluginSymfonyStimulusOptions } from "~/types"; -import { getStimulusControllerFileInfos } from "./helpers/util"; +import { getStimulusControllerFileInfos } from "./util"; const applicationGlobalVarName = "$$stimulusApp$$"; @@ -41,11 +41,8 @@ export default function symfonyStimulus(pluginOptions: VitePluginSymfonyStimulus } }, transform(code, id, options) { - if (viteCommand !== "serve") { - return; - } - if (options?.ssr || process.env.VITEST) { - return; + if (viteCommand !== "serve" || (options?.ssr && !process.env.VITEST) || id.includes("node_modules")) { + return null; } if (id.endsWith("bootstrap.js") || id.endsWith("bootstrap.ts")) { @@ -61,21 +58,20 @@ export default function symfonyStimulus(pluginOptions: VitePluginSymfonyStimulus // we don't need lazy behavior, the module is already loaded and we are in a dev environment const { identifier } = getStimulusControllerFileInfos(id, true); - if (!identifier) return; + if (!identifier) return null; logger.info(`controller ${identifier}`, { timestamp: true }); const metaHotFooter = ` - if (import.meta.hot) { - import.meta.hot.accept(newModule => { - if (!window.${applicationGlobalVarName}) { - console.warn('Simulus app not available. Are you creating app with startStimulusApp() ?'); - import.meta.hot.invalidate(); - } else { - window.${applicationGlobalVarName}.register('${identifier}', newModule.default); - } - }) - } - `; +if (import.meta.hot) { + import.meta.hot.accept(newModule => { + if (!window.${applicationGlobalVarName}) { + console.warn('Simulus app not available. Are you creating app with startStimulusApp() ?'); + import.meta.hot.invalidate(); + } else { + window.${applicationGlobalVarName}.register('${identifier}', newModule.default); + } + }) +}`; return `${code}\n${metaHotFooter}`; }, diff --git a/src/vite-plugin-symfony/src/stimulus/node/bridge.ts b/src/vite-plugin-symfony/src/stimulus/node/bridge.ts index ed73abbd..4b251715 100644 --- a/src/vite-plugin-symfony/src/stimulus/node/bridge.ts +++ b/src/vite-plugin-symfony/src/stimulus/node/bridge.ts @@ -1,4 +1,4 @@ -import { generateStimulusId } from "./util"; +import { generateStimulusId } from "../util"; import { createRequire } from "node:module"; export const virtualSymfonyControllersModuleId = "virtual:symfony/controllers"; diff --git a/src/vite-plugin-symfony/src/stimulus/node/util.ts b/src/vite-plugin-symfony/src/stimulus/node/util.ts deleted file mode 100644 index a1dee28e..00000000 --- a/src/vite-plugin-symfony/src/stimulus/node/util.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Normalize the controller name: remove the initial @ and use Stimulus format -export function generateStimulusId(packageName: string) { - if (packageName.startsWith("@")) { - packageName = packageName.substring(1); - } - return packageName.replace(/_/g, "-").replace(/\//g, "--"); -} diff --git a/src/vite-plugin-symfony/src/stimulus/helpers/util.ts b/src/vite-plugin-symfony/src/stimulus/util.ts similarity index 71% rename from src/vite-plugin-symfony/src/stimulus/helpers/util.ts rename to src/vite-plugin-symfony/src/stimulus/util.ts index de266683..afdcfe40 100644 --- a/src/vite-plugin-symfony/src/stimulus/helpers/util.ts +++ b/src/vite-plugin-symfony/src/stimulus/util.ts @@ -16,3 +16,11 @@ export function getStimulusControllerFileInfos(key: string, onlyControllersDir = lazy: lazy === "lazy", }; } + +// Normalize the controller name: remove the initial @ and use Stimulus format +export function generateStimulusId(packageName: string) { + if (packageName.startsWith("@")) { + packageName = packageName.substring(1); + } + return packageName.replace(/_/g, "-").replace(/\//g, "--"); +} diff --git a/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts b/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts index 37c99b94..d1ac7095 100644 --- a/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/entryPointsHelper.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { getDevEntryPoints } from "../../src/entrypoints/entryPointsHelper"; +import { getDevEntryPoints } from "~/entrypoints/entryPointsHelper"; import type { ResolvedConfig } from "vite"; import { viteBaseConfig } from "../mocks"; diff --git a/src/vite-plugin-symfony/tests/entrypoints/index.test.ts b/src/vite-plugin-symfony/tests/entrypoints/index.test.ts index 861b538b..60f2dbad 100644 --- a/src/vite-plugin-symfony/tests/entrypoints/index.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, vi } from "vitest"; -import vitePluginSymfonyEntrypoints from "../../src/entrypoints/index"; +import vitePluginSymfonyEntrypoints from "~/entrypoints/index"; import type { OutputChunk, OutputAsset } from "rollup"; import { @@ -19,8 +19,9 @@ import { circular2Js, viteUserConfigNoRoot, } from "../mocks"; -import { VitePluginSymfonyOptions } from "../../src/types"; -import { resolvePluginOptions } from "../../src/pluginOptions"; +import { VitePluginSymfonyOptions } from "~/types"; +import { resolvePluginOptions } from "~/pluginOptions"; +import { createLogger } from "vite"; function createBundleObject(files: (OutputChunk | OutputAsset)[]) { const bundles: { @@ -36,7 +37,7 @@ function createBundleObject(files: (OutputChunk | OutputAsset)[]) { function plugin(userOptions: Partial) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { stimulus, ...entrypointsOptions } = resolvePluginOptions(userOptions); - return vitePluginSymfonyEntrypoints(entrypointsOptions); + return vitePluginSymfonyEntrypoints(entrypointsOptions, createLogger()); } describe("vitePluginSymfonyEntrypoints", () => { diff --git a/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts b/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts index c1061cac..e34554e4 100644 --- a/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/utils.test.ts @@ -8,8 +8,8 @@ import { isSubdirectory, parseVersionString, resolveDevServerUrl, -} from "../../src/entrypoints/utils"; -import { resolvePluginOptions } from "../../src/pluginOptions"; +} from "~/entrypoints/utils"; +import { resolvePluginOptions } from "~/pluginOptions"; import { OutputChunk, OutputAsset, NormalizedOutputOptions } from "rollup"; import { asyncDepChunk, @@ -22,7 +22,7 @@ import { welcomeLegacyJs, } from "../mocks"; import { resolveConfig, type ResolvedConfig } from "vite"; -import { VitePluginSymfonyOptions } from "../../src/types"; +import { VitePluginSymfonyOptions } from "~/types"; const viteBaseConfig = { root: "/home/me/project-dir", diff --git a/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts b/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts index f7fdd30c..6bdfc368 100644 --- a/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts +++ b/src/vite-plugin-symfony/tests/entrypoints/utils.win32.test.ts @@ -1,5 +1,5 @@ import { describe, it, vi } from "vitest"; -import { isSubdirectory, normalizePath } from "../../src/entrypoints/utils"; +import { isSubdirectory, normalizePath } from "~/entrypoints/utils"; vi.mock("node:path", async () => { const win32Path = await vi.importActual("node:path/win32"); diff --git a/src/vite-plugin-symfony/tests/pluginOptions.test.ts b/src/vite-plugin-symfony/tests/pluginOptions.test.ts index ac6a4d74..a96c9b38 100644 --- a/src/vite-plugin-symfony/tests/pluginOptions.test.ts +++ b/src/vite-plugin-symfony/tests/pluginOptions.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "vitest"; -import { resolvePluginOptions, resolveBase, resolveOutDir } from "../src/pluginOptions"; -import { VitePluginSymfonyOptions } from "../src/types"; +import { resolvePluginOptions, resolveBase, resolveOutDir } from "~/pluginOptions"; +import { VitePluginSymfonyOptions } from "~/types"; describe("resolvePluginOptions", () => { it("resolves with default options when no config", ({ expect }) => { diff --git a/src/vite-plugin-symfony/tests/stimulus/helpers/vue.test.ts b/src/vite-plugin-symfony/tests/stimulus/helpers/vue.test.ts index 26b9dd8b..18a41fa3 100644 --- a/src/vite-plugin-symfony/tests/stimulus/helpers/vue.test.ts +++ b/src/vite-plugin-symfony/tests/stimulus/helpers/vue.test.ts @@ -2,14 +2,15 @@ * @vitest-environment jsdom */ import { describe, expect, it } from "vitest"; -import { registerVueControllerComponents } from "../../../src/stimulus/helpers/vue"; +import { registerVueControllerComponents } from "~/stimulus/helpers/vue"; +import { ImportedModules, VueModule } from "~/stimulus/helpers/types"; const fakeVueComponent = () => ({}); const createFakeImportedModules = () => { return { "./vue/controllers/Hello.vue": () => Promise.resolve(fakeVueComponent), - }; + } as any as ImportedModules; }; describe("registerVueControllerComponents", () => { diff --git a/src/vite-plugin-symfony/tests/stimulus/index.test.ts b/src/vite-plugin-symfony/tests/stimulus/index.test.ts new file mode 100644 index 00000000..967c408c --- /dev/null +++ b/src/vite-plugin-symfony/tests/stimulus/index.test.ts @@ -0,0 +1,76 @@ +import { ConfigEnv, Plugin, UserConfig, createLogger } from "vite"; +import { describe, it, expect } from "vitest"; +import symfonyStimulus from "~/stimulus"; + +const generateStimulusPlugin = (command: "build" | "serve") => { + const plugin: Plugin = symfonyStimulus( + { + controllersFilePath: "./assets.controllers.json", + hmr: true, + }, + createLogger(), + ); + const userConfig: UserConfig = {}; + const envConfig: ConfigEnv = { command, mode: "development" }; + // @ts-ignore + plugin.config(userConfig, envConfig); + + return plugin; +}; +describe("stimulus index", () => { + it("inject correctly Application global var when server is started", () => { + const plugin = generateStimulusPlugin("serve"); + // @ts-ignore + const returnValue = plugin.transform(`const myApp = startStimulusApp();`, "/path/to/bootstrap.js", {}); + expect(returnValue).toMatchInlineSnapshot(` + "const myApp = startStimulusApp(); + window.$$stimulusApp$$ = myApp" + `); + }); + it("doesn't insert Application global var when startStimulusApp is not present", () => { + const plugin = generateStimulusPlugin("serve"); + // @ts-ignore + const returnValue = plugin.transform(`const hello = "world;`, "/path/to/bootstrap.js", {}); + expect(returnValue).toBeNull(); + }); + it("doesn't insert Application global var when server is started", () => { + const plugin = generateStimulusPlugin("build"); + // @ts-ignore + const returnValue = plugin.transform(`const myApp = startStimulusApp();`, "/path/to/bootstrap.js", {}); + expect(returnValue).toBeNull(); + }); + + it("inject correctly Controller hot accept", () => { + const plugin = generateStimulusPlugin("serve"); + // @ts-ignore + const returnValue = plugin.transform( + `export default class controller extends Controller {}`, + "/path/to/controllers/welcome_controller.js", + {}, + ); + expect(returnValue).toMatchInlineSnapshot(` + "export default class controller extends Controller {} + + if (import.meta.hot) { + import.meta.hot.accept(newModule => { + if (!window.$$stimulusApp$$) { + console.warn('Simulus app not available. Are you creating app with startStimulusApp() ?'); + import.meta.hot.invalidate(); + } else { + window.$$stimulusApp$$.register('welcome', newModule.default); + } + }) + }" + `); + }); + it("doesn't insert Controller hot accept", () => { + const plugin = generateStimulusPlugin("serve"); + // @ts-ignore + const returnValue = plugin.transform( + `export default class controller extends Controller {}`, + "/path/to/assets/other.js", + {}, + ); + expect(returnValue).toBeNull(); + }); +}); diff --git a/src/vite-plugin-symfony/tests/stimulus/node/bridge.test.ts b/src/vite-plugin-symfony/tests/stimulus/node/bridge.test.ts index b78c9902..64e988a7 100644 --- a/src/vite-plugin-symfony/tests/stimulus/node/bridge.test.ts +++ b/src/vite-plugin-symfony/tests/stimulus/node/bridge.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { createControllersModule } from "../../../src/stimulus/node/bridge"; +import { createControllersModule } from "~/stimulus/node/bridge"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; diff --git a/src/vite-plugin-symfony/tests/stimulus/node/util.test.ts b/src/vite-plugin-symfony/tests/stimulus/node/util.test.ts deleted file mode 100644 index ada0062b..00000000 --- a/src/vite-plugin-symfony/tests/stimulus/node/util.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, it } from "vitest"; -import { generateStimulusId } from "../../../src/stimulus/node/util"; - -describe("stimulus", () => { - it("identifierFromThirdParty generate correct identifier", ({ expect }) => { - const list = [ - ["@symfony/ux-toggle-password/toggle-password", "symfony--ux-toggle-password--toggle-password"], - ["my-custom-package/toggle-password", "my-custom-package--toggle-password"], - ]; - list.forEach(([input, result]) => { - expect(generateStimulusId(input)).toBe(result); - }); - }); -}); diff --git a/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts b/src/vite-plugin-symfony/tests/stimulus/util.test.ts similarity index 77% rename from src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts rename to src/vite-plugin-symfony/tests/stimulus/util.test.ts index 216e6fa5..659ecc20 100644 --- a/src/vite-plugin-symfony/tests/stimulus/helpers/util.test.ts +++ b/src/vite-plugin-symfony/tests/stimulus/util.test.ts @@ -1,7 +1,19 @@ import { describe, it, expect } from "vitest"; -import { getStimulusControllerFileInfos } from "../../../src/stimulus/helpers/util"; +import { getStimulusControllerFileInfos, generateStimulusId } from "~/stimulus/util"; -describe("stimulus", () => { +describe("stimulus generateStimulusId", () => { + it("identifierFromThirdParty generate correct identifier", ({ expect }) => { + const list = [ + ["@symfony/ux-toggle-password/toggle-password", "symfony--ux-toggle-password--toggle-password"], + ["my-custom-package/toggle-password", "my-custom-package--toggle-password"], + ]; + list.forEach(([input, result]) => { + expect(generateStimulusId(input)).toBe(result); + }); + }); +}); + +describe("stimulus getStimulusControllerFileInfos", () => { it.each([ { input: "./controllers/welcome_controller.js", diff --git a/src/vite-plugin-symfony/tsconfig.json b/src/vite-plugin-symfony/tsconfig.json index 6abaaae1..39a75797 100644 --- a/src/vite-plugin-symfony/tsconfig.json +++ b/src/vite-plugin-symfony/tsconfig.json @@ -16,6 +16,7 @@ } }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "tests/**/*.ts" ] } \ No newline at end of file From 835f48f5a201df58b8903858e63a056dbeea0184 Mon Sep 17 00:00:00 2001 From: Hugues Tavernier Date: Tue, 21 Nov 2023 09:15:17 +0100 Subject: [PATCH 4/4] doc mermaid chart implementation --- CHANGELOG.md | 2 + docs/.gitignore | 2 + docs/.vitepress/config.ts | 5 +- docs/.vitepress/mermaid.config.json | 3 + docs/.vitepress/mermaid.ts | 84 +++ docs/.vitepress/puppeteer-config.json | 3 + docs/env.d.ts | 1 + docs/package.json | 2 + docs/pnpm-lock.yaml | 535 ++++++++++++++++++ docs/src/fr/stimulus/installation.md | 36 ++ docs/src/guide/getting-started.md | 2 + docs/src/stimulus/installation.md | 36 ++ src/vite-plugin-symfony/.eslintrc | 3 +- .../src/entrypoints/entryPointsHelper.ts | 19 +- .../src/entrypoints/index.ts | 51 +- .../src/entrypoints/pathMapping.ts | 15 + .../src/entrypoints/utils.ts | 23 +- src/vite-plugin-symfony/src/index.ts | 3 +- src/vite-plugin-symfony/src/logger.ts | 166 ++++++ src/vite-plugin-symfony/src/stimulus/index.ts | 6 +- .../tests/entrypoints/index.test.ts | 3 +- .../tests/entrypoints/utils.test.ts | 51 ++ 22 files changed, 974 insertions(+), 77 deletions(-) create mode 100644 docs/.vitepress/mermaid.config.json create mode 100644 docs/.vitepress/mermaid.ts create mode 100644 docs/.vitepress/puppeteer-config.json create mode 100644 docs/env.d.ts create mode 100644 src/vite-plugin-symfony/src/entrypoints/pathMapping.ts create mode 100644 src/vite-plugin-symfony/src/logger.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f76a0f35..2322567f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ - stimulus HMR - fix bug : stimulus restart vite dev server when controllers.json is updated - split vite-plugin-symfony into 2 plugins `vite-plugin-symfony-entrypoints` and `vite-plugin-symfony-stimulus`. +- add new tests to vite-plugin-symfony +- doc : add mermaid charts ## v6.2.0 diff --git a/docs/.gitignore b/docs/.gitignore index 60327a26..d8e5f809 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -5,3 +5,5 @@ node_modules/ .vitepress/dist/ .vscode/ .local/ + +graphs/ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 37936ceb..e0b774ab 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,10 +1,13 @@ import { defineConfig } from 'vitepress'; import { fileURLToPath, URL } from 'node:url'; +import { renderMermaidGraphsPlugin } from './mermaid' // https://vitepress.dev/reference/site-config export default defineConfig({ vite: { - publicDir: "./public", + plugins: [ + renderMermaidGraphsPlugin() + ], resolve: { alias: [ { diff --git a/docs/.vitepress/mermaid.config.json b/docs/.vitepress/mermaid.config.json new file mode 100644 index 00000000..5a8cafb8 --- /dev/null +++ b/docs/.vitepress/mermaid.config.json @@ -0,0 +1,3 @@ +{ + "themeCSS": "* { line-height: 1.5; } span.edgeLabel { padding: 2px 5px; }" +} diff --git a/docs/.vitepress/mermaid.ts b/docs/.vitepress/mermaid.ts new file mode 100644 index 00000000..dab037f4 --- /dev/null +++ b/docs/.vitepress/mermaid.ts @@ -0,0 +1,84 @@ +import { exec } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import type { Plugin, PluginOption } from 'vite'; +import { mkdir, readdir } from 'node:fs/promises' + +export async function getFilesInDirectory(directory: URL): Promise { + return (await readdir(directory)).filter(file => file[0] !== '.'); +} + + +const execPromise = promisify(exec); +const graphsDirectory = new URL('graphs/', import.meta.url); + +const mermaidRegExp = /^```mermaid\n([\S\s]*?)\n```/gm; +const greaterThanRegExp = />/g; +const styleTagRegExp = /