diff --git a/README.adoc b/README.adoc index 192b75b..7d3148d 100644 --- a/README.adoc +++ b/README.adoc @@ -565,6 +565,101 @@ sectid-level-separator (default: .):: The character that must be used to separat All IDs must be lowercase, must start with an alphabetic character, and may only contain alphanumeric characters aside from the aforementioned separators. +=== Javadoc + +*require name:* @springio/asciidoctor-extensions/javadoc-extension + +The javadoc extension allows you to quickly create links to javadoc sites. +For example, `javadoc:com.example.MyClass[]` will create a link to `xref:attachment$api/java/com/example/MyClass.html` with the text `MyClass`. + +==== Syntax + +The following format can be used when declaring a javadoc link: + +---- +[][#] +---- + +Only `` is mandatory, and it must be the fully qualified name of the class to link to. +For example, `com.example.MyClass`. +References to inner-classes should use `$` notation instead of `.`. +For example, `com.example.MyClass$Builder`. + +If a `` is specified, it must end with `/`. + +Any `` must exactly match the anchor in the corresponding javadoc page. + +==== Locations + +Unless otherwise overridden, the default javadoc location will be `xref:attachment$api/java`. +If you want a different default, you can set the `javadoc-location` document attribute. +For example, you can set `javadoc-location` to `xref:api:java` if you publish javadoc under a `api` Antora module. + +NOTE: document attributes can be set in your Antora playback under the https://docs.antora.org/antora/latest/playbook/asciidoc-attributes/#attributes-key[attributes key]. + +You can also override locations on a per-link basis. +For example: + +[,asciidoc] +---- += Example +:url-jdk-javadoc: https://docs.oracle.com/en/java/javase/17/docs/api + +Please read javadoc:{url-jdk-javadoc}/java.base/java.io.InputStream[] +---- + +==== Formats and Link Text + +By default, a short form of the class name is used as the link text. +For example, if you link to `com.example.MyClass`, only `MyClass` is used for the link text. + +If you want to change the format of the link text, you can use the `format` attribute. +For example, `javadoc:com.example.MyClass[format=full]` will use `com.example.MyClass` as the link text. + +The following formats are supported: + +[cols="1,1,3"] +|=== +| Name | Description | Example + +| `short` (default) +| The short class name +| `com.example.MyClass$Builder` -> `MyClass.Builder` + +| `full` +| The fully-qualified class name +| `com.example.MyClass$Builder` -> `com.example.MyClass.Builder` + +| `annotation` +| The short class name prefixed with `@` +| `com.example.MyAnnotation` -> `@MyAnnotation` +|=== + +TIP: You can change the default format by setting a `javadoc-format` document attribute. +This can be done site-wide, or on a page-by-page basis. + +You can also specify directly specify link text if the existing formats are not suitable: + +[,asciidoc] +---- +See javadoc:com.example.MyClass$Builder[Builder] for details. +---- + +NOTE: The link text is _always_ wrapped in a `` block so you should not use backticks. + +==== Anchors + +Anchors may be used to link to a specific part of a javadoc page. +The anchor link must exactly match the link in the target page. +For example, `javadoc:com.example.MyClass#myMethod(java.lang.String)` + +When an anchor is specified, the link text will include a readable version. +The example above would render the link text `MyClass.myMethod(String)`. + +==== UI Support + +All anchors created using the `javadoc` macro will have a role of `apiref` to allow the UI to style them appropriately. + ifndef::env-npm[] == Development Quickstart diff --git a/lib/javadoc-extension.js b/lib/javadoc-extension.js new file mode 100644 index 0000000..6cc889a --- /dev/null +++ b/lib/javadoc-extension.js @@ -0,0 +1,95 @@ +'use strict' + +const toProc = require('./util/to-proc') + +const METHOD_REGEX = /(.*)\((.*)\)/ + +function register (registry, context) { + if (!(registry && context)) return // NOTE only works as scoped extension for now + registry.$groups().$store('springio/javadoc', toProc(createExtensionGroup(context))) + return registry +} + +function createExtensionGroup () { + return function () { + this.inlineMacro(function () { + this.named('javadoc') + this.process((parent, target, attrs) => { + const text = process(parent.getDocument(), parseTarget(target), attrs) + return this.createInline(parent, 'quoted', text, { + type: 'monospaced', + }) + }) + }) + } +} + +function parseTarget (target) { + target = target.replaceAll('…​', '...') + const lastSlash = target.lastIndexOf('/') + const location = lastSlash !== -1 ? target.substring(0, lastSlash) : undefined + const reference = lastSlash !== -1 ? target.substring(lastSlash + 1, target.length) : target + const lastHash = reference.lastIndexOf('#') + const classReference = lastHash !== -1 ? reference.substring(0, lastHash) : reference + const anchor = lastHash !== -1 ? reference.substring(lastHash + 1, reference.length) : undefined + return { location, classReference, anchor } +} + +function process (document, target, attrs) { + const location = target.location || document.getAttribute('javadoc-location', 'xref:attachment$api/java') + const format = attrs.format || document.getAttribute('javadoc-format', 'short') + const linkLocation = link(location, target) + const linkDescription = applyFormat(target, format, attrs.$positional) + return `${linkLocation}['${linkDescription}',role=apiref]` +} + +function link (location, target) { + let link = location + link = !link.endsWith('/') ? link + '/' : link + link += target.classReference.replaceAll('.', '/').replaceAll('$', '.') + '.html' + if (target.anchor) link += '#' + target.anchor + return link +} + +function applyFormat (target, format, positionalAttrs, annotationAnchor) { + if (positionalAttrs?.[0]) return positionalAttrs?.[0] + switch (format) { + case 'full': + return className(target.classReference, 'full') + anchorText(target.anchor, format, annotationAnchor) + case 'annotation': + return '@' + className(target.classReference, 'short') + anchorText(target.anchor, format) + default: + return className(target.classReference, 'short') + anchorText(target.anchor, format) + } +} + +function anchorText (anchor, format, annotationAnchor) { + if (!anchor) return '' + if (format === 'annotation' || annotationAnchor) anchor = anchor.replaceAll('()', '') + const methodMatch = METHOD_REGEX.exec(anchor) + if (methodMatch) { + return '.' + methodText(methodMatch[1], methodMatch[2], format) + } + return '.' + anchor +} + +function methodText (name, params, format) { + const varargs = params.endsWith('...') + if (varargs) params = params.substring(0, params.length - 3) + return ( + name + + '(' + + params + .split(',') + .map((name) => className(name, format)) + .join(', ') + + (!varargs ? ')' : '...)') + ) +} + +function className (classReference, format) { + if (format !== 'full') classReference = classReference.split('.').slice(-1)[0] + return classReference.replaceAll('$', '.') +} + +module.exports = { register, createExtensionGroup } diff --git a/package.json b/package.json index ed373f1..3936fbd 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "./code-folding-extension": "./lib/code-folding-extension.js", "./configuration-properties-extension": "./lib/configuration-properties-extension.js", "./include-code-extension": "./lib/include-code-extension.js", + "./javadoc-extension": "./lib/javadoc-extension.js", "./section-ids-extension": "./lib/section-ids-extension.js" }, "imports": { diff --git a/test/javadoc-extension-test.js b/test/javadoc-extension-test.js new file mode 100644 index 0000000..69b41b6 --- /dev/null +++ b/test/javadoc-extension-test.js @@ -0,0 +1,279 @@ +/* eslint-env mocha */ +'use strict' + +const Asciidoctor = require('@asciidoctor/core')() +const AntoraLoader = require('@antora/asciidoc-loader') +const { expect, heredoc } = require('./harness') +const { name: packageName } = require('#package') + +describe('javadoc-extension', () => { + const ext = require(packageName + '/javadoc-extension') + + let config + let contentCatalog + let file + + const addFile = ({ component = 'acme', version = '2.0', module = 'ROOT', family = 'page', relative }, contents) => { + contents = Buffer.from(contents) + const entry = { + contents, + src: { component, version, module, family, relative, path: relative }, + pub: { moduleRootPath: '' }, + } + contentCatalog.files.push(entry) + return entry + } + + const createContentCatalog = () => ({ + files: [], + findBy (criteria) { + const criteriaEntries = Object.entries(criteria) + const accum = [] + for (const candidate of this.files) { + const candidateSrc = candidate.src + if (criteriaEntries.every(([key, val]) => candidateSrc[key] === val)) accum.push(candidate) + } + return accum + }, + getComponent () {}, + resolveResource (resource) { + resource = resource.replaceAll('attachment$', '_attachments/').replaceAll('api:', 'api/') + return { pub: { url: 'https://docs.example.com/' + resource } } + }, + }) + + const run = (input = [], opts = {}, convert = true) => { + file.contents = Buffer.from(Array.isArray(input) ? input.join('\n') : input) + const context = { config, contentCatalog, file } + opts.extension_registry = ext.register(opts.extension_registry || Asciidoctor.Extensions.create(), context) + opts.sourcemap = true + const document = AntoraLoader.loadAsciiDoc(file, contentCatalog, { extensions: [ext] }) + return !convert ? document : log(document.convert()) + } + + function log (data) { + console.log(data) + return data + } + + beforeEach(() => { + config = {} + contentCatalog = createContentCatalog() + file = addFile({ relative: 'index.adoc' }, '= Index Page') + }) + + describe('bootstrap', () => { + it('should be able to require extension', () => { + expect(ext).to.be.instanceOf(Object) + expect(ext.register).to.be.instanceOf(Function) + }) + + it('should not register to bound extension registry if register function called with no arguments', () => { + try { + ext.register.call(Asciidoctor.Extensions) + const extGroups = Asciidoctor.Extensions.getGroups() + const extGroupKeys = Object.keys(extGroups) + expect(extGroupKeys).to.be.empty() + } finally { + Asciidoctor.Extensions.unregisterAll() + } + }) + + it('should not register extension group if context is undefined', () => { + const input = [] + const opts = { extension_registry: ext.register(Asciidoctor.Extensions.create()) } + const extensions = Asciidoctor.load(input, opts).getExtensions() + expect(extensions).to.be.undefined() + }) + + it('should be able to call register function exported by extension', () => { + const extensions = run([], {}, false).getExtensions() + expect(extensions.getInlineMacros()).to.have.lengthOf(1) + }) + }) + + describe('javadoc macro', () => { + it('should convert using sensible defaults', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified location when has javadoc-location attribute', () => { + const input = heredoc` + = Page Title + :javadoc-location: xref:api:java + + javadoc:com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified location when has javadoc-location attribute with slash', () => { + const input = heredoc` + = Page Title + :javadoc-location: xref:api:java/ + + javadoc:com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified location when has xref location in macro', () => { + const input = heredoc` + = Page Title + + javadoc:xref:api:java/com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified location when has http location in macro', () => { + const input = heredoc` + = Page Title + + javadoc:https://javadoc.example.com/latest/com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified format when has format full', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass[format=full] + ` + const actual = run(input) + expect(actual).to.include( + 'com.example.MyClass' + ) + }) + + it('should convert with specified format when has format annotation', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyAnnotation[format=annotation] + ` + const actual = run(input) + expect(actual).to.include( + '@MyAnnotation' + ) + }) + + it('should convert with specified format when has format short', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass[format=short] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass' + ) + }) + + it('should convert with specified format when has format as document attribute', () => { + const input = heredoc` + = Page Title + :javadoc-format: full + + javadoc:com.example.MyClass[] + ` + const actual = run(input) + expect(actual).to.include( + 'com.example.MyClass' + ) + }) + + it('should convert with specified text when has link text', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass$Builder[Builder] + ` + const actual = run(input) + expect(actual).to.include( + 'Builder' + ) + }) + + it('should convert with specified text when has inner class', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass$Builder[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass.Builder' + ) + }) + + it('should convert with method reference', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass#run(java.lang.Class,java.lang.String)[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass.run(Class, String)' + ) + }) + + it('should convert with varargs method reference', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass#run(java.lang.Class,java.lang.String...)[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass.run(Class, String…​)' + ) + }) + + it('should convert with const reference', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyClass#MY_CONST[] + ` + const actual = run(input) + expect(actual).to.include( + 'MyClass.MY_CONST' + ) + }) + + it('should convert with annotation reference', () => { + const input = heredoc` + = Page Title + + javadoc:com.example.MyAnnotation#format()[format=annotation] + ` + const actual = run(input) + expect(actual).to.include( + '@MyAnnotation.format' + ) + }) + }) +})