diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4e637..ce17b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# unreleased + +* `ViteHtmlPlugin`: Added a new plugin to render the HTML document for Vite builds, the counterpart to `HtmlWebpackPlugin`. +* `ViteILibPlugin`: Added a new plugin to define the `ILIB_*` constants and supply the iLib locale and resource data for Vite builds, with locale filtering support. +* `ViteWebOSMetaPlugin`: Added a new plugin to emit the webOS `appinfo.json` and its referenced assets for Vite builds. +* `mixins`: Added `applyVite` and the Vite mixins supporting `--isomorphic`, `--framework`/`--externals`, `--snapshot`, and the `--no-minify`/`--verbose`/`--stats` build options. + # 7.0.5 (June 23, 2026) * `option-parser`: Set default theme config to `limestone`. diff --git a/index.js b/index.js index acb88a2..d1315ca 100644 --- a/index.js +++ b/index.js @@ -26,5 +26,8 @@ exportOnDemand({ PrerenderPlugin: () => require('./plugins/PrerenderPlugin'), SnapshotPlugin: () => require('./plugins/SnapshotPlugin'), VerboseLogPlugin: () => require('./plugins/VerboseLogPlugin'), - WebOSMetaPlugin: () => require('./plugins/WebOSMetaPlugin') + WebOSMetaPlugin: () => require('./plugins/WebOSMetaPlugin'), + ViteHtmlPlugin: () => require('./plugins/ViteHtmlPlugin'), + ViteILibPlugin: () => require('./plugins/ViteILibPlugin'), + ViteWebOSMetaPlugin: () => require('./plugins/ViteWebOSMetaPlugin') }); diff --git a/mixins/externals.js b/mixins/externals.js index 08d1396..29df55c 100644 --- a/mixins/externals.js +++ b/mixins/externals.js @@ -3,6 +3,7 @@ const path = require('path'); const helper = require('../config-helper'); const packageRoot = require('../package-root'); const EnactFrameworkRefPlugin = require('../plugins/dll/EnactFrameworkRefPlugin'); +const {reactLibraries} = require('./framework-libraries'); module.exports = { apply: function (config, opts = {}) { @@ -13,7 +14,7 @@ module.exports = { const htmlPluginInstance = helper.getPluginByName(config, 'HtmlWebpackPlugin'); const webOSMetaPluginInstance = helper.getPluginByName(config, 'WebOSMetaPlugin'); - const libraries = ['@enact', 'react', 'react-dom', 'react-dom/client', 'react-dom/server', 'ilib']; + const libraries = ['@enact', ...reactLibraries, 'ilib']; const app = packageRoot(); if ( diff --git a/mixins/framework-libraries.js b/mixins/framework-libraries.js new file mode 100644 index 0000000..3c8a3af --- /dev/null +++ b/mixins/framework-libraries.js @@ -0,0 +1,21 @@ +// Shared constants for the framework/externals mixins (webpack `framework.js` + +// `externals.js` and the Vite `vite-framework.js`). The build ALGORITHMS differ per +// bundler (webpack DLL vs Vite import map), but these library lists are common. + +// @enact packages that are build/test tooling — never part of the runtime framework +// bundle (excluded from the framework glob / specifier enumeration). +const nonRuntimeEnactPackages = [ + 'dev-utils', + 'docs-utils', + 'storybook-utils', + 'ui-test-utils', + 'screenshot-test-utils' +]; + +// The react packages bundled into / externalized to the shared framework. +const reactLibraries = ['react', 'react-dom', 'react-dom/client', 'react-dom/server']; + +module.exports = { + nonRuntimeEnactPackages, + reactLibraries +}; diff --git a/mixins/framework.js b/mixins/framework.js index 4d62ebe..a76c3d4 100644 --- a/mixins/framework.js +++ b/mixins/framework.js @@ -4,6 +4,7 @@ const fastGlob = require('fast-glob'); const helper = require('../config-helper'); const packageRoot = require('../package-root'); const EnactFrameworkPlugin = require('../plugins/dll/EnactFrameworkPlugin'); +const {nonRuntimeEnactPackages, reactLibraries} = require('./framework-libraries'); module.exports = { apply: function (config, opts = {}) { @@ -24,11 +25,7 @@ module.exports = { '**/karma.conf.js', '**/build/**/*.*', '**/dist/**/*.*', - '**/@enact/dev-utils/**/*.*', - '**/@enact/docs-utils/**/*.*', - '**/@enact/storybook-utils/**/*.*', - '**/@enact/ui-test-utils/**/*.*', - '**/@enact/screenshot-test-utils/**/*.*', + ...nonRuntimeEnactPackages.map(p => '**/@enact/' + p + '/**/*.*'), '**/ilib/localedata/**/*.*', path.join(config.output.path, '*'), '**/node_modules/**/*.*', @@ -52,7 +49,7 @@ module.exports = { followSymbolicLinks: true }) ) - .concat(['react', 'react-dom', 'react-dom/client', 'react-dom/server']) + .concat(reactLibraries) }; if ( app.meta.name.startsWith('@enact/') && diff --git a/mixins/index.js b/mixins/index.js index 2a5f514..acb5bef 100644 --- a/mixins/index.js +++ b/mixins/index.js @@ -26,5 +26,11 @@ module.exports = { } return config; + }, + // Vite counterpart to `apply`: shapes a resolved Vite config from the same opts + // (--no-minify, --verbose, --stats). The webpack `apply` above can't be reused + // because it mutates webpack-specific config (plugins/minimizers/output). + applyVite: function (config, opts = {}) { + return require('./vite').apply(config, opts); } }; diff --git a/mixins/vite-framework.js b/mixins/vite-framework.js new file mode 100644 index 0000000..26892ca --- /dev/null +++ b/mixins/vite-framework.js @@ -0,0 +1,318 @@ +// Vite counterpart to the webpack DLL framework/externals mixins (framework.js + +// externals.js / EnactFrameworkPlugin + EnactFrameworkRefPlugin). Instead of a webpack +// DLL registry, this builds the shared framework as reusable ESM addressed by an import +// map, and externalizes it out of app builds. See docs/vite-framework-externals-spike.md. +// +// Model (matches webpack): `--framework` builds a shared, app-independent framework from +// the FULL @enact surface + react + ilib, emitting the bundle + a manifest of every +// importable specifier -> output file. `--externals=` externalizes those specifiers +// from the app build and injects an import map (+ the shared stylesheet) resolved from the +// manifest. + +const fs = require('fs'); +const path = require('path'); +const {nonRuntimeEnactPackages} = require('./framework-libraries'); + +const MANIFEST = 'enact-framework-manifest.json'; +const FRAMEWORK_CSS = 'enact.css'; + +// @enact packages that are build/test tooling, never part of the runtime framework. +const ENACT_TOOL_PKGS = new Set(nonRuntimeEnactPackages); + +// react family + ilib are CJS and need enumerate-named-export wrappers (see below). +const CJS_SPECS = ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client', 'ilib']; + +// The core-js polyfill entry, included in the framework only with --externals-polyfill. +const POLYFILL_SPEC = 'core-js/stable'; + +// Whether an import specifier belongs in / should resolve to the shared framework. +function isFrameworkSpec(id) { + if (/^react($|\/)/.test(id) || /^react-dom($|\/)/.test(id) || id === 'ilib') return true; + const m = /^@enact\/([^/]+)/.exec(id); + return Boolean(m) && !ENACT_TOOL_PKGS.has(m[1]); +} + +function isCjsSpec(id) { + return /^react($|\/)/.test(id) || /^react-dom($|\/)/.test(id) || id === 'ilib'; +} + +// Side-effect-only specifiers (core-js polyfill): no default/named exports to re-export. +function isSideEffectSpec(id) { + return /^core-js(\/|$)/.test(id); +} + +// Turn a specifier into a safe, unique output basename. +function specToName(id) { + return id.replace(/[@]/g, '').replace(/[/]/g, '-').replace(/^-+/, ''); +} + +// Enumerate the importable specifiers of the shared framework, independent of any app: +// every @enact/ package root + each of its component subpaths (dirs with a +// package.json `main` or an index.js), plus the react family and ilib. +function enumerateSpecifiers(context, opts = {}) { + const nm = path.join(context, 'node_modules'); + const specs = new Set(CJS_SPECS); + // --externals-polyfill: bundle core-js into the shared framework instead of each app. + if (opts.polyfill) specs.add(POLYFILL_SPEC); + const enactDir = path.join(nm, '@enact'); + let pkgs = []; + + try { + pkgs = fs.readdirSync(enactDir); + } catch (e) { + pkgs = []; + } + + for (const pkg of pkgs) { + if (ENACT_TOOL_PKGS.has(pkg)) continue; + const pkgDir = path.join(enactDir, pkg); + let stat; + + try { + stat = fs.statSync(pkgDir); + } catch (e) { + continue; + } + + if (!stat.isDirectory()) continue; + // package root, if importable + if (fs.existsSync(path.join(pkgDir, 'package.json')) || fs.existsSync(path.join(pkgDir, 'index.js'))) { + specs.add('@enact/' + pkg); + } + // component subpaths + let subs = []; + + try { + subs = fs.readdirSync(pkgDir); + } catch (e) { + subs = []; + } + + for (const sub of subs) { + if (sub === 'node_modules' || sub === 'tests' || sub === 'build' || sub === 'dist' || sub.startsWith('.')) { + continue; + } + const subDir = path.join(pkgDir, sub); + let sstat; + try { + sstat = fs.statSync(subDir); + } catch (e) { + continue; + } + if (!sstat.isDirectory()) continue; + if (fs.existsSync(path.join(subDir, 'package.json')) || fs.existsSync(path.join(subDir, 'index.js'))) { + specs.add('@enact/' + pkg + '/' + sub); + } + } + } + return [...specs].sort(); +} + +// When `--framework` is built INSIDE a theme repo (e.g. limestone), that theme's own +// components live at the repo root, not in node_modules/@enact — so enumerateSpecifiers +// (which scans node_modules) misses them. Mirror webpack's `libraries.push('.')`: if the +// context package is a @enact theme (has ThemeDecorator/MoonstoneDecorator, or is +// @enact/i18n), enumerate its own component subpaths as `@enact//` specifiers. +// Returns {name, root, specs} (or null when not a theme repo). The specifiers resolve via a +// self-alias added in applyFramework (they aren't self-resolvable from the repo's node_modules). +function enumerateSelfSpecs(context) { + let pkg; + try { + pkg = JSON.parse(fs.readFileSync(path.join(context, 'package.json'), 'utf8')); + } catch (e) { + return null; + } + const name = pkg && pkg.name; + if (!name || !name.startsWith('@enact/') || ENACT_TOOL_PKGS.has(name.slice('@enact/'.length))) return null; + const isTheme = + fs.existsSync(path.join(context, 'ThemeDecorator')) || + fs.existsSync(path.join(context, 'MoonstoneDecorator')) || + name === '@enact/i18n'; + if (!isTheme) return null; + + const specs = []; + if (fs.existsSync(path.join(context, 'index.js'))) specs.push(name); + const SKIP = new Set(['node_modules', 'tests', 'build', 'dist', 'samples', 'coverage', 'resources', 'ilib']); + let subs = []; + try { + subs = fs.readdirSync(context); + } catch (e) { + subs = []; + } + for (const sub of subs) { + if (SKIP.has(sub) || sub.startsWith('.')) continue; + const subDir = path.join(context, sub); + try { + if (!fs.statSync(subDir).isDirectory()) continue; + } catch (e) { + continue; + } + if (fs.existsSync(path.join(subDir, 'package.json')) || fs.existsSync(path.join(subDir, 'index.js'))) { + specs.push(name + '/' + sub); + } + } + return specs.length ? {name, root: context, specs} : null; +} + +// Generate a re-export WRAPPER file per specifier into srcDir, returning {input, names}. +// Wrappers (never the resolved file as an entry) keep the real module transitive, which is +// what lets Vite's commonjs interop handle @enact's CJS-in-source the way a normal app +// build does. CJS specifiers additionally need explicit enumerated named exports. +// `selfSet` (optional) marks theme-repo self-specifiers, which resolve via the self-alias +// (applyFramework) rather than appRequire — so they skip the require-resolve existence check. +function writeWrappers(specifiers, srcDir, appRequire, selfSet = null) { + fs.mkdirSync(srcDir, {recursive: true}); + const idRe = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + const input = {}; + const names = {}; + for (const spec of specifiers) { + const name = specToName(spec); + const file = path.join(srcDir, name + '.js'); + try { + if (isSideEffectSpec(spec)) { + // core-js polyfill: side-effect only, no exports. Resolves via the build's + // core-js alias (apps don't depend on core-js directly), so no resolve check. + fs.writeFileSync(file, `import '${spec}';\n`); + } else if (selfSet && selfSet.has(spec)) { + // Theme's own component (framework built inside the theme repo): ESM source that + // resolves via the self-alias during the build; existence is already confirmed + // by enumerateSelfSpecs, so skip the appRequire resolve check. + fs.writeFileSync( + file, + `import * as __m from '${spec}';\n` + + `export * from '${spec}';\n` + + `export default (__m.default !== undefined ? __m.default : __m);\n` + ); + } else if (isCjsSpec(spec)) { + const mod = appRequire(spec); + const exp = Object.keys(mod).filter(k => k !== 'default' && k !== '__esModule' && idRe.test(k)); + fs.writeFileSync( + file, + `import __m from '${spec}';\nexport default __m;\n` + + (exp.length ? `export const { ${exp.join(', ')} } = __m;\n` : '') + ); + } else { + // verify it resolves before emitting a wrapper (skip phantom specifiers) + appRequire.resolve(spec); + fs.writeFileSync( + file, + `import * as __m from '${spec}';\n` + + `export * from '${spec}';\n` + + `export default (__m.default !== undefined ? __m.default : __m);\n` + ); + } + } catch (e) { + continue; // unresolvable specifier; leave it out of the framework + } + input[name] = file; + names[spec] = name; + } + return {input, names}; +} + +// Mutate a resolved app-build config into the FRAMEWORK build config. +function applyFramework(config, {input, outDir, selfAlias}) { + // Theme-repo self-inclusion: resolve `@enact/` (root + subpaths, incl. transitive + // self-references) to the repo root, since it isn't in the repo's own node_modules. + if (selfAlias && config.resolve && Array.isArray(config.resolve.alias)) { + config.resolve.alias.unshift(selfAlias); + // The theme's own source (repo root, outside node_modules) can mix CJS (e.g. a + // `module.exports.foo = …` fontGenerator) with ESM named imports of it. Vite's default + // commonjs only covers node_modules, so extend it to the theme root so those interop. + const rootRe = new RegExp(selfAlias.replacement.replace(/\\/g, '/').replace(/[.*+?^${}()|[\]]/g, '\\$&')); + config.build.commonjsOptions = Object.assign({transformMixedEsModules: true}, config.build.commonjsOptions, { + include: [/node_modules/, rootRe] + }); + } + config.build.outDir = outDir; + config.build.emptyOutDir = true; + // one shared stylesheet (import maps can't load per-chunk CSS) + config.build.cssCodeSplit = false; + config.build.rollupOptions = config.build.rollupOptions || {}; + config.build.rollupOptions.input = input; + config.build.rollupOptions.preserveEntrySignatures = 'strict'; + config.build.rollupOptions.output = Object.assign({}, config.build.rollupOptions.output, { + entryFileNames: '[name].js', + chunkFileNames: 'chunk-[name].js', + assetFileNames: info => ((info.name || '').endsWith('.css') ? FRAMEWORK_CSS : '[name][extname]') + }); + // The framework has no HTML document / appinfo; drop those plugins. + const DROP = new Set(['enact-vite-html', 'enact-vite-webosmeta']); + config.plugins = (config.plugins || []).flat().filter(p => !p || !DROP.has(p.name)); + return config; +} + +// Mutate a resolved app-build config to EXTERNALIZE the framework specifiers, recording +// which are actually imported into `collected`. Externalization is manifest-aware: a +// specifier is only externalized if the framework actually provides it, so an app whose +// imports the framework doesn't cover still builds (the uncovered pieces stay bundled). +function applyExternals(config, collected, manifest, opts = {}) { + const inManifest = id => Boolean(manifest && manifest.imports && manifest.imports[id]); + // --externals-polyfill: the framework provides core-js. Externalizing it as one unit + // needs it to stay a bare specifier, so drop the core-js alias (the app config points + // it at the CLI's copy) before it gets resolved+inlined. + if (opts.polyfill && inManifest(POLYFILL_SPEC) && config.resolve && Array.isArray(config.resolve.alias)) { + config.resolve.alias = config.resolve.alias.filter(a => String(a.find) !== 'core-js'); + } + config.build.rollupOptions = config.build.rollupOptions || {}; + config.build.rollupOptions.external = id => { + const wanted = isFrameworkSpec(id) || (opts.polyfill && id === POLYFILL_SPEC); + if (wanted && inManifest(id)) { + collected.add(id); + return true; + } + return false; + }; + return config; +} + +function writeManifest(outDir, names) { + const manifest = {css: fs.existsSync(path.join(outDir, FRAMEWORK_CSS)) ? FRAMEWORK_CSS : null, imports: {}}; + for (const spec of Object.keys(names)) manifest.imports[spec] = names[spec] + '.js'; + fs.writeFileSync(path.join(outDir, MANIFEST), JSON.stringify(manifest, null, 2)); + return manifest; +} + +function readManifest(frameworkPath) { + return JSON.parse(fs.readFileSync(path.join(frameworkPath, MANIFEST), {encoding: 'utf8'})); +} + +// Inject the import map + shared stylesheet into the app's built index.html. +// `base` is the public URL prefix to the framework files (from --externals-public, or the +// relative ./framework path). +function injectHtml(htmlPath, manifest, collected, base) { + const imports = {}; + + for (const spec of collected) { + if (manifest.imports[spec]) imports[spec] = base.replace(/\/$/, '') + '/' + manifest.imports[spec]; + } + + let html = fs.readFileSync(htmlPath, {encoding: 'utf8'}); + const importmap = ''; + // import map must precede any module import + html = html.replace(/]*>/, m => m + '\n' + importmap); + + if (manifest.css) { + const link = ''; + if (/]+stylesheet/i.test(html)) { + html = html.replace(/(]+stylesheet[^>]*>)/i, link + '\n$1'); + } else { + html = html.replace('', link + '\n'); + } + } + fs.writeFileSync(htmlPath, html); + return Object.keys(imports).length; +} + +// Only the functions the CLI (`pack.js`) actually calls are exported. MANIFEST / +// FRAMEWORK_CSS / isFrameworkSpec are internal helpers. +module.exports = { + enumerateSpecifiers, + enumerateSelfSpecs, + writeWrappers, + applyFramework, + applyExternals, + writeManifest, + readManifest, + injectHtml +}; diff --git a/mixins/vite-isomorphic.js b/mixins/vite-isomorphic.js new file mode 100644 index 0000000..0d0c5b6 --- /dev/null +++ b/mixins/vite-isomorphic.js @@ -0,0 +1,217 @@ +// Vite counterpart to the webpack `--isomorphic` prerendering (mixins/isomorphic.js + +// PrerenderPlugin). Vite has first-class SSR, so this does a real `vite build --ssr` of the +// app entry, renders it per-locale in Node (FileXHR for iLib locale data), and assembles the +// same output shape as webpack: a fallback index.html + deduped index..html files + +// locale-map.json, plus per-locale webOS appinfo. Reuses the bundler-agnostic PrerenderPlugin +// helpers (templates.js, FileXHR). See docs/vite-isomorphic-scope.md. + +const fs = require('fs'); +const path = require('path'); +const templates = require('../plugins/PrerenderPlugin/templates'); +const FileXHR = require('../plugins/PrerenderPlugin/FileXHR'); + +// The client build ignores ALL iLib platform loaders (browser can't use them). For SSR in +// Node we KEEP the Node loader (ilib-node/NodeLoader) so iLib reads locale data from disk; +// only the Qt/Rhino/Ringo loaders are irrelevant here. +const SSR_LOADER_IGNORE = /(?:[/\\]|^\.\/lib\/)ilib-(?:qt|rhino|ringo)[\w-]*\.js$|(?:Rhino|Qt|Ringo)Loader(?:\.js)?$/; + +// Mutate a resolved client build config into the SSR build config (Node-loadable CJS bundle +// whose default export is the app element). +function applySsrBuild(config, {serverEntry, outDir}) { + config.build.outDir = outDir; + config.build.ssr = serverEntry; + config.build.rollupOptions = config.build.rollupOptions || {}; + config.build.rollupOptions.input = serverEntry; + // CJS: the bundle carries leftover require() (iLib's platform loaders); ESM throws. + config.build.rollupOptions.output = { + format: 'cjs', + entryFileNames: 'app.server.cjs', + inlineDynamicImports: true + }; + config.build.cssCodeSplit = false; + // Bundle @enact (JSX-in-.js needs the babel transform) + ilib (its dynamic imports break + // when externalized); react/react-dom stay external (Node provides them). + config.ssr = Object.assign({}, config.ssr, {noExternal: [/^@enact\//, /^ilib($|\/)/]}); + config.build.commonjsOptions = Object.assign({}, config.build.commonjsOptions, { + ignore: id => SSR_LOADER_IGNORE.test(id) + }); + // No HTML document / appinfo for the SSR bundle. + // Also drop the browser Node polyfills (vite-plugin-node-polyfills:*) — the SSR + // bundle runs in Node and must use the real builtins (path/fs/crypto), not shims. + const DROP = new Set(['enact-vite-html', 'enact-vite-webosmeta']); + config.plugins = (config.plugins || []) + .flat() + .filter(p => !p || (!DROP.has(p.name) && !String(p.name).startsWith('vite-plugin-node-polyfills'))); + return config; +} + +// FileXHR subclass: Vite's base is '/', so @enact/i18n builds absolute locale URLs; FileXHR +// reads relative to cwd (the app dir), so strip the leading '/'. +// Must SUBCLASS rather than wrap: the `xhr` package that @enact/i18n's loader uses assigns +// its completion handler as a property (`xhr.onload = fn`) instead of calling +// addEventListener, and FileXHR.send() invokes `this.onload`. A wrapper holding a private +// FileXHR would strand that handler on the outer object, so no locale data would ever load +// and every locale would prerender unlocalized. +function makeLocaleXHR() { + function LocaleXHR() { + FileXHR.call(this); + } + LocaleXHR.prototype = Object.create(FileXHR.prototype); + LocaleXHR.prototype.constructor = LocaleXHR; + LocaleXHR.prototype.open = function (m, uri, async) { + FileXHR.prototype.open.call(this, m, String(uri).replace(/^\/+/, ''), async); + }; + return LocaleXHR; +} + +// Render each locale to markup, extract enact-locale-* root classes (stored separately) and +// dedupe identical (sanitized) markup. `load(locale)` returns the app element (uncached). +// `fontGenerator` (optional path) supplies per-locale font CSS, prepended as a head-append +// block (participates in dedupe; extracted into during assembly). +// Returns {prerenders, attr, aliasOf}: prerenders = unique markups, attr[i] = locale classes, +// aliasOf[i] = index into prerenders for locale i. +function prerender({locales, load, renderToString, fontGenerator}) { + const LocaleXHR = makeLocaleXHR(); + let genFn = null; + if (fontGenerator) { + try { + const m = require(path.resolve(fontGenerator)); + genFn = m && (m.default || m); + } catch (e) { + genFn = null; + } + } + const prerenders = []; + const attr = []; + const aliasOf = []; + for (let i = 0; i < locales.length; i++) { + global.XMLHttpRequest = LocaleXHR; + process.env.LANG = locales[i]; + let markup = renderToString(load(locales[i])); + if (genFn) { + let style = genFn(locales[i]) || ''; + if (typeof genFn.fontOverrideGenerator === 'function') { + style += genFn.fontOverrideGenerator(locales[i]) || ''; + } + if (style) markup = '\n' + style + '\n' + markup; + } + attr[i] = ''; + markup = markup.replace( + /(]*class="((?!enact-locale-)[^"])*)(\senact-locale-[^"]*)"/i, + (match, before, s, cls) => { + attr[i] = cls; + return before + '"'; + } + ); + const found = prerenders.indexOf(markup); + aliasOf[i] = found === -1 ? prerenders.push(markup) - 1 : found; + } + return {prerenders, attr, aliasOf}; +} + +// Assemble the isomorphic output from the client build's index.html + the prerenders. +// Emits the fallback index.html, the deduped index..html file(s), and (for >1 locale) +// locale-map.json. Returns {localeMap, variantOf} where variantOf[v] is the emitted filename. +function assemble({outDir, locales, prerenders, attr, aliasOf, screenTypes = [], snapshot = false}) { + const indexPath = path.join(outDir, 'index.html'); + const clientHtml = fs.readFileSync(indexPath, {encoding: 'utf8'}); + const rootRe = /
\s*<\/div>/; + const jsAssets = [...clientHtml.matchAll(/]+src="([^"]+)"[^>]*>/g)].map(m => m[1]); + + // Fallback index.html: remove the body module script, prepend the startup script that + // shows the (empty here) root then loads the JS at window.onload. + let fallback = clientHtml.replace(/]+type="module"[^>]*><\/script>/g, ''); + // `templates.startup` appends each app asset as a CLASSIC '; + fallback = fallback.replace(/]*>/, m => m + '\n' + startupTag); + + const multi = locales.length > 1; + // Name a variant by its representative (first) locale; a single variant covering every + // locale is named 'multi' (matches webpack's grouped alias). + const variantName = v => { + if (!multi) return 'index.html'; + if (prerenders.length === 1) return 'index.multi.html'; + return 'index.' + locales.find((_, i) => aliasOf[i] === v) + '.html'; + }; + + const localeMap = {fallback: 'index.html', locales: {}}; + const variantOf = []; + for (let v = 0; v < prerenders.length; v++) { + const linked = locales.filter((_, i) => aliasOf[i] === v); + const mapping = linked.reduce( + (mm, loc) => Object.assign(mm, {[loc.toLowerCase()]: {classes: attr[locales.indexOf(loc)]}}), + {} + ); + // Split any font-CSS head-append block out of the markup: it goes into , the + // remaining app markup into #root. + let appMarkup = prerenders[v]; + let headAppend = ''; + appMarkup = appMarkup.replace(/([\s\S]*?)/, (m, content) => { + headAppend = content.trim(); + return ''; + }); + // Only need the locale-mapping update script when a variant serves multiple locales. + const updater = templates.update(prerenders.length > 1 || linked.length > 1 ? mapping : null, null, appMarkup); + let html = fallback.replace(rootRe, '
' + appMarkup + '
'); + if (headAppend) html = html.replace(/]*>/, m => m + '\n' + headAppend); + if (updater) html = html.replace('', '\n'); + + const fname = variantName(v); + fs.writeFileSync(path.join(outDir, fname), html); + variantOf[v] = fname; + linked.forEach(loc => { + localeMap.locales[loc] = fname; + }); + } + + // For a single locale the prerendered page IS index.html; otherwise index.html is the + // fallback shell and each locale maps to a variant file. + if (!multi) { + // variantName returned 'index.html', already written above. + } else { + fs.writeFileSync(indexPath, fallback); + fs.writeFileSync(path.join(outDir, 'locale-map.json'), JSON.stringify(localeMap, null, '\t')); + } + return {localeMap, variantOf}; +} + +// Phase D: webOS appinfo. Tag the root appinfo with usePrerendering, and (for >1 locale) +// write per-locale resources//appinfo.json pointing `main` at the locale's variant. +function writeAppinfo({outDir, locales, localeMap}) { + const rootPath = path.join(outDir, 'appinfo.json'); + if (fs.existsSync(rootPath) && locales.length > 0) { + const root = JSON.parse(fs.readFileSync(rootPath, {encoding: 'utf8'})); + if (typeof root.usePrerendering === 'undefined') { + root.usePrerendering = true; + fs.writeFileSync(rootPath, JSON.stringify(root, null, '\t')); + } + } + if (locales.length > 1) { + for (const loc of locales) { + const variant = localeMap.locales[loc]; + if (!variant) continue; + const dir = path.join(outDir, 'resources', loc.replace(/-/g, path.sep)); + fs.mkdirSync(dir, {recursive: true}); + const p = path.join(dir, 'appinfo.json'); + let meta = {}; + try { + meta = JSON.parse(fs.readFileSync(p, {encoding: 'utf8'})); + } catch (e) { + meta = {}; + } + meta.main = variant; + fs.writeFileSync(p, JSON.stringify(meta, null, '\t')); + } + } +} + +module.exports = {applySsrBuild, prerender, assemble, writeAppinfo}; diff --git a/mixins/vite-snapshot.js b/mixins/vite-snapshot.js new file mode 100644 index 0000000..439acc5 --- /dev/null +++ b/mixins/vite-snapshot.js @@ -0,0 +1,215 @@ +/* eslint-env node, es6 */ +/** + * vite-snapshot.js + * + * Vite/Rollup counterpart to the webpack `SnapshotPlugin` + isomorphic snapshot + * specialization. `--snapshot` implies `--isomorphic`; on top of the isomorphic + * client build it produces a **self-contained UMD** `main.js` that: + * - runs in a bare V8 (what `mksnapshot` executes) — single file, no external + * `import`/`require`, `global` shimmed, attaches the app to `global.App`; + * - prepends the (bundler-agnostic) snapshot helpers so `react-dom/client` is + * initialized against a mock window and `global.updateEnvironment` is defined; + * - substitutes `react-dom/client` → snapshot-helper (and `react-redux` → + * snapshot-redux-helper), except when imported by the helper itself. + * + * The heap that `mksnapshot` captures (after this bundle's top-level eval) becomes + * `snapshot_blob.bin`; on-device the `templates.js` startup script sees the + * preloaded `App`/`ReactDOMClient` globals and hydrates. The snapshot helpers, + * `mock-window`, and `@enact/core/snapshot` deferral are reused as-is from the + * webpack `SnapshotPlugin` — they are plain CJS with no webpack coupling. + * + * Rollup notes (why the helpers are staged under node_modules): + * - The helpers are CommonJS but live outside the app's node_modules, so Vite + * would treat them as ESM source and leave their `require()` intact. We copy + * them into `/node_modules/.cache/enact-vite/snapshot/` so Vite's default + * commonjs transform (which covers node_modules) rewrites their requires. + * - `strictRequires` is scoped to those staged files so their requires stay + * function-scoped/lazy — critical because `require('react-dom/client')` must + * run *after* `mockWindow.activate()` (a hoisted top-level import would load + * react-dom against the absent real window), and `updateEnvironment()`'s + * requires must run on-device, not at snapshot time. + */ +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); + +const SRC_DIR = path.join(__dirname, '..', 'plugins', 'SnapshotPlugin'); +const HELPER_FILES = ['snapshot-mock.js', 'snapshot-helper-esm.js', 'mock-window.js']; + +// Optional deps the snapshot helper facade references; any that don't resolve in +// the app (absent package, or a missing subpath — e.g. `fbjs` is gone in React 19, +// a theme may lack `internal/$L`) fall back to a harmless no-op so the helper's +// calls (clearResBundle, updateLocale, …) do nothing — matching "library not used". +const OPTIONAL_LIBS = [ + '@enact/i18n', + '@enact/moonstone', + '@enact/sandstone', + '@enact/limestone', + 'ilib', + 'react-redux', + 'fbjs' +]; + +// Prelude injected at the ABSOLUTE top of main.js (before esbuild's helper block, +// which captures `__getOwnPropDescs = Object.getOwnPropertyDescriptors` etc. up front). +// The mksnapshot V8 is OLD (chromium 53): no `globalThis` (ES2020) and no window / +// HTML head-shim, so resolve the real global object via `Function('return this')()` +// and expose it as `global`/`globalThis`; and polyfill `Object.getOwnPropertyDescriptors` +// (ES2017) that esbuild's object-spread helpers need but chrome 53 lacks (no WeakMap, so +// it stays snapshot-serializable — unlike core-js's internal state). +const SNAPSHOT_PRELUDE = + 'var global=typeof global!=="undefined"?global:' + + '(typeof globalThis!=="undefined"?globalThis:(typeof self!=="undefined"?self:Function("return this")()));' + + 'if(typeof globalThis==="undefined"){try{global.globalThis=global;}catch(e){}}' + + 'if(!Object.getOwnPropertyDescriptors){Object.getOwnPropertyDescriptors=function(o){' + + 'var r={},k=Object.getOwnPropertyNames(o),s=Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(o):[],i;' + + 'for(i=0;i { + let r = path.relative(dir, target).replace(/\\/g, '/'); + if (!r.startsWith('.')) r = './' + r; + return r; + }; + // core-js polyfills are included in the snapshot by default (as in the webpack path). On a + // modern snapshot V8 (e.g. Chrome 132) the polyfills' WeakMap-based internal state + // serializes fine. Only a VERY OLD snapshot V8 (~Chrome 53) can't serialize a WeakMap + // with entries — mksnapshot throws "illegal access" → 0-byte blob — for BOTH webpack and + // Vite (verified). For such old firmware, set ENACT_SNAPSHOT_NO_COREJS=1 to drop core-js + // (the syntax stays valid via V8_SNAPSHOT_TARGET; runtime builtins go unpolyfilled). + const body = + `import ${JSON.stringify(rel(staged.helperEsmJs))};\n` + + (process.env.ENACT_SNAPSHOT_NO_COREJS ? '' : `import 'core-js/stable';\n`) + + `export {default} from ${JSON.stringify(rel(appEntry))};\n`; + fs.writeFileSync(file, body); + return file; +} + +// Rollup plugin: redirect app imports of `react-dom/client` to the ESM helper +// facade (except the facade's own import, which must reach the real module), and +// resolve absent optional libs to the shared no-op module. +function snapshotResolvePlugin(staged) { + const norm = id => id && id.replace(/\\/g, '/'); + const FACADE = norm(staged.helperEsmJs); + const inSnapshotDir = id => norm(id) && norm(id).indexOf('/enact-vite/snapshot/') !== -1; + return { + name: 'enact-snapshot-resolve', + enforce: 'pre', + async resolveId(source, importer) { + // App imports of react-dom/client → the ESM helper facade (but the facade's + // own import must reach the real module). + if (source === 'react-dom/client' && norm(importer) !== FACADE) return staged.helperEsmJs; + // The staged helper's optional Enact/i18n deps: resolve if available, else no-op. + if (inSnapshotDir(importer) && OPTIONAL_LIBS.some(l => source === l || source.startsWith(l + '/'))) { + const resolved = await this.resolve(source, importer, {skipSelf: true}); + return resolved || staged.noop; + } + return null; + } + }; +} + +// Mutate an isomorphic client config into the self-contained UMD snapshot build. +function applySnapshotBuild(config, {context, appEntry}) { + const staged = stageHelpers(context); + const snapshotEntry = createSnapshotEntry(staged.dir, staged, appEntry); + config.build = config.build || {}; + config.build.cssCodeSplit = false; + // The snapshot must parse in the target board's V8. By default the app's browserslist + // drives the output (matches the firmware for a modern webOS, e.g. Chrome 132). Only for + // a much OLDER firmware than the app targets does the bundler-generated wrapper/helper + // code need extra lowering — set V8_SNAPSHOT_TARGET (e.g. "chrome53") to force esbuild to + // lower the whole output (app code is already lowered by babel-preset-enact; this also + // catches Rollup/esbuild's helpers + the minifier output). + if (process.env.V8_SNAPSHOT_TARGET) config.build.target = process.env.V8_SNAPSHOT_TARGET; + config.build.rollupOptions = config.build.rollupOptions || {}; + config.build.rollupOptions.input = {main: snapshotEntry}; + // UMD so the app's default export is exposed as the `App` global — what mksnapshot + // captures and the startup script reads on-device (mirrors the webpack isomorphic + // `output.library='App'`/`libraryTarget='umd'`). `preserveEntrySignatures:'strict'` + // keeps that export (an app build otherwise drops entry signatures → export-less + // IIFE). `inlineDynamicImports` yields the single self-contained file mksnapshot + // runs; the banner shims `global` for the bare-V8 context. ViteHtmlPlugin is kept so + // index.html is still emitted for the isomorphic assembly. + config.build.rollupOptions.preserveEntrySignatures = 'strict'; + config.build.rollupOptions.output = Object.assign({}, config.build.rollupOptions.output, { + format: 'umd', + name: 'App', + entryFileNames: 'main.js', + inlineDynamicImports: true + }); + config.plugins = config.plugins || []; + config.plugins.unshift(snapshotResolvePlugin(staged)); + config.plugins.push(snapshotPreludePlugin()); + return config; +} + +// Spawn the V8 `mksnapshot` toolchain against the emitted bundle, producing the +// startup blob. Returns {ok, blob, error}. No-op (ok:false) when V8_MKSNAPSHOT is unset. +function runMkSnapshot({outDir, target = 'main.js', exec = process.env.V8_MKSNAPSHOT}) { + const args = process.env.V8_SNAPSHOT_ARGS + ? process.env.V8_SNAPSHOT_ARGS.split(/\s+/) + : [ + '--profile-deserialization', + '--random-seed=314159265', + '--abort_on_uncaught_exception', + '--startup-blob=snapshot_blob.bin' + ]; + const blobArg = args.find(a => a.startsWith('--startup-blob=')); + const blob = blobArg ? blobArg.replace('--startup-blob=', '') : 'snapshot_blob.bin'; + if (!exec) return {ok: false, blob, error: new Error('V8_MKSNAPSHOT is not set')}; + const child = cp.spawnSync(exec, args.concat(target), {cwd: outDir, encoding: 'utf8'}); + if (child.status !== 0) return {ok: false, blob, error: new Error(child.stdout + '\n' + child.stderr)}; + try { + if (fs.statSync(path.join(outDir, blob)).size > 0) return {ok: true, blob}; + } catch (e) { + /* fall through */ + } + return {ok: false, blob, error: new Error(child.stdout + '\n' + child.stderr)}; +} + +// Record the blob in the root appinfo.json so webOS loads the snapshot at launch. +function writeSnapshotAppinfo({outDir, blob = 'snapshot_blob.bin'}) { + const p = path.join(outDir, 'appinfo.json'); + if (!fs.existsSync(p)) return; + const meta = JSON.parse(fs.readFileSync(p, 'utf8')); + meta.v8SnapshotFile = blob; + fs.writeFileSync(p, JSON.stringify(meta, null, '\t')); +} + +module.exports = {applySnapshotBuild, runMkSnapshot, writeSnapshotAppinfo}; diff --git a/mixins/vite.js b/mixins/vite.js new file mode 100644 index 0000000..42e084a --- /dev/null +++ b/mixins/vite.js @@ -0,0 +1,82 @@ +const path = require('path'); + +// Vite counterpart to the webpack `mixins.apply`. Given a resolved Vite `InlineConfig` +// and the CLI `opts`, applies the small build-shaping flags that don't belong in the +// base config factory: `--no-minify`, `--verbose`, and `--stats`. Mirrors the webpack +// mixins (unmangled.js, verbose.js, stats.js) +module.exports = { + apply: function (config, opts = {}) { + config.plugins = config.plugins || []; + + // --no-minify (private): keep Terser's optimizations/dead-code removal but + // disable mangling and beautify the output for readability. Mirrors the webpack + // `unmangled` mixin. Only meaningful when the build actually minifies (production); + // a development build is already unminified, so leave it untouched. + if (opts.minify === false && config.build && config.build.minify) { + config.build.minify = 'terser'; + config.build.terserOptions = Object.assign({}, config.build.terserOptions, { + mangle: false, + compress: true, + format: Object.assign({beautify: true, comments: true}, (config.build.terserOptions || {}).format) + }); + } + + // --verbose: raise Vite's log level and log build-phase transitions with a running + // module count. Rollup doesn't know the total module count up front, so unlike the + // webpack ProgressPlugin there's no percentage; module counts are the honest analog. + if (opts.verbose) { + config.logLevel = 'info'; + config.plugins.push(verbosePlugin()); + } + + // --stats: emit a static bundle-analysis treemap next to the build output + // (webpack: webpack-bundle-analyzer's BundleAnalyzerPlugin -> stats.html). + if (opts.stats) { + let visualizer; + try { + ({visualizer} = require('rollup-plugin-visualizer')); + } catch (e) { + console.log( + 'NOTICE: --stats requires the "rollup-plugin-visualizer" package, which is not ' + + 'installed; skipping bundle analysis.' + ); + return config; + } + const outDir = (config.build && config.build.outDir) || 'dist'; + config.plugins.push( + visualizer({ + filename: path.join(outDir, 'stats.html'), + title: 'Enact bundle analysis', + template: 'treemap', + open: false, + gzipSize: true, + brotliSize: true + }) + ); + } + + return config; + } +}; + +// Lightweight Rollup/Vite plugin that narrates the build phases when `--verbose` is set. +function verbosePlugin() { + let modules = 0; + return { + name: 'enact-vite-verbose', + apply: 'build', + buildStart() { + modules = 0; + console.log('[verbose] build start — resolving & transforming modules…'); + }, + moduleParsed() { + modules++; + }, + renderStart() { + console.log(`[verbose] render start — ${modules} modules parsed, generating chunks…`); + }, + generateBundle(options, bundle) { + console.log(`[verbose] emitting ${Object.keys(bundle).length} output file(s)…`); + } + }; +} diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4e0c3ee..3d8e76b 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -21,6 +21,7 @@ "loader-utils": "^3.3.1", "mock-require": "^3.0.3", "resolve": "^1.22.12", + "rollup-plugin-visualizer": "^6.0.11", "tapable": "^2.3.3", "webpack-bundle-analyzer": "^5.3.0", "webpack-sources": "^3.5.0" @@ -719,7 +720,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -729,7 +729,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1116,11 +1115,24 @@ "node": ">= 10.0" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1133,7 +1145,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -1328,6 +1339,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -1460,6 +1480,12 @@ "integrity": "sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==", "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", @@ -9099,6 +9125,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -9140,6 +9181,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -9369,6 +9419,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -9881,6 +9943,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -10234,6 +10313,15 @@ "strip-ansi": "^6.0.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -10284,6 +10372,57 @@ "node": ">=0.10.0" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.11.tgz", + "integrity": "sha512-TBwVHVY7buHjIKVLqr9scTVFwqZqMXINcCphPwIWKPDCOBIa+jCQfafvbjRJDZgXdq/A996Dy6yGJ/+/NtAXDQ==", + "license": "MIT", + "dependencies": { + "open": "^8.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10632,6 +10771,20 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", @@ -10696,7 +10849,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11378,6 +11530,23 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -11399,6 +11568,51 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 2967650..39a0fd1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "loader-utils": "^3.3.1", "mock-require": "^3.0.3", "resolve": "^1.22.12", + "rollup-plugin-visualizer": "^6.0.11", "tapable": "^2.3.3", "webpack-bundle-analyzer": "^5.3.0", "webpack-sources": "^3.5.0" diff --git a/plugins/ILibPlugin/ilib-paths.js b/plugins/ILibPlugin/ilib-paths.js new file mode 100644 index 0000000..96bcf8d --- /dev/null +++ b/plugins/ILibPlugin/ilib-paths.js @@ -0,0 +1,49 @@ +/* eslint-env node, es6 */ +/** + * ilib-paths + * + * Path/constant helpers shared by `ILibPlugin` (webpack) and `ViteILibPlugin` + * (Vite). Kept dependency-free (only `fs`/`path`) so the Vite path can reuse them + * without pulling webpack, tapable, or fast-glob (which `ILibPlugin/index.js` + * requires at module load). + */ +const fs = require('fs'); +const path = require('path'); + +// Walk up from `dir` looking for `node_modules/`; return its path relative to cwd. +function packageSearch(dir, pkg) { + let pkgPath; + if (!path.isAbsolute(dir)) dir = path.join(process.cwd(), dir); + while (dir.length > 0 && dir !== path.dirname(dir) && !pkgPath) { + const full = path.join(dir, 'node_modules', pkg); + if (fs.existsSync(full)) { + pkgPath = path.relative(process.cwd(), full); + } else { + dir = path.dirname(dir); + } + } + return pkgPath; +} + +// Normalize a filepath to be relative to the context, using forward-slashes, and +// replace each '..' with '_', keeping in line with the file-loader and other standards. +function transformPath(context, file) { + return path + .relative(context, file) + .replace(/\\/g, '/') + .replace(/\.\.(\/)?/g, '_$1'); +} + +// The `ILIB__PATH` constant name for a bundle/package. +function bundleConst(name) { + return ( + 'ILIB_' + + path + .basename(name) + .toUpperCase() + .replace(/[-_\s]/g, '_') + + '_PATH' + ); +} + +module.exports = {packageSearch, transformPath, bundleConst}; diff --git a/plugins/ILibPlugin/index.js b/plugins/ILibPlugin/index.js index a82ffbc..1c40e8f 100644 --- a/plugins/ILibPlugin/index.js +++ b/plugins/ILibPlugin/index.js @@ -4,6 +4,7 @@ const fs = require('graceful-fs'); const {SyncWaterfallHook} = require('tapable'); const {ContextReplacementPlugin, Compilation, DefinePlugin, Template, sources} = require('webpack'); const app = require('../../option-parser'); +const {packageSearch, transformPath, bundleConst} = require('./ilib-paths'); function packageName(file) { try { @@ -13,46 +14,12 @@ function packageName(file) { } } -function packageSearch(dir, pkg) { - let pkgPath; - if (!path.isAbsolute(dir)) dir = path.join(process.cwd(), dir); - while (dir.length > 0 && dir !== path.dirname(dir) && !pkgPath) { - const full = path.join(dir, 'node_modules', pkg); - if (fs.existsSync(full)) { - pkgPath = path.relative(process.cwd(), full); - } else { - dir = path.dirname(dir); - } - } - return pkgPath; -} - // Determine if it's a NodeJS output filesystem or if it's a foreign/virtual one. // The internal webpack5 implementation of outputFileSystem is graceful-fs. function isNodeOutputFS(compiler) { return compiler.outputFileSystem && JSON.stringify(compiler.outputFileSystem) === JSON.stringify(fs); } -// Normalize a filepath to be relative to the webpack context, using forward-slashes, and -// replace each '..' with '_', keeping in line with the file-loader and other webpack standards. -function transformPath(context, file) { - return path - .relative(context, file) - .replace(/\\/g, '/') - .replace(/\.\.(\/)?/g, '_$1'); -} - -function bundleConst(name) { - return ( - 'ILIB_' + - path - .basename(name) - .toUpperCase() - .replace(/[-_\s]/g, '_') + - '_PATH' - ); -} - function resolveBundle({dir, context, symlinks, relative, publicPath}) { const bundle = {resolved: dir, path: dir, emit: true}; if (path.isAbsolute(bundle.path)) { diff --git a/plugins/PrerenderPlugin/index.js b/plugins/PrerenderPlugin/index.js index 85c6376..66e5b5a 100644 --- a/plugins/PrerenderPlugin/index.js +++ b/plugins/PrerenderPlugin/index.js @@ -1,4 +1,3 @@ -const fs = require('fs'); const path = require('path'); const gracefulFs = require('graceful-fs'); const {SyncHook} = require('tapable'); @@ -6,6 +5,7 @@ const {htmlTagObjectToString} = require('html-webpack-plugin/lib/html-tags'); const {Compilation, sources} = require('webpack'); const templates = require('./templates'); const vdomServer = require('./vdom-server-render'); +const {parseLocales} = require('./parse-locales'); const prerenderPluginHooksMap = new WeakMap(); @@ -338,69 +338,6 @@ function isNodeOutputFS(compiler) { return compiler.outputFileSystem && JSON.stringify(compiler.outputFileSystem) === JSON.stringify(gracefulFs); } -// Determine the desired target locales based of option content. -// Can be a preset like 'tv' or 'signage', 'used' for all used app-level locales, 'all' for -// all locales supported by ilib, a custom json file input, or a comma-separated lists -function parseLocales(context, target) { - if (!target || target === 'none') { - return []; - } else if (Array.isArray(target)) { - return target; - } else if (target.toLowerCase() === 'webos') { - return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-webos.json'), {encoding: 'utf8'})).locales; - } else if (target === 'tv') { - return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-tv.json'), {encoding: 'utf8'})).locales; - } else if (target === 'signage') { - return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-signage.json'), {encoding: 'utf8'})).locales; - } else if (target === 'used') { - return detectLocales(path.join(context, 'resources', 'ilibmanifest.json')); - } else if (target === 'all') { - return detectLocales(path.join('node_modules', 'ilib', 'locale', 'ilibmanifest.json'), true); - } else if (/\.json$/i.test(target)) { - return JSON.parse(fs.readFileSync(target, {encoding: 'utf8'})).locales; - } else { - return target.split(/\s*[\n,]\s*/).filter(Boolean); - } -} - -// Scan an ilib manifest and detect all locales that it uses. -function detectLocales(manifest, deepestOnly) { - try { - const meta = JSON.parse(fs.readFileSync(manifest, {encoding: 'utf8'})); - const locales = []; - let curr, currLocale; - for (let i = 0; meta.files && i < meta.files.length; i++) { - curr = path.dirname(meta.files[i]); - currLocale = curr.replace(/[\\/]+/, '-'); - if (locales.indexOf(curr) === -1 && /^([a-z]{2})\b/.test(currLocale)) { - if (deepestOnly) { - // Remove any matches of parent directories. - for (let x = curr; x.indexOf('/') !== -1 || x.indexOf('\\') !== -1; x = path.dirname(x)) { - const index = locales.indexOf(x.replace(/[\\/]+/, '-')); - if (index >= 0) { - locales.splice(index, 1); - } - } - // Only add the entry if children aren't already in the list. - let childFound = false; - for (let k = 0; k < locales.length && !childFound; k++) { - childFound = locales[k].indexOf(currLocale) === 0; - } - if (!childFound) { - locales.push(currLocale); - } - } else { - locales.push(currLocale); - } - } - } - locales.sort((a, b) => a.split('-').length > b.split('-').length); - return locales; - } catch (e) { - return []; - } -} - // Simplifies and groups the locales and aliases to ensure minimal output needed. function simplifyAliases(locales, status) { const links = {}; diff --git a/plugins/PrerenderPlugin/parse-locales.js b/plugins/PrerenderPlugin/parse-locales.js new file mode 100644 index 0000000..2b01289 --- /dev/null +++ b/plugins/PrerenderPlugin/parse-locales.js @@ -0,0 +1,74 @@ +/* eslint-env node, es6 */ +/** + * parse-locales + * + * Locale-list resolution shared by `PrerenderPlugin` (webpack `--isomorphic`) and + * `ViteILibPlugin` (Vite iLib locale filtering, `-l`). + */ +const fs = require('fs'); +const path = require('path'); + +// Determine the desired target locales based of option content. +// Can be a preset like 'tv' or 'signage', 'used' for all used app-level locales, 'all' for +// all locales supported by ilib, a custom json file input, or a comma-separated lists +function parseLocales(context, target) { + if (!target || target === 'none') { + return []; + } else if (Array.isArray(target)) { + return target; + } else if (target.toLowerCase() === 'webos') { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-webos.json'), {encoding: 'utf8'})).locales; + } else if (target === 'tv') { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-tv.json'), {encoding: 'utf8'})).locales; + } else if (target === 'signage') { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'locales-signage.json'), {encoding: 'utf8'})).locales; + } else if (target === 'used') { + return detectLocales(path.join(context, 'resources', 'ilibmanifest.json')); + } else if (target === 'all') { + return detectLocales(path.join('node_modules', 'ilib', 'locale', 'ilibmanifest.json'), true); + } else if (/\.json$/i.test(target)) { + return JSON.parse(fs.readFileSync(target, {encoding: 'utf8'})).locales; + } else { + return target.split(/\s*[\n,]\s*/).filter(Boolean); + } +} + +// Scan an ilib manifest and detect all locales that it uses. +function detectLocales(manifest, deepestOnly) { + try { + const meta = JSON.parse(fs.readFileSync(manifest, {encoding: 'utf8'})); + const locales = []; + let curr, currLocale; + for (let i = 0; meta.files && i < meta.files.length; i++) { + curr = path.dirname(meta.files[i]); + currLocale = curr.replace(/[\\/]+/, '-'); + if (locales.indexOf(curr) === -1 && /^([a-z]{2})\b/.test(currLocale)) { + if (deepestOnly) { + // Remove any matches of parent directories. + for (let x = curr; x.indexOf('/') !== -1 || x.indexOf('\\') !== -1; x = path.dirname(x)) { + const index = locales.indexOf(x.replace(/[\\/]+/, '-')); + if (index >= 0) { + locales.splice(index, 1); + } + } + // Only add the entry if children aren't already in the list. + let childFound = false; + for (let k = 0; k < locales.length && !childFound; k++) { + childFound = locales[k].indexOf(currLocale) === 0; + } + if (!childFound) { + locales.push(currLocale); + } + } else { + locales.push(currLocale); + } + } + } + locales.sort((a, b) => a.split('-').length > b.split('-').length); + return locales; + } catch (e) { + return []; + } +} + +module.exports = {parseLocales, detectLocales}; diff --git a/plugins/SnapshotPlugin/snapshot-helper-esm.js b/plugins/SnapshotPlugin/snapshot-helper-esm.js new file mode 100644 index 0000000..9e0ed48 --- /dev/null +++ b/plugins/SnapshotPlugin/snapshot-helper-esm.js @@ -0,0 +1,83 @@ +/* eslint-disable */ +/* + * snapshot-helper-esm.js + * + * Vite/Rollup ESM port of the CommonJS `snapshot-helper.js`. Import order is + * significant and mirrors the CJS helper's require order: + * 1. `./snapshot-mock.js` — installs the mock window (before react-dom loads) + * 2. `react-dom/client` — initializes against the mock window + * The module body then exposes the initialized client on `global.ReactDOMClient`, + * restores the real globals, and defines `global.updateEnvironment` (called + * on-device to rebind to the real window and flush the `@enact/core/snapshot` + * window-ready queue). The `react-dom/client` named exports (createRoot/hydrateRoot) + * are re-exported so an app importing them resolves through this facade. + * + * The `@enact/*`/`ilib` deps used by `updateEnvironment` are imported statically: + * they're bundled and evaluated at snapshot time (Enact is snapshot-safe by + * design), while their *methods* are only called on-device inside + * `updateEnvironment`. Packages not installed in the app resolve to a no-op (via + * the snapshot resolver), so their calls are harmless. + */ +import {ExecutionEnvironment, mockWindow, inSnapshot} from './snapshot-mock.js'; +import * as ReactDOMClientNS from 'react-dom/client'; + +import * as coreSnapshotNS from '@enact/core/snapshot'; +import * as ilibNS from 'ilib/lib/ilib'; +import * as resBundleNS from '@enact/i18n/src/resBundle'; +import * as moonstoneLNS from '@enact/moonstone/internal/$L'; +import * as sandstoneLNS from '@enact/sandstone/internal/$L'; +import * as limestoneLNS from '@enact/limestone/internal/$L'; +import * as localeNS from '@enact/i18n/locale'; + +var GLOBAL = typeof globalThis !== 'undefined' ? globalThis : global; +var ReactDOMClient = ReactDOMClientNS.default || ReactDOMClientNS; +GLOBAL.ReactDOMClient = ReactDOMClient; + +// react-dom has initialized against the mock; restore the real (absent) globals. +if (inSnapshot) mockWindow.deactivate(); + +function mod(ns) { + return (ns && ns.default) || ns || {}; +} + +GLOBAL.updateEnvironment = function () { + var canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement); + ExecutionEnvironment.canUseDOM = canUseDOM; + ExecutionEnvironment.canUseWorkers = typeof Worker !== 'undefined'; + ExecutionEnvironment.canUseEventListeners = canUseDOM && !!(window.addEventListener || window.attachEvent); + ExecutionEnvironment.canUseViewport = canUseDOM && !!window.screen; + ExecutionEnvironment.isInWorker = !canUseDOM; + mockWindow.attachListeners(ExecutionEnvironment.canUseEventListeners && window); + + try { + var ilib = mod(ilibNS); + if (ilib && ilib._load) { + ilib._load._cacheValidated = false; + if (ilib.clearCache) ilib.clearCache(); + } + var resBundle = mod(resBundleNS); + if (resBundle.clearResBundle) resBundle.clearResBundle(); + [moonstoneLNS, sandstoneLNS, limestoneLNS].forEach(function (ns) { + var b = mod(ns); + if (b.clearResBundle) b.clearResBundle(); + }); + var locale = mod(localeNS); + if (locale.updateLocale) locale.updateLocale(); + } catch (e) {} + + try { + var core = mod(coreSnapshotNS); + var windowReady = core.windowReady || coreSnapshotNS.windowReady; + if (windowReady) windowReady(); + } catch (e) {} +}; + +export function createRoot () { + return ReactDOMClient.createRoot.apply(null, arguments); +} + +export function hydrateRoot () { + return ReactDOMClient.hydrateRoot.apply(null, arguments); +} + +export default ReactDOMClient; diff --git a/plugins/SnapshotPlugin/snapshot-mock.js b/plugins/SnapshotPlugin/snapshot-mock.js new file mode 100644 index 0000000..1ede4d7 --- /dev/null +++ b/plugins/SnapshotPlugin/snapshot-mock.js @@ -0,0 +1,29 @@ +/* eslint-disable */ +/* + * snapshot-mock.js + * + * Vite/Rollup-only ESM module. Activates the mock window and primes fbjs BEFORE + * `react-dom/client` is imported — `snapshot-helper-esm.js` imports this module + * first, and ES module evaluation is source-ordered, so this runs to completion + * (mock window installed) before react-dom loads. Under webpack the CJS + * `snapshot-helper.js` does the same via an in-line require order; Rollup hoists + * requires, so the ordering is expressed through ESM imports instead. + */ +import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment'; +import mockWindow from './mock-window.js'; + +// True while building the snapshot (bare V8: no window, not Node). +export const inSnapshot = + typeof window === 'undefined' && + !(typeof global !== 'undefined' && global.process && global.process.versions && global.process.versions.node); + +if (inSnapshot) { + mockWindow.activate(); + ExecutionEnvironment.canUseDOM = true; + ExecutionEnvironment.canUseWorkers = false; + ExecutionEnvironment.canUseEventListeners = true; + ExecutionEnvironment.canUseViewport = true; + ExecutionEnvironment.isInWorker = false; +} + +export {ExecutionEnvironment, mockWindow}; diff --git a/plugins/ViteHtmlPlugin/README.md b/plugins/ViteHtmlPlugin/README.md new file mode 100644 index 0000000..3e2699b --- /dev/null +++ b/plugins/ViteHtmlPlugin/README.md @@ -0,0 +1,34 @@ +# ViteHtmlPlugin + +Vite counterpart to the `HtmlWebpackPlugin` wiring used by `@enact/cli`. Enact +apps have no `index.html`; this plugin synthesizes the document from the same +`.ejs` template the webpack build uses and injects the app entry as an ES module. + +- **Dev server** serves the synthesized document at `/`, letting Vite inject + its HMR client via `transformIndexHtml`. +- **Production build** emits `index.html` referencing the hashed entry chunk + and linking its CSS, honoring the configured `base` public path. + +## Usage + +```js +const {ViteHtmlPlugin} = require('@enact/dev-utils'); + +module.exports = { + plugins: [ + ViteHtmlPlugin({ + entry: '/abs/path/to/combined-entry.js', + title: 'My App', + template: '/abs/path/to/html-template.ejs' + }) + ] +}; +``` + +## Options + +| Option | Type | Description | +| --- | --- | --- | +| `entry` | `string` | Absolute path to the (combined polyfills + app) entry module. | +| `title` | `string` | Document title substituted into the template's `<%= … %>` token. | +| `template` | `string` | Absolute path to an `.ejs`/`.html` template. Falls back to a built-in default when omitted. | diff --git a/plugins/ViteHtmlPlugin/index.js b/plugins/ViteHtmlPlugin/index.js new file mode 100644 index 0000000..3217660 --- /dev/null +++ b/plugins/ViteHtmlPlugin/index.js @@ -0,0 +1,129 @@ +/* eslint-env node, es6 */ +/** + * ViteHtmlPlugin + * + * Vite counterpart to the webpack `HtmlWebpackPlugin` wiring used by @enact/cli. + * Enact apps do not ship an `index.html`; Therefore, default Vite bahaviour cannot be applied + * the document is synthesized from an`.ejs` template (the same one the webpack build uses) with the app entry + * injected as an ES module. + * + * It handles both modes: + * - dev server: serves the synthesized document at `/`, letting Vite inject + * its HMR client via `transformIndexHtml`. + * - production build: emits `index.html` with the hashed entry chunk and its + * CSS linked, honoring the configured `base` public path. + * + * Options: + * entry {string} Absolute path to the (combined) app entry module. + * title {string} Document title. + * template {string} Absolute path to an .ejs/.html template (optional). + */ +const fs = require('fs'); + +// A classic (non-module) head script that runs before any deferred module script. +// Enact's `polyfills.js` and core-js reference the Node `global`, which doesn't +// exist in the browser (webpack supplied it via node-polyfill-webpack-plugin). +// Defining it on the global object makes bare `global` references to be resolved without +// modifying any code. `globalThis === window` on the browser main thread. +const NODE_GLOBALS_SHIM = ''; + +// Render the base document from the template (or a sane default), substituting +// the single `<%= ... %>` title token used by the Enact HTML template, and +// injecting the Node-globals shim into . +function baseHtml(template, title) { + let html; + if (template && fs.existsSync(template)) { + html = fs.readFileSync(template, 'utf8').replace(/<%=[^%]*%>/g, title || ''); + } else { + html = + '' + + '' + + '' + + `${title || ''}
`; + } + return /<\/head>/i.test(html) ? html.replace(/<\/head>/i, `${NODE_GLOBALS_SHIM}`) : NODE_GLOBALS_SHIM + html; +} + +// Dev: reference the source entry through Vite's filesystem endpoint so the +// combined polyfills+app module (and its CSS) is loaded and HMR-tracked. +function injectDev(html, entry) { + const src = '/@fs/' + entry.replace(/\\/g, '/'); + const script = ``; + return html.replace(/<\/body>/i, `${script}`); +} + +// Build: reference the emitted entry chunk and link its CSS. +function injectBuild(html, {scriptFile, cssFiles, base}) { + const prefix = base.endsWith('/') ? base : base + '/'; + const links = cssFiles.map(f => ``).join(''); + const script = ``; + return html.replace(/<\/head>/i, `${links}`).replace(/<\/body>/i, `${script}`); +} + +module.exports = function ViteHtmlPlugin({entry, title = '', template} = {}) { + let base = '/'; + return { + name: 'enact-vite-html', + // SPA so Vite doesn't expect an MPA-style html input. + config() { + return {appType: 'spa'}; + }, + configResolved(resolved) { + base = resolved.base || '/'; + }, + // Dev server: intercept the root request and serve the synthesized document. + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = req.url && req.url.split('?')[0]; + if (url !== '/' && url !== '/index.html') return next(); + try { + let html = injectDev(baseHtml(template, title), entry); + html = await server.transformIndexHtml(req.url, html); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html'); + res.end(html); + } catch (err) { + next(err); + } + }); + }, + // Production build: locate the entry chunk + its CSS and emit index.html. + generateBundle(options, bundle) { + let entryChunk; + const cssFiles = []; + const seen = new Set(); + const addCss = f => { + if (!seen.has(f)) { + seen.add(f); + cssFiles.push(f); + } + }; + + for (const fileName of Object.keys(bundle)) { + const chunk = bundle[fileName]; + if (chunk.type === 'chunk' && chunk.isEntry) { + entryChunk = chunk; + const imported = chunk.viteMetadata && chunk.viteMetadata.importedCss; + if (imported) imported.forEach(addCss); + } + } + // Include any remaining top-level CSS assets (e.g. --no-split-css output). + for (const fileName of Object.keys(bundle)) { + if (bundle[fileName].type === 'asset' && fileName.endsWith('.css')) addCss(fileName); + } + + if (!entryChunk) { + this.warn('ViteHtmlPlugin: no entry chunk found; skipping index.html emission.'); + return; + } + + const html = injectBuild(baseHtml(template, title), { + scriptFile: entryChunk.fileName, + cssFiles, + base + }); + this.emitFile({type: 'asset', fileName: 'index.html', source: html}); + } + }; +}; diff --git a/plugins/ViteILibPlugin/README.md b/plugins/ViteILibPlugin/README.md new file mode 100644 index 0000000..ff90162 --- /dev/null +++ b/plugins/ViteILibPlugin/README.md @@ -0,0 +1,54 @@ +# ViteILibPlugin + +Vite counterpart to the webpack `ILibPlugin`. `@enact/i18n`'s runtime loader is +bundler-agnostic (it fetches locale JSON over HTTP from a set of `ILIB_*` global +constants), so this plugin only needs to define those constants and make the data +available at the URLs they point to. + +- **Constants** (via the `config` hook → `define`): `ILIB_BASE_PATH`, + `ILIB_RESOURCES_PATH`, `ILIB_CACHE_ID`, `ILIB_NO_ASSETS`, per-app/per-theme + `ILIB__PATH`, and optional `ILIB_ADDITIONAL_RESOURCES_PATH` matching the + values the webpack ILibPlugin computes. +- **Data** build: copies the iLib `locale/`, app `resources/`, and theme + `resources/` trees into the output on `writeBundle` (a directory copy, not 6.7k + individual `emitFile` calls). Dev: serves them from their on-disk source via + middleware. +- **Locale filtering** (`locales` option; webpack's `enact pack -l …`) when set, + the `ilibmanifest.json` file lists are trimmed to the requested locales plus + shared (non-locale) data, and a trimmed manifest is emitted/served in place of + the full one. Accepts a preset (`used`/`tv`/`signage`/`webos`/`all`), a `.json` + file, or a comma/newline list; without it the full trees are used. Example: + `-l en-US,ko-KR` trims ~70 MB → ~19 MB. + +The webpack plugin's neutralization of iLib's non-browser platform loaders is +handled separately in `@enact/cli`'s `vite.config.js` (`ILIB_LOADER_RE` stub). + +## Usage + +```js +const {ViteILibPlugin} = require('@enact/dev-utils'); + +module.exports = { + plugins: [ + ViteILibPlugin({ + context: appContext, // app root (defaults to cwd) + publicPath: '/', // matches Vite `base` + ilibAdditionalResourcesPath // optional + }) + ] +}; +``` + +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `context` | `process.cwd()` | App root; output paths are computed relative to it. | +| `ilib` | auto-detected | iLib base package path (`@enact/i18n/ilib` or `ilib`). | +| `resources` | `'resources'` | App resources directory. | +| `publicPath` | `'/'` | Public base URL; should match Vite `base`. | +| `symlinks` | `true` | Resolve symlinked packages to their real path. | +| `emit` | `true` | Copy/serve the data. When `false`, sets `ILIB_NO_ASSETS`. | +| `cacheId` | timestamp | Value for `ILIB_CACHE_ID`. | +| `ilibAdditionalResourcesPath` | — | Extra runtime resource path. | +| `locales` | — | Locale-filter target (`-l`): preset / `.json` / comma list. When set, only the matching locale data (plus shared data) is emitted/served. | diff --git a/plugins/ViteILibPlugin/index.js b/plugins/ViteILibPlugin/index.js new file mode 100644 index 0000000..33d4fa0 --- /dev/null +++ b/plugins/ViteILibPlugin/index.js @@ -0,0 +1,222 @@ +/* eslint-env node, es6 */ +/** + * ViteILibPlugin + * + * Vite counterpart to the webpack `ILibPlugin`. `@enact/i18n`'s runtime loader + * (`Loader.js`) is bundler-agnostic. It reads a set of `ILIB_*` global constants + * for the base URLs of iLib locale data / app `resources` / theme bundles, then + * fetches the JSON over HTTP. So this plugin only needs to: + * + * 1. `define` those `ILIB_*` constants (via the `config` hook), matching the + * values the webpack ILibPlugin computes, for the Rollup build and, via + * `optimizeDeps.esbuildOptions.define`, the dev-server pre-bundled deps. + * 2. Make the referenced data available at those URLs: + * - build → copy the source trees into the output on `writeBundle`. + * - dev → serve them from their on-disk source via middleware. + * + * Locale filtering (webpack's `enact pack -l …`) is supported via the `locales` + * option: when set, the iLib/resource `ilibmanifest.json` file lists are trimmed + * to the requested locales (plus shared, non-locale data), and a trimmed manifest + * is emitted/served in place of the full one. Without it, the full trees are + * copied/served (matching an unfiltered webpack build). + * + * The webpack plugin's neutralization of iLib's non-browser platform loaders is + * handled separately in `@enact/cli`'s `vite.config.js` (`ILIB_LOADER_RE` stub). + */ +const path = require('path'); +const fs = require('fs'); +const app = require('../../option-parser'); +// Shared with PrerenderPlugin (webpack `--isomorphic`) — dependency-free locale +// resolution, so reusing it here does not pull webpack into the Vite path. +const {parseLocales} = require('../PrerenderPlugin/parse-locales'); +// Path/constant helpers shared with ILibPlugin (webpack) — dependency-free. +const {transformPath, bundleConst, packageSearch} = require('../ILibPlugin/ilib-paths'); + +// Join URL segments with single slashes, preserving a leading slash. +function joinUrl(...parts) { + return parts + .filter(p => p != null && p !== '') + .join('/') + .replace(/([^:])\/{2,}/g, '$1/') + .replace(/^\/{2,}/, '/'); +} + +// True if two slash-separated locale paths lie on the same root-to-leaf lineage. +function sameLineage(a, b) { + const as = a.split('/'); + const bs = b.split('/'); + const n = Math.min(as.length, bs.length); + for (let i = 0; i < n; i++) { + if (as[i] !== bs[i]) return false; + } + return true; +} + +module.exports = function ViteILibPlugin(options = {}) { + const opts = Object.assign({}, options); + const context = opts.context || process.cwd(); + const symlinks = opts.symlinks !== false; + const publicPath = opts.publicPath || '/'; + const emit = opts.emit !== false; + + // Locale filtering (webpack `-l`). Null = no filtering (emit everything). + const locales = opts.locales ? parseLocales(context, opts.locales) : null; + const allowedLocales = locales ? locales.map(l => l.replace(/-/g, '/')) : null; + + // Keep a manifest file if it's shared (non-locale) data, or its locale + // directory lies on the lineage of a requested locale. A locale directory has a + // 2-3 char lowercase language code as its first segment; the few 3-char iLib + // data dirs that would collide (nfc/nfd) and the `und` fallback are shared. + // Longer data dirs (zoneinfo, charmaps, charset, ctype, scripts, nfkc, nfkd) + // are excluded by the length test. + const SHARED_TOP = new Set(['nfc', 'nfd', 'und']); + function fileAllowed(relFile) { + if (!allowedLocales) return true; + const dir = path.dirname(relFile).replace(/\\/g, '/'); + if (dir === '.' || dir === '') return true; // root files (shared) + const first = dir.split('/')[0]; + if (SHARED_TOP.has(first) || !/^[a-z]{2,3}$/.test(first)) return true; // shared data + return allowedLocales.some(loc => sameLineage(dir, loc)); + } + + // Resolve the iLib base package (with @enact/i18n/ilib backward-compat), matching ILibPlugin. + const ilibRel = + opts.ilib || + process.env.ILIB_BASE_PATH || + packageSearch(context, path.join('@enact', 'i18n', 'ilib')) || + packageSearch(context, 'ilib'); + const resourcesRel = opts.resources || 'resources'; + + const defined = {}; + // {urlDir, srcDir, files} — files is a filtered manifest list, or null for a full copy. + const assets = []; + + function addBundle(name, dirRel, subdir, loaderAppendsSubdir) { + if (!dirRel) return; + let abs = path.isAbsolute(dirRel) ? dirRel : path.join(context, dirRel); + if (symlinks && fs.existsSync(abs)) abs = fs.realpathSync(abs); + const rel = transformPath(context, abs); + const srcDir = subdir ? path.join(abs, subdir) : abs; + const urlDir = subdir ? joinUrl(rel, subdir) : rel; + // The constant must point at the directory the runtime loader fetches from. + // For theme/app resources that is the served data dir (urlDir). For the iLib + // *base*, the loader appends the `locale/` subdir itself, so the constant + // points at the package dir (rel) instead. + const url = joinUrl(publicPath, loaderAppendsSubdir ? rel : urlDir); + if (name) defined[name] = JSON.stringify(url); + if (emit && fs.existsSync(srcDir)) { + let files = null; + if (allowedLocales) { + const manifest = path.join(srcDir, 'ilibmanifest.json'); + if (fs.existsSync(manifest)) { + try { + const all = JSON.parse(fs.readFileSync(manifest, {encoding: 'utf8'})).files || []; + files = all.filter(fileAllowed); + } catch (e) { + files = null; + } + } + } + assets.push({urlDir, srcDir, files}); + } + return url; + } + + // iLib base data lives under /locale; ILIB_BASE_PATH points at + // (the loader appends `locale/` itself). + addBundle('ILIB_BASE_PATH', ilibRel, 'locale', true); + // App resources. + const resourcesUrl = addBundle('ILIB_RESOURCES_PATH', resourcesRel); + // Per-app + per-theme bundle path constants (theme resources supply locale data too). + defined[bundleConst(app.name)] = JSON.stringify(resourcesUrl); + let pkgDir = context; + for (let t = app.theme; t; t = t.theme) { + const themeDir = packageSearch(pkgDir, t.name); + if (themeDir) { + pkgDir = themeDir; + addBundle(bundleConst(t.name), themeDir, 'resources'); + } + } + + defined.ILIB_CACHE_ID = JSON.stringify(String(opts.cacheId || 'enact-ilib-' + Date.now())); + defined.ILIB_NO_ASSETS = JSON.stringify(!emit); + if (opts.ilibAdditionalResourcesPath) { + defined.ILIB_ADDITIONAL_RESOURCES_PATH = JSON.stringify(opts.ilibAdditionalResourcesPath); + } + + let resolvedBase = publicPath; + + return { + name: 'enact-vite-ilib', + // Inject the ILIB_* constants as build-time defines. `define` covers the + // Rollup build and live-transformed dev modules; `optimizeDeps.esbuildOptions + // .define` is required additionally so the constants also reach dev-server + // pre-bundled dependencies (e.g. @enact/i18n's Loader) — Vite does not apply + // `config.define` to the dep optimizer for arbitrary global constants. + config() { + return { + define: defined, + optimizeDeps: {esbuildOptions: {define: defined}} + }; + }, + configResolved(resolved) { + resolvedBase = resolved.base || publicPath; + }, + // Build: copy the data into the output — the filtered file set (with a trimmed + // manifest) when locale filtering is active, otherwise the whole tree. + writeBundle(outputOptions) { + const outDir = outputOptions.dir || (outputOptions.file && path.dirname(outputOptions.file)); + if (!outDir) return; + for (const {urlDir, srcDir, files} of assets) { + const dest = path.join(outDir, urlDir); + try { + if (files) { + fs.mkdirSync(dest, {recursive: true}); + for (const f of files) { + const s = path.join(srcDir, f); + const d = path.join(dest, f); + if (fs.existsSync(s)) { + fs.mkdirSync(path.dirname(d), {recursive: true}); + fs.copyFileSync(s, d); + } + } + fs.writeFileSync(path.join(dest, 'ilibmanifest.json'), JSON.stringify({files}, null, '\t')); + } else { + fs.cpSync(srcDir, dest, {recursive: true}); + } + } catch (e) { + this.warn(`ViteILibPlugin: failed to copy iLib data ${srcDir} -> ${dest}: ${e.message}`); + } + } + }, + // Dev: serve the data from its on-disk source; when filtering, serve the + // trimmed manifest and only allow the kept files. + configureServer(server) { + const routes = assets.map(({urlDir, srcDir, files}) => ({ + prefix: joinUrl(resolvedBase, urlDir).replace(/\/?$/, '/'), + srcDir, + allowed: files ? new Set(files.map(f => f.replace(/\\/g, '/'))) : null, + manifest: files ? JSON.stringify({files}, null, '\t') : null + })); + server.middlewares.use((req, res, next) => { + const url = decodeURIComponent((req.url || '').split('?')[0]); + const route = routes.find(r => url.startsWith(r.prefix)); + if (!route) return next(); + const rel = url.slice(route.prefix.length); + if (route.manifest && rel === 'ilibmanifest.json') { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + return res.end(route.manifest); + } + if (route.allowed && !route.allowed.has(rel)) return next(); + const file = path.join(route.srcDir, rel); + if (!file.startsWith(route.srcDir) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + return next(); + } + res.statusCode = 200; + res.setHeader('Content-Type', file.endsWith('.json') ? 'application/json' : 'application/octet-stream'); + fs.createReadStream(file).pipe(res); + }); + } + }; +}; diff --git a/plugins/ViteWebOSMetaPlugin/README.md b/plugins/ViteWebOSMetaPlugin/README.md new file mode 100644 index 0000000..429bc17 --- /dev/null +++ b/plugins/ViteWebOSMetaPlugin/README.md @@ -0,0 +1,43 @@ +# ViteWebOSMetaPlugin + +Vite counterpart to the webpack `WebOSMetaPlugin`. Discovers the root +`appinfo.json` (project root or `./webos-meta/`) and any localized +`resources/**/appinfo.json`, and makes them — plus the image assets they +reference (`icon`, `largeIcon`, `splashBackground`, …). + +- **build** — writes the appinfo file(s) and copies referenced assets into the + output on `writeBundle`. +- **dev** — serves them from computed content / on-disk source via middleware. + +The `` fallback (use `appinfo.title` when no app/theme title is set) is +applied in `@enact/cli`'s `vite.config.js` via the static +`ViteWebOSMetaPlugin.readTitle(context)` helper, since `ViteHtmlPlugin` owns the +HTML document. + +## Usage + +```js +const {ViteWebOSMetaPlugin} = require('@enact/dev-utils'); + +module.exports = { + plugins: [ViteWebOSMetaPlugin({context: appContext, publicPath: '/'})] +}; +``` + +## Options + +| Option | Default | Description | +| --- | --- | --- | +| `context` | `process.cwd()` | App root. | +| `path` | — | Explicit directory to search for `appinfo.json` first. | +| `publicPath` | `'/'` | Public base URL; should match Vite `base`. | + +## System assets (`$`-prefixed) + +An appinfo value starting with `$` (e.g. `"icon": "$icon.png"`) is a **system +asset**: the real file lives in a variable spec directory under `sys-assets/` +(`sys-assets/HD720/icon.png`, `sys-assets/HD1080/icon.png`, …). For each spec that +has the file, the plugin emits it preserving the `sys-assets/<spec>/` layout and +**leaves the appinfo value unchanged** — the platform resolves `$` to the active +spec at runtime. `sysAssetsBasePath` in appinfo overrides the `sys-assets` base. +Matches the webpack `WebOSMetaPlugin` behavior. diff --git a/plugins/ViteWebOSMetaPlugin/index.js b/plugins/ViteWebOSMetaPlugin/index.js new file mode 100644 index 0000000..cbfa216 --- /dev/null +++ b/plugins/ViteWebOSMetaPlugin/index.js @@ -0,0 +1,172 @@ +/* eslint-env node, es6 */ +/** + * ViteWebOSMetaPlugin + * + * Vite counterpart to the webpack `WebOSMetaPlugin`. Discovers the root + * `appinfo.json` (in the project root or `./webos-meta/`) plus any localized + * `resources/**\/appinfo.json`, and makes them — together with the image assets + * they reference (icons, splash, etc.): + * - build → written/copied into the output on `writeBundle`. + * - dev → served from computed content / on-disk source via middleware. + * + * The document `<title>` fallback (use appinfo.title when no app title is set) is + * handled in `vite.config.js` where `ViteHtmlPlugin` gets its title, since that + * plugin owns the HTML document. + * + * `$`-prefixed system assets (`$icon.png` → sys-assets/<spec>/icon.png) are + * emitted preserving their `sys-assets/<spec>/` layout; the appinfo value is left + * untouched (the platform resolves `$` to the active spec at runtime). + * + * Options: {context, path (explicit appinfo dir), publicPath} + */ +const fs = require('fs'); +const path = require('path'); +const glob = require('fast-glob'); +// appinfo helpers shared with WebOSMetaPlugin (webpack) — dependency-free. +const {props: ASSET_PROPS, readAppInfo, rootAppInfo} = require('../WebOSMetaPlugin/appinfo'); + +const MIME = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.json': 'application/json' +}; + +function joinUrl(...parts) { + return parts + .filter(p => p != null && p !== '') + .join('/') + .replace(/([^:])\/{2,}/g, '$1/') + .replace(/^\/{2,}/, '/'); +} + +// Read the appinfo title (root) for use as an HTML <title> fallback. +ViteWebOSMetaPlugin.readTitle = function (context, specific) { + const meta = rootAppInfo(context, specific); + return meta && meta.obj && meta.obj.title; +}; + +function ViteWebOSMetaPlugin(options = {}) { + const context = options.context || process.cwd(); + const scan = options.path; + const publicPath = options.publicPath || '/'; + + // Output entries: {name: output-relative path, src?: source file, source?: string content}. + const outputs = []; + const seenAssets = new Set(); + + function addAsset(name, src) { + // De-dupe by output name — sys-assets are shared across locales/props. + if (seenAssets.has(name)) return; + seenAssets.add(name); + outputs.push({name, src}); + } + + // System assets: appinfo values starting with '$' refer to a file within a + // variable spec directory (`$icon.png` → sys-assets/<spec>/icon.png, where + // <spec> is 'HD720', 'HD1080', etc.). `sysAssetsBasePath` overrides the base. + let sysAssetsPath = 'sys-assets'; + let variableSysPaths = null; + + function loadSysAssetDirs(appinfo) { + // Honor a per-appinfo sysAssetsBasePath override, then list the spec subdirs. + if (appinfo.sysAssetsBasePath && appinfo.sysAssetsBasePath !== sysAssetsPath) { + sysAssetsPath = appinfo.sysAssetsBasePath; + variableSysPaths = null; + } + if (!variableSysPaths) { + const base = path.join(context, sysAssetsPath); + variableSysPaths = fs.existsSync(base) + ? fs + .readdirSync(base) + .map(name => path.join(base, name)) + .filter(p => fs.statSync(p).isDirectory()) + : []; + } + } + + function detectSysAssets(val) { + // Every `<spec>/<name>` (name minus the leading '$') that exists on disk. + const trueName = val.substring(1); + return variableSysPaths.map(dir => path.resolve(dir, trueName)).filter(abs => fs.existsSync(abs)); + } + + function addAssets(metaDir, outDir, appinfo) { + for (const prop of ASSET_PROPS) { + const val = appinfo[prop]; + if (!val) continue; + if (val.charAt(0) === '$') { + loadSysAssetDirs(appinfo); + for (const abs of detectSysAssets(val)) { + // Output name relative to context → preserves the sys-assets/<spec>/ path. + addAsset(path.relative(context, abs).replace(/\\/g, '/'), abs); + } + } else { + const src = path.resolve(metaDir, val); + if (fs.existsSync(src)) { + addAsset(joinUrl(outDir, val), src); + } + } + } + } + + // Root appinfo + its assets. + const meta = rootAppInfo(context, scan); + if (meta && meta.obj) { + addAssets(meta.path, '', meta.obj); + outputs.push({name: 'appinfo.json', source: JSON.stringify(meta.obj, null, '\t')}); + } + + // Localized appinfo files under resources/ + their assets. + const localized = glob.sync('resources/**/appinfo.json', {cwd: context, onlyFiles: true}); + for (const rel of localized) { + const file = path.join(context, rel); + const locMeta = readAppInfo(file); + if (locMeta) { + addAssets(path.dirname(file), path.dirname(rel), locMeta); + outputs.push({name: rel.replace(/\\/g, '/'), source: JSON.stringify(locMeta, null, '\t')}); + } + } + + let resolvedBase = publicPath; + + return { + name: 'enact-vite-webosmeta', + configResolved(resolved) { + resolvedBase = resolved.base || publicPath; + }, + // Build: write appinfo.json(s) and copy their referenced assets into the output. + writeBundle(outputOptions) { + const outDir = outputOptions.dir || (outputOptions.file && path.dirname(outputOptions.file)); + if (!outDir) return; + for (const o of outputs) { + const dest = path.join(outDir, o.name); + try { + fs.mkdirSync(path.dirname(dest), {recursive: true}); + if (o.src) fs.copyFileSync(o.src, dest); + else fs.writeFileSync(dest, o.source); + } catch (e) { + this.warn(`ViteWebOSMetaPlugin: failed to emit ${o.name}: ${e.message}`); + } + } + }, + // Dev: serve the appinfo files and assets from computed content / source. + configureServer(server) { + const routes = new Map(); + for (const o of outputs) routes.set(joinUrl(resolvedBase, o.name), o); + server.middlewares.use((req, res, next) => { + const url = decodeURIComponent((req.url || '').split('?')[0]); + const o = routes.get(url); + if (!o) return next(); + res.statusCode = 200; + res.setHeader('Content-Type', MIME[path.extname(o.name).toLowerCase()] || 'application/octet-stream'); + if (o.src) fs.createReadStream(o.src).pipe(res); + else res.end(o.source); + }); + } + }; +} + +module.exports = ViteWebOSMetaPlugin; diff --git a/plugins/WebOSMetaPlugin/appinfo.js b/plugins/WebOSMetaPlugin/appinfo.js new file mode 100644 index 0000000..641e902 --- /dev/null +++ b/plugins/WebOSMetaPlugin/appinfo.js @@ -0,0 +1,60 @@ +/* eslint-env node, es6 */ +/** + * appinfo + * + * appinfo.json helpers shared by `WebOSMetaPlugin` (webpack) and + * `ViteWebOSMetaPlugin` (Vite). Kept dependency-free (only `fs`/`path`) so the + * Vite path can reuse them without pulling webpack, tapable, or fast-glob (which + * `WebOSMetaPlugin/index.js` requires at module load). + */ +const fs = require('fs'); +const path = require('path'); + +// List of asset-pointing appinfo properties. +const props = [ + 'icon', + 'largeIcon', + 'extraLargeIcon', + 'miniicon', + 'smallicon', + 'splashicon', + 'splashBackground', + 'bgImage', + 'imageForRecents' +]; + +function readAppInfo(file) { + // Read and parse appinfo.json file if it exists. + if (fs.existsSync(file)) { + try { + const meta = JSON.parse(fs.readFileSync(file, {encoding: 'utf8'})); + return meta; + } catch (e) { + console.log('ERROR: unable to read/parse appinfo.json at ' + file); + } + } +} + +function rootAppInfo(context, specific) { + // The accepted root locations to search for the appinfo.json and its relative + // assets are project root or ./webos-meta. + const rootDir = [context, path.join(context, './webos-meta')]; + // If a specific path is requested, prepend it to the search list + if (specific) { + if (path.isAbsolute(specific)) { + rootDir.unshift(specific); + } else { + rootDir.unshift(path.join(context, specific)); + } + } + // Check each search location, and if found, return the data and path it was found at. + let meta; + for (let i = 0; i < rootDir.length; i++) { + meta = readAppInfo(path.join(rootDir[i], 'appinfo.json')); + if (meta) { + return {path: rootDir[i], obj: meta}; + } + } +} + +module.exports = {props, readAppInfo, rootAppInfo}; diff --git a/plugins/WebOSMetaPlugin/index.js b/plugins/WebOSMetaPlugin/index.js index 1ba9ead..87913e1 100644 --- a/plugins/WebOSMetaPlugin/index.js +++ b/plugins/WebOSMetaPlugin/index.js @@ -3,19 +3,8 @@ const path = require('path'); const glob = require('fast-glob'); const {SyncWaterfallHook} = require('tapable'); const {Compilation, sources} = require('webpack'); +const {props, readAppInfo, rootAppInfo} = require('./appinfo'); -// List of asset-pointing appinfo properties. -const props = [ - 'icon', - 'largeIcon', - 'extraLargeIcon', - 'miniicon', - 'smallicon', - 'splashicon', - 'splashBackground', - 'bgImage', - 'imageForRecents' -]; // System assets starting with '$' are dynamic and will be within a variable // directory within sysAssetsPath to denote system spec ('HD720', 'HD1080', etc.). let sysAssetsPath = 'sys-assets'; @@ -25,18 +14,6 @@ let variableSysPaths = null; // share assets. const assetPathCache = {}; -function readAppInfo(file) { - // Read and parse appinfo.json file if it exists. - if (fs.existsSync(file)) { - try { - const meta = JSON.parse(fs.readFileSync(file, {encoding: 'utf8'})); - return meta; - } catch (e) { - console.log('ERROR: unable to read/parse appinfo.json at ' + file); - } - } -} - function handleSysAssetPath(context, appinfo) { // If the sysAsset base is specified, override the default one if (appinfo.sysAssetsBasePath && appinfo.sysAssetsBasePath !== sysAssetsPath) { @@ -72,28 +49,6 @@ function detectSysAssets(name) { return result; } -function rootAppInfo(context, specific) { - // The accepted root locations to search for the appinfo.json and its relative - // assets are project root or ./webos-meta. - const rootDir = [context, path.join(context, './webos-meta')]; - // If a specific path is requested, prepend it to the search list - if (specific) { - if (path.isAbsolute(specific)) { - rootDir.unshift(specific); - } else { - rootDir.unshift(path.join(context, specific)); - } - } - // Check each search location, and if found, return the data and path it was found at. - let meta; - for (let i = 0; i < rootDir.length; i++) { - meta = readAppInfo(path.join(rootDir[i], 'appinfo.json')); - if (meta) { - return {path: rootDir[i], obj: meta}; - } - } -} - function addMetaAssets(metaDir, outDir, appinfo, compilation) { // For each appinfo.json property that contains a webos meta asset, resolve that asset, // and add its data to the compilation assets array.