From c11dfaba3eea8a5d8f01250bfec9ebad3f1b2be3 Mon Sep 17 00:00:00 2001 From: Dan Allen Date: Mon, 22 Sep 2025 20:41:45 -0600 Subject: [PATCH] resolves #116 introduce linkReferenceStyle configuration key to control how linkable targets are rewritten --- CHANGELOG.adoc | 2 + .../modules/ROOT/pages/concepts.adoc | 10 ++- .../ROOT/pages/configure-assembly.adoc | 33 +++++++ .../ROOT/pages/custom-exporter-extension.adoc | 3 + packages/assembler/lib/load-config.js | 5 +- .../assembler/lib/produce-assembly-file.js | 86 ++++++++++++------- .../assembler/lib/produce-assembly-files.js | 38 ++++++-- .../test/produce-assembly-files-test.js | 4 + .../scenarios/attachment-reference/data.yml | 1 + .../resolve-image-targets-for-html/data.yml | 38 ++++++++ .../expects/index.adoc | 39 +++++++++ 11 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 packages/assembler/test/scenarios/resolve-image-targets-for-html/data.yml create mode 100644 packages/assembler/test/scenarios/resolve-image-targets-for-html/expects/index.adoc diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 75e0e73..8a134c5 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -11,12 +11,14 @@ For a detailed view of what's changed, refer to the {url-repo}/commits[commit hi * add `build.stderr` key to Assembler config to control stderr lines emitted by command (`ignore`, `print`, `log`) (#112) * route stderr lines from command to Antora log if value of `build.stderr` key is `log` (#112) * introduce `embedReferenceStyle` property on exporter's converter object to control how image targets are rewritten (when embedded or bundled) (#100) +* introduce `linkReferenceStyle` subkey on the `assembly` category key in the Assembler configuration to control how linkable targets are rewritten (#116) * assign intrinsic attributes assembler-filetype and assembler-filetype- (#122) === Changed * cache default command on build config object in built-in converters (#113) * convert function is expected to cache default command on build config object if not already set (#113) +* use link semantics to rewrite image macro target when exporting to HTML (#116) === Fixed diff --git a/docs/assembler/modules/ROOT/pages/concepts.adoc b/docs/assembler/modules/ROOT/pages/concepts.adoc index 5702ab9..47c793b 100644 --- a/docs/assembler/modules/ROOT/pages/concepts.adoc +++ b/docs/assembler/modules/ROOT/pages/concepts.adoc @@ -43,7 +43,7 @@ Assembly files are created per component version, though they may pull in conten [#how-are-pages-merged] == How are pages merged? -Assembler is designed so the AsciiDoc converter does not have to know anything about Antora or resolve any references outside the document. +Assembler is designed so the AsciiDoc converter does not have to know anything about Antora, how to resolve Antora references, or how to include content outside of the document. In order to achieve that, Assembler has to "`reduce`" the AsciiDoc source of each page. This step is handled by Asciidoctor Reducer. Asciidoctor Reducer is a tool that reduces an AsciiDoc document by resolving all include directives and preprocessor conditionals to produce a single, self-contained AsciiDoc document. @@ -95,8 +95,12 @@ If the module name is ROOT in this case, it is replaced by an empty string. == What happens to references that point outside the assembly? -All references to pages that point outside the assembly, or to any attachment, are rewritten as links to the published site using the site URL as a prefix. -If the site URL is not set, the target of these references will remain unresolved. +All references to pages that point outside the assembly, or to any attachment, are rewritten. +How they are rewritten is controlled by the `link_reference_style` key in the Assembler configuration. +For HTML exports, the link can point to an absolute, root-relative, or relative URL. +For all other export formats, the link always points to an absolute URL. +Absolute and root-relative URLs are built using the site URL (or the value of the `primary-site-url` AsciiDoc attribute) as a prefix. +In those cases, if a site URL is not set, the target of these references will remain unresolved. == How do Asciidoctor extensions work in Assembler? diff --git a/docs/assembler/modules/ROOT/pages/configure-assembly.adoc b/docs/assembler/modules/ROOT/pages/configure-assembly.adoc index 80c98c3..3490c49 100644 --- a/docs/assembler/modules/ROOT/pages/configure-assembly.adoc +++ b/docs/assembler/modules/ROOT/pages/configure-assembly.adoc @@ -395,6 +395,39 @@ The separator is repeated three times between the page ID and the section ID (e. When `xml_ids` is `true`, the separator is repeated four times betweeen each resource ID coordinate (e.g., `module----page`). This repetition is used to avoid conflicts with the endash replacement in AsciiDoc. +[#link-reference-style-key] +== link_reference_style + +The `link_reference_style` key controls how Assembler rewrites references to linkable resources (such as pages and attachments) that are outside the assembly. + +The `link_reference_style` key is an optional key that can be set in [.path]_antora-assembler.yml_. +When exporting to HTML, it accepts the values `absolute`, `root-relative`, and `relative`. +When exporting to any other format, it only accepts the value `absolute`. +The default value is `absolute`. + +.antora-assembler.yml +[,yaml] +---- +assembly: + link_reference_style: relative +---- + +When the value is `absolute`, the references are rewritten using an absolute URL, starting with the site URL. +This allows the HTML export to be downloaded and viewed outside of the context of the site. +(If the site URL is only specified as a root-relative path, then these references gracefully degrate to a root-relative URL.) + +When the value is `root-relative`, the references are rewritten using a root-relative URL, starting with the path of the site URL. +This allows the HTML export to be relocated to another location on the same host (i.e., the same domain name). + +When the value is `relative`, the references are rewritten using a relative path starting from the export file. +This allows the HTML export to seamlessly integrate with pages and other resources in the site without coupling the reference to the site URL. + +In order for the functionality used by this key to work properly, the site URL must be set. +One exception is when the export is HTML and the value of the key is `relative`. + +When exporting to HTML, this key is also used to control how image references are rewritten. +When exporting to any other format, the converter controls this behavior. + [#profile-key] == profile diff --git a/docs/assembler/modules/ROOT/pages/custom-exporter-extension.adoc b/docs/assembler/modules/ROOT/pages/custom-exporter-extension.adoc index d278842..7354168 100644 --- a/docs/assembler/modules/ROOT/pages/custom-exporter-extension.adoc +++ b/docs/assembler/modules/ROOT/pages/custom-exporter-extension.adoc @@ -28,6 +28,9 @@ mediaType:: The MIME type of the target format (e.g., `application/epub+zip`). embedReferenceStyle:: Controls whether images are rewritten relative to the export file (`relative`) or the output directory (`output-relative`). The `docdir` attribute passed to the convert function is set accordingly. (optional, default: `relative`) ++ +This setting is not used when exporting to HTML (since images are linkable resources in that case). +Instead, the `link_reference_style` key on the Assembler configuration is used. loggerName:: The name of the Antora logger to use when logging messages captured from stderr of the command (e.g., `@antora/epub-extension`). (optional, default: `@antora/assembler`) diff --git a/packages/assembler/lib/load-config.js b/packages/assembler/lib/load-config.js index b745dfb..c3a2d1f 100644 --- a/packages/assembler/lib/load-config.js +++ b/packages/assembler/lib/load-config.js @@ -46,7 +46,7 @@ function loadConfig (playbook, configSource = './antora-assembler.yml') { const remapAssemblyKeys = !('assembly' in config) const assembly = (config.assembly ??= {}) if (remapAssemblyKeys) { - for (const key of ['rootLevel', 'insertStartPage', 'sectionMergeStrategy']) { + for (const key of ['rootLevel', 'insertStartPage', 'sectionMergeStrategy', 'linkReferenceStyle']) { if (!(key in config)) continue assembly[key] = config[key] delete config[key] @@ -59,6 +59,9 @@ function loadConfig (playbook, configSource = './antora-assembler.yml') { if (['discrete', 'fuse', 'enclose'].indexOf(assembly.sectionMergeStrategy) < 0) { assembly.sectionMergeStrategy = 'discrete' } + if (['relative', 'root-relative', 'absolute'].indexOf(assembly.linkReferenceStyle) < 0) { + assembly.linkReferenceStyle = 'absolute' + } const build = (config.build ??= {}) if (build.dir === '$' + '{playbook.output.dir}') { throw new Error('Not implemented') diff --git a/packages/assembler/lib/produce-assembly-file.js b/packages/assembler/lib/produce-assembly-file.js index f4b2f3b..bc4dd4c 100644 --- a/packages/assembler/lib/produce-assembly-file.js +++ b/packages/assembler/lib/produce-assembly-file.js @@ -114,18 +114,22 @@ function mergeAsciiDoc ( let navtitlePlain = sanitize(navtitle) let navtitleAsciiDoc = unconvertInlineAsciiDoc(navtitle) const { items = [], unresolved, urlType, url } = outlineEntry + const { + doctype, + filetype, + embedReferenceStyle: embedRefStyle, + linkReferenceStyle: linkRefStyle, + outDirname, + siteRoot, + xmlIds, + } = 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 && assemblyModel.doctype === 'book' && (supportsParts = true) + const atBookRoot = atDocumentRoot && !level && doctype === 'book' && (supportsParts = true) const hasItems = items.length > 0 - const outdir = asciidocConfig.attributes.outdir - const siteUrl = ((val) => { - if (!val) return - return val.charAt(val.length - 1) === '/' ? val.slice(0, val.length - 1) : val - })(asciidocConfig.attributes['site-url'] || asciidocConfig.attributes['primary-site-url']) - const embedRefStyle = assemblyModel.embedReferenceStyle - const idSeparator = assemblyModel.xmlIds ? '-' : ':' + const pubRoot = outDirname ? '/' + outDirname : '' + const idSeparator = xmlIds ? '-' : ':' const idScopeSeparator = idSeparator.repeat(3) const idCoordinateSeparator = idSeparator === '-' ? '----' : idSeparator if (page && !pagesInOutline.assembled.pages.has(page)) { @@ -405,9 +409,9 @@ function mergeAsciiDoc ( if (~hashIdx && !relativePart.endsWith('.adoc')) relativePart += '.adoc' } if (!isPage || ~relativePart.indexOf('@') || /:.*:/.test(relativePart)) { - if (siteUrl != null && (resource = contentCatalog.resolveResource(relativePart, page.src, 'page'))?.pub) { + if (siteRoot && (resource = contentCatalog.resolveResource(relativePart, page.src, 'page'))?.pub) { text ||= resource.asciidoc?.xreftext || target - return `${resolveLinkTarget(resource, siteUrl)}${fragment && '#' + fragment}[${text}]` + return `${resolveLinkTarget(resource, siteRoot, pubRoot, linkRefStyle)}${fragment && '#' + fragment}[${text}]` } // TODO: handle unresolved resource better return m @@ -423,9 +427,9 @@ function mergeAsciiDoc ( } const pageResourceRef = targetModule === 'ROOT' ? relativePart : `${targetModule}:${relativePart}` if (!(resource = pagesInOutline.get(pageResourceRef))) { - if (siteUrl != null && (resource = contentCatalog.resolvePage(pageResourceRef, page.src)) && resource.out) { + if (siteRoot && (resource = contentCatalog.resolvePage(pageResourceRef, page.src)) && resource.out) { text ||= resource.asciidoc?.xreftext || target - return `${resolveLinkTarget(resource, siteUrl)}${fragment && '#' + fragment}[${text}]` + return `${resolveLinkTarget(resource, siteRoot, pubRoot, linkRefStyle)}${fragment && '#' + fragment}[${text}]` } // TODO: handle unresolved page better return m @@ -441,7 +445,7 @@ function mergeAsciiDoc ( if (~line.indexOf('link:{attachmentsdir}/')) { line = line.replace(/(? { const attachment = - siteUrl != null && + siteRoot && contentCatalog.getById({ component: componentVersion.name, version: componentVersion.version, @@ -450,7 +454,7 @@ function mergeAsciiDoc ( relative, }) // TODO: handle unresolved attachment page - return attachment?.out ? `${resolveLinkTarget(attachment, siteUrl)}[${text}]` : m + return attachment?.out ? `${resolveLinkTarget(attachment, siteRoot, pubRoot, linkRefStyle)}[${text}]` : m }) } if (~line.indexOf('image:') && !line.startsWith('image::')) { @@ -458,9 +462,11 @@ function mergeAsciiDoc ( if (isResourceSpec(target)) { const image = contentCatalog.resolveResource(target, page.src, 'image', ['image']) // TODO: handle (or report) unresolved image better - if (image?.out) { + if (image?.out && (filetype !== 'html' || siteRoot)) { pagesInOutline.assembled.assets.add(image) - return `image:${resolveEmbedTarget(image, outdir, embedRefStyle, true)}[${attrlist}]` + return filetype === 'html' + ? `image:${resolveLinkTarget(image, siteRoot, pubRoot, linkRefStyle)}[${attrlist}]` + : `image:${resolveEmbedTarget(image, outDirname, embedRefStyle, true)}[${attrlist}]` } } return m @@ -514,10 +520,13 @@ function mergeAsciiDoc ( 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) { + if (image?.out && (filetype !== 'html' || siteRoot)) { const attrlist = line.slice(line.indexOf('[') + 1, -1) pagesInOutline.assembled.assets.add(image) - lines[idx] = `${prefix}image::${resolveEmbedTarget(image, outdir, embedRefStyle)}[${attrlist}]` + lines[idx] = + filetype === 'html' + ? `${prefix}image::${resolveLinkTarget(image, siteRoot, pubRoot, linkRefStyle, false)}[${attrlist}]` + : `${prefix}image::${resolveEmbedTarget(image, outDirname, embedRefStyle)}[${attrlist}]` } } lastImageMacroAt = [idx, imageMacroOffset] @@ -586,8 +595,8 @@ function mergeAsciiDoc ( if (refid.startsWith('./')) refid = topicPrefix + refid.slice(2) if (resource.src.module !== 'ROOT') refid = `${resource.src.module}${idCoordinateSeparator}${refid}` sectionTitle = `<<${refid},${navtitleAsciiDoc}>>` - } else if (siteUrl != null) { - sectionTitle = `${resolveLinkTarget(resource, siteUrl)}[${navtitleAsciiDoc.replace(/\]/g, '\\]')}]` + } else if (siteRoot) { + sectionTitle = `${resolveLinkTarget(resource, siteRoot, pubRoot, linkRefStyle)}[${navtitleAsciiDoc.replace(/\]/g, '\\]')}]` } } } @@ -794,20 +803,35 @@ function safePush (onto, entries) { } } -function resolveLinkTarget (resource, siteUrl) { - let target = resource.pub.url - let prefix = '' - if (!resource.site?.url) { - if (siteUrl.charAt() === '/') prefix = 'link:' - target = siteUrl + target +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)] + } } - return prefix + target.replace(/_/g, '{underscore}') + if (escapeForInline) target[1] = target[1].replace(/_/g, '{underscore}') + return target.join('') } -function resolveEmbedTarget (resource, outdir, referenceStyle, escapeForInline) { - const target = - referenceStyle === 'output-relative' ? resource.out.path : path.relative(outdir + '/', resource.out.path) - return escapeForInline ? target.replace(/_/g, '{underscore}') : target +function computeRelativeUrl (from, to) { + const rel = path.relative(from, to) + return to.charAt(to.length - 1) === '/' ? rel + '/' : rel } module.exports = produceAssemblyFile diff --git a/packages/assembler/lib/produce-assembly-files.js b/packages/assembler/lib/produce-assembly-files.js index e660a57..2d1a249 100644 --- a/packages/assembler/lib/produce-assembly-files.js +++ b/packages/assembler/lib/produce-assembly-files.js @@ -19,12 +19,14 @@ function produceAssemblyFiles (loadAsciiDoc, contentCatalog, assemblerConfig, re navigation: componentVersion.navigation, xmlIds: assemblyConfig.xmlIds, embedReferenceStyle: assemblyConfig.embedReferenceStyle, + linkReferenceStyle: assemblyConfig.linkReferenceStyle, }) const assemblerAsciiDocAttributes = Object.assign({}, assemblerAsciiDocConfig.attributes) const { revdate, 'source-highlighter': sourceHighlighter } = assemblerAsciiDocAttributes delete assemblerAsciiDocAttributes.revdate delete assemblerAsciiDocAttributes['source-highlighter'] const publishableFiles = contentCatalog.getFiles().filter((file) => file.out) + let siteRoot const configMdc = assemblerConfig.file ? { file: { path: assemblerConfig.file } } : {} return filterComponentVersions(contentCatalog.getComponents(), assemblerConfig.componentVersionFilter.names).reduce( (accum, componentVersion) => { @@ -37,15 +39,33 @@ function produceAssemblyFiles (loadAsciiDoc, contentCatalog, assemblerConfig, re assemblerAsciiDocAttributes, { logger: assemblyModel.logger, mdc: configMdc } ) - mergedAsciiDocAttributes.outdir = computeOut.call(contentCatalog, { - component: componentVersion.name, - version: componentVersion.version, - family: 'export', - relative: '.index.adoc', - }).dirname const mergedAsciiDocConfig = Object.assign({}, componentVersionAsciiDocConfig, { attributes: mergedAsciiDocAttributes, }) + assemblyModel.outDirname = computeOut.call(contentCatalog, { + component: componentName, + version, + family: 'export', + relative: '.index.adoc', + }).dirname + assemblyModel.filetype = assemblerAsciiDocAttributes['assembler-filetype'] + assemblyModel.siteRoot = + siteRoot === undefined + ? (siteRoot ??= ((val) => { + if (!val) return null + if (val.charAt(val.length - 1) === '/') val = val.slice(0, val.length - 1) + if (!val || val.charAt() === '/') return { path: val } + return { url: val, path: extractUrlPath(val) } + })(mergedAsciiDocAttributes['site-url'] || mergedAsciiDocAttributes['primary-site-url'])) + : siteRoot + if (assemblyModel.filetype === 'html') { + let linkRefStyle = assemblyModel.linkReferenceStyle + if (linkRefStyle === 'absolute' && siteRoot?.url == null) linkRefStyle = 'root-relative' + if (linkRefStyle === 'root-relative' && siteRoot?.path == null) linkRefStyle = 'relative' + assemblyModel.linkReferenceStyle = linkRefStyle + } else { + assemblyModel.linkReferenceStyle = 'absolute' + } const auxiliaryImages = new Set() Object.entries(mergedAsciiDocAttributes).forEach(([name, val]) => { const match = name.endsWith('-image') && val.startsWith('image:') && IMAGE_MACRO_RX.exec(val) @@ -185,4 +205,10 @@ function collateAsciiDocAttributes (collated, additional, { logger, mdc }) { return collated } +function extractUrlPath (url) { + if (!url) return '' + const urlPath = new URL(url).pathname + return urlPath === '/' ? '' : urlPath +} + module.exports = produceAssemblyFiles diff --git a/packages/assembler/test/produce-assembly-files-test.js b/packages/assembler/test/produce-assembly-files-test.js index 9af8ed4..4d22044 100644 --- a/packages/assembler/test/produce-assembly-files-test.js +++ b/packages/assembler/test/produce-assembly-files-test.js @@ -105,6 +105,10 @@ describe('produceAssemblyFiles()', () => { await runScenario('resolve-image-targets', __dirname) }) + it('should resolve image targets using link semantics when exporting to HTML', async () => { + await runScenario('resolve-image-targets-for-html', __dirname) + }) + it('should resolve image target in image attribute', async () => { const expectedOutPath = 'the-component/1.0/_images/logo.png' const assemblyFiles = await runScenario('resolve-image-in-image-attribute', __dirname) diff --git a/packages/assembler/test/scenarios/attachment-reference/data.yml b/packages/assembler/test/scenarios/attachment-reference/data.yml index a8ce040..9896523 100644 --- a/packages/assembler/test/scenarios/attachment-reference/data.yml +++ b/packages/assembler/test/scenarios/attachment-reference/data.yml @@ -20,4 +20,5 @@ files: assembler: asciidoc: attributes: + assembler-filetype: 'html' site-url: /docs diff --git a/packages/assembler/test/scenarios/resolve-image-targets-for-html/data.yml b/packages/assembler/test/scenarios/resolve-image-targets-for-html/data.yml new file mode 100644 index 0000000..802b786 --- /dev/null +++ b/packages/assembler/test/scenarios/resolve-image-targets-for-html/data.yml @@ -0,0 +1,38 @@ +name: the-component +version: '1.0' +title: The Component +navigation: + items: + - content: xref:the-page.adoc[The Page Title] + - content: xref:the-other-page.adoc[The Other Page Title] +files: +- relative: the-page.adoc + contents: |- + = The Page Title + :underscore: _ + + image::shared:the-image.png[] + + Click image:save.svg[] to save the document. +- relative: the-other-page.adoc + contents: |- + = The Other Page Title + + image::common::logo.png[] + + image::shared:the-image.png[] +- relative: the-image.png + module: shared + family: image +- relative: save.svg + family: image +- component: common + relative: logo.png + family: image +assembler: + assembly: + linkReferenceStyle: absolute + asciidoc: + attributes: + assembler-filetype: html + site-url: https://docs.example.org diff --git a/packages/assembler/test/scenarios/resolve-image-targets-for-html/expects/index.adoc b/packages/assembler/test/scenarios/resolve-image-targets-for-html/expects/index.adoc new file mode 100644 index 0000000..da0b444 --- /dev/null +++ b/packages/assembler/test/scenarios/resolve-image-targets-for-html/expects/index.adoc @@ -0,0 +1,39 @@ += The Component +:revnumber: 1.0 +:doctype: book +:underscore: _ +:page-component-name: the-component +:page-component-version: 1.0 +:page-version: {page-component-version} +:page-component-display-version: 1.0 +:page-component-title: The Component + +:docname: the-page +:page-module: ROOT +:page-relative-src-path: the-page.adoc +:page-origin-url: https://github.com/acme/the-component +:page-origin-start-path: +:page-origin-refname: v1.0 +:page-origin-reftype: branch +:page-origin-refhash: a00000000000000000000000000000000000000z +[#the-page] +== The Page Title + +image::https://docs.example.org/the-component/1.0/shared/_images/the-image.png[] + +Click image:https://docs.example.org/the-component/1.0/{underscore}images/save.svg[] to save the document. + +:docname: the-other-page +:page-module: ROOT +:page-relative-src-path: the-other-page.adoc +:page-origin-url: https://github.com/acme/the-component +:page-origin-start-path: +:page-origin-refname: v1.0 +:page-origin-reftype: branch +:page-origin-refhash: a00000000000000000000000000000000000000z +[#the-other-page] +== The Other Page Title + +image::https://docs.example.org/common/_images/logo.png[] + +image::https://docs.example.org/the-component/1.0/shared/_images/the-image.png[] -- GitLab