@antora/assembler
Version:
A JavaScript library that merges AsciiDoc content from multiple pages in an Antora site into assembly files and delegates to an exporter to convert those files to another format, such as PDF.
860 lines (838 loc) • 34.2 kB
JavaScript
const createAsciiDocFile = require('./util/create-asciidoc-file')
const parseResourceRef = require('./util/parse-resource-ref')
const path = require('node:path/posix')
const sanitize = require('./util/sanitize')
const unconvertInlineAsciiDoc = require('./util/unconvert-inline-asciidoc')
const AttributeEntryRx = /^:([^:-][^:]*):(?: .*)?$/
const BuiltInNamedEntities = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' }
const CharRefRx = /&(?:([a-z][a-z]+\d{0,2})|#(?:(\d{2,6})|x([a-z\d]{2,5})));/g
const DiscardAttributes = 'doctype leveloffset assembly-navtitle assembly-style underscore'.split(' ')
const ReservedIdNames = 'content header footnotes footer footer-text premable toc toctitle'.split(' ')
function produceAssemblyFile (
loadAsciiDoc,
contentCatalog,
componentVersion,
outline,
files,
asciidocConfig,
mutableAttributes,
assemblyModel
) {
const pagesByUrl = files.reduce((map, it) => (it.src.family === 'page' ? map.set(it.pub.url, it) : map), new Map())
if (outline.urlType === 'internal' && !pagesByUrl.get(outline.url) && !(outline.items || []).length) return
const pagesInOutline = selectPagesInOutline(outline, pagesByUrl, componentVersion)
const buffer = mergeAsciiDoc(
loadAsciiDoc,
contentCatalog,
buildAsciiDocHeader(componentVersion, outline.content, assemblyModel),
componentVersion,
outline,
files,
pagesInOutline,
asciidocConfig,
mutableAttributes,
assemblyModel
)
const rootLevel = assemblyModel.rootLevel
const stem = rootLevel === 0 ? 'index' : generateSlug(buffer.navtitle)
const downloadStem = [componentVersion.name, componentVersion.version, rootLevel === 0 ? '' : stem]
.filter((it) => it)
.join('-')
return createAsciiDocFile(contentCatalog, {
asciidoc: asciidocConfig,
assembler: { assembled: pagesInOutline.assembled, downloadStem, rootLevel },
contents: Buffer.from(buffer.join('\n') + '\n'),
src: {
component: componentVersion.name,
version: componentVersion.version,
module: 'ROOT',
family: 'export',
relative: stem + '.adoc',
},
})
}
function buildAsciiDocHeader (componentVersion, navtitle, assemblyModel) {
const doctype = assemblyModel.doctype ?? 'book'
const navtitlePlain = sanitize(navtitle)
const navtitleAsciiDoc = unconvertInlineAsciiDoc(navtitle)
let doctitle = navtitleAsciiDoc
if (navtitlePlain !== componentVersion.title) doctitle = `${componentVersion.title}: ${doctitle}`
const version = componentVersion.version === 'master' ? '' : componentVersion.version
const buffer = [
`= ${doctitle}`,
...(version ? [`:revnumber: ${version}`] : []),
...(doctype === 'article' ? [] : [`:doctype: ${doctype ?? 'book'}`]),
':underscore: _',
// Q: should we pass these via the CLI so they cannot be modified?
`:page-component-name: ${componentVersion.name}`,
`:page-component-version:${version ? ' ' + version : ''}`,
':page-version: {page-component-version}',
`:page-component-display-version: ${componentVersion.displayVersion}`,
`:page-component-title: ${componentVersion.title}`,
]
return Object.assign(buffer, { navtitle })
}
function selectPagesInOutline (outlineEntry, pagesByUrl, componentVersion, accum) {
accum ??= Object.assign(new Map(), { assembled: { pages: new Map(), assets: new Set() } })
const page = outlineEntry.urlType === 'internal' ? pagesByUrl.get(outlineEntry.url) : undefined
if (page) {
accum.set(createResourceKey(page.src), page)
accum.set(outlineEntry.url, page)
}
for (const item of outlineEntry.items || []) selectPagesInOutline(item, pagesByUrl, componentVersion, accum)
return accum
}
function mergeAsciiDoc (
loadAsciiDoc,
contentCatalog,
buffer,
componentVersion,
outlineEntry,
files,
pagesInOutline,
asciidocConfig,
mutableAttributes,
assemblyModel,
lastComponentVersion = componentVersion,
level = 0,
supportsParts = false
) {
// TODO: we could try to be smart about it and make sure the page with fragment is included at least once
if (outlineEntry.hash) {
buffer.inBody ??= false
return buffer
}
let navtitle = outlineEntry.content
let navtitlePlain = sanitize(navtitle)
let navtitleAsciiDoc = unconvertInlineAsciiDoc(navtitle)
const { items = [], unresolved, urlType, url } = outlineEntry
const {
doctype,
filetype,
embedReferenceStyle: embedRefStyle,
linkReferenceStyle: linkRefStyle,
outDirname,
siteRoot,
xmlIds,
logger,
} = assemblyModel
// FIXME: ideally, resource ID would be stored in navigation so we can look up the page more efficiently
const page = urlType === 'internal' && !unresolved ? pagesInOutline.get(url) : undefined
const atDocumentRoot = !buffer.inBody
const atBookRoot = atDocumentRoot && !level && doctype === 'book' && (supportsParts = true)
const hasItems = items.length > 0
const pubRoot = outDirname ? '/' + outDirname : ''
const idSeparator = xmlIds ? '-' : ':'
const idScopeSeparator = idSeparator.repeat(3)
const idCoordinateSeparator = idSeparator === '-' ? '----' : idSeparator
if (page && !pagesInOutline.assembled.pages.has(page)) {
const contents = page.src.contents
if (contents == null) {
buffer.inBody ??= false
return buffer
}
const { component, version, module: module_, relative, origin, mediaType } = page.src
const pageAsAsciiDoc = new page.constructor(
Object.assign({}, page, { contents: trimAsciiDoc(contents), mediaType })
)
const doc = loadAsciiDoc(pageAsAsciiDoc, contentCatalog, asciidocConfig)
let asciidoctorLogger = doc.getLogger()
if (logger) {
asciidoctorLogger = Object.assign(asciidoctorLogger.$dup(), {
delegate: {
warn () {
return logger.warn.apply(logger, arguments)
},
},
})
}
if (doc.hasAttribute('assembly-navtitle')) {
navtitleAsciiDoc = doc.getAttribute('assembly-navtitle')
navtitlePlain = sanitize((navtitle = doc.$apply_reftext_subs(navtitleAsciiDoc)))
if (buffer.inBody == null) {
buffer.navtitle = navtitle
// Q do we need to assert !level here?
if (assemblyModel.rootLevel) {
let doctitle = navtitleAsciiDoc
if (navtitlePlain !== componentVersion.title) doctitle = `${componentVersion.title}: ${doctitle}`
buffer[0] = `= ${doctitle}`
}
}
}
if (atDocumentRoot) {
const authors = doc.getAuthors()
if (authors.length) {
const authorLine = authors
.map((author) => {
const email = author.getEmail()
return email ? `${author.getName()} <${author.getEmail()}>` : author.getName()
})
.join('; ')
buffer.splice(1, 0, authorLine)
}
}
// NOTE: in Antora, docname is relative src path from module without file extension
const docname = doc.getAttribute('docname')
const { idPrefix, id: idScope } = generateId(page.src, componentVersion, idCoordinateSeparator, idScopeSeparator)
let pageFragment = ''
let pageRoles = ''
let pageStyle = doc.getAttribute('assembly-style', '')
let part
if ((part = pageStyle === 'part')) {
pageStyle = ''
if (!supportsParts) part = undefined
} else if ((part = pageStyle.endsWith('-part'))) {
pageStyle = pageStyle.slice(0, -5)
if (!supportsParts) part = undefined
}
let nextSectionLevel = 1
const lines = doc.getSourceLines()
const ignoreLines = []
buffer.inBody = true
buffer.push('')
buffer.push(`:docname: ${docname}`)
if (component !== lastComponentVersion.name) {
const thisComponentVersion =
component === componentVersion.name && version === componentVersion.version
? componentVersion
: contentCatalog.getComponentVersion(component, version)
if (thisComponentVersion) {
buffer.push(`:page-component-name: ${thisComponentVersion.name}`)
buffer.push(`:page-component-version:${thisComponentVersion.version ? ' ' + thisComponentVersion.version : ''}`)
buffer.push(':page-version: {page-component-version}')
buffer.push(`:page-component-display-version: ${thisComponentVersion.displayVersion}`)
buffer.push(`:page-component-title: ${thisComponentVersion.title}`)
lastComponentVersion = thisComponentVersion
}
}
buffer.push(`:page-module: ${module_}`)
buffer.push(`:page-relative-src-path: ${relative}`)
buffer.push(`:page-origin-url: ${origin.url}`)
buffer.push(`:page-origin-start-path:${origin.startPath && ' '}${origin.startPath}`)
buffer.push(`:page-origin-refname: ${origin.branch || origin.tag}`)
buffer.push(`:page-origin-reftype: ${origin.branch ? 'branch' : 'tag'}`)
buffer.push(`:page-origin-refhash: ${origin.worktree ? '(worktree)' : origin.refhash}`)
if (doc.hasHeader()) pageRoles = processDocumentHeader(doc, lines, buffer, ignoreLines)
let heading
if (pageStyle && (part && level === 1 ? (level = 0) : level) === 0) {
let htitleAsciiDoc = navtitleAsciiDoc
let htitlePlain = navtitlePlain
let htitleOverride
if (
(htitleOverride = pageStyle === 'preface' ? doc.getAttribute('preface-title') : undefined) ||
(atDocumentRoot && (htitleOverride = outlineEntry.navtitle))
) {
htitleAsciiDoc = unconvertInlineAsciiDoc(htitleOverride)
htitlePlain = sanitize(htitleOverride)
}
if (atDocumentRoot && pageStyle === 'preface' && htitlePlain === componentVersion.title) {
assemblyModel = Object.assign({}, assemblyModel, { sectionMergeStrategy: 'discrete' })
} else {
if (atDocumentRoot) {
pageFragment = `#${idPrefix}${doc.getId() ?? pageStyle}`
buffer.unshift(`[#${idScope}]`)
} else {
pageFragment = `#${idScope}`
}
heading = { title: htitleAsciiDoc, level: part ? 1 : 2 }
nextSectionLevel++
}
} else if (level) {
if (atDocumentRoot && navtitlePlain === componentVersion.title) {
level--
} else {
pageFragment = `#${idScope}`
if (part && level === 1) level--
if ((heading = { title: navtitleAsciiDoc, level: level + 1 }).level > 6) {
Object.assign(heading, { level: 6, style: `discrete.h${heading.level}` })
}
}
}
if (heading) {
buffer.push(`[${heading.style ?? pageStyle}${pageFragment}${pageRoles}]`)
buffer.push(`${'='.repeat(heading.level)} ${heading.title}`)
} else if (atDocumentRoot) {
buffer.unshift(`[#${idScope}]`)
}
let enclosed
if (assemblyModel.sectionMergeStrategy === 'enclose' && hasItems && doc.hasSections()) {
enclosed = true
// TODO: make overview section title configurable
//let overviewTitle = doc.getDocumentTitle()
//if (overviewTitle === navtitle) overviewTitle = doc.getAttribute('overview-title', 'Overview')
const overviewTitle = doc.getAttribute('overview-title', 'Overview')
buffer.push('')
// NOTE: try to toggle sectids; otherwise, fallback to globally unique synthetic ID
let toggleSectids, syntheticId
if (doc.isAttribute('sectids')) {
if (doc.isAttributeLocked('sectids')) {
syntheticId = `__object-id-${getObjectId(outlineEntry)}`
} else {
buffer.push(':!sectids:')
toggleSectids = true
}
}
let hlevel = level + 2
if (hlevel > 6) {
const blockStyle = `discrete.h${hlevel}`
hlevel = 6
buffer.push(syntheticId ? `[${blockStyle}#${syntheticId}]` : `[${blockStyle}]`)
} else if (syntheticId) {
buffer.push(`[#${syntheticId}]`)
}
buffer.push(`${'='.repeat(hlevel)} ${overviewTitle}`)
if (toggleSectids) buffer.push(':sectids:')
}
pagesInOutline.assembled.pages.set(page, pageFragment)
if (doc.hasSections()) {
fixSectionLevels(doc.getSections(), atBookRoot && !pageStyle ? undefined : nextSectionLevel)
}
const allBlocks = doc.findBy({ traverse_documents: true }, (it) =>
it.getContext() === 'document'
? it.getDocument().isNested()
: !(it.getContext() === 'table_cell' && it.getStyle() === 'asciidoc')
)
if (doc.getDoctype() === 'manpage') {
const firstSectionIdx = doc.hasSections() ? doc.getSections()[0].getLineNumber() - 1 : lines.length
for (let idx = 0; idx < firstSectionIdx; idx++) {
if (~ignoreLines.indexOf(idx)) continue
const line = lines[idx]
if (line.startsWith('== ') && line.length > 3) {
allBlocks.unshift({
getContext: () => 'section',
getDocument: () => doc,
getId: () => doc.getAttribute('manname-id'),
getLineNumber: () => idx + 1,
getSectionName: () => undefined,
level: 1,
})
break
}
}
}
const refs = doc.getCatalog().refs
allBlocks.forEach((block) => {
const contentModel = block.content_model
if (
((contentModel === 'verbatim' && block.getContext() !== 'table_cell') ||
contentModel === 'simple' ||
contentModel === 'pass') &&
!block.hasSubstitution('macros')
) {
const lineno = block.getLineNumber()
const idx = typeof lineno === 'number' ? lineno - 1 : undefined
const startLine = lines[idx]
// NOTE: one case this happens if when sourcemap isn't enabled when reducing
if (startLine == null) {
console.log(`null startLine for ${block.getContext()} at ${lineno} in ${relative}`)
return
}
const char0 = startLine.charAt()
// FIXME: needs to be more robust; move logic to helper
const delimited =
startLine.length > 3 &&
startLine === char0.repeat(startLine.length) &&
(char0 === '-' || char0 === '.' || char0 === '+')
// QUESTION: exclude block attribute lines too? what about attribute entries?
for (let i = idx; i < block.lines.length + (delimited ? idx + 2 : idx); i++) ignoreLines.push(i)
}
})
let skipping
for (let idx = 0, lastIdx = lines.length - 1; idx <= lastIdx; idx++) {
if (~ignoreLines.indexOf(idx)) continue
let line = lines[idx]
if (line.startsWith('//')) {
if (line[2] !== '/') continue
if (line.length > 3 && line === '/'.repeat(line.length)) {
if (skipping) {
if (line === skipping) skipping = undefined
} else {
skipping = line
}
continue
}
} else if (skipping) {
continue
}
if (
line.charAt() === ':' &&
~line.indexOf(':', 2) &&
(line.match(AttributeEntryRx) || ['', ''])[1].replace('!', '') === 'leveloffset'
) {
if (lines[idx - 1] === '') lines[idx - 1] = undefined
lines[idx] = undefined
continue
}
if (~line.indexOf('<<')) {
line = line.replace(/(?<![\\+])<<#?([\p{Alpha}0-9_/.:{][^>,]*?)(?:|, *([^>]+?))?>>/gu, (m, refid, text) => {
// support natural xref
if (!refs['$key?'](refid) && (~refid.indexOf(' ') || refid.toLowerCase() !== refid)) {
if ((refid = doc.$resolve_id(refid))['$nil?']()) return m
}
return `<<${idPrefix}${refid}${text ? ',' + text : ''}>>`
})
}
// NOTE: the next check takes care of inline and block anchors
if (~line.indexOf('[[')) {
line = line.replace(/\[\[([\p{Alpha}_:][\p{Alpha}0-9_\-:.]*)(|, *.+?)\]\]/gu, `[[${idPrefix}$1$2]]`)
}
if (~line.indexOf('xref:')) {
// Q: should we allow : as first character of target?
line = line.replace(/(?<![\\+])xref:((?:\.\/)?[\p{Alpha}0-9_/.{#].*?)\[(|.*?[^\\])\]/gu, (_, target, text) => {
let fragment, resource, resourceRef
const hashIdx = target.indexOf('#')
if (~hashIdx) {
resourceRef = target.slice(0, hashIdx)
fragment = target.slice(hashIdx + 1)
} else if (target.endsWith('.adoc') || ~target.indexOf('$')) {
resourceRef = target
fragment = ''
} else {
fragment = target
}
// Q: should we validate the internal ID here?
if (!resourceRef) return `xref:${idPrefix}${fragment}[${text}]`
const resourceId = parseResourceRef(resourceRef, page.src, 'page', contentCatalog)
if (resourceId.family !== 'page' || !(resource = pagesInOutline.get(createResourceKey(resourceId)))) {
if ((resource = contentCatalog.getById(resourceId))?.pub) {
text ||= resource.asciidoc?.xreftext || target
if (siteRoot || linkRefStyle === 'relative') {
return `${resolveLinkTarget(resource, siteRoot, pubRoot, linkRefStyle)}${fragment && '#' + fragment}[${text}]`
}
asciidoctorLogger.warn(
doc.createLogMessage(
`Cannot create external ${resourceId.family} reference in assembly because site URL is unknown: ${target}`,
{ source_location: { file: relative, lineno: idx + 1 } }
)
)
}
const linkAttrlist = text
? (~text.indexOf(',') ? `"${text}"` : text) + ',role=unresolved'
: 'role=unresolved'
return `link:${target}[${linkAttrlist}]`
}
if (fragment === resource.asciidoc.id) fragment = ''
if (
text &&
(assemblyModel.dropExplicitXrefText === 'always' ||
(assemblyModel.dropExplicitXrefText === 'if-redundant' && text === resource.title))
) {
text = ''
}
const refid = generateId(
resource.src,
componentVersion,
idCoordinateSeparator,
idScopeSeparator,
text.length > 0,
fragment
).id
return `xref:${refid}[${text}]`
})
}
if (~line.indexOf('link:{attachmentsdir}/')) {
line = line.replace(/(?<![\\+])link:\{attachmentsdir\}\/([^\s[]+)\[(|.*?[^\\])\]/g, (m, relative, text) => {
const attachment =
(siteRoot || linkRefStyle === 'relative') &&
contentCatalog.getById({
component: componentVersion.name,
version: componentVersion.version,
module: module_,
family: 'attachment',
relative,
})
// TODO: handle unresolved attachment page
return attachment?.out ? `${resolveLinkTarget(attachment, siteRoot, pubRoot, linkRefStyle)}[${text}]` : m
})
}
if (~line.indexOf('image:') && !line.startsWith('image::')) {
line = line.replace(/(?<![\\+])image:([^:\s[](?:[^[]*[^\s[])?)\[([^\]]*)\]/g, (m, target, attrlist) => {
let image
if (
isResourceSpec(target) &&
(image = contentCatalog.resolveResource(target, page.src, 'image', ['image']))?.out
) {
if (filetype !== 'html') {
pagesInOutline.assembled.assets.add(image)
return `image:${resolveEmbedTarget(image, outDirname, embedRefStyle, true)}[${attrlist}]`
}
if (siteRoot || linkRefStyle === 'relative') {
pagesInOutline.assembled.assets.add(image)
return `image:${resolveLinkTarget(image, siteRoot, pubRoot, linkRefStyle)}[${attrlist}]`
}
asciidoctorLogger.warn(
doc.createLogMessage(
`Cannot create external image reference in assembly because site URL is unknown: ${target}`,
{ source_location: { file: relative, lineno: idx + 1 } }
)
)
}
return m
})
}
lines[idx] = line
}
// NOTE: need to do this last since it modifies the line numbers
// we could remap the line numbers to make them resilient
// or we could mark which lines to remove and filter them after
let lastImageMacroAt
;[...allBlocks].reverse().forEach((block) => {
const lineno = block.getLineNumber()
// NOTE: lineno is not defined for preamble
if (typeof lineno !== 'number') return
const context = block.getContext()
let idx = lineno - 1
if (context === 'section' && !block.getDocument().isNested()) {
if (block.getSectionName() === 'header') return
let blockStyle = (assemblyModel.sectionMergeStrategy || 'discrete') === 'discrete' ? 'discrete' : undefined
lines[idx] = lines[idx].replace(/^=+ (.+)/, (_, rest) => {
let targetMarkerLength = block.level + 1 + level + (enclosed ? 1 : 0)
if (targetMarkerLength > 6) {
blockStyle = `discrete.h${targetMarkerLength}`
targetMarkerLength = 6
}
return '='.repeat(targetMarkerLength) + ' ' + rest
})
// NOTE: ID will be undefined if sectids are turned off
if (block.getId()) rewriteStyleAttribute(block, lines, idx, idPrefix, blockStyle)
} else {
if (context === 'image') {
let line = lines[idx] || ''
let prefix = ''
// Q: can we use startsWith('image::') in certain cases?
let imageMacroOffset = (
lastImageMacroAt?.[0] === idx ? line.slice(0, lastImageMacroAt[1]) : line
).lastIndexOf('image::')
if (imageMacroOffset > 0) {
if (
block.getDocument().isNested() &&
(prefix = line.slice(0, imageMacroOffset)).trimRight().endsWith('|')
) {
line = line.slice(prefix.length)
} else {
imageMacroOffset = -1
}
}
if (imageMacroOffset >= 0) {
const target = block.getAttribute('target')
if (isResourceSpec(target)) {
const image = contentCatalog.resolveResource(target, page.src, 'image', ['image'])
// FIXME: handle (or report) case when image is not resolved
if (image?.out && (filetype !== 'html' || siteRoot)) {
const attrlist = line.slice(line.indexOf('[') + 1, -1)
pagesInOutline.assembled.assets.add(image)
lines[idx] =
filetype === 'html'
? `${prefix}image::${resolveLinkTarget(image, siteRoot, pubRoot, linkRefStyle, false)}[${attrlist}]`
: `${prefix}image::${resolveEmbedTarget(image, outDirname, embedRefStyle)}[${attrlist}]`
}
}
lastImageMacroAt = [idx, imageMacroOffset]
}
} else if (context === 'document' && block.hasHeader()) {
// nested document
idx = (block.getHeader().getLineNumber() || idx + 1) - 1
}
if (block.getId()) rewriteStyleAttribute(block, lines, idx, idPrefix)
}
})
safePush(
buffer,
lines.filter((it) => it !== undefined)
)
const attributeEntries = Object.entries(doc.source_header_attributes?.$$smap || {})
if (attributeEntries.length) {
const resolvedAttributeEntries = attributeEntries.reduce(
(accum, [name, val]) => {
// Q: couldn't we just check if attribute is locked?
if (name in mutableAttributes) {
const initialVal = mutableAttributes[name]
if (initialVal == null) {
if (val != null) accum.push(`:!${name}:`)
} else if (val !== initialVal) {
accum.push(`:${name}:${initialVal ? ' ' + initialVal : ''}`)
}
} else if (!(val == null || doc.isAttributeLocked(name) || DiscardAttributes.includes(name))) {
accum.push(`:!${name}:`)
}
return accum
},
['']
)
if (resolvedAttributeEntries.length > 1) safePush(buffer, resolvedAttributeEntries)
}
} else if (level) {
if (atDocumentRoot && navtitlePlain === componentVersion.title) {
buffer.inBody ??= false
level--
} else {
buffer.inBody = true
buffer.push('')
// NOTE: try to toggle sectids; otherwise, fallback to globally unique synthetic ID
// Q: should we unset docname, page-module, etc?
let toggleSectids, syntheticId
if (!('sectids' in asciidocConfig.attributes)) {
buffer.push(':!sectids:')
toggleSectids = true
} else if (typeof asciidocConfig.attributes.sectids === 'string') {
if ('sectids' in mutableAttributes) {
buffer.push(':!sectids:')
toggleSectids = true
} else {
syntheticId = `__object-id-${global.Opal.hash(outlineEntry).$object_id()}`
}
}
let sectionTitle = navtitleAsciiDoc
if (urlType === 'external') {
sectionTitle = `${url}[${navtitleAsciiDoc.replace(/\]/g, '\\]')}]`
} else if (urlType === 'internal' && !unresolved) {
const resource = files.find((it) => it.pub.url === url)
if (resource) {
if (resource.src.family === 'page' && pagesInOutline.has(resource.pub.url)) {
const refid = generateId(resource.src, componentVersion, idCoordinateSeparator, idScopeSeparator, true).id
sectionTitle = `xref:${refid}[${navtitleAsciiDoc.replace(/\]/g, '\\]')}]`
} else if (siteRoot) {
sectionTitle = `${resolveLinkTarget(resource, siteRoot, pubRoot, linkRefStyle)}[${navtitleAsciiDoc.replace(/\]/g, '\\]')}]`
}
}
}
let hlevel = level + 1
if (hlevel > 6) {
hlevel = 6
buffer.push(syntheticId ? `[discrete#${syntheticId}]` : '[discrete]')
} else if (syntheticId) {
buffer.push(`[#${syntheticId}]`)
}
buffer.push(`${'='.repeat(hlevel)} ${sectionTitle}`)
if (toggleSectids) buffer.push(':sectids:')
}
}
if (hasItems) {
const nextLevel = level + 1
// NOTE: drop first child if same as parent; should we keep if content is different?
;(urlType === 'internal' && urlType === items[0].urlType && url === items[0].url && !items[0].items
? items.slice(1)
: items
).forEach((item) => {
mergeAsciiDoc(
loadAsciiDoc,
contentCatalog,
buffer,
componentVersion,
item,
files,
pagesInOutline,
asciidocConfig,
mutableAttributes,
assemblyModel,
lastComponentVersion,
nextLevel,
atBookRoot
)
})
}
return buffer
}
function processDocumentHeader (doc, lines, buffer, ignoreLines) {
const doctitleIdx = doc.getHeader().getLineNumber() - 1
const end = doc.getBlocks()[0]?.getLineNumber() ?? lines.length
let belowDoctitle
let open
const implicitLines = []
for (let idx = 0; idx < end; idx++) {
if (idx === doctitleIdx) {
lines[idx] = undefined
ignoreLines.push(idx)
belowDoctitle = true
continue
}
const line = lines[idx]
if (open === ':' || open === '-:') {
if (open === ':') buffer.push(line)
if (!line || !line.endsWith(' \\')) open = undefined
} else if (line) {
const chr0 = line.charAt()
let attributeEntryMatch
if (chr0 === '/' && line.charAt(1) === '/') {
if (line.startsWith('////')) {
open = open ? (open === line ? undefined : open) : line
} else if (belowDoctitle && !open && line.charAt(2) === '/') {
break
}
buffer.push(line)
} else if (open) {
buffer.push(line)
} else if (chr0 === ':' && ~line.indexOf(':', 2) && (attributeEntryMatch = line.match(AttributeEntryRx))) {
const attributeName = attributeEntryMatch[1].replace('!', '')
if (DiscardAttributes.includes(attributeName)) {
if (line.endsWith(' \\')) open = '-:' // disallow value continuation
} else {
if (line.endsWith(' \\')) open = ':'
buffer.push(line)
}
} else if (belowDoctitle) {
if (implicitLines.length === 2 || !/[\p{Alpha}0-9]/u.test(chr0)) break
implicitLines.push(line)
} else if (chr0 === '[' && line.charAt(line.length - 1) === ']') {
const attrlist = line
.slice(1, -1)
.trim()
.replace(/(?:^\w[\w-]*(?!=|[\w-]))?([#.%]\w[\w-]*)*/, '')
if (attrlist) buffer.push('[' + attrlist + ']')
}
} else if (belowDoctitle) {
break
}
lines[idx] = undefined
ignoreLines.push(idx)
}
return doc
.getRoles()
.map((role) => '.' + role)
.join('')
}
function generateSlug (title) {
return title
.toLowerCase()
.replace(/<[^>]+>/g, '')
.replace(CharRefRx, (_, name, dec, hex) => {
if (name) return BuiltInNamedEntities[name] ?? '?'
return String.fromCharCode(dec ? parseInt(dec, 10) : parseInt(hex, 16))
})
.replace(/[\x27\u2019]/g, '')
.replace(/[^\p{Alpha}0-9-]/gu, '-')
.replace(/^-+|-+$|(-)-+/g, '$1')
}
function fixSectionLevels (sections, expectedLevel) {
sections.forEach((sect) => {
const forceLevel = expectedLevel ?? Math.min(1, sect.getLevel())
if (sect.getLevel() !== forceLevel) sect.level = forceLevel
if (sect.hasSections()) fixSectionLevels(sect.getSections(), forceLevel + 1)
})
}
function rewriteStyleAttribute (block, lines, idx, idPrefix, replacementStyle = '') {
let prevLine = lines[idx - 1]
const char0 = prevLine?.charAt()
if (char0) {
if (
(char0 === '.' && /^\.\.?[^ \t.]/.test(prevLine)) ||
(char0 === '[' &&
prevLine.charAt(1) === '[' &&
/^\[\[(?:|[\p{Alpha}_:][\p{Alpha}0-9_\-:.]*(?:, *.+)?)\]\]$/u.test(prevLine))
) {
return rewriteStyleAttribute(block, lines, idx - 1, idPrefix, replacementStyle)
}
}
let cellSpec
if (
char0 &&
(char0 === '[' || (block.getDocument().isNested() && (cellSpec = prevLine.match(/^([^[|]*)\| *(\[.+)/)))) &&
prevLine.charAt(prevLine.length - 1) === ']'
) {
if (cellSpec) {
prevLine = cellSpec[2]
cellSpec = cellSpec[1]
}
let rawStyle
const commaIdx = prevLine.indexOf(',')
if (~commaIdx) {
rawStyle = prevLine.slice(1, commaIdx)
if (~rawStyle.indexOf('=')) rawStyle = undefined
} else if (!~prevLine.indexOf('=')) {
rawStyle = prevLine.slice(1, prevLine.length - 1)
}
if (rawStyle) {
if (~rawStyle.indexOf('#')) {
prevLine = prevLine.replace(/#[^.%,\]]+/, `#${idPrefix}${block.getId()}`)
if (replacementStyle) prevLine = prevLine.replace(/^[^#.%,\]]+/, `[${replacementStyle}`)
} else {
prevLine = `[${
replacementStyle ? rawStyle.replace(/^[^.%]*/, replacementStyle) : rawStyle
}#${idPrefix}${block.getId()}${prevLine.slice(rawStyle.length + 1)}`
}
} else {
prevLine = `[${replacementStyle}#${idPrefix}${block.getId()}${rawStyle == null ? ',' : ''}${prevLine.slice(1)}`
}
if (cellSpec) prevLine = `${cellSpec}|${prevLine}`
lines[idx - 1] = prevLine
} else {
lines.splice(idx, 0, `[${replacementStyle}#${idPrefix}${block.getId()}]`)
}
}
function isResourceSpec (str) {
return !(~str.indexOf(':') && (~str.indexOf('://') || (str.startsWith('data:') && ~str.indexOf(','))))
}
function getObjectId (obj) {
return global.Opal.id(obj)
}
// NOTE: blank lines at top and bottom of document create mismatch when using line numbers to navigate source lines
// IMPORTANT: this must not leave behind lines the parser will drop!
// IDEA: another option is to capture initial lineno of reader and use as offset (but preseves those blank lines)
function trimAsciiDoc (buffer) {
return Buffer.from(
buffer
.toString()
.replace(/^(?:[ \t]*\r\n?|[ \t]*\n)+/, '')
.trimRight()
)
}
function safePush (onto, entries) {
try {
onto.push(...entries)
} catch (err) {
/* istanbul ignore if */
if (!(err instanceof RangeError)) throw err
for (const e of entries) onto.push(e)
}
}
function resolveEmbedTarget (resource, outDirname, referenceStyle, escapeForInline) {
const target =
referenceStyle === 'output-relative' ? resource.out.path : path.relative(outDirname + '/', resource.out.path)
return escapeForInline ? target.replace(/_/g, '{underscore}') : target
}
function resolveLinkTarget (resource, siteRoot, pubRoot, referenceStyle, escapeForInline = true) {
let target
if (resource.site?.url) {
target = ['', resource.pub.url]
} else {
switch (referenceStyle) {
case 'absolute':
target = ['', siteRoot.url + resource.pub.url]
break
case 'root-relative':
target = ['link:', siteRoot.path + resource.pub.url]
break
default:
target = ['link:', computeRelativeUrl(pubRoot + '/', resource.pub.url)]
}
}
if (escapeForInline) target[1] = target[1].replace(/_/g, '{underscore}')
return target.join('')
}
function computeRelativeUrl (from, to) {
const rel = path.relative(from, to)
return to.charAt(to.length - 1) === '/' ? rel + '/' : rel
}
function createResourceKey ({ component, version, module: mod, family, relative }) {
return `${version}@${component}:${mod === 'ROOT' ? '' : mod}:${family === 'page' ? '' : family + '$'}${relative}`
}
function generateId (componentSrc, componentVersion, coordinateSep, scopeSep, asXrefTarget, fragment) {
let { component, module: mod, relative } = componentSrc
let id = relative.replace(/\.adoc$/, '').replace(/[/.]/g, '-')
if (component !== componentVersion.name) {
id = [component, mod === 'ROOT' ? '' : mod, id].join(coordinateSep)
} else if (mod !== 'ROOT') {
if (asXrefTarget && coordinateSep === ':' && /(?:pass|stem)$/.test(mod) && /^[a-z]+(?:,[a-z-]+)*$/.test(id)) {
mod = mod.replace(/(?:pass|stem)$/, '\\$&')
}
id = mod + coordinateSep + id
} else if (ReservedIdNames.includes(id)) {
id += scopeSep
scopeSep = ''
}
const idPrefix = id + scopeSep
if (fragment) id = idPrefix + fragment
return { idPrefix, id }
}
module.exports = produceAssemblyFile