@asciidoctor/core 4.0.0-alpha.2 → 4.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asciidoctor/core",
3
- "version": "4.0.0-alpha.2",
3
+ "version": "4.0.0-alpha.4",
4
4
  "description": "Asciidoctor.js: AsciiDoc in JavaScript powered by Asciidoctor",
5
5
  "type": "module",
6
6
  "main": "build/node/index.cjs",
@@ -28,6 +28,7 @@
28
28
  "build:types": "tsc && node scripts/strip-internal.js && node scripts/patch-jsdoc.js",
29
29
  "docs": "typedoc --tsconfig tsconfig.docs.json",
30
30
  "test": "node --test test/**.test.js",
31
+ "test:deno": "deno test --allow-env --no-check --allow-read --allow-sys --allow-write --allow-run --allow-net test/*.test.js",
31
32
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='src/**' test/**.test.js",
32
33
  "test:browser": "vitest run --config vitest.browser.config.js",
33
34
  "lint": "biome lint src test",
@@ -65,7 +66,7 @@
65
66
  },
66
67
  "homepage": "https://github.com/asciidoctor/asciidoctor.js",
67
68
  "volta": {
68
- "node": "24.14.1"
69
+ "node": "24.15.0"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@biomejs/biome": "^2.4.13",
@@ -31,6 +31,7 @@ import {
31
31
  extname,
32
32
  prepareSourceString,
33
33
  } from './helpers.js'
34
+ import { fetchUri } from './http_cache.js'
34
35
 
35
36
  // ── Node.js fs (lazy, optional) ───────────────────────────────────────────────
36
37
  // Loaded on first use in Node.js; silently absent in browser/WebWorker environments.
@@ -485,10 +486,7 @@ export class AbstractNode {
485
486
  (targetImage = this.normalizeWebPath(targetImage, imagesBase, false)))
486
487
  ) {
487
488
  return doc.hasAttribute('allow-uri-read')
488
- ? this.generateDataUriFromUri(
489
- targetImage,
490
- doc.hasAttribute('cache-uri')
491
- )
489
+ ? this.generateDataUriFromUri(targetImage)
492
490
  : targetImage
493
491
  }
494
492
  return this.generateDataUri(targetImage, assetDirKey)
@@ -541,10 +539,7 @@ export class AbstractNode {
541
539
  )
542
540
  : this.normalizeSystemPath(targetImage)
543
541
  if (isUriish(imagePath)) {
544
- return await this.generateDataUriFromUri(
545
- imagePath,
546
- this.document.hasAttribute('cache-uri')
547
- )
542
+ return await this.generateDataUriFromUri(imagePath)
548
543
  }
549
544
  if (await isReadable(imagePath)) {
550
545
  const data = await _fsp.readFile(imagePath)
@@ -564,12 +559,12 @@ export class AbstractNode {
564
559
  * imageUri, the caller must await the returned Promise.
565
560
  *
566
561
  * @param {string} imageUri - The URI from which to read the image data (http/https/ftp).
567
- * @param {boolean} [cacheUri=false] - A Boolean to control caching (not yet supported in JS).
568
562
  * @returns {Promise<string>} a Promise resolving to a String data URI.
569
563
  */
570
- async generateDataUriFromUri(imageUri, cacheUri = false) {
564
+ async generateDataUriFromUri(imageUri) {
571
565
  try {
572
- const response = await fetch(imageUri)
566
+ const doc = this.document
567
+ const response = await fetchUri(imageUri, doc)
573
568
  if (response.ok) {
574
569
  const mimetype = (
575
570
  response.headers.get('content-type') || 'application/octet-stream'
@@ -740,7 +735,7 @@ export class AbstractNode {
740
735
  ) {
741
736
  if (doc.hasAttribute('allow-uri-read')) {
742
737
  try {
743
- const response = await fetch(resolvedTarget)
738
+ const response = await fetchUri(resolvedTarget, doc)
744
739
  const text = await response.text()
745
740
  contents = opts.normalize ? prepareSourceString(text).join(LF) : text
746
741
  } catch {
@@ -765,7 +760,7 @@ export class AbstractNode {
765
760
  })
766
761
  }
767
762
 
768
- if (contents && opts.warnIfEmpty && contents.length === 0) {
763
+ if (contents != null && opts.warnIfEmpty && contents.length === 0) {
769
764
  this.logger.warn(`contents of ${label} is empty: ${resolvedTarget}`)
770
765
  }
771
766
  return contents
@@ -1,3 +1,19 @@
1
+ // Symbol key used to store attribute entries on a block-attributes object without
2
+ // polluting the public attributes (invisible to Object.keys / for-in / JSON.stringify).
3
+ // Spread ({ ...attrs }) copies Symbol-keyed properties, so the entry survives the
4
+ // shallow clone made in AbstractNode's constructor.
5
+ export const ATTR_ENTRIES_KEY = Symbol('attribute_entries')
6
+
7
+ /**
8
+ * Return the attribute entries stored for the given block attributes object,
9
+ * or undefined if none have been saved.
10
+ * @param {Object} blockAttributes
11
+ * @returns {AttributeEntry[]|undefined}
12
+ */
13
+ export function getAttributeEntries(blockAttributes) {
14
+ return blockAttributes[ATTR_ENTRIES_KEY]
15
+ }
16
+
1
17
  export class AttributeEntry {
2
18
  constructor(name, value, negate = null) {
3
19
  this.name = name
@@ -6,7 +22,7 @@ export class AttributeEntry {
6
22
  }
7
23
 
8
24
  saveTo(blockAttributes) {
9
- ;(blockAttributes.attribute_entries ??= []).push(this)
25
+ ;(blockAttributes[ATTR_ENTRIES_KEY] ??= []).push(this)
10
26
  return this
11
27
  }
12
28
  }
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // This logic is specific to Asciidoctor.js and has no equivalent in the upstream Ruby asciidoctor
7
7
  // implementation. It handles the case where the document is loaded in a browser environment
8
- // (XMLHttpRequest / Fetch IO module) where paths can be file:// or http(s):// URIs.
8
+ // where paths can be file:// or http(s):// URIs.
9
9
  //
10
10
  // The key behavioural differences from the standard file-system resolver:
11
11
  // - Relative targets are resolved by string concatenation against a URI context dir,
@@ -55,7 +55,7 @@ function _linkReplacement(reader, target, attrlist) {
55
55
  * 1. target starts with file:// → inc_path = relpath = target
56
56
  * 2. target is a URI → must descend from baseDir or allow-uri-read; else → link
57
57
  * 3. target is an absolute OS path → prepend file:// (or file:///)
58
- * 4. baseDir == '.' → inc_path = relpath = target (resolved by XMLHttpRequest/fetch)
58
+ * 4. baseDir == '.' → inc_path = relpath = target (resolved by fetch)
59
59
  * 5. baseDir starts with file:// OR baseDir is not a URI → inc_path = baseDir/target; relpath = target
60
60
  * 6. baseDir is an absolute URL → inc_path = baseDir/target; relpath = target
61
61
  *
package/src/browser.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  RevisionInfo,
11
11
  } from './document.js'
12
12
  import { Logger, LoggerManager, MemoryLogger, NullLogger } from './logging.js'
13
+ import { HttpCache, MemoryHttpCache, HttpCacheManager } from './http_cache.js'
13
14
  import { SafeMode, ContentModel } from './constants.js'
14
15
  import { Timings } from './timings.js'
15
16
  import { AbstractNode } from './abstract_node.js'
@@ -28,9 +29,23 @@ import {
28
29
  BlockMacroProcessor,
29
30
  Extensions,
30
31
  } from './extensions.js'
32
+ // Re-export DSL interface types for TypeScript consumers.
33
+ /**
34
+ * @typedef {import('./extensions.js').ProcessorDslInterface} ProcessorDslInterface
35
+ * @typedef {import('./extensions.js').DocumentProcessorDslInterface} DocumentProcessorDslInterface
36
+ * @typedef {import('./extensions.js').SyntaxProcessorDslInterface} SyntaxProcessorDslInterface
37
+ * @typedef {import('./extensions.js').IncludeProcessorDslInterface} IncludeProcessorDslInterface
38
+ * @typedef {import('./extensions.js').DocinfoProcessorDslInterface} DocinfoProcessorDslInterface
39
+ * @typedef {import('./extensions.js').BlockProcessorDslInterface} BlockProcessorDslInterface
40
+ * @typedef {import('./extensions.js').MacroProcessorDslInterface} MacroProcessorDslInterface
41
+ * @typedef {import('./extensions.js').InlineMacroProcessorDslInterface} InlineMacroProcessorDslInterface
42
+ */
31
43
  import {
32
44
  Converter,
45
+ ConverterBase,
46
+ CustomFactory,
33
47
  DefaultFactory as DefaultConverterFactory,
48
+ deriveBackendTraits,
34
49
  } from './converter.js'
35
50
  import { Inline } from './inline.js'
36
51
  import { Block } from './block.js'
@@ -97,6 +112,9 @@ export {
97
112
  LoggerManager,
98
113
  MemoryLogger,
99
114
  NullLogger,
115
+ HttpCache,
116
+ MemoryHttpCache,
117
+ HttpCacheManager,
100
118
  SafeMode,
101
119
  ContentModel,
102
120
  Timings,
@@ -114,7 +132,10 @@ export {
114
132
  Extensions,
115
133
  Cursor,
116
134
  Converter as ConverterFactory,
135
+ ConverterBase,
136
+ CustomFactory as ConverterCustomFactory,
117
137
  DefaultConverterFactory,
138
+ deriveBackendTraits,
118
139
  DefaultSyntaxHighlighterFactory,
119
140
  Html5Converter,
120
141
  SyntaxHighlighter,
package/src/convert.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // - Ruby File.write → async writeFile() via node:fs/promises.
12
12
  // - Ruby Helpers.mkdir_p → mkdirP() from helpers.js.
13
13
  // - Ruby Helpers.uriish? → isUriish() from helpers.js.
14
- // - Ruby Stylesheets.instance.write_primary_stylesheet → not yet ported to JS; skipped.
14
+ // - Ruby Stylesheets.instance.write_primary_stylesheet → Stylesheets.instance.writePrimaryStylesheet() in stylesheets.js; returns false in browser environments.
15
15
  // - Ruby doc.syntax_highlighter → doc.syntaxHighlighter.
16
16
  // - Ruby syntax_hl.write_stylesheet? doc → syntaxHl.writeStylesheet(doc).
17
17
  // - Ruby syntax_hl.write_stylesheet doc, dir → syntaxHl.writeStylesheetToDisk(doc, dir).
@@ -24,6 +24,7 @@
24
24
  import { load } from './load.js'
25
25
  import { isUriish, mkdirP } from './helpers.js'
26
26
  import { SafeMode, DEFAULT_STYLESHEET_KEYS } from './constants.js'
27
+ import { Stylesheets } from './stylesheets.js'
27
28
 
28
29
  // ── convert ───────────────────────────────────────────────────────────────────
29
30
 
@@ -214,7 +215,7 @@ export async function convert(input, options = {}) {
214
215
  let copyAsciidoctorStylesheet = false
215
216
  let copyUserStylesheet = false
216
217
  const stylesheet = doc.getAttribute('stylesheet')
217
- if (stylesheet) {
218
+ if (stylesheet != null) {
218
219
  if (DEFAULT_STYLESHEET_KEYS.has(stylesheet)) {
219
220
  copyAsciidoctorStylesheet = true
220
221
  } else if (!isUriish(stylesheet)) {
@@ -246,7 +247,13 @@ export async function convert(input, options = {}) {
246
247
  }
247
248
 
248
249
  if (copyAsciidoctorStylesheet) {
249
- // NOTE Stylesheets.instance.write_primary_stylesheet is not yet ported to JS
250
+ if (
251
+ !(await Stylesheets.instance.writePrimaryStylesheet(stylesoutdir))
252
+ ) {
253
+ doc.logger.info(
254
+ 'skipping default stylesheet copy: filesystem writes are not supported in this environment'
255
+ )
256
+ }
250
257
  } else if (copyUserStylesheet) {
251
258
  let stylesheetSrc = doc.getAttribute('copycss')
252
259
  if (stylesheetSrc === '' || stylesheetSrc === true) {
@@ -29,7 +29,7 @@ import {
29
29
  INLINE_MATH_DELIMITERS,
30
30
  } from '../constants.js'
31
31
  import { XmlSanitizeRx } from '../rx.js'
32
- import { extname, isUriish } from '../helpers.js'
32
+ import { extname } from '../helpers.js'
33
33
  import { Stylesheets } from '../stylesheets.js'
34
34
 
35
35
  // ── Local regex constants ─────────────────────────────────────────────────────
@@ -1767,32 +1767,13 @@ Your browser does not support the video tag.
1767
1767
 
1768
1768
  // NOTE expose readSvgContents for Bespoke converter
1769
1769
  async readSvgContents(node, target) {
1770
- const imagesdir = node.document.getAttribute('imagesdir')
1771
- let resolvedPath
1772
- let svg
1773
- if (isUriish(target) || (imagesdir && isUriish(imagesdir))) {
1774
- svg = await node.readContents(target, {
1775
- start: imagesdir,
1776
- normalize: true,
1777
- warnOnFailure: true,
1778
- label: 'SVG',
1779
- })
1780
- resolvedPath = target
1781
- } else {
1782
- resolvedPath = node.normalizeSystemPath(target, imagesdir, null, {
1783
- targetName: 'image',
1784
- })
1785
- svg = await node.readAsset(resolvedPath, {
1786
- normalize: true,
1787
- warnOnFailure: true,
1788
- label: 'SVG',
1789
- })
1790
- }
1791
- if (svg == null) return null // file not found/readable; warning already emitted
1792
- if (!svg) {
1793
- node.logger.warn(`contents of SVG is empty: ${resolvedPath}`)
1794
- return null
1795
- }
1770
+ let svg = await node.readContents(target, {
1771
+ start: node.document.getAttribute('imagesdir'),
1772
+ normalize: true,
1773
+ label: 'SVG',
1774
+ warnIfEmpty: true,
1775
+ })
1776
+ if (!svg) return null
1796
1777
  if (!svg.startsWith('<svg')) svg = svg.replace(SvgPreambleRx, '')
1797
1778
  // Fix incomplete SVG start tag (missing closing >) by inserting > before the first child element.
1798
1779
  // This handles cases like: <svg width="500"\n<circle .../> where the > is missing.
@@ -276,7 +276,7 @@ export class TemplateConverter extends ConverterBase {
276
276
 
277
277
  let entries
278
278
  try {
279
- entries = await fsp.readdir(templateDir)
279
+ entries = (await fsp.readdir(templateDir)).sort()
280
280
  } catch {
281
281
  return result
282
282
  }
package/src/document.js CHANGED
@@ -68,7 +68,11 @@ export class ImageReference {
68
68
  import { Footnote } from './footnote.js'
69
69
  export { Footnote }
70
70
 
71
- import { AttributeEntry } from './attribute_entry.js'
71
+ import {
72
+ AttributeEntry,
73
+ getAttributeEntries,
74
+ ATTR_ENTRIES_KEY,
75
+ } from './attribute_entry.js'
72
76
  export { AttributeEntry }
73
77
 
74
78
  /**
@@ -927,8 +931,9 @@ export class Document extends AbstractBlock {
927
931
  * @param {Object} blockAttributes
928
932
  */
929
933
  playbackAttributes(blockAttributes) {
930
- if (!('attribute_entries' in blockAttributes)) return
931
- for (const entry of blockAttributes.attribute_entries) {
934
+ const entries = getAttributeEntries(blockAttributes)
935
+ if (!entries) return
936
+ for (const entry of entries) {
932
937
  if (entry.negate) {
933
938
  delete this.attributes[entry.name]
934
939
  if (entry.name === 'compat-mode') this.compatMode = false
@@ -1657,6 +1662,12 @@ export class Document extends AbstractBlock {
1657
1662
  * Handles titles (AbstractBlock), list item text, table cell text, and reftexts.
1658
1663
  */
1659
1664
  async _resolveAllTexts(block) {
1665
+ // The header section lives outside document.blocks; pre-compute its title here so
1666
+ // that doc.doctitle() returns the fully-substituted title (with replacements applied,
1667
+ // e.g. ' → &#8217;) rather than the header-subs-only fallback.
1668
+ if (block === this && this.header) {
1669
+ await this.header.precomputeTitle?.()
1670
+ }
1660
1671
  // Skip title pre-computation for blocks with an explicit empty id ([id=]).
1661
1672
  // In Ruby, apply_title_subs is lazy: it is never called during parsing for such
1662
1673
  // blocks because section.title is never accessed. An explicit empty id is
@@ -1740,7 +1751,7 @@ export class Document extends AbstractBlock {
1740
1751
  * @internal
1741
1752
  */
1742
1753
  _clearPlaybackAttributes(attributes) {
1743
- delete attributes.attribute_entries
1754
+ delete attributes[ATTR_ENTRIES_KEY]
1744
1755
  }
1745
1756
 
1746
1757
  /**
@@ -2080,11 +2091,10 @@ export class Document extends AbstractBlock {
2080
2091
  // ── Helpers ───────────────────────────────────────────────────────────────────
2081
2092
 
2082
2093
  function _expandPath(p) {
2083
- try {
2084
- return require('node:path').resolve(p)
2085
- } catch {
2086
- return p
2087
- }
2094
+ const resolver = new PathResolver()
2095
+ const posixed = p.replace(/\\/g, '/')
2096
+ if (resolver.absolutePath(posixed)) return resolver.expandPath(posixed)
2097
+ return resolver.expandPath(`${resolver.workingDir}/${posixed}`)
2088
2098
  }
2089
2099
 
2090
2100
  function _cwd() {