apostrophe
Version:
The Apostrophe Content Management System.
758 lines (709 loc) • 31.6 kB
JavaScript
const fs = require('fs-extra');
const path = require('node:path');
const { glob } = require('../../lib/path');
const { stripIndent } = require('common-tags');
// High and Low level public API for external modules.
module.exports = (self) => {
const getBuildManager = require('./managers')(self);
return {
// Get the absolute path to the project build directory.
// Can be used with both external build and legacy webpack mode.
getBuildRootDir() {
const namespace = self.getNamespace();
if (self.hasBuildModule()) {
return path.join(
self.apos.rootDir,
'apos-build',
self.getBuildModuleConfig().name,
namespace
);
}
return path.join(
self.apos.rootDir,
'apos-build',
namespace
);
},
// Get the absolute path to the project bundle (`public/`) directory.
// Can be used with both external build and legacy webpack mode.
getBundleRootDir() {
return path.join(
self.apos.rootDir,
'public/apos-frontend/',
self.getNamespace()
);
},
// Get the absolute path to the current project release directory.
// If `isUploadFs` is `true`, the path is relative to the uploadfs root.
getCurrentRelaseDir(isUploadFs) {
const releaseId = self.getReleaseId();
const namespace = self.getNamespace();
if (isUploadFs) {
// the relative to the uploadfs root path
return `/apos-frontend/releases/${releaseId}/${namespace}`;
}
// the absolute path to the release local directory
return `${self.apos.rootDir}/public/apos-frontend/releases/${releaseId}/${namespace}`;
},
// This is used only by the external build systems to assemble
// the URL for a dev server middleware. It's not in effect when
// `self.options.autoBuild` is `false` because the external module
// is not asked to build the assets. In this case the dev server URL
// is detected from the saved build manifest.
getBaseMiddlewareUrl() {
return (self.apos.baseUrl || '') + self.apos.prefix;
},
// Get entrypoints configuration for the build module.
// Provide recompute `true` to force the recomputation of the entrypoints.
// This is useful in HMR mode, where after a "create" file event, the
// verified bundles can change and the entrypoints configuration should be
// updated. Usually the the core asset module will take care of this.
// Optional `types` array can be provided to filter the entrypoints by type.
//
// Returns an array of objects with the following properties:
// - `name`: the entrypoint name. It's usually the relative to `ui` folder
// name(`src`, `apos`, `public`) or an extra bundle name.
// - `label`: the human-readable label for the entrypoint, used to print
// CLI messages. - `type`: (enum) the entrypoint type. It can be `index`,
// `apos`, `custom` (e.g. extra bundles) or - `ignoreSources`: an array of
// sources that shouldn't be processed when creating the entrypoint. -
// `sources`: an object with `js` and `scss` arrays of extra sources to be
// included in the entrypoint. These sources are not affected by the
// `ignoreSources` configuration. - `extensions`: an optional object with
// the additional configuration for the entrypoint, gathered from the
// `build.extensions` modules property. - `prologue`: a string with the
// prologue to be added to the entrypoint. - `condition`: the JS `module` or
// `nomodule` condition. Undefined for no specific condition. - `outputs`:
// an array of output extensions for the entrypoint (currently not fully
// utilized) - `inputs`: an array of input extensions for the entrypoint
// (currently not fully utilized) - `scenes`: an array of scenes to be in
// the final post-bundle step. The scenes are instructions for the
// Apostrophe core to combine the builds and release them. Currently
// supported scenes are `apos` and `public` and custom scene names equal to
// extra bundle (only those who should be loaded separately in the browser).
//
// Additional properties added after entrypoints are processed by the core
// and the external build module: - `manifest`: object, see the manifest
// section of `configureBuildModule()` docs for more information. -
// `bundles`: a `Set` containing the bundle names that this entrypoint is
// part of (both css and js).
getBuildEntrypoints(types, recompute = false) {
if (!self.hasBuildModule()) {
return self.builds;
}
if (typeof types === 'boolean') {
recompute = types;
types = null;
}
if (recompute) {
self.setBuildExtensionsForExternalModule();
}
if (types) {
return self.moduleBuildEntrypoints.filter((entry) => types.includes(entry.type));
}
return self.moduleBuildEntrypoints;
},
// Get the entrypoint manager for a given `entrypoint` by its type.
// The entrypoint parameter is an item from the entrypoints configuration.
// See `getBuildEntrypoints()` for the entrypoint configuration schema.
// the following methods:
// - getSourceFiles(meta, { composePath? }): get the source files for the
// entrypoint. The `composePath` is an optional function to compose the path
// to the source file. It accepts `file` (a relative to
// `ui/{folderToSearch}` file path) and `metaEntry` (the module metadata
// entry, see `computeSourceMeta()`). - async getOutput(sourceFiles, {
// modules, suppressErrors }): get the output data for the entrypoint. The
// `sourceFiles` is in format compatible with the output of
// `manager.getSourceFiles()`. The `modules` option is the list of all
// modules, usually the cached result of `self.getRegisteredModules()`.
// `suppressErrors` is an optional boolean to suppress the errors in the
// output generation (useful in the development environment and HMR). -
// match(relPath, rootPath): vote on whether a given source file is part of
// this entrypoint. The `relPath` is a relative to the `ui` folder path
// (e.g. `src/index.js`). The `rootPath` is the absolute path to the `ui`
// folder. The method should return boolean `true` if the file is part of
// the entrypoint.
getEntrypointManger(entrypoint) {
return getBuildManager(entrypoint);
},
// The cached result of `apos.modulesToBeInstantiated()`.
getRegisteredModules() {
return self.modulesToBeInstantiated;
},
// A high level public helper for writing entrypoint files based on the
// generated by an entrypoint manager output. Write the entrypoint file in
// the build source folder. The possible argument properties: - importFile:
// The absolute path to the entrypoint file. No file is written if the
// property is not provided. - prologue: The prologue string to prepend to
// the file. - icons: The admin UI icon import code. Should be in a format
// compatible to the `getImportFileOutput()` output. - components: The admin
// UI component import code. Should be in a format compatible to the
// `getImportFileOutput()` output. - tiptap: The admin UI tiptap import
// code. Should be in a format compatible to the `getImportFileOutput()`
// output. - apps: The admin UI app import code. Should be in a format
// compatible to the `getImportFileOutput()` output. - js: A generic JS
// import code. Should be in a format compatible to the
// `getImportFileOutput()` output. - scss: A generic Sass import code.
// Should be in a format compatible to the `getImportFileOutput()` output. -
// raw: string raw content to write to the file.
//
// Only the `importFile` property is required. The rest will be used
// to generate the entrypoint file content only when available.
async writeEntrypointFile({
importFile,
prologue,
raw,
icons,
components,
tiptap,
apps,
js,
scss
}) {
let output = '';
output += prologue?.trim()
? prologue.trim() + '\n'
: '';
output += (scss && scss.importCode) || '';
output += (js && js.importCode) || '';
output += (icons && icons.importCode) || '';
output += (components && components.importCode) || '';
output += (tiptap && tiptap.importCode) || '';
output += (apps && apps.importCode) || '';
output += (icons && icons.registerCode) || '';
output += (components && components.registerCode) || '';
output += (tiptap && tiptap.registerCode) || '';
// Do not strip indentation here, keep it nice and formatted
output += apps
? `if (document.readyState !== 'loading') {
setTimeout(invoke, 0);
} else {
window.addEventListener('DOMContentLoaded', invoke);
}
function invoke() {
${apps.invokeCode.trim()}
}` + '\n'
: '';
// Remove the indentation per line.
// It may look weird, but the result is nice and formatted import file.
output += (js && js.invokeCode.trim().split('\n').map(l => l.trim()).join('\n') + '\n') || '';
// Just raw content, no need to format it.
output += (raw && raw + '\n') || '';
if (importFile) {
await fs.writeFile(importFile, output);
}
return output;
},
// Helper function for external build modules to find the last package
// change timestamp in milliseconds. Works with Node.js and npm, yarn, and
// pnpm package managers. Might be extended if a need arises.
async getSystemLastChangeMs() {
const packageLock = await findPackageLock();
if (!packageLock) {
return false;
}
return (await fs.stat(packageLock)).mtimeMs;
async function findPackageLock() {
const packageLockPath = path.join(self.apos.npmRootDir, 'package-lock.json');
const yarnPath = path.join(self.apos.npmRootDir, 'yarn.lock');
const pnpmPath = path.join(self.apos.npmRootDir, 'pnpm-lock.yaml');
if (await fs.pathExists(packageLockPath)) {
return packageLockPath;
} else if (await fs.pathExists(yarnPath)) {
return yarnPath;
} else if (await fs.pathExists(pnpmPath)) {
return pnpmPath;
} else {
return false;
}
}
},
// Retrieve saved during build core metadata. The metadata is saved in the
// `.manifest.json` file in the bundle root directory. The manifest format
// is independent, internal standard (see `saveBuildManifest()` method
// docs). Additional property `bundles` (Set). See the manifest section in
// `configureBuildModule()` method docs for more information.
async loadSavedBuildManifest(silent = false) {
const manifestPath = path.join(self.getBundleRootDir(), '.manifest.json');
try {
return JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
} catch (e) {
if (!silent && self.apos.options.autoBuild !== false) {
self.apos.util.error(`Error loading the saved build manifest: ${e.message}`);
}
return {};
}
},
// Save the core metadata during the build process. It's async to allow
// future improvements.
async forcePageReload() {
self.restartId = self.apos.util.generateId();
},
// A low-level helper to compute the source metadata for the external
// modules. Compute UI source and public files metadata of all modules.The
// result array order follows the following rules: - process modules in the
// order they are passed - process each module chain starting from the base
// parent instance and ending with the the final extension This complies
// with a "last wins" strategy for sources overrides - the last module in
// the chain should win. Handling override scenarios is NOT the
// responsibility of this method, it only provides the metadata in the right
// order.
//
// If the `asyncHandler` is an optional async function, it will be called
// for each module entry. This is useful for external build modules to
// e.g. copy files to the build directory during the traversal.
//
// 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.
//
// Returns an array of objects with the following properties:
// - dirname - absolute module path with `/ui` appended.
// For example `path/to/project/article/ui`
// or `/path/to/project/node_modules/@apostrophecms/admin-bar/ui`.
// - `id`: the module name, prefixed with `my-` if it's a project module.
// For example `my-article` or `@apostrophecms/my-admin-bar`.
// - `name`: the original module name (no `my-` prefix).
// - `importAlias`: the alias base that is used for importing the module.
// For example `Modules/@apostrophecms/admin-bar/`. This is used to fast
// resolve the module in the Vite build.
// - `npm`: a boolean indicating if the module is a npm module
// - `files`: an array of paths paths relative to the module `ui/` folder,
// - `exists`: a boolean indicating if the `dirname` exists.
// - `symlink`: a boolean indicating if the npm module is a symlink.
// Non-npm modules are always considered as non-symlinks.
async computeSourceMeta({
modules,
stats = true,
asyncHandler
}) {
const seen = {};
const npmSeen = {};
const meta = [];
for (const name of modules) {
const metadata = await self.apos.synth.getMetadata(name);
for (const entry of metadata.__meta.chain) {
if (seen[entry.dirname]) {
continue;
}
const moduleName = entry.my
? entry.name
.replace('/my-', '/')
.replace(/^my-/, '')
: entry.name;
const dirname = `${entry.dirname}/ui`;
let exists = null;
let isSymlink = null;
const files = await glob('**/*', {
cwd: dirname,
ignore: [
'**/node_modules/**'
// Keep the public folder for now so that
// we can easily copy it to the bundle folder later.
// Remove it if there's a better way to handle it.
// 'public/**'
],
nodir: true,
follow: false,
absolute: false
});
files.sort((a, b) => a.localeCompare(b, 'en'));
if (stats) {
// optimize fs calls
exists = files.length > 0 ? true : fs.existsSync(dirname);
isSymlink = exists ? checkSymlink(entry) : false;
}
seen[entry.dirname] = true;
const metaEntry = {
id: entry.name,
name: moduleName,
dirname,
importAlias: `Modules/${moduleName}/`,
npm: entry.npm ?? false,
symlink: isSymlink,
exists,
files
};
meta.push(metaEntry);
if (asyncHandler) {
await asyncHandler(metaEntry);
}
}
}
function checkSymlink(entry) {
if (!entry.npm) {
return false;
}
let dir;
if (entry.bundled) {
const baseChunks = entry.dirname.split('/node_modules/');
const end = baseChunks.pop();
const base = baseChunks.join('/node_modules/');
if (end.startsWith('@')) {
dir = `${base}/node_modules/${end.split('/').slice(0, 2).join('/')}`;
} else {
dir = `${base}/node_modules/${end.split('/')[0]}`;
}
} else {
dir = entry.dirname;
}
if (typeof npmSeen[dir] === 'boolean') {
return npmSeen[dir];
}
npmSeen[dir] = fs.lstatSync(dir, { throwIfNoEntry: false })
?.isSymbolicLink() ?? false;
return npmSeen[dir];
}
return meta;
},
// A low level public helper used internally in the entrypoint managers.
// It allows finding source files from the computed source metadata
// for a given entrypoint configuration.
//
// The `meta` array is the (cached) return value of `computeSourceMeta()`.
// The `pathComposer` option is used to create the component import path.
// It should be a function that takes
// the file relative to a module `ui/` folder and a metadata entry object
// as arguments and returns the relative path to the file from within the
// apos-build folder.
// The default path composer: (file, entry) => `./${entry.name}/${file}`
// If not provided, the default composer will be used.
//
// The `predicates` object is used to filter the files and determines the
// output.
// It should contain the output name as the key and a predicate function as
// the value. The function takes the same arguments as the `pathComposer`
// (file and entry) and should return a boolean - `true` if the file should
// be included in the output.
// Example:
// {
// js: (file, entry) => file.endsWith('.js'),
// scss: (file, entry) => file.endsWith('.scss')
// }
// will result in return value like:
// {
// js: [
// {
// component: './module-name/file.js',
// path: '/path/to/module-name/file.js'
// }
// ],
// scss: [
// {
// component: './module-name/file.scss',
// path: '/path/to/module-name/file.scss'
// }
// ]
// }
//
// If the `skipPredicates` option is set to `true`, the function will skip
// the predicates and only validate and include the extra sources if
// provided. In this case, the `predicates` object values (the functions)
// will be ignored and can be set to `null`. Example: const sources =
// self.apos.asset.findSourceFiles( meta, self.myComposeSourceImportPath, {
// js: null, scss: null }, { skipPredicates: true, extraSources: { js: [
// '/path/to/module-name/file.js' ], scss: [
// '/path/to/module-name/file.scss' ] } } );
//
// The `options` object can be used to customize the filtering.
// The following options are available:
// - extraSources: An object with the same structure as the `predicates`
// object. The object values should be arrays of absolute paths to the
// source files. The files will be validated against the metadata and
// included in the output regardless of the predicates and the
// `ignoreSources` option. - componentOverrides: If `true`, the function
// will filter out earlier versions of a component if a later version
// exists. If an array of predicate names is passed, the function will only
// filter the components for the given predicates. For example, passing
// `['js']` will only apply the override algorithm to the result of the `js`
// predicate. - ignoreSources: An array of source files to ignore. The files
// should be absolute paths. - skipPredicates: If `true`, the function will
// skip the predicates and only include the extra sources if provided. This
// option makes no sense if the `extraSources` option is not provided. -
// pathComposer: A function to compose the path to the source file. See
// above for more information.
//
// Usage:
// const sources = self.apos.asset.findSourceFiles(
// meta,
// {
// js: (file, entry) => file.startsWith(`${entry.name}/components/`) &&
// file.endsWith('.vue')
// },
// {
// componentOverrides: true
// }
// );
// Example output:
// {
// js: [
// {
// component: './module-name/components/MyComponent.vue',
// path: '/path/to/module-name/components/MyComponent.vue'
// },
// // ...
// ]
// }
findSourceFiles(meta, predicates, options = {}) {
const composePathDefault = (file, metaEntry) => `./${metaEntry.name}/${file}`;
const composer = options.pathComposer || composePathDefault;
const map = Object.entries(predicates)
.reduce(
(acc, [ name, predicate ]) => (
acc.set(
name,
{
predicate,
results: new Map()
}
)
),
new Map()
);
for (const entry of meta) {
if (!entry.files.length) {
continue;
}
for (const [ name, { predicate, results } ] of map) {
if (options.skipPredicates !== true) {
entry.files.filter(f => predicate(f, entry))
.forEach((file) => {
const fullPath = path.join(entry.dirname, file);
if (options.ignoreSources?.includes(fullPath)) {
return;
}
const result = {
component: composer(file, entry),
path: fullPath
};
results.set(result.component, result);
});
}
if (options.extraSources) {
const files = options.extraSources[name]
?.filter(sourcePath => sourcePath.includes(entry.dirname)) ?? [];
for (const sourcePath of files) {
const source = self.getSourceByPath(entry, composer, sourcePath);
if (source) {
results.set(source.component, source);
}
}
}
}
}
const result = {};
for (const [ name, { results } ] of map) {
result[name] = [ ...results.values() ];
}
if (options.componentOverrides) {
for (let [ name, components ] of Object.entries(result)) {
if (
Array.isArray(options.componentOverrides) &&
!options.componentOverrides.includes(name)
) {
continue;
}
// Reverse the list so we can easily find the last configured import
// of a given component, allowing "improve" modules to win over
// the originals when shipping an override of a Vue component
// with the same name, and filter out earlier versions
components.reverse();
const seen = new Set();
components = components.filter(item => {
const name = self.getComponentNameByPath(item.component);
if (seen.has(name)) {
return false;
}
seen.add(name);
return true;
});
// Put the components back in their original order
components.reverse();
result[name] = components;
}
}
return result;
},
// A low level public helper used internally in the entrypoint managers.
// Identify an absolute path to an Apostrophe UI source and return the
// component relative build path and the path to the source file.
// The method returns `null` if the source path is not found or
// an object with `component` and `path` properties.
getSourceByPath(metaOrEntry, pathComposer, sourcePath) {
const entry = Array.isArray(metaOrEntry)
? metaOrEntry.find((entry) => sourcePath.includes(entry.dirname))
: metaOrEntry;
if (!entry) {
self.logDebug('getSourceByPath', `No meta entry found for "${sourcePath}".`);
return null;
}
const component = sourcePath.replace(entry.dirname + '/', '');
if (entry.files.includes(component)) {
return {
component: pathComposer(component, entry),
path: sourcePath
};
}
self.logDebug('getSourceByPath', `No match found for "${sourcePath}" in "${entry.id}".`, {
entry: entry.id,
component,
sourcePath
});
return null;
},
// A low level public helper used internally in the entrypoint managers.
// Generate the import code for the given components.
// The components array should contain objects with `component` and `path`
// properties (usually the output of `findSourceFiles()` method).
// The `component` property is the relative path to the file
// from within the apos-build folder, and the `path` property is the
// absolute path to the original file.
//
// The `options` object can be used to customize the output.
// The following options are available:
//
// - requireDefaultExport: If true, the function will throw an error
// if a component does not have a default export.
// - registerComponents: If true, the function will generate code to
// register the components in the window.apos.vueComponents object.
// - registerTiptapExtensions: If true, the function will generate code
// to register the components in the window.apos.tiptapExtensions array.
// - invokeApps: If true, the function will generate code to invoke the
// components as functions.
// - importSuffix: A string that will be appended to the import name.
// - importName: If false, the function will not generate an import name.
// - enumerateImports: If true, the function will enumerate the import
// names. - suppressErrors: If true, the function will not throw an error
// and will attempt to generate a correct output. Use with caution. This
// option is meant to be used in the development environment (HMR) where the
// build should not e.g. empty file (when creating a new file).
//
// The function returns an object with `importCode`, `registerCode`, and
// `invokeCode` string properties.
getImportFileOutput(components, options = {}) {
let registerCode = '';
if (options.registerComponents) {
registerCode = 'window.apos.vueComponents = window.apos.vueComponents || {};\n';
} else if (options.registerTiptapExtensions) {
registerCode = 'window.apos.tiptapExtensions = window.apos.tiptapExtensions || [];\n';
}
const output = {
importCode: '',
registerCode,
invokeCode: ''
};
const useAbsoluteImportPaths = process.platform === 'win32';
const toRelativeImportPath = (componentPath) => {
const normalized = componentPath.replace(/\\/g, '/');
if (normalized.startsWith('./') || normalized.startsWith('../')) {
return normalized;
}
if (normalized.startsWith('/')) {
return `.${normalized}`;
}
return `./${normalized}`;
};
components.forEach((entry, i) => {
const { component, path: realPath } = entry;
if (options.requireDefaultExport) {
let content = '';
try {
content = fs.readFileSync(realPath, 'utf8');
} catch (e) {
throw new Error(`The file ${realPath} does not exist.`);
}
if (!content.match(/export[\s\n]+default/)) {
if (!options.suppressErrors) {
throw new Error(stripIndent`
The file ${component} does not have a default export.
Any ui/src/index.js file that does not have a function as
its default export will cause the build to fail in production.
`);
} else if (content.trim().length === 0) {
// Patch it
fs.writeFileSync(realPath, 'export default () => {\n\n}\n');
}
}
}
// Windows needs absolute paths for native Node.js builds whereas
// *nix builds must stay relative to keep Vite's dev server happy.
// pathToFileURL breaks both environments, so stick to plain strings.
const importTarget = (useAbsoluteImportPaths || !component)
? realPath
: toRelativeImportPath(component);
const importPath = JSON.stringify(importTarget);
const name = self.getComponentNameByPath(
component,
{ enumerate: options.enumerateImports === true ? i : false }
);
const jsName = JSON.stringify(name);
const importName = `${name}${options.importSuffix || ''}`;
const importCode = options.importName === false
? `import ${importPath};\n`
: `import ${importName} from ${importPath};\n`;
output.importCode += `${importCode}`;
if (options.registerComponents) {
output.registerCode += `window.apos.vueComponents[${jsName}] = ${importName};\n`;
}
if (options.registerTiptapExtensions) {
output.registerCode += stripIndent`
apos.tiptapExtensions.push(${importName});
` + '\n';
}
if (options.invokeApps) {
output.invokeCode += ` ${name}${options.importSuffix || ''}();\n`;
}
});
return output;
},
// Generate the import code for all registered icons (`icons` module prop).
// The function returns an object with `importCode`, `registerCode`,
// and `invokeCode` string properties.
// Modules is the cached list of modules, usually the result of
// `self.getRegisteredModules()`. See `getRegisteredModules()` method for
// more information.
async getAposIconsOutput(modules) {
for (const name of modules) {
const metadata = await self.apos.synth.getMetadata(name);
// icons is an unparsed section, so getMetadata gives it back
// to us as an object with a property for each class in the
// inheritance tree, root first. Just keep merging in
// icons from that
for (const [ name, layer ] of Object.entries(metadata.icons)) {
if ((typeof layer) === 'function') {
// We should not support invoking a function to define the icons
// because the developer would expect `(self)` to behave
// normally, and they won't during an asset build. So we only
// accept a simple object with the icon mappings
throw new Error(`Error in ${name} module: the "icons" property may not be a function.`);
}
Object.assign(self.iconMap, layer || {});
}
}
// Load global vue icon components.
const output = {
importCode: '',
registerCode: 'window.apos.iconComponents = window.apos.iconComponents || {};\n',
invokeCode: ''
};
const importIndex = [];
for (const [ registerAs, importFrom ] of Object.entries(self.iconMap)) {
let importName = importFrom;
if (!importIndex.includes(importFrom)) {
if (importFrom.substring(0, 1) === '~') {
// An npm module name
importName = self.apos.util.slugify(importFrom).replaceAll('-', '');
output.importCode += `import ${importName}Icon from '${importFrom.substring(1)}';\n`;
} else {
output.importCode +=
`import ${importName}Icon from '@apostrophecms/vue-material-design-icons/${importFrom}.vue';\n`;
}
importIndex.push(importFrom);
}
output.registerCode += `window.apos.iconComponents['${registerAs}'] = ${importName}Icon;\n`;
}
return output;
}
};
};