@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.
756 lines (740 loc) • 30.8 kB
JavaScript
const createAsciiDocFile = require('./util/create-asciidoc-file')
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-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 navtitle = outline.content
const buffer = mergeAsciiDoc(
loadAsciiDoc,
contentCatalog,
buildAsciiDocHeader(componentVersion, navtitle, assemblyModel),
componentVersion,
outline,
files,
pagesInOutline,
asciidocConfig,
mutableAttributes,
assemblyModel
)
const rootLevel = assemblyModel.rootLevel
const stem = rootLevel === 0 ? 'index' : generateSlug(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
return [
`= ${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}`,
]
}
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) {
if (page.src.component === componentVersion.name && page.src.version === componentVersion.version) {
accum.set(`${page.src.module === 'ROOT' ? '' : page.src.module + ':'}${page.src.relative}`, page)
}
accum.set(page.pub.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) return buffer
const { content: navtitle, items = [], unresolved, urlType, url } = outlineEntry
const atDocumentRoot = !buffer.inBody
const atBookRoot = atDocumentRoot && !level && assemblyModel.doctype === 'book' && (supportsParts = true)
const hasItems = items.length > 0
const navtitlePlain = sanitize(navtitle)
const navtitleAsciiDoc = unconvertInlineAsciiDoc(navtitle)
const siteUrl = ((val) => {
if (!val || val === '/') return ''
return val.charAt(val.length - 1) === '/' ? val.slice(0, val.length - 1) : val
})(asciidocConfig.attributes['primary-site-url'] || asciidocConfig.attributes['site-url'])
const idSeparator = assemblyModel.xmlIds ? '-' : ':'
const idScopeSeparator = idSeparator.repeat(3)
const idCoordinateSeparator = idSeparator === '-' ? '----' : idSeparator
// FIXME: ideally, resource ID would be stored in navigation so we can look up the page more efficiently
let page = urlType === 'internal' && !unresolved ? pagesInOutline.get(url) : undefined
if (page && pagesInOutline.assembled.pages.has(page)) page = undefined
if (page) {
let contents = page.src.contents
if (contents == null) return buffer
// 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)
contents = Buffer.from(
contents
.toString()
.replace(/^(?:[ \t]*\r\n?|[ \t]*\n)+/, '')
.trimRight()
)
const { component, version, module: module_, relative, origin } = page.src
const topicPrefix = ~relative.indexOf('/') ? path.dirname(relative) + '/' : ''
const pageAsAsciiDoc = new page.constructor(Object.assign({}, page, { contents, mediaType: page.src.mediaType }))
const doc = loadAsciiDoc(pageAsAsciiDoc, contentCatalog, asciidocConfig)
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 docnameForId = docname.replace(/[/.]/g, '-')
const qualifyId = component !== componentVersion.name
let idScope = docnameForId
let idPrefix
if (qualifyId) {
idScope = [component, module_ === 'ROOT' ? '' : module_, idScope].join(idCoordinateSeparator)
} else if (module_ !== 'ROOT') {
idScope = module_ + idCoordinateSeparator + idScope
} else if (ReservedIdNames.includes(docnameForId)) {
idScope = idPrefix = idScope + idScopeSeparator
}
idPrefix ??= idScope + 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-type: ${origin.type}`)
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, (m, target, text) => {
let pagePart, fragment, targetPage
const hashIdx = target.indexOf('#')
if (~hashIdx) {
pagePart = target.slice(0, hashIdx)
fragment = target.slice(hashIdx + 1)
// TODO: for now, assume .adoc; in the future, consider other file extensions
if (pagePart && !pagePart.endsWith('.adoc')) pagePart += '.adoc'
} else if (target.endsWith('.adoc')) {
pagePart = target
fragment = ''
} else {
fragment = target
}
if (!pagePart) {
// Q: should we validate the internal ID here?
return text && ~text.indexOf('=')
? `xref:${idPrefix}${fragment}[${text}]`
: `<<${idPrefix}${fragment}${text ? ',' + text.replace(/\\]/g, ']') : ''}>>`
}
if (~pagePart.indexOf('@') || /:.*:/.test(pagePart)) {
if (siteUrl && (targetPage = contentCatalog.resolvePage(pagePart, page.src)) && targetPage.out) {
text ||= targetPage.asciidoc?.xreftext || target
return `${siteUrl}${targetPage.pub.url}${fragment && '#' + fragment}[${text}]`
}
// TODO: handle unresolved page better
return m
}
let targetModule
const colonIdx = pagePart.indexOf(':')
if (~colonIdx) {
targetModule = pagePart.slice(0, colonIdx)
pagePart = pagePart.slice(colonIdx + 1)
} else {
targetModule = module_
}
if (pagePart.startsWith('./')) pagePart = topicPrefix + pagePart.slice(2)
const pageResourceRef = targetModule === 'ROOT' ? pagePart : `${targetModule}:${pagePart}`
if (!(targetPage = pagesInOutline.get(pageResourceRef))) {
if (siteUrl && (targetPage = contentCatalog.resolvePage(pageResourceRef, page.src)) && targetPage.out) {
text ||= targetPage.asciidoc?.xreftext || target
return `${siteUrl}${targetPage.pub.url}${fragment && '#' + fragment}[${text}]`
}
// TODO: handle unresolved page better
return m
}
if (targetModule !== 'ROOT') pagePart = `${targetModule}${idCoordinateSeparator}${pagePart}`
pagePart = pagePart.replace(/\.adoc$/, '').replace(/[/.]/g, '-')
const refid = fragment
? `${pagePart}${idScopeSeparator}${fragment}`
: pagePart + (ReservedIdNames.includes(pagePart) ? idScopeSeparator : '')
return `<<${refid}${text && text !== targetPage.title ? ',' + text.replace(/\\]/g, ']') : ''}>>`
})
}
if (~line.indexOf('link:{attachmentsdir}/')) {
line = line.replace(/(?<![\\+])link:\{attachmentsdir\}\/([^\s[]+)\[(|.*?[^\\])\]/g, (m, relative, text) => {
const attachment =
siteUrl &&
contentCatalog.getById({
component: componentVersion.name,
version: componentVersion.version,
module: module_,
family: 'attachment',
relative,
})
// TODO: handle unresolved attachment page
return attachment?.out ? `${siteUrl}${attachment.pub.url.replace(/_/g, '{underscore}')}[${text}]` : m
})
}
if (~line.indexOf('image:') && !line.startsWith('image::')) {
line = line.replace(/(?<![\\+])image:([^:\s[](?:[^[]*[^\s[])?)\[([^\]]*)\]/g, (m, target, attrlist) => {
if (isResourceSpec(target)) {
const image = contentCatalog.resolveResource(target, page.src, 'image', ['image'])
// TODO: handle (or report) unresolved image better
if (image?.out) {
pagesInOutline.assembled.assets.add(image)
return `image:${image.out.path.replace(/_/g, '{underscore}')}[${attrlist}]`
}
}
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) {
const boxedAttrlist = line.slice(line.indexOf('['))
pagesInOutline.assembled.assets.add(image)
lines[idx] = `${prefix}image::${image.out.path}${boxedAttrlist}`
}
}
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) {
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 && siteUrl) {
const resource = files.find((it) => it.pub.url === url)
if (resource) sectionTitle = `${siteUrl}${resource.pub.url}[${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 (line) {
if (open === ':') buffer.push(line)
if (!line.endsWith(' \\')) open = undefined
} else {
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 = '-:'
} 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 (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.uid()
}
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)
}
}
module.exports = produceAssemblyFile