UNPKG

shark-mvc

Version:

Shark-MVC is a small MVC framework born to make our small JS apps easier to build.

384 lines (317 loc) 14.7 kB
import fs from "fs"; import path from "path"; import MagicString from "magic-string"; // Matches Shark's self-registration convention: `Shark.registerComponent(x)`, // `Shark.registerView(x)`, `Shark.registerController(x)`, `Shark.registerPageTemplate(x)`. const REGISTER_CALL_RE = /Shark\s*\.\s*register(?:Component|Controller|View|PageTemplate)\s*\(/; // Matches `Shark.registerModel("ModelName", ModelClass)`, capturing the literal name string - // only a plain string/template literal is supported, since the name has to be re-embedded as a // literal in the injected snippet below. const REGISTER_MODEL_RE = /Shark\s*\.\s*registerModel\s*\(\s*["'`]([^"'`]+)["'`]/; const NODE_MODULES_RE = /[\\/]node_modules[\\/]/; const TRANSFORMABLE_EXT_RE = /\.(m?jsx?|tsx?)$/; const VIRTUAL_AUTO_COMPONENTS_ID = "virtual:shark-mvc-auto-components"; const RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID = "\0" + VIRTUAL_AUTO_COMPONENTS_ID; /** * Normalizes a user-supplied `componentsFolder` (e.g. "./src/components", "src/components/", * "/src/components", or the Windows-style equivalents) into the project-root-relative form * `import.meta.glob` expects (e.g. "/src/components"). */ function toRootGlobPrefix (folder) { let normalized = folder.replace(/\\/g, "/").replace(/\/+$/, ""); if (normalized.startsWith("./")) { normalized = normalized.slice(1); } if (!normalized.startsWith("/")) { normalized = "/" + normalized; } return normalized; } const SCRIPT_TAG_RE = /<script\b([^>]*)>/gi; const TYPE_MODULE_ATTR_RE = /\btype\s*=\s*["']module["']/i; const SRC_ATTR_RE = /\bsrc\s*=\s*["']([^"']+)["']/i; /** * Finds the `<script type="module" src="...">` entry point(s) declared in an HTML file, resolved * to absolute paths: root-relative ("/src/main.js") against `rootDir`, anything else against the * HTML file's own directory - matching how a browser would resolve the same `src`. */ function findHtmlEntryModules (htmlFilePath, rootDir) { let htmlContent; try { htmlContent = fs.readFileSync(htmlFilePath, "utf-8"); } catch { return []; } const entries = []; let match; SCRIPT_TAG_RE.lastIndex = 0; while ((match = SCRIPT_TAG_RE.exec(htmlContent))) { const attrs = match[1]; if (!TYPE_MODULE_ATTR_RE.test(attrs)) { continue; } const srcMatch = attrs.match(SRC_ATTR_RE); if (!srcMatch || /^(https?:)?\/\//i.test(srcMatch[1])) { continue; } const src = srcMatch[1]; const absolutePath = src.startsWith("/") ? path.join(rootDir, src) : path.resolve(path.dirname(htmlFilePath), src); entries.push(absolutePath); } return entries; } /** * Resolves the set of JS entry module absolute paths for the project, so the auto-import glob * can be injected directly into them - mirroring Vite's own default of treating `<root>/index.html` * as the entry when `build.rollupOptions.input` isn't set, while also supporting a custom `input` * (a single path, an array, or a named-entries object) pointing at HTML or JS/TS files directly. */ function resolveEntryModulePaths (config) { const rootDir = config.root || process.cwd(); const input = config.build?.rollupOptions?.input; const inputPaths = !input ? [path.join(rootDir, "index.html")] : (Array.isArray(input) ? input : (typeof input === "string" ? [input] : Object.values(input))); const entries = new Set(); for (const inputPath of inputPaths) { const absoluteInputPath = path.isAbsolute(inputPath) ? inputPath : path.resolve(rootDir, inputPath); if (/\.html?$/i.test(absoluteInputPath)) { findHtmlEntryModules(absoluteInputPath, rootDir).forEach(entry => entries.add(normalizePathSlashes(entry))); } else { entries.add(normalizePathSlashes(absoluteInputPath)); } } return entries; } function normalizePathSlashes (filePath) { return filePath.replace(/\\/g, "/"); } const HMR_SNIPPET = ` if (import.meta.hot) { import.meta.hot.accept((newModule) => { if (!newModule?.default || !Shark.hotUpdate(newModule.default)) { import.meta.hot.invalidate(); } }); } `; function modelHmrSnippet (modelName) { const literalName = JSON.stringify(modelName); return ` if (import.meta.hot) { import.meta.hot.dispose(() => { Shark.__expectModelHotUpdate(${literalName}); }); import.meta.hot.accept((newModule) => { if (!newModule?.default || !Shark.hotUpdateModel(${literalName}, newModule.default)) { import.meta.hot.invalidate(); } }); } `; } const MAX_CHAIN_DEPTH = 100; /** * Mirrors Vite's own HMR boundary search (the `propagateUpdate` walk in Vite's dev server) to * find out *why* a specific module would force a full reload, instead of just whether *some* * cycle exists somewhere in its ancestry. * * The walk goes up a module's `.importers` (who imports this module), and for each branch: * - stops immediately if it reaches a module that `.isSelfAccepting` - that branch is fine * (this is what makes a plain "is this module part of a cycle at all" check too broad: a * module can be transitively connected to a distant cycle through `.importers` while still * hot-updating perfectly fine, because a self-accepting boundary is found first); * - reports "circular" if it revisits a module already on *that same branch*; * - reports "no-boundary" if it runs out of importers (reached the app's entry) without ever * finding a self-accepting module. * The first bad branch found wins and stops the search, matching Vite's own short-circuiting * behavior: one broken import path is enough to force a full reload even when other paths to * the same module are perfectly fine. * * @returns {{ type: "circular" | "no-boundary", chain: import("vite").ModuleNode[] } | null} */ function findReloadCause (startModule) { function walk (mod, chain) { if (mod.isSelfAccepting) { return null; } if (chain.length > MAX_CHAIN_DEPTH) { return null; // bail out of pathological/very deep graphs rather than hang } if (mod.importers.size === 0) { return { type: "no-boundary", chain }; } for (const importer of mod.importers) { if (chain.includes(importer)) { return { type: "circular", chain: chain.concat(importer) }; } const result = walk(importer, chain.concat(importer)); if (result) { return result; } } return null; } return walk(startModule, [startModule]); } function moduleLabel (mod) { return mod.url || mod.id || mod.file || "<unknown module>"; } /** * Vite plugin that gives Shark-MVC Components/Views/Controllers/PageTemplates real HMR with * zero per-file boilerplate: write your `Shark.registerXxx(instance)` call as usual, and this * plugin injects the `import.meta.hot.accept(...)` wiring for you at dev-server transform time. * * It has to work this way (rather than a shared runtime helper) because Vite only treats a * module as a fully reliable HMR boundary ("self-accepts") when it can see * `import.meta.hot.accept(...)` in the module's own code *after all transforms have run* - * injecting it here, before Vite's own import-analysis pass, satisfies that. Calling a shared * `Shark.acceptHot()`-style function at runtime instead does not: Vite classifies it as generic * "detected api usage", which fails to isolate updates for a module reachable via a circular * import anywhere else in the graph (common when Views/Components reference each other for * navigation) - see `Shark.acceptHot`'s doc comment for the full explanation. * * No effect on production builds: `import.meta.hot` is always `undefined` there, so bundlers * dead-code-eliminate the injected block regardless. * * It also (unless disabled) makes sure every Component under `componentsFolder` actually ends up * in the app, even ones no View/Component ever `import`s in JS ("standalone" components only * ever referenced by a `<shark-xxx>` tag in some template). Bundlers only include a module because * something in the import graph reaches it - a name appearing in an HTML template string doesn't * count - so without this, such a component's self-registration (`Shark.registerComponent(...)`) * simply never runs, and RenderableJaw's auto component-tag binding can never find it. This * injects a single `import.meta.glob(componentsFolder + "/**\/*.{js,jsx,mjs,ts,tsx}", { eager: * true })` (Vite's own glob import, which it expands by actually scanning the folder) into a * virtual module, and adds a `<script type="module">` tag importing it to the HTML entry - so * every matching file is eagerly imported, and self-registers, with zero manual imports anywhere * in your own code. Works in both dev and production builds: the import is injected directly into * the app's own entry module (found by parsing the `<script type="module" src="...">` tag(s) in * `index.html`, or `build.rollupOptions.input` if set), exactly like the manual import you'd * otherwise add to it by hand - so it's just as much a part of the module graph as anything else. * * @param {object} [options] * @param {boolean} [options.autoImportComponents=true] - Set to `false` to disable the * auto-import behavior described above, e.g. if you'd rather list components manually. * @param {string} [options.componentsFolder="./src/components"] - The folder (relative to the * Vite project root) containing your Components, scanned recursively. * * @example * // vite.config.js * import { defineConfig } from "vite"; * import sharkHMR from "shark-mvc/vite-plugin"; * * export default defineConfig({ * plugins: [sharkHMR()], * // or, to customize: * // plugins: [sharkHMR({ autoImportComponents: true, componentsFolder: "./src/components" })], * }); */ export default function sharkHMR ({ autoImportComponents = true, componentsFolder = "./src/components" } = {}) { let isDev = false; let entryModulePaths = new Set(); const componentsGlobPrefix = toRootGlobPrefix(componentsFolder); const componentsGlobPattern = componentsGlobPrefix + "/**/*.{js,jsx,mjs,ts,tsx}"; return { name: "shark-mvc:hmr", configResolved (config) { isDev = config.command === "serve"; if (!autoImportComponents) { return; } const absoluteComponentsFolder = path.join(config.root, componentsGlobPrefix); if (!fs.existsSync(absoluteComponentsFolder)) { config.logger.warn( `\n[shark-mvc:hmr] autoImportComponents is enabled but "${absoluteComponentsFolder}" doesn't exist - no Component will be auto-imported. Pass the right componentsFolder, or autoImportComponents: false to silence this.\n`, { timestamp: true } ); } entryModulePaths = resolveEntryModulePaths(config); if (entryModulePaths.size === 0) { config.logger.warn( `\n[shark-mvc:hmr] autoImportComponents couldn't find an entry module to auto-import components into (looked for a <script type="module" src="..."> in index.html, or build.rollupOptions.input). No Component will be auto-imported.\n`, { timestamp: true } ); } }, resolveId (id) { if (autoImportComponents && id === VIRTUAL_AUTO_COMPONENTS_ID) { return RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID; } return null; }, load (id) { if (id !== RESOLVED_VIRTUAL_AUTO_COMPONENTS_ID) { return null; } // Vite expands this itself (it scans the folder and eagerly imports every match) - the // pattern just needs to be a literal string at the point Vite parses this module's code, // which it is, even though componentsGlobPattern was built dynamically from options above. return `import.meta.glob(${JSON.stringify(componentsGlobPattern)}, { eager: true });\n`; }, transform (code, id) { const [pathWithoutQuery] = id.split("?"); let output = code; let mutated = false; if (autoImportComponents && entryModulePaths.has(normalizePathSlashes(pathWithoutQuery))) { // Same effect as the developer adding this import to their own entry file by hand - // it's now a real, ordinary part of the module graph, not a separate bolt-on chunk, // so it works identically in dev and in a production build. output = `import ${JSON.stringify(VIRTUAL_AUTO_COMPONENTS_ID)};\n` + output; mutated = true; } if (!isDev) { return mutated ? output : null; } if (NODE_MODULES_RE.test(id) || !TRANSFORMABLE_EXT_RE.test(pathWithoutQuery)) { return mutated ? output : null; } if (code.includes("import.meta.hot.accept(")) { // The developer already wired HMR for it manually - don't inject a second, // conflicting accept() call. return mutated ? output : null; } const modelMatch = code.match(REGISTER_MODEL_RE); const isJaw = REGISTER_CALL_RE.test(code); if (!modelMatch && !isJaw) { // Doesn't self-register a Jaw or a Model - nothing for this plugin to do. return mutated ? output : null; } const s = new MagicString(output); s.append(modelMatch ? modelHmrSnippet(modelMatch[1]) : HMR_SNIPPET); return { code: s.toString(), map: s.generateMap({ hires: true, source: id }), }; }, // Runs on every file save, before Vite decides how to propagate the update. If the // changed file needs a full reload, explain exactly why: either a circular import along // its specific boundary-search path, or no self-accepting module anywhere up to the // app's entry. Silent otherwise - a module being *transitively reachable* from some // cycle elsewhere in the graph doesn't necessarily mean *this* update is affected by it. handleHotUpdate ({ modules, server }) { const seen = new Set(); for (const mod of modules) { const result = findReloadCause(mod); if (!result) { continue; } const key = `${result.type}:${result.chain.map(moduleLabel).join(">")}`; if (seen.has(key)) { continue; } seen.add(key); const chain = result.chain.map(moduleLabel).join("\n is imported by "); if (result.type === "circular") { server.config.logger.warn( `\n[shark-mvc:hmr] "${moduleLabel(mod)}" needs a full reload: circular import along this path:\n\n ${chain}\n`, { timestamp: true } ); } else { server.config.logger.warn( `\n[shark-mvc:hmr] "${moduleLabel(mod)}" needs a full reload: no module accepts the update anywhere along this import chain (reached the app's entry without finding one):\n\n ${chain}\n`, { timestamp: true } ); } } }, }; }