//@ts-check
import fs from 'fs-extra';
import path from 'path';
import globby from 'globby';
import logger from '@docusaurus/logger';
/**
* RegExp use to match the old markdown format for fiddle
* in `fiddleTransformer`.
*/
const fiddleRegex = /^```javascript fiddle='docs\/(\S+)?'$/;
const fiddlePathFixRegex = /```fiddle docs\//;
/**
* Updates the markdown fiddle format from:
* ```
* ```javascript fiddle='docs/fiddles/screen/fit-screen'
* ```
* To
* ```
* ```fiddle docs/latest/fiddles/example
* ```
* @param line
*/
const fiddleTransformer = (line: string) => {
const matches = fiddleRegex.exec(line);
const hasNewPath = fiddlePathFixRegex.test(line);
if (matches) {
return `\`\`\`fiddle docs/latest/${matches[1]}`;
} else if (hasNewPath) {
return (
line
.replace(fiddlePathFixRegex, '```fiddle docs/latest/')
// we could have a double transformation if the path is already the good one
// this happens especially with the i18n content
.replace('latest/latest', 'latest')
);
} else {
return line;
}
};
/**
* Crowdin translations put markdown content right
* after HTML comments and thus breaking Docusaurus
* parse engine. We need to add a new EOL after `-->`
* is found.
* @param line
*/
const newLineOnHTMLComment = (line: string) => {
// The `startsWith('*')` part is to prevent messing the document `api/native-theme.md` 😓
if (line.includes('-->') && !line.endsWith('-->') && !line.startsWith('*')) {
return line.replace('-->', '-->\n');
}
return line;
};
/**
* Crowdin needs extra blank lines surrounding the admonition characters so it doesn't
* break Docusaurus with the translated content.
* @param line
*/
const newLineOnAdmonition = (line: string) => {
if (line.trim().startsWith(':::') || line.trim().endsWith(':::')) {
return `\n${line.trim()}\n`;
}
return line;
};
/**
* MDX requires tag to be on its own line for some reason.
* @param line
*/
const newLineOnDetails = (line: string) => {
if (line.trim().endsWith(' ')) {
const restOfContent = line.trim().split(' ')[0];
return `${restOfContent}\n`;
}
return line;
};
/**
* MDX requires tags to be closed (e.g. ).
* This fixer isn't perfect and only works for tags that take up a whole line.
* @param line
*/
const noUnclosedImageTags = (line: string) => {
if (line.match(/^(]+)(?$/)) {
return `${line.slice(0, -1)}/>`;
} else {
return line;
}
};
/**
* Applies any transformation that can be executed line by line on
* the document to make sure it is ready to be consumed by
* docusaurus and our md extensions:
* * Fix types on regular text
* * Update the fiddle format
* @param doc
*/
const transform = (doc: string) => {
const lines = doc.split('\n');
const newDoc = [];
const transformers = [
fiddleTransformer,
newLineOnHTMLComment,
newLineOnAdmonition,
newLineOnDetails,
noUnclosedImageTags,
];
for (const line of lines) {
const newLine = transformers.reduce((newLine, transformer) => {
return transformer(newLine);
}, line);
newDoc.push(newLine);
}
return newDoc.join('\n');
};
/**
* Does a best effort to fix internal links
* @param content
* @param linksMaps
*/
const fixLinks = (content: string, linksMaps: Map) => {
/**
* This regex should match the following examples:
* * [link label]: ./somewhere.md
* * [link label]:../anywhere
* * [link label]: nowhere
* * [link](./somewhere.md)
* * [link](../anywhere)
* * [link](nowhere)
* * [link](https://github.com/electron/electron/blob/HEAD/path-to-file/file.md)
* * [link]: https://github.com/electron/electron/
* * [link]:https://another.place/
*
* The 2nd group contains the link.
* See https://regex101.com/r/i40SRL/1 for testing
*/
let updatedContent = content;
const mdLinkRegex = /(]:\s*|]\()(\S*?)?(?:\s|$|\))/gi;
let val: RegExpExecArray;
while ((val = mdLinkRegex.exec(content)) !== null) {
const link = val[2];
// Don't map links from outside the electron docs
if (
link.startsWith('https://') &&
!link.includes('github.com/electron/electron/')
) {
continue;
}
// Link could be `glossary.md#main-process` and we just need `glossary.md`
const basename = path.basename(link);
const parts = basename.split('#');
const key = parts.shift();
if (key && linksMaps.has(key)) {
const newLink = [linksMaps.get(key), ...parts];
const replacement = val[0].replace(val[2], newLink.join('#'));
updatedContent = updatedContent.replace(val[0], replacement);
}
}
/**
* Docusaurus has a problem when the title of an image spawns multiple lines. E.g.:
*
* ```md
* ![This is an
* image](path/to/image)
* ```
*
* Surprisingly, it has no problem with multiline regular links 🤷♂️
* */
const multilineImageTitle = /(?:!\[([^\]]+?)\])\(/gm;
while ((val = multilineImageTitle.exec(updatedContent)) !== null) {
const title = val[1];
if (!title.includes('\n')) {
continue;
}
const fixedTitle = title.replace(/\n/g, ' ');
updatedContent = updatedContent.replace(val[1], fixedTitle);
}
return updatedContent;
};
/**
* Removes unnecessary extra blank lines
* @param content
*/
const fixReturnLines = (content: string) => {
return content.replace(/\n\n(\n)+/g, '\n\n');
};
/**
* Inline API structure content if a link URL query parameter is ?inline.
*
* This will place the content of the structure (minus the document header)
* on the line following the link. If the line with the link is a list, the
* inlined content will be indented so that it is the next level in the list.
*
* Fairly heavy on assumptions and heuristics about how the docs are laid out
* so this code may be fragile to upstream changes.
*
* @param filePath
* @param content
*/
const inlineApiStructures = async (filePath: string, content: string) => {
// This is a modified version of the regex in `fixLinks`
const inlineApiStructureRegex = /\[\S+(?:]\()((\S*?)\?inline)?(?:\s|$|\))/g;
// This is from vscode-markdown-languageservice
const linkDefinitionPattern =
/^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm;
let updatedContent = content;
for (const val of content.matchAll(inlineApiStructureRegex)) {
const link = val[2];
// Don't consider links from outside the electron docs
if (
link.startsWith('https://') &&
!link.includes('github.com/electron/electron/')
) {
continue;
}
logger.info(
`Inlining API structure content for '${logger.green(
link
)}' in ${logger.green(filePath)}`
);
try {
// Recursively inline to ensure all inline links have been inlined
const apiStructureFilePath = path.join(path.dirname(filePath), link);
let apiStructureContent = await inlineApiStructures(
apiStructureFilePath,
await fs.readFile(apiStructureFilePath, 'utf-8')
);
// Strip the header if there is one
if (apiStructureContent.match(/^# /m)) {
const headerIdx = apiStructureContent.match(/^# /m).index;
const firstNewline = apiStructureContent.indexOf('\n', headerIdx);
apiStructureContent = apiStructureContent.slice(
apiStructureContent.indexOf('\n', firstNewline + 1) + 1
);
}
const indexOfLineStart = updatedContent.lastIndexOf('\n', val.index) + 1;
const indexOfLineEnd =
val.index + updatedContent.slice(val.index).indexOf('\n');
const line = updatedContent.slice(indexOfLineStart, indexOfLineEnd);
// The line with the link is a list item
if (line.trim().startsWith('*')) {
const indentation = line.indexOf('*');
if (![0, 2, 4, 6].includes(indentation)) {
throw new Error(
'Expected an indentation level of 0, 2, 4, or 6 for list item'
);
}
// Assume list indentation is a multiple of 2, should be enforced by
// upstream linter. Increase the indentation of the API structure
// content by two spaces for the list of properties, which is presumed
// to be the first block in the document after the header, which ends
// when there's a blank line, or end of file
let initialPropsSection = true;
const lines = apiStructureContent.split('\n');
apiStructureContent = lines
.map((line) => {
if (line.trim() === '') {
initialPropsSection = false;
}
return initialPropsSection
? `${' '.repeat(indentation + 2)}${line}`
: line;
})
.join('\n');
}
// Pull out any reference link definitions so they don't interfere
// with list indentation when inlining the structure properties
const apiStructureContentLines = apiStructureContent.split('\n');
const referenceLinkDefinitions = apiStructureContentLines.filter((line) =>
line.match(linkDefinitionPattern)
);
if (referenceLinkDefinitions.length) {
apiStructureContent = apiStructureContentLines
.filter((line) => !line.match(linkDefinitionPattern))
.join('\n');
}
// Insert the API structure content
const preContent = updatedContent.slice(0, indexOfLineEnd);
const postContent = updatedContent.slice(indexOfLineEnd + 1);
updatedContent =
preContent + '\n' + apiStructureContent.trimEnd() + '\n' + postContent;
// Replace the special link to strip off the ?inline query parameter
updatedContent = updatedContent.replace(val[1], val[2]);
// Place any reference links from API structure content at end
if (referenceLinkDefinitions.length) {
updatedContent =
updatedContent + '\n' + referenceLinkDefinitions.join('\n') + '\n';
}
} catch (err) {
logger.error(
`Error inlining API structure link in file ${filePath}: ${err}`
);
}
}
return updatedContent;
};
/**
* The current doc's format on `electron/electron` cannot be used
* directly by docusaurus. This function transform all the md files
* found in the given `root` (recursively) and makes sure they are
* ready to consumed by the website.
* @param root
* @param version
*/
export const fixContent = async (root: string, version = 'latest') => {
const files = await globby(`${version}/**/*.md`, {
cwd: root,
});
/**
* Filenames in Electron docs are usually unique so best effort
* consist on using the filename (basename) to identify the right
* place where it should point.
*/
const linksMaps = new Map();
for (const filePath of files) {
linksMaps.set(path.basename(filePath), filePath);
}
for (const filePath of files) {
const fullFilePath = path.join(root, filePath);
const content = await fs.readFile(fullFilePath, 'utf-8');
// Inline API structures first so all other fixes affect them
let fixedContent = await inlineApiStructures(fullFilePath, content);
fixedContent = transform(fixedContent);
// These analyze the document globally instead of line by line,
// thus why they cannot be part of `transform`
fixedContent = fixReturnLines(fixLinks(fixedContent, linksMaps));
await fs.writeFile(path.join(root, filePath), fixedContent, 'utf-8');
}
};