This function takes the sections and format them as
HTML, alternating docsText and (Markdown-indented) codeText
This function is essentially split into two subfunctions; they are both
run passing on all of the parameters:
formatSections()
makeHtmlBlob()
The first one, formatSections(), will take the sections array; remember:
each element has two properties, the leading docsText and the trailing
codeText. After running formatSections(), each element will also have
docsHtml and codeHtml (their respective HTML versions). This is done using
markdown for the docs, and shiki for the code.
If a plugin was
specified, the following functions from the plugin may be run:
plugin.beforeMarked – run before feeding the text to Marked. This
allows users to extend Markdown as neeed.
plugin.afterHtml – run after the HTML has been generated
plugin.finalPass – run once the whole file has been generated and
it’s ready to be written on the disk.
Since Markdown documentation can also contain code (by indenting 4 spaces),
the shiki option is set for Markdown, instructing it what to do when
a code block is encountered: obviously, the shiki library
will be used to format it.
Since modern versions of Marked have moved many features to extensions,
Docco Next manually enables the ones required to maintain its original
functionality:
gfmHeadingId – restores automatic generation of ID attributes for headings
markedSmartypants – provides “smart” typographic punctuation (curly quotes, dashes, etc.)
The second functtion, makeHtmlBlob(), actually creates the final HTML code
using the formatted sections as a starting point. The conversion is done
by using the EJS template provided, and passing it important variables:
sources – it’s the list of sources, useful to create table of contents
css – it’s the path to the CSS file, relative to the processed file.
title – the title of the file. If the file starts with a markdown
heading, this variable will have the contents of that heading; otherwise,
it will have the file name.
firstSectionIsTitle – if true, the title variable is indeed the first
section’s markdown heading. Templates can use this information to write
the logic around the title.
sections – array of the various sections, which include docsHtml and codeHtml.
This array is used by the EJS template to know what the file actually
contains
finalPath and relativeToThisFile – two functions often used together
in templates to know how to (HTML) link to another file in sources. For
example relativeToThisFile(finalPath(source)).
One note about the _getTemplate() function. The aim of the function is
to load the template file, and return an EJS compiler. The file itself
is memoized, so that calling _getTemplate() doesn’t result in multiple
reloading of the same file (since formatAsHtml() is potentially called
multiple times, once for each passed file). Memoization avoids using a
global variable.
async function formatAsHtml(source, sections, config = {}) {
await configure(config)
const lang = config.lang
const highlighter = await createHighlighter({
themes: config.shikiThemeDark
? [config.shikiTheme, config.shikiThemeDark]
: [config.shikiTheme],
langs: [
'javascript',
'typescript',
'python',
'ruby',
'c',
'cpp',
'java',
'go',
'rust',
'markdown',
'json',
'css',
'html'
] // Add more as needed, or dynamic
})
/* [Markdown](https://github.com/markedjs/marked) and Shiki */
/* In modern marked, we use extensions for highlighting */
const localMarked = new Marked()
/* We use the `gfmHeadingId` extension to restore automatic generation */
/* of ID attributes for headings, which is no longer part of core Marked */
localMarked.use(gfmHeadingId())
/* If the `smartypants` option is set (which it is by default), we use */
/* the `markedSmartypants` extension to provide "smart" typographic punctuation */
if (config.marked?.smartypants) {
localMarked.use(markedSmartypants())
}
/* Custom renderer and async token walker for code blocks in markdown */
/* Code might happen within the markdown documentation as well! If that */
/* is the case, it will highlight code either using the language specified */
/* within the Markdown codeblock, or the default language used for the processes */
/* file. */
/**
* Since Shiki's highlighting is asynchronous, we use Marked's `walkTokens`
* and `async: true` mode. This ensures all code blocks are highlighted
* before the final HTML rendering starts, preventing `[object Promise]`
* from appearing in the output.
*/
localMarked.use({
async walkTokens(token) {
if (token.type === 'code') {
const displayLang = token.lang || lang.name
try {
token.renderedCode = await codeToHtml(
highlighter,
token.text,
displayLang,
undefined,
config
)
} catch {
console.warn(
`${source}: language '${displayLang}' not recognised, code block not highlighted`
)
token.renderedCode = `<pre><code>${token.text}</code></pre>`
}
}
},
renderer: {
code(token) {
/* The renderer returns the pre-rendered HTML stored on the token. */
return token.renderedCode
}
},
async: true
})
if (config.marked) {
localMarked.use(config.marked)
}
/* Format and highlight the various section of the code */
for (const section of sections) {
let code = ''
try {
code = await codeToHtml(
highlighter,
section.codeText,
lang.name,
section.startLineNumber,
config
)
} catch {
code = `<pre><code>${section.codeText}</code></pre>`
}
code = code.replace(/\s+$/, '')
if (code !== '') section.codeHtml = `${code}`
else section.codeHtml = ''
let docsText = section.docsText
if (config.plugin.beforeMarked) {
const newText = await config.plugin.beforeMarked(docsText)
docsText = newText
}
section.docsHtml = await localMarked.parse(docsText)
if (config.plugin.afterHtml) {
const newHtml = await config.plugin.afterHtml(section.docsHtml)
section.docsHtml = newHtml
}
}
/* return the HTML blob */
return makeHtmlBlob(source, sections, config, lang)
}
/* Once all of the code has finished highlighting, we can **write** the resulting */
/* documentation file by passing the completed HTML sections into the template, */
/* and rendering it to the specified output path. */
async function makeHtmlBlob(source, sections, config, lang) {
let first
async function _getTemplate(templatePath) {
if (formatAsHtml._template) return formatAsHtml._template
const templateContent = await fs.readFile(templatePath, 'utf8')
formatAsHtml._template = ejs.compile(templateContent)
return formatAsHtml._template
}
const thisFile = finalPath(source, config)
function relativeToThisFile(file) {
const from = path.resolve(path.dirname(thisFile))
const to = path.resolve(path.dirname(file))
return path.join(path.relative(from, to), path.basename(file))
}
function includeText(s, silentFail) {
try {
return fs.readFileSync(s, 'utf8')
} catch (e) {
if (silentFail && e.code === 'ENOENT') return ''
if (e.code === 'ENOENT') {
console.error('Could not load included file:', s)
} else {
console.log(e)
}
process.exit(100)
}
}
/* Work out `title`, which will be either the first heading in the */
/* documentation, or (as a last resort) the file name */
const firstSection = sections.find((s) => s.docsText.length > 0)
let lexed
if (firstSection) {
lexed = marked.lexer(firstSection.docsText)
first = lexed[0]
}
const maybeTitle = first && first.type === 'heading' && first.depth === 1
const title = maybeTitle ? first.text : path.basename(source)
const firstSectionIsTitle = maybeTitle && lexed.length === 1
/* If the first section is the title, then get rid of it */
/* since the title is already being displayed by the template anyway */
if (firstSectionIsTitle) {
sections.shift()
}
/* The `css` variable will be available in the template as a relative */
/* link to the CSS file */
const css = relativeToThisFile(
path.join(config.output, path.basename(config.css))
)
const template = await _getTemplate(config.template)
/* Make up the HTML based on the template */
let html = template({
lang,
includeText,
source,
sources: config.sources,
css,
firstSectionIsTitle,
title,
sections,
finalPath: (p) => finalPath(p, config),
relativeToThisFile,
hasTitle: firstSectionIsTitle,
destination: (p) => finalPath(p, config),
relative: relativeToThisFile
})
/* Run the final pass */
if (config.plugin.finalPass) {
html = await config.plugin.finalPass(html)
}
return html
}