specpress
Version:
Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files
639 lines (577 loc) • 24.2 kB
JavaScript
const fs = require('fs')
const path = require('path')
const { parseBullet, DEFAULT_BULLET } = require('./bullets')
/**
* Shared utilities for specification processing.
*
* Provides paragraph classification, file collection, and helper functions
* used by both the HTML renderer (md2html) and the DOCX converter (md2docx).
*/
/** Paragraph classification constants returned by classifyParagraph. */
const PARA = {
NOTE: 'note',
EDITORS_NOTE: 'editorsNote',
EXAMPLE: 'example',
TOC: 'toc',
CR_HISTORY: 'crHistory',
TABLE_CAPTION: 'tableCaption',
FIGURE_CAPTION: 'figureCaption',
DISPLAY_MATH: 'displayMath',
IMAGE: 'image',
PARAGRAPH: 'paragraph',
}
/**
* Extracts plain text from a markdown-it inline token.
*
* @param {Object} token - A markdown-it token.
* @returns {string}
*/
function extractText(token) {
if (!token) return ''
if (token.children) return token.children.map(t => t.content || '').join('')
return token.content || ''
}
/**
* Checks whether the token at idx is a paragraph containing a {ChangeHistory} placeholder.
*/
function isChangeHistoryPlaceholder(tokens, idx) {
if (tokens[idx].type !== 'paragraph_open') return false
const inline = tokens[idx + 1]
if (!inline || inline.type !== 'inline') return false
return /^\{ChangeHistory\}$/.test(extractText(inline).trim())
}
/**
* Checks whether the token at idx is a paragraph containing a JsonTable link.
*
* @param {Object[]} tokens - markdown-it token array.
* @param {number} idx - Index of the paragraph_open token.
* @returns {boolean}
*/
function isJsonTableLink(tokens, idx) {
if (tokens[idx].type !== 'paragraph_open') return false
const inline = tokens[idx + 1]
if (!inline || inline.type !== 'inline' || !inline.children) return false
return inline.children.some((c, i) =>
c.type === 'link_open' && c.attrGet('href')?.endsWith('.json') &&
inline.children[i + 1]?.type === 'text' && inline.children[i + 1].content === 'JsonTable'
)
}
/**
* Scans a token array and returns a Set of indices where inline tokens
* represent [JsonTable](*.json) links.
*
* @param {Object[]} tokens - markdown-it token array.
* @returns {Set<number>}
*/
function buildJsonTableIndices(tokens) {
const indices = new Set()
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'inline' && tokens[i].children) {
const children = tokens[i].children
if (children.length === 3 &&
children[0].type === 'link_open' &&
children[1].type === 'text' && children[1].content === 'JsonTable' &&
children[2].type === 'link_close') {
const href = children[0].attrGet('href')
if (href && href.endsWith('.json')) indices.add(i)
}
}
}
return indices
}
/**
* Classifies a paragraph_open token based on its content and surrounding
* context. Returns a classification string from PARA.
*
* Used by both the HTML paragraph_open renderer rule and the DOCX
* walkTokens dispatch loop, ensuring consistent classification.
*
* @param {Object[]} tokens - Full token array.
* @param {number} idx - Index of the paragraph_open token.
* @param {Set<number>} jsonTableIndices - Indices of JsonTable link inline tokens.
* @returns {string} One of the PARA.* classification constants.
*/
function classifyParagraph(tokens, idx, jsonTableIndices) {
const content = tokens[idx + 1]
if (!content || content.type !== 'inline') return PARA.PARAGRAPH
const text = extractText(content)
if (/^\$\$[\s\S]+\$\$$/.test(content.content)) return PARA.DISPLAY_MATH
if (content.children && content.children.some(c => c.type === 'image')) return PARA.IMAGE
if (/^(\*\*)?Editor[\u2019']s Note(\*\*)?/.test(text)) return PARA.EDITORS_NOTE
if (/^(\*\*)?NOTE(\*\*)?( \d+)?:/.test(text)) return PARA.NOTE
if (/^(\*\*)?EXAMPLE(\*\*)?/.test(text)) return PARA.EXAMPLE
if (/^\{TableOfContent\(\d+-\d+\)\}$/.test(text.trim())) return PARA.TOC
if (/^\{ChangeHistory\}$/.test(text.trim())) return PARA.CR_HISTORY
if (/^(\*\*)?Table/i.test(text)) {
let followIdx = idx + 2
while (followIdx < tokens.length && tokens[followIdx].type === 'paragraph_close') followIdx++
if (followIdx < tokens.length && (
tokens[followIdx].type === 'table_open' ||
(tokens[followIdx].type === 'fence' && tokens[followIdx].info === 'jsonTable') ||
isJsonTableLink(tokens, followIdx) ||
jsonTableIndices.has(followIdx + 1) ||
isChangeHistoryPlaceholder(tokens, followIdx)
)) return PARA.TABLE_CAPTION
}
if (/^(\*\*)?Figure/i.test(text)) {
let prevIdx = idx - 1
while (prevIdx >= 0 && (tokens[prevIdx].type === 'paragraph_close' || tokens[prevIdx].hidden)) prevIdx--
if (prevIdx >= 0) {
const prevToken = tokens[prevIdx]
if (prevToken.type === 'fence' ||
(prevToken.type === 'inline' && prevToken.children && prevToken.children.some(c => c.type === 'image'))) {
return PARA.FIGURE_CAPTION
}
}
}
return PARA.PARAGRAPH
}
/**
* Recursively collects markdown and ASN.1 files from paths.
*
* @param {string|string[]} paths - Absolute path(s) to files or directories.
* @returns {string[]} Sorted array of absolute file paths.
*/
function collectFiles(paths) {
if (typeof paths === 'string') paths = [paths]
const results = []
const walk = (dir) => {
for (const item of fs.readdirSync(dir)) {
const full = path.join(dir, item)
if (fs.statSync(full).isDirectory()) walk(full)
else if (/\.(md|markdown|asn)$/.test(item)) results.push(full)
}
}
for (const p of paths) {
if (fs.statSync(p).isDirectory()) walk(p)
else results.push(p)
}
return results.sort()
}
/**
* Generates markdown heading lines for intermediate folders that haven't
* been seen yet. For each ancestor folder between specRootPath and the
* file's parent directory, emits a heading if the folder has a valid
* section number and hasn't been emitted before.
*
* @param {string} filePath - Absolute path to the current file.
* @param {string} specRootPath - Absolute path to the specification root.
* @param {Set<string>} emittedFolders - Set of folder paths already emitted.
* @returns {string} Markdown heading lines to prepend (may be empty).
*/
function generateFolderHeadings(filePath, specRootPath, emittedFolders) {
const dir = path.dirname(filePath)
const rel = path.relative(specRootPath, dir).replace(/\\/g, '/')
if (!rel || rel.startsWith('..')) return ''
// If this file covers its parent folder's heading (leading 0 in filename),
// mark that folder as emitted so we don't duplicate it
const basename = path.basename(filePath).replace(/\.[^.]+$/, '')
const bm = basename.match(/^(\d+)/)
if (bm && parseInt(bm[1], 10) === 0) {
emittedFolders.add(dir.replace(/\\/g, '/'))
}
const parts = rel.split('/')
let result = ''
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
const absFolderPath = path.join(specRootPath, folderPath).replace(/\\/g, '/')
if (emittedFolders.has(absFolderPath)) continue
emittedFolders.add(absFolderPath)
const seg = parts[i]
const m = seg.match(/^(\d+)/)
if (!m) continue
const n = parseInt(m[1], 10)
if (n === 0) continue
const numbers = []
for (let j = 0; j <= i; j++) {
const fm = parts[j].match(/^(\d+)/)
if (fm) {
const fn = parseInt(fm[1], 10)
if (fn !== 0) numbers.push(fn)
}
}
if (numbers.length === 0) continue
const sectionNumber = numbers.join('.')
const level = numbers.length
const heading = seg.replace(/^\d+[_\s-]*/, '').trim()
const hashes = '#'.repeat(level)
result += `<!-- AUTO-HEADING -->\n${hashes} ${sectionNumber} ${heading}\n\n`
}
return result
}
/**
* Reads and concatenates files into a single markdown string.
*
* @param {string[]} files - Sorted array of absolute file paths.
* @param {Function} [readFile] - Optional callback `(filePath) => string`.
* @param {string} [specRootPath=''] - Specification root for ASN heading level derivation.
* @returns {string} Concatenated markdown content.
*/
function concatenateFiles(files, readFile, specRootPath) {
const { asnToMarkdown } = require('../md2html/handlers/asnHandler')
if (!readFile) readFile = (f) => fs.readFileSync(f, 'utf8')
const emittedFolders = new Set()
return files.map(f => {
const text = readFile(f).replace(/\r\n/g, '\n')
const folderHeadings = specRootPath ? generateFolderHeadings(f, specRootPath, emittedFolders) : ''
if (f.endsWith('.asn')) return `<!-- FILE: ${f} -->\n` + folderHeadings + asnToMarkdown(text, specRootPath, f)
return `<!-- FILE: ${f} -->\n` + folderHeadings + text
}).join('\n\n')
}
/**
* Formats a consistent export completion message.
*
* @param {string} fileType - Output format (e.g. 'HTML', 'DOCX').
* @param {number} fileCount - Number of source files processed.
* @param {number} imageCount - Number of images embedded.
* @param {string} [extra] - Optional extra info appended inside the parentheses.
* @returns {string}
*/
function formatExportMessage(fileType, fileCount, imageCount, extra) {
const parts = [`${imageCount} image${imageCount !== 1 ? 's' : ''}`]
if (extra) parts.push(extra)
return `Generated ${fileType} file from ${fileCount} source file${fileCount !== 1 ? 's' : ''} (${parts.join(', ')}).`
}
/**
* Extracts a section number and heading name from a file path relative to
* the specification root.
*
* Walks each path segment (folders + filename without extension) from rootPath
* to filePath. If a segment starts with a number, that number (as integer) is
* collected. The collected numbers are joined with dots.
*
* The derivedSectionHeading is the filename remainder after stripping the
* leading number, the extension, and surrounding whitespace/underscores.
*
* @param {string} filePath - Absolute path to the source file.
* @param {string} rootPath - Absolute path to the specification root directory.
* @returns {{ sectionNumber: string, sectionHeading: string }}
*/
function extractSectionNumber(filePath, rootPath) {
const rel = path.relative(rootPath, filePath)
if (!rel || rel.startsWith('..')) return { sectionNumber: '', sectionHeading: '' }
const segments = rel.replace(/\\/g, '/').split('/')
const last = segments.length - 1
const baseName = segments[last].replace(/\.[^.]+$/, '')
segments[last] = baseName
const numbers = []
for (const seg of segments) {
const m = seg.match(/^(\d+)/)
if (m) {
const n = parseInt(m[1], 10)
if (n !== 0) numbers.push(n)
}
}
const sectionHeading = baseName.replace(/^\d+[_\s-]*/, '').trim()
return { sectionNumber: numbers.join('.'), sectionHeading }
}
const ERROR_MARKER = 'E.R.R.O.R'
/**
* Parses a heading text for an x-placeholder and trailing numeric parts.
*
* The placeholder is one or more literal 'x' separated by dots (e.g. x, x.x,
* x.x.x), optionally followed by dot-delimited numbers. Together they form
* the actualSectionNumber.
*
* @param {string} text - Heading text content.
* @returns {{ placeholder: string, actualSectionNumber: string, actualHeadingLevel: number, actualHeadingName: string }|null}
* null if no placeholder found.
*/
function parsePlaceholder(text) {
const m = text.match(/^(x(?:\.x)*)((?:\.\d+)*)\s*(.*)/)
if (!m) return null
const placeholder = m[1]
const actualSectionNumber = m[1] + m[2]
const actualHeadingLevel = actualSectionNumber.split('.').length
const actualHeadingName = m[3].trim()
return { placeholder, actualSectionNumber, actualHeadingLevel, actualHeadingName }
}
/**
* Parses a Figure/Table caption for an x-placeholder.
*
* Matches patterns like "Figure x.x-1: caption" or "Table x.x.x-2: caption".
* The placeholder is one or more literal 'x' separated by dots.
*
* @param {string} text - Caption text after "Figure " or "Table ".
* @returns {{ placeholder: string, placeholderLevel: number }|null}
* null if no x-placeholder found (e.g. starts with a digit).
*/
function parseCaptionPlaceholder(text) {
const m = text.match(/^(x(?:\.x)*)(-)/)
if (!m) return null
const placeholder = m[1]
return { placeholder, placeholderLevel: placeholder.split('.').length }
}
/**
* Injects section numbers into heading and caption inline tokens based on file path.
*
* Parses x-placeholders in heading text and replaces them with the derived
* section number. Also replaces x-placeholders in Figure/Table captions
* with the current section number from the most recent resolved heading.
*
* @param {Object[]} tokens - markdown-it token array (or slice).
* @param {string} filePath - Absolute path to the source file.
* @param {string} specRootPath - Absolute path to the specification root.
*/
function injectSectionNumbers(tokens, filePath, specRootPath) {
const { sectionNumber, sectionHeading } = extractSectionNumber(filePath, specRootPath)
const derivedHeadingLevel = sectionNumber ? sectionNumber.split('.').length : 0
let hadDerivedLevel = false
let currentResolvedSection = null
let currentResolvedLevel = 0
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'heading_open') {
const styleHeadingLevel = parseInt(tokens[i].tag.slice(1), 10)
const inline = tokens[i + 1]
if (!inline || inline.type !== 'inline') continue
const firstText = inline.children && inline.children.find(c => c.type === 'text')
const textContent = firstText ? firstText.content : inline.content || ''
const trimmed = textContent.trimStart()
const parsed = parsePlaceholder(trimmed)
if (!parsed) {
if (/^\d/.test(trimmed)) {
// Auto-generated folder headings have a preceding <!-- AUTO-HEADING --> comment
const prev = i > 0 ? tokens[i - 1] : null
if (prev && prev.type === 'html_block' && /<!--\s*AUTO-HEADING\s*-->/.test(prev.content)) continue
// User-written heading with manual section number → error
if (firstText) firstText.content = ERROR_MARKER + ' ' + firstText.content
else inline.content = ERROR_MARKER + ' ' + inline.content
}
// No placeholder and no leading number → unnumbered heading, leave as-is
continue
}
// Placeholder found but no derivedSectionNumber → error
if (!sectionNumber) {
if (firstText) firstText.content = ERROR_MARKER + ' ' + firstText.content
else inline.content = ERROR_MARKER + ' ' + inline.content
continue
}
const { placeholder, actualSectionNumber, actualHeadingLevel, actualHeadingName } = parsed
let error = false
const placeholderXCount = placeholder.split('.').length
if (placeholderXCount !== derivedHeadingLevel) error = true
if (styleHeadingLevel !== actualHeadingLevel) error = true
if (styleHeadingLevel < derivedHeadingLevel) error = true
if (!error && styleHeadingLevel === derivedHeadingLevel) {
if (hadDerivedLevel) error = true
else hadDerivedLevel = true
}
if (error) {
if (firstText) firstText.content = ERROR_MARKER + ' ' + firstText.content
else inline.content = ERROR_MARKER + ' ' + inline.content
continue
}
const replaced = actualSectionNumber.replace(placeholder, sectionNumber)
let newText = trimmed.replace(actualSectionNumber, replaced)
// Auto-append file-derived heading only when the heading has no name at all
// (not even in emphasis/bold tokens beyond the first text node)
if (!actualHeadingName && sectionHeading) {
const firstTextIdx = inline.children ? inline.children.indexOf(firstText) : -1
const hasMoreContent = inline.children && inline.children.slice(firstTextIdx + 1).some(c => c.type === 'text' || c.type === 'code_inline' || c.tag === 'em' || c.tag === 'strong' || c.tag === 's')
if (!hasMoreContent) {
newText = newText.trimEnd() + ' ' + sectionHeading
}
}
const leadingWs = textContent.substring(0, textContent.length - trimmed.length)
if (firstText) firstText.content = leadingWs + newText
else inline.content = leadingWs + newText
// Track resolved section for caption replacement
currentResolvedSection = replaced
currentResolvedLevel = actualHeadingLevel
continue
}
// Handle Figure/Table captions
if (tokens[i].type !== 'inline') continue
const firstText = tokens[i].children && tokens[i].children.find(c => c.type === 'text')
if (!firstText) continue
const captionMatch = firstText.content.match(/^(Figure|Table)\s+(.*)/i)
if (!captionMatch) continue
const keyword = captionMatch[1]
const afterKeyword = captionMatch[2]
const captionParsed = parseCaptionPlaceholder(afterKeyword)
if (!captionParsed) continue // no placeholder, leave unchanged
const { placeholder, placeholderLevel } = captionParsed
if (!currentResolvedSection || placeholderLevel !== currentResolvedLevel) {
firstText.content = firstText.content.replace(
new RegExp(`^(${keyword}\\s+)${placeholder.replace(/\./g, '\\.')}`),
`$1${ERROR_MARKER}`
)
} else {
firstText.content = firstText.content.replace(placeholder, currentResolvedSection)
}
}
}
/**
* Injects section numbers into all heading tokens, handling both single-file
* and multi-file (FILE comment delimited) content.
*
* @param {Object[]} tokens - markdown-it token array.
* @param {string} specRootPath - Absolute path to the specification root.
* @param {string|null} [filePath=null] - Source file path (single-file mode).
*/
function injectAllSectionNumbers(tokens, specRootPath, filePath) {
if (!specRootPath) return
if (filePath) {
injectSectionNumbers(tokens, filePath, specRootPath)
} else {
// Multi-file: track current file via FILE comments
let currentFile = null
let fileTokenStart = 0
const segments = []
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'html_block') {
const m = tokens[i].content.match(/^<!--\s*FILE:\s*(.+?)\s*-->/)
if (m) {
if (currentFile) segments.push({ file: currentFile, start: fileTokenStart, end: i })
currentFile = m[1]
fileTokenStart = i + 1
}
}
}
if (currentFile) segments.push({ file: currentFile, start: fileTokenStart, end: tokens.length })
for (const seg of segments) {
injectSectionNumbers(tokens.slice(seg.start, seg.end), seg.file, specRootPath)
}
}
}
/**
* Inserts a softbreak token after the first colon in an inline token's children.
* Used for Annex headings to place the title on a new line after the colon.
*
* @param {Object} inlineToken - A markdown-it inline token.
*/
function insertBreakAfterColon(inlineToken) {
if (!inlineToken || !inlineToken.children) return
for (let i = 0; i < inlineToken.children.length; i++) {
const child = inlineToken.children[i]
if (child.type === 'text' && child.content.includes(':')) {
const colonIdx = child.content.indexOf(':')
const before = child.content.substring(0, colonIdx + 1)
const after = child.content.substring(colonIdx + 1).replace(/^\s+/, '')
child.content = before
const br = { type: 'softbreak', tag: 'br' }
if (after) {
inlineToken.children.splice(i + 1, 0, br, { type: 'text', content: after })
} else {
inlineToken.children.splice(i + 1, 0, br)
// Remove leading whitespace from the next text token if any
if (i + 2 < inlineToken.children.length && inlineToken.children[i + 2].type === 'text') {
inlineToken.children[i + 2].content = inlineToken.children[i + 2].content.replace(/^\s+/, '')
}
}
return
}
}
}
/**
* Parses a {TableOfContent(N-M)} placeholder and returns the range string.
*
* @param {string} text - The paragraph text.
* @returns {string|null} The range string (e.g. "1-9"), or null if not a TOC placeholder.
*/
function parseTocRange(text) {
const m = text.trim().match(/^\{TableOfContent\((\d+-\d+)\)\}$/)
return m ? m[1] : null
}
/**
* Inserts OMITTED markers into a concatenated markdown string where gaps exist
* between selected files (i.e. files from the spec root that were not selected).
*
* @param {string} content - Concatenated markdown content (from concatenateFiles).
* @param {string[]} selectedFiles - Sorted array of selected file paths.
* @param {string[]} allFiles - Sorted array of all file paths in the spec root.
* @returns {string} Content with OMITTED markers inserted at gap positions.
*/
function insertOmittedMarkers(content, selectedFiles, allFiles) {
if (!allFiles || allFiles.length === 0 || selectedFiles.length === allFiles.length) return content
const norm = (f) => f.replace(/\\/g, '/').toLowerCase()
const selectedNorm = selectedFiles.map(norm)
const allNorm = allFiles.map(norm)
const indices = []
for (let i = 0; i < allNorm.length; i++) {
if (selectedNorm.includes(allNorm[i])) indices.push(i)
}
if (indices.length === 0) return content
const insertBeforeFiles = []
if (indices[0] > 0) {
insertBeforeFiles.push(selectedFiles[0])
}
for (let i = 1; i < indices.length; i++) {
if (indices[i] - indices[i - 1] > 1) {
insertBeforeFiles.push(selectedFiles[i])
}
}
const appendMarker = indices[indices.length - 1] < allNorm.length - 1
if (insertBeforeFiles.length === 0 && !appendMarker) return content
let result = content
for (let i = insertBeforeFiles.length - 1; i >= 0; i--) {
const file = insertBeforeFiles[i]
let fileIdx = result.indexOf('<!-- FILE: ' + file + ' -->')
if (fileIdx === -1) fileIdx = result.indexOf('<!-- FILE: ' + file.replace(/\\/g, '/') + ' -->')
if (fileIdx === -1) continue
const afterFile = result.substring(fileIdx)
const autoRe = /^<!-- FILE: [^\n]+ -->\n((?:<!-- AUTO-HEADING -->\n#+ [^\n]+\n\n)*)/
const m = afterFile.match(autoRe)
const hasAutoHeadings = m && m[1].length > 0
const fileDir = path.dirname(file).replace(/\\/g, '/').toLowerCase()
let intraFolderGap = false
if (hasAutoHeadings) {
const fileNorm = norm(file)
const fileAllIdx = allNorm.indexOf(fileNorm)
if (fileAllIdx > 0) {
const prevInAll = allFiles[fileAllIdx - 1]
const prevDir = path.dirname(prevInAll).replace(/\\/g, '/').toLowerCase()
if (prevDir === fileDir && !selectedNorm.includes(allNorm[fileAllIdx - 1])) {
intraFolderGap = true
}
}
}
let interFolderGap = false
if (hasAutoHeadings) {
const fileNorm = norm(file)
const fileAllIdx = allNorm.indexOf(fileNorm)
for (let j = fileAllIdx - 1; j >= 0; j--) {
const jDir = path.dirname(allFiles[j]).replace(/\\/g, '/').toLowerCase()
if (jDir !== fileDir && !selectedNorm.includes(allNorm[j])) {
interFolderGap = true
break
}
if (jDir !== fileDir) break
}
}
if (hasAutoHeadings && intraFolderGap && interFolderGap) {
const afterAutoPos = fileIdx + m[0].length
result = result.substring(0, afterAutoPos) + '<!-- OMITTED -->\n\n' + result.substring(afterAutoPos)
result = result.substring(0, fileIdx) + '<!-- OMITTED -->\n\n' + result.substring(fileIdx)
} else if (hasAutoHeadings && intraFolderGap) {
const afterAutoPos = fileIdx + m[0].length
result = result.substring(0, afterAutoPos) + '<!-- OMITTED -->\n\n' + result.substring(afterAutoPos)
} else {
result = result.substring(0, fileIdx) + '<!-- OMITTED -->\n\n' + result.substring(fileIdx)
}
}
if (appendMarker) {
result = result + '\n<!-- OMITTED -->\n'
}
return result
}
module.exports = {
PARA,
DEFAULT_BULLET,
ERROR_MARKER,
extractText,
isJsonTableLink,
buildJsonTableIndices,
classifyParagraph,
collectFiles,
concatenateFiles,
formatExportMessage,
extractSectionNumber,
parsePlaceholder,
parseCaptionPlaceholder,
injectSectionNumbers,
injectAllSectionNumbers,
insertBreakAfterColon,
parseTocRange,
insertOmittedMarkers,
}