'use strict';
Error.stackTraceLimit = Infinity;
const acquit = require('acquit');
const fs = require('fs');
const fsextra = require('fs-extra');
const path = require('path');
const pug = require('pug');
const pkg = require('../package.json');
const { findTest } = require('acquit-require');
const childProcess = require('child_process');
const cwd = path.resolve(__dirname, '..');
const docsVendorPath = path.join(cwd, 'docs/vendor');
const vendorFiles = [
{
src: path.join(path.dirname(require.resolve('marked/package.json')), 'lib/marked.umd.js'),
dest: path.join(docsVendorPath, 'marked.umd.js')
},
{
src: path.join(path.dirname(require.resolve('xss/package.json')), 'dist/xss.min.js'),
dest: path.join(docsVendorPath, 'xss.min.js')
}
];
const CustomIdRegex = /{#([a-zA-Z0-9_-]+)}(?: *)$/;
const isMain = require.main === module;
let opencollectiveSponsors = [];
try {
opencollectiveSponsors = require('../docs/data/opencollective.json');
} catch {}
require('acquit-ignore')();
const markdown = require('marked');
const highlight = require('highlight.js');
const { promisify } = require('util');
markdown.use({
renderer: {
heading: function({ tokens, depth }) {
let raw = this.parser.parseInline(tokens);
let slug;
const idMatch = CustomIdRegex.exec(raw);
if (idMatch) {
slug = idMatch[1];
raw = raw.replace(CustomIdRegex, '');
} else {
slug = createSlug(raw.trim());
}
return `<h${depth} id="${slug}">
<a href="#${slug}">
${raw}
</a>
</h${depth}>\n`;
},
code: function({ text, lang }) {
if (lang) {
lang = lang.split(' ')[0];
} else if (!lang) {
lang = 'javascript';
}
if (lang === 'no-highlight') {
return text;
}
return `<pre><code lang="${lang}">${highlight.highlight(text, { language: lang }).value}</code></pre>`;
}
}
});
const testPath = path.resolve(cwd, 'test');
const additionalTestFiles = [
'geojson.test.js',
'schema.alias.test.js',
'model.middleware.test.js'
];
const ignoredTestFiles = [
'asyncIterator.test.js'
];
* Load all test file contents with acquit
* @returns {Object[]} acquit ast array
*/
function getTests() {
const testDocs = path.resolve(testPath, 'docs');
const filesToScan = [
...additionalTestFiles.map(v => path.join(testPath, v)),
...fs.readdirSync(testDocs).filter(v => !ignoredTestFiles.includes(v)).map(v => path.join(testDocs, v))
];
const retArray = [];
for (const file of filesToScan) {
try {
retArray.push(acquit.parse(fs.readFileSync(file).toString()));
} catch (err) {
err.filePath = file;
throw err;
}
}
return retArray.flat();
}
function deleteAllHtmlFiles() {
try {
console.log('Delete', path.join(versionObj.versionedPath, 'index.html'));
fs.unlinkSync(path.join(versionObj.versionedPath, 'index.html'));
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
const foldersToClean = [
path.join('.', versionObj.versionedPath, 'docs'),
path.join('.', versionObj.versionedPath, 'docs', 'tutorials'),
path.join('.', versionObj.versionedPath, 'docs', 'typescript'),
path.join('.', versionObj.versionedPath, 'docs', 'api'),
path.join('.', versionObj.versionedPath, 'docs', 'source', '_docs'),
'./tmp'
];
for (const folder of foldersToClean) {
let files = [];
try {
files = fs.readdirSync(folder);
} catch (err) {
if (err.code === 'ENOENT') {
continue;
}
}
for (const file of files) {
if (file.endsWith('.html')) {
console.log('Delete', path.join(folder, file));
fs.unlinkSync(path.join(folder, file));
}
}
}
}
function moveDocsToTemp() {
console.log('Moving docs to tmp dir...');
if (!versionObj.versionedPath) {
throw new Error('Cannot move unversioned deploy to /tmp');
}
try {
fs.rmSync('./tmp', { recursive: true });
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
}
const folder = versionObj.versionedPath.replace(/^\//, '');
const directory = fs.readdirSync(folder);
for (const file of directory) {
fsextra.moveSync(`${folder}/${file}`, `./tmp/${file}`);
}
}
* Array of array of semver numbers, sorted with highest number first
* @example
* [[1,2,3], [0,1,2]]
* @type {number[][]}
*/
let filteredTags = [];
* Parse a given semver version string to a number array
* @param {string} str The string to parse
* @returns number array or undefined
*/
function parseVersion(str) {
const versionReg = /^v?(\d+)\.(\d+)\.(\d+)/i;
const match = versionReg.exec(str);
if (match) {
const parsed = [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
if (Number.isNaN(parsed[0]) || Number.isNaN(parsed[1]) || Number.isNaN(parsed[2])) {
console.log(`some version was matched but did not parse to int! "${str}"`);
return undefined;
}
return parsed;
}
if (str === 'test') {
return undefined;
}
console.log(`Failed to parse version! got: ${str}`);
return undefined;
}
* Get versions from git tags and put them into {@link filteredTags}
*/
function getVersions() {
const res = childProcess.execSync('git tag').toString().trim();
filteredTags = res.split('\n')
.map(parseVersion)
.filter(v => !!v)
.sort((a, b) => {
if (a[0] === b[0]) {
if (a[1] === b[1]) {
return b[2] - a[2];
}
return b[1] - a[1];
}
return b[0] - a[0];
});
if (filteredTags.length === 0) {
console.error('no tags found!');
filteredTags.push([0, 0, 0]);
}
}
* Stringify a semver number array
* @param {number[]} arr The array to stringify
* @param {boolean} dotX If "true", return "5.X" instead of "5.5.5"
* @returns
*/
function stringifySemverNumber(arr, dotX) {
if (dotX) {
return `${arr[0]}.x`;
}
return `${arr[0]}.${arr[1]}.${arr[2]}`;
}
* Get the latest version available
* @returns {Version}
*/
function getLatestVersion() {
return { listed: stringifySemverNumber(filteredTags[0]), path: '' };
}
* Get the latest version for the provided major version
* @param {number} version major version to search for
* @returns {Version}
*/
function getLatestVersionOf(version) {
let foundVersion = filteredTags.find(v => v[0] === version);
if (!foundVersion) {
console.error(`Could not find a version for major "${version}"`);
foundVersion = [0, 0, 0];
}
return { listed: stringifySemverNumber(foundVersion), path: stringifySemverNumber(foundVersion, true) };
}
* Try to get the current version on the checked-out branch
* @returns {Version}
*/
function getCurrentVersion() {
let versionToUse = pkg.version;
if (!pkg.version) {
console.log('no version from package?');
versionToUse = getLatestVersion();
}
return { listed: versionToUse, path: stringifySemverNumber(parseVersion(versionToUse), true) };
}
getVersions();
* @typedef {Object} Version
* @property {string} listed The string it is displayed as
* @property {string} path The path to use for the actual url
*/
* Object for all version information
* @property {Version} currentVersion The current version this branch is built for
* @property {string} latestVersion The latest version available across the repository
* @property {Version[]} pastVersions The latest versions of past major versions to include as selectable
* @property {boolean} versionedDeploy Indicates wheter to build for a version or as latest (no prefix)
* @property {string} versionedPath The path to use for versioned deploy (empty string when "versionedDeploy" is false)
*/
const versionObj = (() => {
const base = {
currentVersion: getCurrentVersion(),
latestVersion: getLatestVersion(),
pastVersions: [
getLatestVersionOf(8),
getLatestVersionOf(7),
getLatestVersionOf(6)
]
};
const versionedDeploy = process.env.DOCS_DEPLOY ? !(base.currentVersion.listed === base.latestVersion.listed) : false;
const versionedPath = versionedDeploy ? `/docs/${base.currentVersion.path}` : '';
return {
...base,
versionedDeploy,
versionedPath
};
})();
try {
fs.mkdirSync(path.join(cwd, './docs/api'));
} catch {}
const docsFilemap = require('../docs/source/index');
const files = Object.keys(docsFilemap.fileMap);
const apiReq = require('../docs/source/api');
const generateLLMsTXT = require('./generateLLMsTXT');
const wrapMarkdown = (md, baseLayout, versionedPath, markdownUrl) => {
const newlineIdx = md.indexOf('\n');
const firstLine = newlineIdx === -1 ? md : md.slice(0, newlineIdx);
const rest = newlineIdx === -1 ? '' : md.slice(newlineIdx + 1);
return `
extends ${baseLayout}
block content
.article-header
:markdown
${firstLine}
<div class="doc-links">
<a class="edit-docs-link" href="#{editLink}" target="_blank">
<img src="${versionedPath}/docs/images/pencil.svg" />
</a>
<button class="copy-markdown-link" data-md-url="${markdownUrl}" title="Copy page as Markdown" aria-label="Copy page as Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
</div>
script.
(function() {
const copyMarkdownButton = document.querySelector('.copy-markdown-link');
if (copyMarkdownButton == null) {
return;
}
copyMarkdownButton.addEventListener('click', async function() {
const response = await fetch(copyMarkdownButton.dataset.mdUrl);
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
copyMarkdownButton.classList.add('copied');
setTimeout(() => copyMarkdownButton.classList.remove('copied'), 2000);
});
})();
:markdown
${rest.split('\n').map(line => ' ' + line).join('\n')}
`;
};
const cpc = `
<div class="sponsored-ad">
<a href="https://localizejs.com/?utm_campaign=Mongoose&utm_source=mongoose&utm_medium=banner">
<img src="https://res.cloudinary.com/drfhhq8wu/image/upload/v1770464125/localize2_acgiup.webp">
</a>
</div>
`;
const pugRender = promisify(pug.render);
const mongooseComRegex = /(?:href=")(https:\/\/mongoosejs\.com\/?)/g;
const versionedDocs = /docs\/\d/;
* Map urls (https://mongoosejs.com/) to local paths
* @param {String} block The String block to look for urls
* @param {String} currentUrl The URL the block is for (non-versioned)
*/
function mapURLs(block, currentUrl) {
let match;
let out = '';
let lastIndex = 0;
while ((match = mongooseComRegex.exec(block)) !== null) {
const startIndex = match.index + match[0].length - match[1].length;
out += block.slice(lastIndex, startIndex);
lastIndex = startIndex + match[1].length;
const fullUrl = /^\/[^"]+/.exec(block.slice(lastIndex - 1));
let noPrefix = false;
if (fullUrl) {
if (fullUrl[0].startsWith(currentUrl)) {
const indexMatch = /#/.exec(fullUrl);
if (indexMatch) {
lastIndex += indexMatch.index - 1;
noPrefix = true;
}
}
}
if (!noPrefix) {
if (!versionedDocs.test(block.slice(lastIndex, lastIndex + 10))) {
out += versionObj.versionedPath + '/';
} else {
out += '/';
}
}
}
out += block.slice(lastIndex);
return out;
}
* Render a given file with the given options
* @param {String} filename The documentation file path to render
* @param {import("../docs/source/index").DocsOptions} options The options to use to render the file (api may be overwritten at reload)
* @param {Boolean} isReload Indicate this is a reload of the file
* @returns
*/
async function renderFile(filename, options, isReload = false) {
let newfile = undefined;
options = options || {};
options.package = pkg;
let markdownSource = null;
const _editLink = 'https://github.com/Automattic/mongoose/edit/master' +
filename.replace(cwd, '');
options.editLink = options.editLink || _editLink;
let inputFile = filename;
if (options.api) {
if (isReload) {
apiReq.parseFile(options.file);
options = { ...options, ...apiReq.docs.get(options.file) };
}
inputFile = path.resolve(cwd, 'docs/api_split.pug');
}
if (options.apiMarkdown) {
newfile = path.resolve(cwd, filename);
const str = options.markdownSource;
if (typeof str !== 'string') {
throw new Error(`No markdown source found for API page "${filename}"`);
}
if (versionObj.versionedDeploy) {
const versionedMarkdownPath = path.resolve(cwd, path.join('.', versionObj.versionedPath), path.relative(cwd, filename));
await fs.promises.mkdir(path.dirname(versionedMarkdownPath), { recursive: true });
await fs.promises.writeFile(versionedMarkdownPath, str);
console.log('%s : rendered %s', (new Date()).toISOString(), versionedMarkdownPath);
}
await fs.promises.mkdir(path.dirname(newfile), { recursive: true });
await fs.promises.writeFile(newfile, str).catch((err) => {
console.error('could not write', err.stack);
}).then(() => {
console.log('%s : rendered %s', (new Date()).toISOString(), newfile);
});
return;
}
let contents = fs.readFileSync(path.resolve(cwd, inputFile)).toString();
const originalContents = contents;
if (options.acquit) {
const tests = getTests();
contents = contents.replace(/```javascript acquit:([^\n]+)\n[\s\S]*?```/g, (match, pattern) => {
const code = findTest(pattern, tests);
if (!code) {
throw new Error(`No test found for acquit pattern "${pattern}" in ${filename}`);
}
return '```javascript acquit:' + pattern + '\n' + code + '\n```';
});
if (contents !== originalContents) {
fs.writeFileSync(path.resolve(cwd, inputFile), contents);
console.log('%s : rendered %s', (new Date()).toISOString(), path.resolve(cwd, inputFile));
}
}
if (options.markdown) {
markdownSource = contents;
const markdownUrl = versionObj.versionedPath + '/' + path.relative(cwd, filename);
const lines = contents.split('\n');
lines.splice(2, 0, cpc);
contents = lines.join('\n');
contents = wrapMarkdown(
contents,
path.relative(path.dirname(filename), path.join(cwd, 'docs/layout')),
versionObj.versionedPath,
markdownUrl
);
newfile = filename.replace('.md', '.html');
}
options.filename = inputFile;
options.filters = {
markdown: function(block) {
return markdown.parse(block);
}
};
if (options.api) {
newfile = path.resolve(cwd, filename);
options.docs = Array.from(docsFilemap.apiDocs.values());
}
newfile = newfile || filename.replace('.pug', '.html');
const docsPath = newfile;
if (versionObj.versionedDeploy) {
newfile = path.resolve(cwd, path.join('.', versionObj.versionedPath), path.relative(cwd, newfile));
await fs.promises.mkdir(path.dirname(newfile), { recursive: true });
}
options.outputUrl = newfile.replace(cwd, '');
options.versions = versionObj;
options.affiliateAd = options.affiliateAd || null;
options.opencollectiveSponsors = opencollectiveSponsors;
let str = await pugRender(contents, options).catch(console.error);
if (typeof str !== 'string') {
return;
}
str = mapURLs(str, '/' + path.relative(cwd, docsPath));
await fs.promises.writeFile(newfile, str).catch((err) => {
console.error('could not write', err.stack);
}).then(() => {
console.log('%s : rendered %s', (new Date()).toISOString(), newfile);
});
if (versionObj.versionedDeploy && markdownSource != null) {
const versionedMarkdownPath = path.resolve(cwd, path.join('.', versionObj.versionedPath), path.relative(cwd, filename));
await fs.promises.mkdir(path.dirname(versionedMarkdownPath), { recursive: true });
await fs.promises.writeFile(versionedMarkdownPath, markdownSource);
console.log('%s : rendered %s', (new Date()).toISOString(), versionedMarkdownPath);
}
}
function startWatch() {
Object.entries(docsFilemap.fileMap).forEach(([file, fileValue]) => {
let watchPath = path.resolve(cwd, file);
const notifyPath = path.resolve(cwd, file);
if (fileValue.api) {
watchPath = path.resolve(cwd, fileValue.file);
}
fs.watchFile(watchPath, { interval: 1000 }, (cur, prev) => {
if (cur.mtime > prev.mtime) {
renderFile(notifyPath, docsFilemap.fileMap[file], true);
}
});
});
fs.watchFile(path.join(cwd, 'docs/layout.pug'), { interval: 1000 }, (cur, prev) => {
if (cur.mtime > prev.mtime) {
console.log('docs/layout.pug modified, reloading all files');
renderAllFiles(true, true);
}
});
fs.watchFile(path.join(cwd, 'docs/api_split.pug'), { interval: 1000 }, (cur, prev) => {
if (cur.mtime > prev.mtime) {
console.log('docs/api_split.pug modified, reloading all api files');
Promise.all(files.filter(v => v.startsWith('docs/api')).map(async(file) => {
const filename = path.join(cwd, file);
await renderFile(filename, docsFilemap.fileMap[file], true);
}));
}
});
}
* Render all files at once
* @param {Boolean} noWatch Set whether to start file watchers for reload
* @param {Boolean} isReload Indicate this is a reload of all files
*/
async function renderAllFiles(noWatch, isReload = false) {
await copyVendorFiles();
await Promise.all(files.map(async(file) => {
const filename = path.join(cwd, file);
await renderFile(filename, docsFilemap.fileMap[file], isReload);
}));
if (!versionObj.versionedDeploy) {
await generateLLMsTXT();
}
if (!noWatch && isMain && process.argv[2] === '--watch') {
startWatch();
}
}
const pathsToCopy = [
'docs/js',
'docs/css',
'docs/images',
'docs/vendor'
];
async function copyVendorFiles() {
await fs.promises.mkdir(docsVendorPath, { recursive: true });
await Promise.all(vendorFiles.map(file => fs.promises.copyFile(file.src, file.dest)));
}
async function copyAllRequiredFiles() {
if (!versionObj.versionedDeploy) {
return;
}
await Promise.all(pathsToCopy.map(async v => {
const resultPath = path.resolve(cwd, path.join('.', versionObj.versionedPath, v));
await fsextra.copy(v, resultPath);
}));
}
exports.default = renderFile;
exports.renderFile = renderFile;
exports.startWatch = startWatch;
exports.renderAllFiles = renderAllFiles;
exports.generateLLMsTXT = generateLLMsTXT;
exports.copyAllRequiredFiles = copyAllRequiredFiles;
exports.copyVendorFiles = copyVendorFiles;
exports.versionObj = versionObj;
exports.cwd = cwd;
if (isMain) {
(async function main() {
console.log(`Processing ~${files.length} files`);
let generateSearchPromise;
if (process.env.GENERATE_SEARCH) {
const generateSearch = require('./generateSearch');
try {
const config = generateSearch.getConfig();
generateSearchPromise = generateSearch.generateSearch(config);
} catch (err) {
console.error('Generating Search failed:', err);
}
} else {
console.log('Skipping generate search');
}
await deleteAllHtmlFiles();
await renderAllFiles();
await copyAllRequiredFiles();
if (!!process.env.DOCS_DEPLOY && !!versionObj.versionedPath) {
await moveDocsToTemp();
}
if (generateSearchPromise) {
console.log('Generating search...');
await generateSearchPromise;
console.log('Search generated successfully');
}
console.log('Done Processing');
})().catch((err) => {
console.error('Website Generation failed:', err);
process.exit(-1);
});
}
function createSlug(value) {
if (typeof value !== 'string') {
return '';
}
value = value.toLowerCase();
return value.replace(/<\/?code>/g, '').replace(/[^a-z0-9-_\s]/g, '').replace(/ /g, '-');
}