apostrophe
Version:
The Apostrophe Content Management System.
600 lines (541 loc) • 21.8 kB
JavaScript
const fs = require('fs-extra');
const path = require('node:path');
const util = require('node:util');
const { glob } = require('../../lib/path');
const { getBuildExtensions, fillExtraBundles } = require('./utils');
const Concat = require('concat-with-sourcemaps');
// Internal build interface.
module.exports = (self) => {
return {
// Compute the configuration provided per module as a `build` property.
// It has the same shape as the legacy `webpack` property. The difference
// is that the `build` property now supports different "vendors". An upgrade
// path would be moving existing `webpack` configurations to
// `build.webpack`. However, we keep the legacy `webpack` property for
// compatibility reasons. Only external build modules will consume the
// `build` property. Uses the public API `getRegisteredModules()` to get the
// cached list of modules.
async setBuildExtensions() {
self.moduleBuildExtensions = await getBuildExtensions({
getMetadata: self.apos.synth.getMetadata,
modulesToInstantiate: self.getRegisteredModules(),
rebundleModulesConfig: self.options.rebundleModules
});
},
// Ensure the namespaced by alias `moduleBuildExtensions` data is available
// for the existing systems (BC).
// Generate the entrypoints configuration - the new format for the build
// module, describing the entrypoint configuration, including the extra
// bundles. Pass evnironment variable `APOS_ASSET_DEBUG=1` to see the debug
// output in both external build and the legacy webpack mode. See the
// `getBuildEntrypoints()` method for the entrypoint configuration schema.
setBuildExtensionsForExternalModule() {
if (!self.hasBuildModule()) {
return;
}
const {
extensions = {},
extensionOptions = {},
verifiedBundles = {},
rebundleModules = []
} = self.moduleBuildExtensions[self.getBuildModuleAlias()] ?? {};
self.extraBundles = fillExtraBundles(verifiedBundles);
self.verifiedBundles = verifiedBundles;
self.rebundleModules = rebundleModules;
self.extraExtensions = extensions;
self.extraExtensionOptions = extensionOptions;
// Generate files, write bundles based on those options, make it much more
// abstract and extendable.
// Generate the entrypoints configuration.
const entrypoints = [];
for (const [ name, config ] of Object.entries(self.builds)) {
// 1. Transform the core configuration, more abstract, standard format.
const enhancedConfig = {
name,
type: config.type,
label: config.label,
scenes: config.scenes,
inputs: config.inputs,
outputs: config.outputs,
condition: config.condition,
prologue: config.prologue,
ignoreSources: [],
sources: {
js: [],
scss: []
}
};
entrypoints.push(enhancedConfig);
// 2. Add the extra bundles as separate "virtual" configuration entries,
// similar to the core ones, positioned after the `index` entry.
// Manage the extraFiles and ignoredModules arrays of the `index` entry.
// Add the extensions configuration to the `index` entry.
if (enhancedConfig.type === 'index') {
enhancedConfig.extensions = extensions;
for (const [ bundleName, bundleConfig ] of Object.entries(verifiedBundles)) {
// 2.1. Add extra files to the index bundle.
if (bundleConfig.main) {
enhancedConfig.sources.js.push(...bundleConfig.js);
enhancedConfig.sources.scss.push(...bundleConfig.scss);
}
// 2.2. Exclude sources from the index bundle.
if (!bundleConfig.main && bundleConfig.withIndex) {
enhancedConfig.ignoreSources.push(...bundleConfig.js);
enhancedConfig.ignoreSources.push(...bundleConfig.scss);
}
// 2.3. Add the extra bundle configuration so that
// it only processes the configured `sources`
if (!bundleConfig.main) {
entrypoints.push({
name: bundleName,
type: 'custom',
label: `Extra bundle: ${bundleName}`,
scenes: [ bundleName ],
inputs: enhancedConfig.inputs,
outputs: enhancedConfig.outputs,
condition: enhancedConfig.condition,
prologue: '',
ignoreSources: [],
sources: {
js: bundleConfig.js,
scss: bundleConfig.scss
}
});
}
}
}
}
self.moduleBuildEntrypoints = entrypoints;
self.printDebug('setBuildExtensionsForExternalModule', {
moduleBuildEntrypoints: self.moduleBuildEntrypoints
});
},
// Get the entrypoints containing manifest data currently initialized. The
// information is available after the build initialization is done: - after
// an actual build task (any environment) - after the dev server is started
// (development) - after a saved build manifest is loaded (production)
getCurrentBuildEntrypoints() {
return self.currentBuildManifest.entrypoints ?? [];
},
// Get the component name from a file path. The `enumerate` option allows
// to append a number to the component name.
getComponentNameByPath(componentPath, { enumerate } = {}) {
const result = path
.basename(componentPath)
.replace(/-/g, '_')
.replace(/\s+/g, '')
.replace(/\.\w+/, '') + (typeof enumerate === 'number' ? `_${enumerate}` : '');
return result;
},
// Return the reported by the external module during build dev server URL.
// It is set after the build is performed or a manifest is loaded.
getDevServerUrl() {
return self.currentBuildManifest.devServerUrl;
},
// Retrieve only existing `/ui` paths for local and npm symlinked modules.
// Modules is usually the rsult of `self.getRegisteredModules()`.
async computeWatchMeta(modules) {
const meta = (await self.computeSourceMeta({
modules,
stats: true
}))
.filter(entry => {
return (!entry.npm && entry.exists) || (entry.npm && entry.symlink);
});
return meta;
},
// Compute the list of watch folders based on the registered modules.
async computeWatchFolders() {
return (await self.computeWatchMeta(self.getRegisteredModules()))
.map(entry => entry.dirname);
},
// Saves the build manifest to disk. Also adds `bundles` to the manifest
// if available as entrypoint information.
// See the manifest section in `configureBuildModule()` method docs for
// more information.
async saveBuildManifest(manifest) {
const {
entrypoints, ts, devServerUrl, hmrTypes
} = manifest;
const content = [];
for (const entrypoint of entrypoints) {
const {
manifest, name, bundles
} = entrypoint;
if (!manifest) {
continue;
}
content.push({
...manifest,
name,
bundles: Array.from(bundles ?? [])
});
}
const current = await self.loadSavedBuildManifest(true);
await fs.outputJson(
path.join(self.getBundleRootDir(), '.manifest.json'),
{
ts: ts || current.ts,
devServerUrl,
hmrTypes,
manifest: content
}
);
},
// Called by the asset build process to compute the bundle data, write
// `-bundle` files, enhance the entrypoints with a `bundles` property, and
// return a list of all bundle files.
//
// The `bundles` (Set) property added to the entrypoints configuration
// contains the bundle files used later when injecting the scripts and
// stylesheets in the browser. The `metadata` is the return value of the
// external build module build method (see `self.build()` and
// `configureBuildModule()`).
async computeBuildScenes(metadata, { write = true } = {}) {
const needSourceMap = self.options.productionSourceMaps;
const bundlePath = self.getBundleRootDir();
const buildRoot = self.getBuildRootDir();
const { entrypoints } = metadata;
const configs = entrypoints.filter((entry) => !!entry.manifest);
const scenes = [
...new Set(configs.reduce((acc, { scenes }) => [ ...acc, ...scenes ], []))
];
const bundles = scenes.reduce((acc, scene) => {
const sceneConfigs = configs.filter((config) => config.scenes.includes(scene));
acc.push(
...writeScene({
configs: sceneConfigs,
scene,
bundlePath
})
);
return acc;
}, []);
return bundles;
function writeScene({
scene, configs, bundlePath
}) {
const bundles = configs.reduce((acc, config) => {
const { root, files } = config.manifest;
// const jsTargetName = `${scene}-${config.condition ??
// 'module'}-bundle.js`; Combining script type modules is a bad idea.
// We need to load them per entrypoint and not a scene.
const prefix = config.name === scene ? scene : `${scene}-${config.name}`;
const jsTargetName = `${prefix}-${config.condition ?? 'module'}-bundle.js`;
// CSS bundles are always scene based.
const cssTargetName = `${scene}-bundle.css`;
const jsFilePaths = files.js?.map(f => path.join(buildRoot, root, f)) ?? [];
const cssFilePaths = files.css?.map(f => path.join(buildRoot, root, f)) ?? [];
if (jsFilePaths.length) {
config.bundles = config.bundles || new Set();
config.bundles.add(jsTargetName);
acc[jsTargetName] = acc[jsTargetName] || [];
acc[jsTargetName].push(...jsFilePaths);
}
if (cssFilePaths.length) {
config.bundles = config.bundles || new Set();
config.bundles.add(cssTargetName);
acc[cssTargetName] = acc[cssTargetName] || [];
acc[cssTargetName].push(...cssFilePaths);
}
return acc;
}, {});
if (!write) {
return Object.keys(bundles);
}
for (const [ target, files ] of Object.entries(bundles)) {
let content = null;
let sourceMap = null;
if (!files.length) {
delete bundles[target];
continue;
}
const filePath = path.join(bundlePath, target);
const fileName = filePath.split('/').at(-1);
if (needSourceMap) {
// Concatenate in a way that preserves sitemaps
const concat = new Concat(true, bundlePath, '\n');
for (const file of files) {
const map = `${file}.map`;
// concat-with-sourcemaps does not strip old sourcemap comments for us
const source = stripSourceMapComment(fs.readFileSync(file, 'utf8'));
if (!fs.existsSync(map)) {
concat.add(
file,
source
);
} else {
concat.add(
file,
source,
// Per docs for concat-source-maps: this one should be read as a string
fs.readFileSync(map, 'utf8')
);
}
}
content = concat.content.toString('utf8') + `\n//# sourceMappingURL=${fileName}.map\n`;
sourceMap = concat.sourceMap;
} else {
content = files.map(f => fs.readFileSync(f, 'utf-8'))
.join('\n')
.trim();
}
if (!content.trim().length) {
delete bundles[target];
continue;
}
fs.writeFileSync(
filePath,
content
);
if (sourceMap != null) {
const dir = self.options.productionSourceMapsDir;
const sourceMapPath = dir ? `${dir}/${fileName}.map` : `${filePath}.map`;
fs.writeFileSync(
sourceMapPath,
sourceMap
);
}
}
return Object.keys(bundles);
}
},
// Accepts the result of the external build module build method (see
// `self.build()` and `configureBuildModule()`), creates a list of all files
// that need to be copied from the build (`apos-build`) to the bundle
// (`public/apos-frontent`) directory based on the manifest data available,
// copies them, and returns the list.
async copyBuildArtefacts(metadata) {
const buildRoot = self.getBuildRootDir();
const bundleRoot = self.getBundleRootDir();
const { entrypoints } = metadata;
const result = [];
const seen = {};
for (const entrypoint of entrypoints) {
const { manifest } = entrypoint;
if (!manifest) {
continue;
}
const { root, files } = manifest;
const {
imports = [], assets = [], dynamicImports = []
} = files;
for (const file of [ ...imports, ...dynamicImports, ...assets ]) {
if (seen[file]) {
continue;
}
await copy(file, root);
seen[file] = true;
}
}
async function copy(file, root) {
const from = path.join(buildRoot, root, file);
const to = path.join(bundleRoot, file);
const base = path.dirname(to);
await fs.mkdirp(base);
await fs.copyFile(from, to);
result.push(file);
}
return result;
},
// Copies source maps from the build directory to the bundle directory,
// preserving the directory structure.
async copyBuildSourceMaps(manifest) {
if (!manifest.sourceMapsRoot) {
return [];
}
const bundleRoot = self.getBundleRootDir();
const result = [];
const sourceMaps = await glob('**/*.map', {
cwd: manifest.sourceMapsRoot,
nodir: true,
follow: false,
absolute: false
});
for (const file of sourceMaps) {
const from = path.join(manifest.sourceMapsRoot, file);
const to = path.join(bundleRoot, file);
const base = path.dirname(to);
await fs.mkdirp(base);
await fs.copyFile(from, to);
result.push(file);
}
return result;
},
// Copy a `folder` (if exists) from any existing module to the `target`
// directory. The `modules` option is usually the result of
// `self.getRegisteredModules()`. It's not resolved internally to avoid
// overhead (it's not cheap). The caller is responsible for resolving and
// caching the modules list. `target` is the absolute path to the target
// directory. Usage: const modules = self.getRegisteredModules(); const
// copied = await self.copyModulesFolder({ target: '/path/to/build', folder:
// 'public', modules }); Returns an array of objects with the following
// properties: - `name`: the module name. - `source`: the absolute path to
// the source directory. - `target`: the absolute path to the target
// directory.
async copyModulesFolder({
target, folder, modules
}) {
await fs.remove(target);
await fs.mkdirp(target);
let names = {};
const directories = {};
const result = [];
// Most other modules are not actually instantiated yet, but
// we can access their metadata, which is sufficient
for (const name of modules) {
const ancestorDirectories = [];
const metadata = await self.apos.synth.getMetadata(name);
for (const entry of metadata.__meta.chain) {
const effectiveName = entry.my
? entry.name
.replace('/my-', '/')
.replace(/^my-/, '')
: entry.name;
names[effectiveName] = true;
ancestorDirectories.push(entry.dirname);
directories[effectiveName] = directories[effectiveName] || [];
for (const dir of ancestorDirectories) {
if (!directories[effectiveName].includes(dir)) {
directories[effectiveName].push(dir);
}
}
}
}
names = Object.keys(names);
for (const name of names) {
const moduleDir = `${target}/${name}`;
for (const dir of directories[name]) {
const srcDir = `${dir}/${folder}`;
if (fs.existsSync(srcDir)) {
await fs.copy(srcDir, moduleDir);
result.push({
name,
source: srcDir,
target: moduleDir
});
}
}
}
return result;
},
// Generate the browser script/stylesheet import markup for a scene based
// on the available manifest data and environmnent. The `scene` argument is
// the scene name (`apos` or `public`), and the `output` argument is the
// output type - `js` or `css`. The `modulePreload` argument is a Set that
// might be provided by the caller to collect the unique list of module
// preload links (this method is called multiple times).
getBundlePageMarkup({
scene, output, modulePreload = new Set()
}) {
let entrypoints;
// CSS is special (as always!). In HMR mode, we want to serve ONLY
// the CSS that is not HMRed (because we run either `apos` or `public`
// dev server). We filter it no matter the output, because `apos` type
// doesn't have `css` in its `output property. This is intended, the CSS
// is combined and delivered via the `index` entrypoint.
if (self.currentBuildManifest.hmrTypes && output === 'css') {
entrypoints = self.apos.asset.getCurrentBuildEntrypoints()
.filter(e => !!e.manifest &&
e.scenes.includes(scene) &&
!self.currentBuildManifest.hmrTypes.includes(e.type)
);
} else {
entrypoints = self.apos.asset.getCurrentBuildEntrypoints()
.filter((e) =>
!!e.manifest && e.scenes.includes(scene) && e.outputs?.includes(output));
}
const markup = [];
const seen = {};
for (const {
manifest, condition, bundles: bundleSet
} of entrypoints) {
// For CSS, in HMR mode we already filtered the entries, so we can
// use the bundled files directly. For JS, we need to check if we
// have a dev server and use the dev server URL if available.
const hasDevServer = output === 'css'
? false
: manifest.devServer && self.hasDevServer();
const assetUrl = hasDevServer ? self.getDevServerUrl() : self.getAssetBaseUrl();
const bundles = [ ...bundleSet ?? [] ]
.filter(b => b.startsWith(scene) && b.endsWith(`.${output}`));
const files = hasDevServer
? manifest.src?.[output] ?? []
: bundles;
const preload = !hasDevServer && output === 'js'
? manifest.files?.imports ?? []
: [];
preload.forEach(file =>
modulePreload.add(`<link rel="modulepreload" href="${assetUrl}/${file}">`)
);
markup.push(...getMarkup(
{
files,
output,
condition,
assetUrl
}
));
}
return markup;
function getMarkup({
files, output, condition, assetUrl
}) {
if (output === 'css') {
return files
.filter(file => !seen[`${assetUrl}/${file}`])
.map(file => {
seen[`${assetUrl}/${file}`] = true;
return `<link rel="stylesheet" href="${assetUrl}/${file}">`;
});
}
// What is it?
if (output !== 'js') {
return [];
}
const attr = condition !== 'nomodule' ? 'type="module"' : 'nomodule';
return files.map(file => `<script ${attr} src="${assetUrl}/${file}"></script>`);
}
},
// Deploy all public assets for release. Executes only in production.
// `files` is a flat array of relative to the bundleRoot (getBundleRoot)
// paths to the files to be deployed.
async deploy(files) {
if (process.env.NODE_ENV !== 'production') {
return;
}
let copyIn;
let releaseDir;
const bundleDir = self.getBundleRootDir();
if (process.env.APOS_UPLOADFS_ASSETS) {
// The right choice if uploadfs is mapped to S3, Azure, etc.,
// not the local filesystem
copyIn = util.promisify(self.uploadfs.copyIn);
releaseDir = self.getCurrentRelaseDir(true);
} else {
// The right choice with Docker if uploadfs is just the local filesystem
// mapped to a volume (a Docker build step can't access that)
copyIn = fsCopyIn;
releaseDir = self.getCurrentRelaseDir();
await fs.mkdirp(releaseDir);
}
for (const file of files) {
const src = path.join(bundleDir, file);
await copyIn(
src,
path.join(releaseDir, file)
);
// await fs.remove(src);
}
async function fsCopyIn(from, to) {
const base = path.dirname(to);
await fs.mkdirp(base);
return fs.copyFile(from, to);
}
}
};
};
// Helper to remove existing sourceMappingURL comments
function stripSourceMapComment(code) {
return code.replace(/\/\/[@#]\s*sourceMappingURL=.*$/gm, '');
}