diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index d904386352c..b71a1040d4f 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -36,6 +36,10 @@ on: # The Hugo build embeds JavaDocs produced by this script, so a change # to how the docs are generated must redeploy the website too. - '.github/scripts/build_javadocs.sh' + # The doclet that renders the API pages themselves. It is outside the + # maven reactor and built only by this workflow, so nothing else would + # catch a change to it. + - 'maven/javadoc-hugo-doclet/**' - '.github/workflows/website-docs.yml' push: branches: [main, master] @@ -61,6 +65,10 @@ on: # The Hugo build embeds JavaDocs produced by this script, so a change # to how the docs are generated must redeploy the website too. - '.github/scripts/build_javadocs.sh' + # The doclet that renders the API pages themselves. It is outside the + # maven reactor and built only by this workflow, so nothing else would + # catch a change to it. + - 'maven/javadoc-hugo-doclet/**' - '.github/workflows/website-docs.yml' workflow_dispatch: inputs: @@ -206,6 +214,41 @@ jobs: # on their actual date. HUGO_BUILD_FUTURE: ${{ github.event_name == 'pull_request' && 'true' || 'false' }} + - name: Validate API reference output + run: | + set -euo pipefail + test -f docs/website/public/javadoc/index.html + # Pages are published at the directory URL, because Cloudflare Pages + # will not serve a .html one: see check-javadoc-urls.sh. + test -f docs/website/public/javadoc/com/codename1/ui/Component/index.html + test -f docs/website/public/javadoc/com/codename1/ui/package-summary/index.html + test -f docs/website/public/javadoc-search.json + # The fragment encoding is the compatibility surface, so assert one of + # the awkward spellings rather than only that the page exists. This is + # a canary for the parity gate rather than a duplicate of it: the gate + # compares two trees, and a gate that ends up comparing nothing reports + # nothing missing. + # + # Quotes are optional because the site is built with --minify and the + # minifier drops them wherever HTML allows: this page really does carry + # id=createImage(byte[],int,int) with no quotes at all. + grep -Eq 'id="?createImage\(byte\[\],int,int\)"?' \ + docs/website/public/javadoc/com/codename1/ui/Image/index.html + # The old integration copied the standard doclet's tree into static/ + # and scoped its stylesheet with a script. Nothing should reintroduce + # either: they are what broke dark mode. + test ! -e docs/website/static/javadoc + test ! -e docs/website/assets/js/cn1-javadoc.js + + - name: Validate API URLs on the Pages runtime + run: | + set -euo pipefail + # Asks the server rather than the filesystem. Everything else was green + # on a build where clicking any class left the site: Pages redirects + # /x.html to /x before an asset is considered, so the page published at + # the extension was one nobody could reach. + scripts/website/check-javadoc-urls.sh docs/website/public 8789 + - name: Validate Port Status page output run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 59ea2a7976a..1ea2c0ac45b 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,15 @@ dependency-reduced-pom.xml # docs/developer-guide on every run, so a committed copy is both duplicated # source and guaranteed stale. 485 files of it were committed by accident once. /docs/website/static/developer-guide/ +# The API reference is generated into the content tree by the Hugo doclet on +# every run of scripts/website/build.sh, one file per documented type. Roughly +# 1850 generated pages have no business in the history: the sources they are +# rendered from are already committed. +/docs/website/content/javadoc/ +/docs/website/static/javadoc-search.json +# Left behind by the previous integration, which copied the whole standard +# doclet tree into static/ and served it through a scoping script. +/docs/website/static/javadoc/ /docs/website/.hugo_build.lock /docs/website/hugo_stats.json /docs/website/resources/ diff --git a/docs/website/assets/css/extended/cn1-api.css b/docs/website/assets/css/extended/cn1-api.css new file mode 100644 index 00000000000..eeb174a2dd7 --- /dev/null +++ b/docs/website/assets/css/extended/cn1-api.css @@ -0,0 +1,479 @@ +/* + * API reference pages. + * + * Everything here is expressed in the site's own design tokens, so dark mode is + * whatever body.dark already says it is. That is the point of the rewrite: the + * previous integration copied javadoc's stylesheet in, rewrote every selector to + * scope it under one div, and then tried to force roughly thirty of javadoc's + * own custom properties back onto theme colours with !important. It lost that + * fight in dark mode, because a stylesheet that was never built to be themed + * cannot be themed from the outside. + */ + +.cn1-api { + max-width: var(--cn1-shell); +} + +.cn1-api__header { + border-bottom: 1px solid var(--cn1-border); + padding-bottom: 1rem; + margin-bottom: 1.5rem; +} + +.cn1-api__crumbs { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + font-family: var(--cn1-mono); + font-size: 0.85rem; + color: var(--cn1-muted); + margin-bottom: 0.5rem; +} + +.cn1-api__crumbs a { + color: var(--cn1-accent); +} + +.cn1-api__title { + font-family: var(--cn1-mono); + word-break: break-word; +} + +.cn1-api__kind { + display: inline-block; + font-family: var(--cn1-font); + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--cn1-muted); + margin-right: 0.5rem; + vertical-align: middle; +} + +.cn1-api__intro { + color: var(--cn1-muted); +} + +/* Inheritance chain, indented one step per level the way javadoc stacks it. */ +.cn1-api__inheritance { + list-style: none; + padding: 0; + margin: 0.75rem 0 0; + font-family: var(--cn1-mono); + font-size: 0.85rem; +} + +.cn1-api__inheritance li { + padding-left: 1.1rem; + position: relative; + color: var(--cn1-muted); +} + +.cn1-api__inheritance li + li::before { + content: "\2514"; + position: absolute; + left: 0.2rem; + color: var(--cn1-border-strong); +} + +.cn1-api__inheritance li + li { + margin-left: 0.8rem; +} + +.cn1-api__inheritance .is-current { + color: var(--cn1-text); + font-weight: 600; +} + +.cn1-api__relations { + font-size: 0.9rem; + color: var(--cn1-muted); + margin: 0.6rem 0 0; +} + +.cn1-api__relations > span { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.72rem; + margin-right: 0.4rem; +} + +/* -------------------------------------------------------------- signatures */ + +.cn1-api-signature { + font-family: var(--cn1-mono); + font-size: 0.88rem; + line-height: 1.7; + background: none; + padding: 0; + color: var(--cn1-text); + overflow-wrap: anywhere; +} + +.cn1-api-mod { + color: var(--cn1-muted); +} + +.cn1-api-name { + font-weight: 600; + color: var(--cn1-text); +} + +.cn1-api-arg { + color: var(--cn1-text); +} + +.cn1-api-punct { + color: var(--cn1-muted); +} + +.cn1-api-const { + color: var(--cn1-success); +} + +a.cn1-api-type, +.cn1-api-type { + color: var(--cn1-accent); +} + +.cn1-api-table__member a { + text-decoration: none; +} + +.cn1-api-table__member a:hover .cn1-api-name { + text-decoration: underline; +} + +/* ----------------------------------------------------------------- tables */ + +.cn1-api-summary { + margin: 2rem 0; +} + +.cn1-api-summary__title, +.cn1-api-details__title { + font-size: 1.15rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--cn1-muted); + border-bottom: 1px solid var(--cn1-border); + padding-bottom: 0.4rem; + margin-bottom: 0.8rem; +} + +.cn1-api-table { + width: 100%; + border-collapse: collapse; + display: table; + margin: 0; +} + +.cn1-api-table td { + border-bottom: 1px solid var(--cn1-border); + padding: 0.6rem 0.8rem 0.6rem 0; + vertical-align: top; +} + +.cn1-api-table tr:hover td { + background: var(--cn1-surface-soft); +} + +.cn1-api-table__member { + width: 45%; + min-width: 16rem; +} + +.cn1-api-table__summary { + color: var(--cn1-muted); + font-size: 0.92rem; +} + +.cn1-api-table__summary p { + margin: 0; +} + +.cn1-api-table tr.is-deprecated .cn1-api-name { + text-decoration: line-through; +} + +.cn1-api-tag { + display: inline-block; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--cn1-surface); + background: var(--cn1-muted); + border-radius: var(--cn1-radius-sm); + padding: 0.05rem 0.4rem; + margin-right: 0.3rem; + vertical-align: 1px; +} + +/* ---------------------------------------------------------------- members */ + +.cn1-api-member { + border-left: 3px solid var(--cn1-border); + padding: 0.2rem 0 0.2rem 1rem; + margin: 1.6rem 0; + /* Anchored jumps land under the sticky header without this. */ + scroll-margin-top: 5rem; +} + +.cn1-api-member:target { + border-left-color: var(--cn1-accent); + background: var(--cn1-accent-soft); + border-radius: 0 var(--cn1-radius-sm) var(--cn1-radius-sm) 0; +} + +.cn1-api-member__name { + font-family: var(--cn1-mono); + font-size: 1.05rem; + margin: 0 0 0.35rem; +} + +/* A zero-size marker carrying an alternate spelling of the same fragment. */ +.cn1-api-alias { + display: block; + height: 0; + scroll-margin-top: 5rem; +} + +.cn1-api-block { + margin: 0.9rem 0 0; +} + +.cn1-api-block__title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--cn1-muted); + margin: 0 0 0.3rem; +} + +.cn1-api-params { + margin: 0; + display: grid; + grid-template-columns: minmax(6rem, max-content) 1fr; + gap: 0.25rem 1rem; +} + +.cn1-api-params dt { + font-family: var(--cn1-mono); + font-size: 0.85rem; +} + +.cn1-api-params dt code { + background: none; + padding: 0; + color: var(--cn1-text); + font-weight: 600; +} + +.cn1-api-params__type { + color: var(--cn1-muted); + font-weight: 400; +} + +.cn1-api-params dd { + margin: 0; + color: var(--cn1-text); +} + +.cn1-api-params dd p { + margin: 0; +} + +.cn1-api-undocumented { + color: var(--cn1-muted); + font-style: italic; +} + +.cn1-api-see { + margin: 0; + padding-left: 1.1rem; +} + +/* Typography for comment prose. + * + * Documentation comments are markdown and go through the same goldmark as the + * rest of the site, but they are rendered into these containers rather than + * into PaperMod's .post-content, so none of the theme's element spacing reaches + * them. Without this every paragraph, list and heading in a comment runs + * together into one block of text. */ +.cn1-api-prose p, +.cn1-api-prose ul, +.cn1-api-prose ol, +.cn1-api-prose blockquote, +.cn1-api-prose table, +.cn1-api-prose pre { + margin: 0 0 0.85rem; +} + +.cn1-api-prose ul, +.cn1-api-prose ol { + padding-left: 1.4rem; +} + +.cn1-api-prose li { + margin-bottom: 0.25rem; +} + +.cn1-api-prose h1, +.cn1-api-prose h2, +.cn1-api-prose h3, +.cn1-api-prose h4, +.cn1-api-prose h5, +.cn1-api-prose h6 { + margin: 1.4rem 0 0.5rem; + line-height: 1.3; +} + +/* A comment's own headings must never outrank the page structure around them, + however deep the author nested them. */ +.cn1-api-prose h1, +.cn1-api-prose h2, +.cn1-api-prose h3 { + font-size: 1.05rem; +} + +.cn1-api-prose h4, +.cn1-api-prose h5, +.cn1-api-prose h6 { + font-size: 0.95rem; +} + +.cn1-api-prose img { + max-width: 100%; + height: auto; + border-radius: var(--cn1-radius-sm); +} + +.cn1-api-prose blockquote { + border-left: 3px solid var(--cn1-border); + padding-left: 0.9rem; + color: var(--cn1-muted); +} + +.cn1-api-prose table { + display: block; + overflow-x: auto; + max-width: 100%; +} + +.cn1-api-prose > :first-child { + margin-top: 0; +} + +.cn1-api-prose > :last-child { + margin-bottom: 0; +} + +.cn1-api-deprecated { + border-left: 3px solid var(--cn1-accent); + background: var(--cn1-surface-soft); + border-radius: 0 var(--cn1-radius-sm) var(--cn1-radius-sm) 0; + padding: 0.6rem 0.9rem; + margin: 0.6rem 0; + font-size: 0.92rem; +} + +.cn1-api-deprecated p { + margin: 0; +} + +.cn1-api-inherited__group { + margin: 0.9rem 0; +} + +.cn1-api-inherited__from { + font-size: 0.85rem; + font-weight: 600; + color: var(--cn1-muted); + margin: 0 0 0.25rem; +} + +.cn1-api-inherited__list { + margin: 0; + font-family: var(--cn1-mono); + font-size: 0.82rem; + line-height: 1.9; + color: var(--cn1-muted); +} + +.cn1-api-inherited__list code { + background: none; + padding: 0; +} + +@media (max-width: 720px) { + /* Two columns of signature and prose do not fit a phone, so the summary + tables become stacked blocks rather than scrolling sideways. */ + .cn1-api-table, + .cn1-api-table tbody, + .cn1-api-table tr, + .cn1-api-table td { + display: block; + width: auto; + } + + .cn1-api-table td { + border-bottom: none; + padding: 0.15rem 0; + } + + .cn1-api-table tr { + border-bottom: 1px solid var(--cn1-border); + padding: 0.6rem 0; + } + + .cn1-api-table__member { + min-width: 0; + } + + .cn1-api-params { + grid-template-columns: 1fr; + gap: 0.1rem; + } + + .cn1-api-params dd { + margin-bottom: 0.5rem; + } +} + +/* The sentence that a third of the See-also entries carry after the reference. + It is prose about the link, not part of the name, so it is not inside it. */ +.cn1-api-see__note { + color: var(--cn1-muted); + font-size: 0.9em; +} + +/* The repository's own @warning tag: a safety note, not prose. */ +.cn1-api-warning { + border-left: 3px solid var(--cn1-accent); + background: var(--cn1-surface-soft); + border-radius: 0 var(--cn1-radius-sm) var(--cn1-radius-sm) 0; + padding: 0.6rem 0.9rem; + margin: 0.6rem 0; + font-size: 0.92rem; +} + +.cn1-api-warning p { + margin: 0; + display: inline; +} + +.cn1-api__annotations { + font-family: var(--cn1-mono); + font-size: 0.82rem; + color: var(--cn1-muted); + margin: 0 0 0.2rem; +} + +.cn1-api__annotations code { + background: none; + padding: 0; +} diff --git a/docs/website/assets/css/extended/cn1-javadoc-embed.css b/docs/website/assets/css/extended/cn1-javadoc-embed.css deleted file mode 100644 index 887b2c51f3a..00000000000 --- a/docs/website/assets/css/extended/cn1-javadoc-embed.css +++ /dev/null @@ -1,169 +0,0 @@ -.cn1-javadoc-page .main { - max-width: 100vw; - width: 100vw; - margin: 0; - padding-left: 0; - padding-right: 0; -} - -body.cn1-javadoc-page .main:has(> .post-single--api-javadoc) { - max-width: 100vw !important; - width: 100vw !important; - margin: 0 !important; - padding-left: 0 !important; - padding-right: 0 !important; -} - -.post-single--api-javadoc { - max-width: 100vw; - width: 100%; - margin: 0; - padding-left: 0; - padding-right: 0; -} - -.cn1-javadoc-links { - display: flex; - align-items: center; - gap: 0.55rem; - margin: 0.4rem 0 1rem; - font-size: 0.95rem; -} - -.cn1-javadoc { - border: 1px solid var(--border); - border-radius: 0; - padding: 0.7rem; - background: var(--entry); - color: var(--content); - overflow: visible; - --code-bg: color-mix(in srgb, var(--entry) 90%, var(--theme) 10%); - --body-text-color: var(--content) !important; - --block-text-color: var(--content) !important; - --body-background-color: var(--entry) !important; - --section-background-color: var(--entry) !important; - --detail-background-color: var(--entry) !important; - --code-background-color: var(--code-bg); - --pre-background-color: var(--code-bg); - --pre-text-color: var(--content); - --snippet-background-color: var(--code-bg); - --snippet-text-color: var(--content); - --table-header-color: color-mix(in srgb, var(--entry) 70%, var(--theme) 30%) !important; - --even-row-color: var(--entry) !important; - --odd-row-color: color-mix(in srgb, var(--entry) 90%, var(--theme) 10%) !important; - --border-color: var(--border) !important; - --title-color: var(--primary) !important; - --link-color: var(--primary) !important; - --link-color-active: var(--primary) !important; - --toc-background-color: var(--theme) !important; - --toc-hover-color: color-mix(in srgb, var(--entry) 80%, var(--theme) 20%) !important; - --toc-highlight-color: color-mix(in srgb, var(--entry) 70%, var(--theme) 30%) !important; - --navbar-background-color: color-mix(in srgb, var(--entry) 70%, #1f4a75 30%) !important; - --navbar-text-color: var(--primary) !important; - --subnav-background-color: color-mix(in srgb, var(--entry) 78%, var(--theme) 22%) !important; - --subnav-link-color: var(--primary) !important; - --selected-background-color: color-mix(in srgb, var(--primary) 26%, var(--entry) 74%) !important; - --selected-text-color: var(--content) !important; - --search-input-background-color: var(--entry) !important; - --search-input-text-color: var(--content) !important; - --search-input-placeholder-color: var(--secondary) !important; -} - -.cn1-javadoc .main-grid { - width: 100% !important; - max-width: none !important; - margin: 0; -} - -.cn1-javadoc .header, -.cn1-javadoc .sub-nav, -.cn1-javadoc .top-nav, -.cn1-javadoc .flex-content, -.cn1-javadoc .main-content, -.cn1-javadoc .contentContainer { - width: 100% !important; - max-width: none !important; - overflow: visible !important; -} - -.cn1-javadoc .table-header, -.cn1-javadoc .table-sub-heading-color, -.cn1-javadoc .summary-table div[class^="col-"], -.cn1-javadoc .summary-table div[class*=" col-"] { - min-height: 2.25rem; - height: auto !important; - line-height: 1.35 !important; - padding-top: 0.4rem !important; - padding-bottom: 0.4rem !important; - overflow: visible !important; -} - -.cn1-javadoc input[type="search"], -.cn1-javadoc input#search-input, -.cn1-javadoc #search-input, -.cn1-javadoc .search-input { - pointer-events: auto !important; - position: relative; - z-index: 30; -} - -.cn1-javadoc-page .ui-autocomplete, -.cn1-javadoc-page .ui-menu { - background: var(--entry) !important; - color: var(--content) !important; - border: 1px solid var(--border) !important; - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28) !important; - opacity: 1 !important; - max-width: min(92vw, 48rem) !important; - overflow: auto !important; - z-index: 2000 !important; -} - -.cn1-javadoc-page .ui-menu .ui-menu-item-wrapper { - color: var(--content) !important; -} - -.cn1-javadoc-page .ui-menu .ui-state-active, -.cn1-javadoc-page .ui-menu .ui-menu-item-wrapper.ui-state-active { - background: color-mix(in srgb, var(--primary) 22%, var(--entry) 78%) !important; - border-color: var(--border) !important; - color: var(--content) !important; -} - -.cn1-javadoc pre, -.cn1-javadoc code, -.cn1-javadoc pre code, -.cn1-javadoc .snippet-container, -.cn1-javadoc .snippet-container pre, -.cn1-javadoc .block pre, -.cn1-javadoc .block code { - background: var(--code-bg) !important; - color: var(--content) !important; -} - -.cn1-javadoc a { - color: var(--primary); -} - -body.dark .cn1-javadoc { - background: var(--theme); - --code-bg: #141f31; - --body-background-color: var(--theme) !important; - --section-background-color: var(--theme) !important; - --detail-background-color: var(--theme) !important; - --table-header-color: color-mix(in srgb, var(--theme) 70%, #1b2b40 30%) !important; - --even-row-color: var(--theme) !important; - --odd-row-color: color-mix(in srgb, var(--theme) 86%, #0d1726 14%) !important; - --toc-background-color: #101a2a !important; - --toc-hover-color: #172439 !important; - --toc-highlight-color: #1d2d45 !important; - --navbar-background-color: #1b2d45 !important; - --subnav-background-color: #152235 !important; - --subnav-link-color: #cfe2ff !important; - --link-color: #b7d2ff !important; - --link-color-active: #d6e6ff !important; - --title-color: #e3edff !important; - --search-input-background-color: #111b2b !important; - --search-input-text-color: #e9eef7 !important; - --search-input-placeholder-color: #8ea1bc !important; -} diff --git a/docs/website/assets/css/extended/cn1-search.css b/docs/website/assets/css/extended/cn1-search.css index c22f30b6808..59f4651e79b 100644 --- a/docs/website/assets/css/extended/cn1-search.css +++ b/docs/website/assets/css/extended/cn1-search.css @@ -132,3 +132,76 @@ color: var(--cn1-search-soft-text); white-space: nowrap; } + +/* API reference hits, listed above the page results on the search page. */ +.cn1-search-api { + margin: 1.5rem 0 2rem; +} + +.cn1-search-api__title { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--cn1-muted); + margin: 0 0 0.5rem; +} + +.cn1-search-api__results { + border: 1px solid var(--cn1-border); + border-radius: var(--cn1-radius); + overflow: hidden; +} + +.cn1-search-api__hit { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.15rem 0.7rem; + padding: 0.55rem 0.8rem; + border-bottom: 1px solid var(--cn1-border); + text-decoration: none; + color: inherit; +} + +.cn1-search-api__hit:last-child { + border-bottom: none; +} + +.cn1-search-api__hit:hover, +.cn1-search-api__hit:focus-visible { + background: var(--cn1-surface-soft); +} + +.cn1-search-api__name { + font-family: var(--cn1-mono); + font-size: 0.9rem; + font-weight: 600; + color: var(--cn1-accent); + background: none; + padding: 0; + overflow-wrap: anywhere; +} + +.cn1-search-api__kind { + grid-column: 2; + grid-row: 1; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--cn1-muted); + align-self: center; +} + +.cn1-search-api__context { + grid-column: 1 / -1; + font-family: var(--cn1-mono); + font-size: 0.75rem; + color: var(--cn1-muted); + overflow-wrap: anywhere; +} + +.cn1-search-api__summary { + grid-column: 1 / -1; + font-size: 0.85rem; + color: var(--cn1-muted); +} diff --git a/docs/website/assets/js/cn1-javadoc.js b/docs/website/assets/js/cn1-javadoc.js deleted file mode 100644 index 32f1511f43b..00000000000 --- a/docs/website/assets/js/cn1-javadoc.js +++ /dev/null @@ -1,139 +0,0 @@ -(() => { - const root = document.querySelector(".cn1-javadoc"); - if (!root) return; - document.body.classList.add("cn1-javadoc-page"); - - if (window.location.pathname === "/javadoc") { - window.history.replaceState(null, "", "/javadoc/"); - } - - let currentDocPath = "/javadoc/_index-raw.html"; - - const reviveSearchUi = () => { - const inputs = root.querySelectorAll('input[type="search"], input#search-input, #search-input'); - inputs.forEach((input) => { - input.removeAttribute("disabled"); - input.readOnly = false; - input.tabIndex = 0; - input.style.pointerEvents = "auto"; - input.style.position = "relative"; - input.style.zIndex = "30"; - }); - clampSearchPopup(); - }; - - const clampSearchPopup = () => { - document.querySelectorAll(".ui-autocomplete").forEach((menu) => { - menu.style.maxWidth = "min(92vw, 48rem)"; - menu.style.background = "var(--entry)"; - menu.style.color = "var(--content)"; - menu.style.border = "1px solid var(--border)"; - menu.style.boxShadow = "0 10px 24px rgba(0, 0, 0, 0.28)"; - menu.style.opacity = "1"; - menu.style.transform = "translateX(0)"; - const rect = menu.getBoundingClientRect(); - let dx = 0; - if (rect.right > window.innerWidth - 8) dx = (window.innerWidth - 8) - rect.right; - if (rect.left < 8) dx = 8 - rect.left; - if (dx !== 0) menu.style.transform = `translateX(${dx}px)`; - }); - }; - - const ensureSearchAssets = (fetchPath) => { - window.pathtoroot = calcPathToRoot(fetchPath); - reviveSearchUi(); - }; - - const resolveHref = (rawHref) => { - if (!rawHref) return null; - if (rawHref.startsWith("http://") || rawHref.startsWith("https://") || rawHref.startsWith("//")) { - return rawHref; - } - if (rawHref.startsWith("/")) { - return `${window.location.origin}${rawHref}`; - } - const base = `${window.location.origin}${currentDocPath}`; - try { - return new URL(rawHref, base).toString(); - } catch (_e) { - return null; - } - }; - - const toRoute = (href) => { - let url; - try { - url = new URL(href, window.location.href); - } catch (_e) { - return null; - } - if (url.origin !== window.location.origin) return null; - if (!url.pathname.startsWith("/javadoc")) return null; - if (url.pathname === "/javadoc" || url.pathname === "/javadoc/") { - return { fetchPath: "/javadoc/_index-raw.html", browserPath: "/javadoc/" }; - } - if (!url.pathname.endsWith(".html")) return null; - return (url.pathname === "/javadoc/index.html" || url.pathname === "/javadoc/_index-raw.html") - ? { fetchPath: "/javadoc/_index-raw.html", browserPath: "/javadoc/" } - : { fetchPath: url.pathname, browserPath: url.pathname }; - }; - - const calcPathToRoot = (fetchPath) => { - const rel = fetchPath.replace(/^\/javadoc\//, ""); - const depth = Math.max(0, rel.split("/").length - 1); - return depth === 0 ? "./" : "../".repeat(depth); - }; - - const loadIntoContainer = async (route, pushState) => { - const res = await fetch(route.fetchPath, { credentials: "same-origin" }); - if (!res.ok) return; - const html = await res.text(); - const doc = new DOMParser().parseFromString(html, "text/html"); - if (!doc.body) return; - root.innerHTML = doc.body.innerHTML; - currentDocPath = route.fetchPath; - ensureSearchAssets(route.fetchPath); - reviveSearchUi(); - if (pushState) { - window.history.pushState({ cn1Javadoc: route.browserPath }, "", route.browserPath); - } - window.scrollTo({ top: 0, behavior: "auto" }); - }; - - root.addEventListener("click", (event) => { - const link = event.target.closest("a[href]"); - if (!link || link.target || link.hasAttribute("download")) return; - const rawHref = link.getAttribute("href"); - if (!rawHref || rawHref.startsWith("#")) return; - const resolved = resolveHref(rawHref); - if (!resolved) return; - const route = toRoute(resolved); - if (!route) return; - event.preventDefault(); - loadIntoContainer(route, true).catch(() => {}); - }); - - document.addEventListener("click", (event) => { - const link = event.target.closest(".ui-autocomplete a[href], .ui-menu a[href]"); - if (!link || link.target || link.hasAttribute("download")) return; - const rawHref = link.getAttribute("href"); - if (!rawHref || rawHref.startsWith("#")) return; - const resolved = resolveHref(rawHref); - if (!resolved) return; - const route = toRoute(resolved); - if (!route) return; - event.preventDefault(); - loadIntoContainer(route, true).catch(() => {}); - }, true); - - window.addEventListener("popstate", () => { - const route = toRoute(window.location.href); - if (!route) return; - loadIntoContainer(route, false).catch(() => {}); - }); - window.addEventListener("resize", clampSearchPopup); - root.addEventListener("input", clampSearchPopup, true); - root.addEventListener("focusin", clampSearchPopup, true); - - ensureSearchAssets(currentDocPath); -})(); diff --git a/docs/website/content/api.md b/docs/website/content/api.md deleted file mode 100644 index ab3384aaf87..00000000000 --- a/docs/website/content/api.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: "API" -date: 2015-03-03 -slug: "javadoc" -layout: "api-javadoc" -description: "Codename One JavaDoc" -aliases: - - /api/ ---- diff --git a/docs/website/layouts/_default/api-javadoc.html b/docs/website/layouts/_default/api-javadoc.html deleted file mode 100644 index 9b93803c2b6..00000000000 --- a/docs/website/layouts/_default/api-javadoc.html +++ /dev/null @@ -1,21 +0,0 @@ -{{- define "main" -}} -
- - -
- {{- $javadocFragment := "generated/javadoc-content.html" -}} - {{- if fileExists $javadocFragment -}} - {{- readFile $javadocFragment | safeHTML -}} - {{- else -}} -

- JavaDoc content isn't generated in this build. - Run the website build with WEBSITE_INCLUDE_JAVADOCS=true. -

- {{- end -}} -
-
-{{- end -}} diff --git a/docs/website/layouts/_default/search.html b/docs/website/layouts/_default/search.html index 011a3087172..9d2e7f5e28a 100644 --- a/docs/website/layouts/_default/search.html +++ b/docs/website/layouts/_default/search.html @@ -10,11 +10,18 @@

{{ .Title }}

- Note: this index does not cover the Developer Guide. + Note: API types and members are matched by name and listed separately below. + This index does not cover the Developer Guide. The guide is a single large document and would match almost every query — search it directly using your browser's find-in-page (Ctrl/Cmd‑F).

Loading search index…

+ + +
@@ -189,4 +196,128 @@

${escapeHtml(doc.title)}

}); })(); + + {{- end -}} diff --git a/docs/website/layouts/javadoc/overview.html b/docs/website/layouts/javadoc/overview.html new file mode 100644 index 00000000000..bfb81b1c6b1 --- /dev/null +++ b/docs/website/layouts/javadoc/overview.html @@ -0,0 +1,27 @@ +{{- define "main" -}} +{{- $j := .Params.javadoc -}} +
+
+ {{ partial "breadcrumbs.html" . }} +

Codename One API

+

+ {{ $j.typeCount }} documented types across {{ len $j.packages }} packages. + Search the API from the site search, or + download the whole reference as a zip. +

+
+ +
+ + + {{- range $j.packages }} + + + + + {{- end }} + +
{{ .name }}{{ with .summary }}{{ $.RenderString . }}{{ end }}
+
+
+{{- end -}} diff --git a/docs/website/layouts/javadoc/package.html b/docs/website/layouts/javadoc/package.html new file mode 100644 index 00000000000..f74fd757c93 --- /dev/null +++ b/docs/website/layouts/javadoc/package.html @@ -0,0 +1,39 @@ +{{- define "main" -}} +{{- $j := .Params.javadoc -}} +
+
+ +

+ package + {{ $j.qualified }} +

+
+ + {{- if $j.deprecated }} +
+ Deprecated. + {{- with $j.deprecatedText }} {{ $.RenderString . }}{{ end }} +
+ {{- end }} + + {{- with $j.description }} +
{{ $.RenderString . }}
+ {{- end }} + +
+

Types

+ + + {{- range $j.types }} + + + + + {{- end }} + +
{{ .kind }} {{ .name }}{{ with .summary }}{{ $.RenderString . }}{{ end }}
+
+
+{{- end -}} diff --git a/docs/website/layouts/javadoc/type.html b/docs/website/layouts/javadoc/type.html new file mode 100644 index 00000000000..a21fe976ca1 --- /dev/null +++ b/docs/website/layouts/javadoc/type.html @@ -0,0 +1,188 @@ +{{- define "main" -}} +{{- $j := .Params.javadoc -}} +
+
+ + {{- with $j.annotations }} +

{{ range . }}{{ . }} {{ end }}

+ {{- end }} +

+ {{- /* abstract and final are API-significant: whether a type can be + instantiated or subclassed was not shown anywhere. */ -}} + {{ with $j.modifiers }}{{ . }} {{ end }}{{ $j.keyword | default $j.kind }} + {{ $j.simple }}{{ $j.typeParameters }} +

+ + {{- if $j.inheritance }} +
    + {{- range $j.inheritance }}
  1. {{ partial "javadoc/type-ref.html" . }}
  2. {{ end }} +
  3. {{ $j.simple }}
  4. +
+ {{- end }} + + {{- if $j.interfaces }} + {{- /* TypeElement.getInterfaces() returns the SUPERINTERFACES of an + interface, so "Collection extends Iterable" was being shown as + "Implements Iterable". Only a class implements. */ -}} +

{{ if eq $j.kind "interface" }}Extends{{ else }}Implements{{ end }} + {{- range $i, $t := $j.interfaces }}{{ if $i }}, {{ end }}{{ partial "javadoc/type-ref.html" $t }}{{ end }} +

+ {{- end }} + + {{- if $j.subclasses }} +

Known subtypes + {{- range $i, $t := $j.subclasses }}{{ if $i }}, {{ end }}{{ partial "javadoc/type-ref.html" $t }}{{ end }} +

+ {{- end }} +
+ + {{- if $j.deprecated }} +
+ Deprecated. + {{- with $j.deprecatedText }} {{ $.RenderString . }}{{ end }} +
+ {{- end }} + + {{- with $j.description }} +
{{ $.RenderString . }}
+ {{- end }} + + {{- range $j.warnings }} +
+ Warning. {{ $.RenderString . }} +
+ {{- end }} + + {{- if $j.seeAlso }} +
+

See also

+ +
+ {{- end }} + + {{- if $j.typeParameterDocs }} +
+

Type parameters

+
+ {{- range $j.typeParameterDocs }} +
{{ .name }}
+
{{ $.RenderString .doc }}
+ {{- end }} +
+
+ {{- end }} + + {{- if $j.nested }} +
+

Nested types

+ + + {{- range $j.nested }} + + + + + {{- end }} + +
{{ .kind }} {{ .name }}{{ with .summary }}{{ $.RenderString . }}{{ end }}
+
+ {{- end }} + + {{ partial "javadoc/summary-table.html" (dict "rows" $j.enumConstants "title" "Enum constants" "page" .) }} + {{ partial "javadoc/summary-table.html" (dict "rows" $j.fields "title" "Fields" "page" .) }} + {{ partial "javadoc/summary-table.html" (dict "rows" $j.constructors "title" "Constructors" "page" .) }} + {{ partial "javadoc/summary-table.html" (dict "rows" $j.methods "title" "Methods" "page" .) }} + + {{- if $j.inheritedNested }} +
+

Inherited nested types

+ {{- range $j.inheritedNested }} +
+

From + {{- if .url }} {{ .from }}{{ else }} {{ .from }}{{ end }}

+

+ {{- range $i, $m := .members }}{{ if $i }}, {{ end -}} + {{- if $m.url }}{{ $m.name }}{{ else }}{{ $m.name }}{{ end -}} + {{- end }} +

+
+ {{- end }} +
+ {{- end }} + + {{- if $j.inheritedFields }} +
+

Inherited fields

+ {{- range $j.inheritedFields }} +
+

From + {{- if .url }} {{ .from }}{{ else }} {{ .from }}{{ end }}

+

+ {{- range $i, $m := .members }}{{ if $i }}, {{ end -}} + {{- if $m.url }}{{ $m.name }}{{ else }}{{ $m.name }}{{ end -}} + {{- end }} +

+
+ {{- end }} +
+ {{- end }} + + {{- if $j.inherited }} +
+

Inherited methods

+ {{- range $j.inherited }} +
+

From + {{- if .url }} {{ .from }}{{ else }} {{ .from }}{{ end }}

+

+ {{- range $i, $m := .members }}{{ if $i }}, {{ end -}} + {{- if $m.url }}{{ $m.name }}{{ else }}{{ $m.name }}{{ end -}} + {{- end }} +

+
+ {{- end }} +
+ {{- end }} + + {{- if $j.enumConstants }} +
+

Enum constant details

+ {{- range $j.enumConstants }}{{ partial "javadoc/member-detail.html" (dict "member" . "page" $) }}{{ end }} +
+ {{- end }} + + {{- if $j.fields }} +
+

Field details

+ {{- range $j.fields }}{{ partial "javadoc/member-detail.html" (dict "member" . "page" $) }}{{ end }} +
+ {{- end }} + + {{- if $j.constructors }} +
+

Constructor details

+ {{- range $j.constructors }}{{ partial "javadoc/member-detail.html" (dict "member" . "page" $) }}{{ end }} +
+ {{- end }} + + {{- if $j.methods }} +
+

Method details

+ {{- range $j.methods }}{{ partial "javadoc/member-detail.html" (dict "member" . "page" $) }}{{ end }} +
+ {{- end }} +
+{{- end -}} diff --git a/docs/website/layouts/partials/footer.html b/docs/website/layouts/partials/footer.html index 705cf4cb249..9e154fb2b0b 100644 --- a/docs/website/layouts/partials/footer.html +++ b/docs/website/layouts/partials/footer.html @@ -78,7 +78,6 @@

Community

{{- $cn1Demo := resources.Get "js/cn1-demo.js" | minify | fingerprint }} {{- $cn1Pricing := resources.Get "js/cn1-pricing.js" | minify | fingerprint }} {{- $cn1Crisp := resources.Get "js/cn1-crisp.js" | minify | fingerprint }} -{{- $cn1Javadoc := resources.Get "js/cn1-javadoc.js" | minify | fingerprint }} {{- $cn1PortStatus := resources.Get "js/cn1-port-status.js" | minify | fingerprint }} {{- if or (hasPrefix (.RelPermalink | default "") "/demo-") (eq .Layout "demos") }} @@ -88,9 +87,6 @@

Community

{{- end }} -{{- if eq .Layout "api-javadoc" -}} - -{{- end -}} {{- if eq .Layout "port-status" -}} {{- end -}} diff --git a/docs/website/layouts/partials/javadoc/member-detail.html b/docs/website/layouts/partials/javadoc/member-detail.html new file mode 100644 index 00000000000..5fa7aa48e52 --- /dev/null +++ b/docs/website/layouts/partials/javadoc/member-detail.html @@ -0,0 +1,80 @@ +{{- /* The full entry for one member. Called with a dict of "member" and "page", + because prose has to be rendered through the page that owns it. */ -}} +{{- $m := .member -}} +{{- $page := .page -}} +
+ {{- /* Javadoc answers to both the declared and the erased spelling of a + signature containing a type variable, and links in the wild use both, + so every alternate identifier gets an anchor of its own. */ -}} + {{- range $i, $a := $m.anchors }}{{ if $i }}{{ end }}{{ end }} +

{{ $m.name }}

+ {{ partial "javadoc/signature.html" $m }} + + {{- if $m.deprecated }} +
+ Deprecated. + {{- with $m.deprecatedText }} {{ $page.RenderString . }}{{ end }} +
+ {{- end }} + + {{- with $m.description }} +
{{ $page.RenderString . }}
+ {{- end }} + + {{- range $m.warnings }} +
+ Warning. {{ $page.RenderString . }} +
+ {{- end }} + + {{- if $m.parameters }} + {{- $documented := false }} + {{- range $m.parameters }}{{ if .doc }}{{ $documented = true }}{{ end }}{{ end }} + {{- if $documented }} +
+

Parameters

+
+ {{- range $m.parameters }} +
{{ .name }}{{ with .type.label }} {{ . }}{{ end }}
+
{{ with .doc }}{{ $page.RenderString . }}{{ else }}Not documented.{{ end }}
+ {{- end }} +
+
+ {{- end }} + {{- end }} + + {{- with $m.returns }} +
+

Returns

+
{{ $page.RenderString . }}
+
+ {{- end }} + + {{- if $m.throws }} +
+

Throws

+
+ {{- range $m.throws }} +
{{ if .url }}{{ .name }}{{ else }}{{ .name }}{{ end }}
+
{{ with .doc }}{{ $page.RenderString . }}{{ end }}
+ {{- end }} +
+
+ {{- end }} + + {{- if $m.seeAlso }} +
+

See also

+ +
+ {{- end }} +
diff --git a/docs/website/layouts/partials/javadoc/signature.html b/docs/website/layouts/partials/javadoc/signature.html new file mode 100644 index 00000000000..1bec1d548a9 --- /dev/null +++ b/docs/website/layouts/partials/javadoc/signature.html @@ -0,0 +1,30 @@ +{{- /* A member's declaration, rendered as markup rather than as a code fence so + that every type in it links to its own page. Called with a member map. + + Fields and executables are told apart by whether the doclet set a + "parameters" key: it always does for a method or constructor, including + one that takes none, and never does for a field. */ -}} + + {{- with .modifiers }}{{ . }} {{ end -}} + {{- if isset . "parameters" -}} + {{- with .typeParameters }}{{ . }} {{ end -}} + {{- with .returnType }}{{ partial "javadoc/type-ref.html" . }} {{ end -}} + {{ .name }}( + {{- range $i, $p := .parameters -}} + {{- if $p.type.label -}} + {{- if $i }}, {{ end -}} + {{ partial "javadoc/type-ref.html" $p.type }} {{ $p.name }} + {{- end -}} + {{- end -}} + ) + {{- with .declaredThrows }} + throws + {{- range $i, $t := . }}{{ if $i }}, {{ end }}{{ partial "javadoc/type-ref.html" $t }}{{ end -}} + {{- end }} + {{- with .defaultValue }} default {{ . }}{{ end -}} + {{- else -}} + {{- with .fieldType }}{{ partial "javadoc/type-ref.html" . }} {{ end -}} + {{ .name }} + {{- with .constant }} = {{ . }}{{ end -}} + {{- end -}} + diff --git a/docs/website/layouts/partials/javadoc/summary-table.html b/docs/website/layouts/partials/javadoc/summary-table.html new file mode 100644 index 00000000000..7d5192dcfb1 --- /dev/null +++ b/docs/website/layouts/partials/javadoc/summary-table.html @@ -0,0 +1,22 @@ +{{- /* A summary table for one member group. Called with "rows", "title", "page". */ -}} +{{- if .rows }} +{{- $page := .page }} +
+

{{ .title }}

+ + + {{- range .rows }} + + + + + {{- end }} + +
+ {{ partial "javadoc/signature.html" . }} + + {{- if .deprecated }}Deprecated {{ end -}} + {{- with .summary }}{{ $page.RenderString . }}{{ end -}} +
+
+{{- end }} diff --git a/docs/website/layouts/partials/javadoc/type-ref.html b/docs/website/layouts/partials/javadoc/type-ref.html new file mode 100644 index 00000000000..90bded2ff96 --- /dev/null +++ b/docs/website/layouts/partials/javadoc/type-ref.html @@ -0,0 +1,5 @@ +{{- /* One type reference: linked when we publish a page for it, plain when we do not. + Called with a {label, url} map from the doclet. */ -}} +{{- if and . .label -}} +{{- if .url -}}{{ .label }}{{- else -}}{{ .label }}{{- end -}} +{{- end -}} diff --git a/maven/javadoc-hugo-doclet/pom.xml b/maven/javadoc-hugo-doclet/pom.xml new file mode 100644 index 00000000000..1e8c90a7f2c --- /dev/null +++ b/maven/javadoc-hugo-doclet/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.codenameone + codenameone-javadoc-hugo-doclet + 8.0-SNAPSHOT + Codename One Hugo JavaDoc Doclet + Renders the framework API documentation as Hugo content. A build-time tool, never shipped inside an application + + + + 25 + UTF-8 + 5.9.3 + + + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/CommentRenderer.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/CommentRenderer.java new file mode 100644 index 00000000000..7babe6d8005 --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/CommentRenderer.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import com.sun.source.doctree.DocTree; +import com.sun.source.doctree.EntityTree; +import com.sun.source.doctree.LinkTree; +import com.sun.source.doctree.LiteralTree; +import com.sun.source.doctree.RawTextTree; +import com.sun.source.doctree.ReferenceTree; +import com.sun.source.doctree.TextTree; +import com.sun.source.util.DocTreePath; +import com.sun.source.util.DocTrees; +import java.util.List; +import javax.lang.model.element.Element; + +/** + * Turns a documentation comment body into the markdown that goes into the page. + * + *

For this codebase the job is mostly to get out of the way. 1972 of the 2024 + * core sources use markdown documentation comments, and JDK 23 and later hand a + * markdown comment to a doclet as a single {@code RawTextTree} of kind + * {@link DocTree.Kind#MARKDOWN} holding the author's text verbatim. That text is + * copied straight through and rendered by the same goldmark that renders the + * rest of the site, so a code fence, a table or a list in a comment looks like a + * code fence, a table or a list anywhere else on the site. Deliberately no + * markdown library is involved here: a second implementation could only + * disagree with the one that actually renders the page. + * + *

What does need translating is the inline tags, which markdown has no + * spelling for. They are converted to markdown links against the real resolved + * element, so an unqualified {@code {@link #paintDirty()}} lands on the right + * page. + */ +final class CommentRenderer { + + /** Resolves a documented element to its site URL, or null when it has no page. */ + interface Links { + String url(Element target); + } + + private final DocTrees trees; + private final Links links; + + CommentRenderer(DocTrees trees, Links links) { + this.trees = trees; + this.links = links; + } + + /** + * Renders a run of documentation nodes. + * + * @param nodes the nodes, typically a comment's full body or one block tag's content + * @param path the path of the enclosing comment, used to resolve references; + * references are left as plain text when this is null + * @return markdown + */ + String render(List nodes, DocTreePath path) { + StringBuilder out = new StringBuilder(); + for (DocTree node : nodes) { + append(out, node, path); + } + return out.toString(); + } + + private void append(StringBuilder out, DocTree node, DocTreePath path) { + switch (node.getKind()) { + case MARKDOWN -> out.append(((RawTextTree) node).getContent()); + case TEXT -> out.append(((TextTree) node).getBody()); + // {@code} and {@literal} are both LiteralTree; only the kind differs. + case CODE -> appendCode(out, ((LiteralTree) node).getBody().getBody()); + case LITERAL -> out.append(escape(((LiteralTree) node).getBody().getBody())); + case ENTITY -> out.append("&").append(((EntityTree) node).getName()).append(";"); + case LINK -> appendLink(out, (LinkTree) node, path, true); + case LINK_PLAIN -> appendLink(out, (LinkTree) node, path, false); + // START_ELEMENT, END_ELEMENT and anything else a legacy comment can hold + // are passed through as written. Nothing in CodenameOne/src still uses a + // /** */ comment, so this is the tail: Ports/CLDC11 and whatever a future + // contributor writes by hand. + default -> out.append(node.toString()); + } + } + + /** Wraps text in enough backticks that its own backticks survive. */ + private void appendCode(StringBuilder out, String body) { + // A {@code} spanning lines is a code block, and the javadoc idiom for one + // is

{@code ... }
. Rendering it as an inline span put the whole + // example on one line between backticks. + if (body.indexOf('\n') >= 0) { + String fence = "```"; + while (body.contains(fence)) { + fence += "`"; + } + out.append('\n').append(fence).append('\n') + .append(body.strip()) + .append('\n').append(fence).append('\n'); + return; + } + int longest = 0; + int run = 0; + for (int i = 0; i < body.length(); i++) { + run = body.charAt(i) == '`' ? run + 1 : 0; + longest = Math.max(longest, run); + } + String fence = "`".repeat(longest + 1); + out.append(fence); + // A code span whose content starts or ends with a backtick needs padding + // spaces, which markdown strips again when it renders. + if (body.startsWith("`") || body.endsWith("`")) { + out.append(' ').append(body).append(' '); + } else { + out.append(body); + } + out.append(fence); + } + + private void appendLink(StringBuilder out, LinkTree link, DocTreePath path, boolean code) { + ReferenceTree reference = link.getReference(); + String label = render(link.getLabel(), path).strip(); + if (label.isEmpty()) { + label = defaultLabel(reference); + } + + String url = resolve(reference, path); + String text = code ? "`" + label + "`" : label; + if (url == null) { + out.append(text); + return; + } + out.append('[').append(text).append("](").append(markdownUrl(url)).append(')'); + } + + /** The URL a reference points at, or null when it resolves to nothing we publish. */ + private String resolve(ReferenceTree reference, DocTreePath path) { + if (reference == null || path == null) { + return null; + } + DocTreePath referencePath = DocTreePath.getPath(path, reference); + if (referencePath == null) { + return null; + } + Element target = trees.getElement(referencePath); + return target == null ? null : links.url(target); + } + + /** + * The text javadoc shows when a link carries no explicit label: the reference + * with its package qualifiers dropped and its leading {@code #} removed. + */ + private static String defaultLabel(ReferenceTree reference) { + if (reference == null) { + return ""; + } + String signature = reference.getSignature().strip(); + int hash = signature.indexOf('#'); + String type = hash < 0 ? signature : signature.substring(0, hash); + String member = hash < 0 ? "" : signature.substring(hash + 1); + + // The type name starts at the first segment that begins with a capital, + // and everything from there is kept: Pose.Landmark is a nested type, not + // a package called Pose. Testing the character *before* each dot instead + // read "Pose" as a package segment because it ends in a lower case + // letter, and shortened Pose.Landmark.getName() to Landmark.getName(). + String simpleType = type; + int cut = -1; + int segmentStart = 0; + for (int i = 0; i <= type.length(); i++) { + if (i == type.length() || type.charAt(i) == '.') { + if (segmentStart < type.length() + && Character.isUpperCase(type.charAt(segmentStart))) { + cut = segmentStart; + break; + } + segmentStart = i + 1; + } else if (type.charAt(i) == '(') { + break; + } + } + if (cut > 0) { + simpleType = type.substring(cut); + } + + if (member.isEmpty()) { + return simpleType; + } + return simpleType.isEmpty() ? member : simpleType + "." + member; + } + + /** + * Makes a URL safe to sit in a markdown link destination. + * + *

A constructor's fragment is {@code #(...)}, and markdown reads + * angle brackets in a destination as a delimiter of its own, so + * {@code [x](/p/T.html#(int))} comes out mangled -- the one link on the + * site that pointed at a constructor from inside a comment arrived as + * {@code &lt;init>}. Percent encoding them is transparent to the + * browser, which decodes a fragment before matching it against an id. + * + *

Parentheses are deliberately left alone: they are balanced in every + * signature javadoc emits, and CommonMark allows balanced parentheses in a + * destination. + */ + private static String markdownUrl(String url) { + return url.replace("<", "%3C").replace(">", "%3E").replace(" ", "%20"); + } + + /** Escapes the characters markdown would otherwise treat as syntax. */ + private static String escape(String text) { + StringBuilder out = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if ("\\`*_{}[]()#+-.!<>|~".indexOf(c) >= 0) { + out.append('\\'); + } + out.append(c); + } + return out.toString(); + } +} diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/DocReader.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/DocReader.java new file mode 100644 index 00000000000..fc8fe4d129b --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/DocReader.java @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import com.sun.source.doctree.BlockTagTree; +import com.sun.source.doctree.DeprecatedTree; +import com.sun.source.doctree.DocCommentTree; +import com.sun.source.doctree.DocTree; +import com.sun.source.doctree.InheritDocTree; +import com.sun.source.doctree.ParamTree; +import com.sun.source.doctree.ReturnTree; +import com.sun.source.doctree.SeeTree; +import com.sun.source.doctree.ThrowsTree; +import com.sun.source.util.DocTreePath; +import com.sun.source.util.DocTrees; +import com.sun.source.util.TreePath; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; + +/** + * Reads one element's documentation and merges the two ways this codebase + * expresses it. + * + *

Both forms are live: 816 {@code @param} tags against 9068 + * {@code #### Parameters} headings, 1036 {@code @return} against 7224 + * {@code #### Returns}. The markdown form is parsed by {@link MarkdownSections} + * and wins where the two overlap, because it is what the author most recently + * wrote; block tags fill in anything it did not cover. + * + *

Two tags are dropped rather than rendered. {@code @since} and the + * {@code #### Since} heading are dropped because Codename One does not publish + * availability metadata at all -- {@code scripts/check-since-tags.sh} fails the + * build over one in a source file, on the grounds that a guessed version is + * worse than no version. {@code @hidden} is not dropped but obeyed: the element + * disappears from the output entirely. + */ +final class DocReader { + + /** Guards against a cycle in overriding chains while resolving inherited docs. */ + private static final int MAX_INHERIT_DEPTH = 16; + + private final DocTrees trees; + private final Elements elements; + private final Types types; + private final CommentRenderer renderer; + + /** + * Memo of everything already read. + * + *

Not an optimisation of last resort: every element is read at least twice + * over -- once to decide whether {@code @hidden} keeps it out of the index, + * again to render it, and once more for each summary row that quotes it -- + * and reading a method walks its whole supertype chain looking for the + * declaration it inherits from. Without this the generator re-walks the + * hierarchy of roughly 1850 types several times each. + */ + private final Map cache = new HashMap<>(); + + DocReader(DocTrees trees, Elements elements, Types types, CommentRenderer renderer) { + this.trees = trees; + this.elements = elements; + this.types = types; + this.renderer = renderer; + } + + ElementDoc read(Element element) { + ElementDoc cached = cache.get(element); + if (cached != null) { + return cached; + } + ElementDoc doc = read(element, 0); + cache.put(element, doc); + return doc; + } + + private ElementDoc read(Element element, int depth) { + ElementDoc doc = new ElementDoc(); + DocCommentTree comment = trees.getDocCommentTree(element); + + if (comment == null) { + // An undocumented override still documents itself through its parent, + // which is the behaviour every Java developer expects from javadoc. + ElementDoc inherited = inherit(element, depth); + ElementDoc result = inherited == null ? doc : adoptParameterNames(element, inherited); + markAnnotationDeprecation(element, result); + return result; + } + + DocTreePath path = pathOf(element, comment); + // Goldmark drops raw HTML rather than rendering it, so the leftovers in + // the comments that were converted to markdown have to be dealt with + // before anything else reads the body. + doc.description = LegacyHtml.convert(renderer.render(comment.getFullBody(), path)); + + MarkdownSections.Result sections = + MarkdownSections.parse(doc.description, element instanceof ExecutableElement); + doc.description = sections.description(); + doc.parameters.addAll(sections.parameters()); + doc.exceptions.addAll(sections.exceptions()); + doc.seeAlso.addAll(sections.seeAlso()); + doc.returns = sections.returns(); + if (sections.deprecated() != null) { + doc.deprecated = true; + doc.deprecatedText = sections.deprecated(); + } + + readBlockTags(element, comment, path, doc); + markAnnotationDeprecation(element, doc); + resolveInheritDoc(element, doc, depth); + return doc; + } + + /** + * Re-labels an inherited doc with this element's own parameter names. + * + *

An override with no comment of its own takes its parent's whole + * documentation, and the parent named the parameters as it saw fit. + * {@code GridBagLayout.addLayoutComponent} calls its first parameter + * "constraints" where {@code Layout} calls it "value", so the page looked up + * "constraints", found nothing, and printed "Not documented" beside it while + * the other two inherited normally. + * + *

Copies rather than edits: the doc handed back belongs to the parent and + * is its own cached answer. + */ + private ElementDoc adoptParameterNames(Element element, ElementDoc inherited) { + if (!(element instanceof ExecutableElement executable)) { + return inherited; + } + ExecutableElement overridden = findOverridden(executable); + if (overridden == null) { + return inherited; + } + List mine = executable.getParameters(); + List theirs = overridden.getParameters(); + + ElementDoc copy = new ElementDoc(); + copy.description = inherited.description; + copy.returns = inherited.returns; + // Deprecation is deliberately NOT carried over. Java does not inherit + // @Deprecated and neither do the standard pages: ScaleImageLabel's + // setPreferredH and setPreferredW carry only @Override, and taking the + // parent's flag with its prose put a Deprecated banner on both. The + // override's own tag or annotation decides, and markAnnotationDeprecation + // runs after this. + copy.hidden = inherited.hidden; + copy.exceptions.addAll(inherited.exceptions); + copy.seeAlso.addAll(inherited.seeAlso); + + for (int i = 0; i < mine.size(); i++) { + String name = mine.get(i).getSimpleName().toString(); + String text = i < theirs.size() + ? inherited.parameterText(theirs.get(i).getSimpleName().toString()) + : null; + if (text == null) { + text = inherited.parameterText(name); + } + if (text != null) { + copy.addParameter(new MarkdownSections.NamedText(name, text)); + } + } + // Anything the parent documented that is not a parameter of this method + // -- a type parameter, most often -- carries over as written. + for (MarkdownSections.NamedText parameter : inherited.parameters) { + if (parameter.name().startsWith("<")) { + copy.addParameter(parameter); + } + } + return copy; + } + + /** + * Marks an element deprecated because it is annotated, tag or no tag. + * + *

{@code @Deprecated} and {@code @deprecated} are independent: the + * annotation is what the compiler warns on, the tag is what explains it, and + * an API may carry either. 23 files here carry the annotation, and + * {@code com.codename1.ui.util.MutableResouce} carries it with no tag at all, + * so reading only the documentation lost its deprecated marking entirely + * while the standard pages showed it. + * + *

Only ever sets the flag. A comment that documented a deprecation keeps + * whatever text it gave. + * + *

Note the site marks 299 more members deprecated than the standard pages + * do, and that is correct rather than a leak. Those carry a + * {@code #### Deprecated} section, which the standard doclet renders as an + * ordinary heading inside the description because it cannot see the + * convention -- the same reason it shows no parameter tables. Measured: every + * one of the 299 has deprecation text, and none is an undocumented override + * inheriting the flag from its parent. + */ + private void markAnnotationDeprecation(Element element, ElementDoc doc) { + if (elements.isDeprecated(element)) { + doc.deprecated = true; + } + } + + private void readBlockTags(Element element, DocCommentTree comment, DocTreePath path, + ElementDoc doc) { + for (DocTree tag : comment.getBlockTags()) { + switch (tag.getKind()) { + case PARAM -> { + ParamTree param = (ParamTree) tag; + // Type parameter documentation has no column in the rendered + // signature table, so it is folded into the description rather + // than silently dropped. + String name = param.getName().getName().toString(); + String text = takeTrailingSections( + renderer.render(param.getDescription(), path), element, doc); + doc.addParameter(new MarkdownSections.NamedText( + param.isTypeParameter() ? "<" + name + ">" : name, text)); + } + case RETURN -> { + String text = takeTrailingSections( + renderer.render(((ReturnTree) tag).getDescription(), path), element, doc); + if (doc.returns == null && !text.isEmpty()) { + doc.returns = text; + } + } + case THROWS, EXCEPTION -> { + ThrowsTree thrown = (ThrowsTree) tag; + doc.addException(new MarkdownSections.NamedText( + thrown.getExceptionName().getSignature(), + takeTrailingSections( + renderer.render(thrown.getDescription(), path), element, doc))); + } + case SEE -> { + String text = renderer.render(((SeeTree) tag).getReference(), path).strip(); + if (!text.isEmpty()) { + doc.seeAlso.add(text); + } + } + case DEPRECATED -> { + doc.deprecated = true; + // The house convention writes @deprecated under a + // "#### Deprecated" heading, and anything that follows -- + // "#### See also" and its bullets -- is still inside the tag + // as far as the JDK is concerned. Storing the body whole put + // those headings inside the deprecation banner and left the + // references unresolved: CellRenderer and + // ImageDownloadService both lost their See-also entirely. + String text = takeTrailingSections( + renderer.render(((DeprecatedTree) tag).getBody(), path), element, doc); + if (!text.isEmpty()) { + doc.deprecatedText = text; + } + } + case HIDDEN -> doc.hidden = true; + default -> readUnknownTag(tag, path, doc); + } + } + } + + /** + * Splits the sections a block tag's body ran on into, and returns what is + * genuinely the tag's own text. + * + *

The house convention writes a block tag underneath a heading of the + * same name, and everything after it -- another heading and its bullets -- + * is still inside that tag as far as the JDK is concerned. Storing a body + * whole therefore swallows whatever followed it: 205 methods rendered a + * "#### Throws" heading and its bullet inside their Returns text and emitted + * no exception row at all, com.codename1.ui.CN.requestFullScreen among them. + * + *

Applied to every tag with a body rather than to @deprecated alone, + * which is where this was first noticed and fixed one tag too narrowly. + */ + private String takeTrailingSections(String body, Element element, ElementDoc doc) { + MarkdownSections.Result split = + MarkdownSections.parse(body, element instanceof ExecutableElement); + doc.seeAlso.addAll(split.seeAlso()); + for (MarkdownSections.NamedText parameter : split.parameters()) { + doc.addParameter(parameter); + } + for (MarkdownSections.NamedText thrown : split.exceptions()) { + doc.addException(thrown); + } + if (doc.returns == null && split.returns() != null) { + doc.returns = split.returns(); + } + if (split.deprecated() != null) { + doc.deprecated = true; + if (doc.deprecatedText.isEmpty()) { + doc.deprecatedText = split.deprecated(); + } + } + return split.description().strip(); + } + + /** + * Tags with no case of their own. + * + *

Silence here loses content. This repository writes its own + * {@code @warning} tag -- 22 of them, and they are safety notes: + * {@code Body.createFixture} warns that the function is locked during + * callbacks -- and every one was being dropped on the floor because the tag + * is not one javadoc knows. + * + *

Only the tags that are deliberately not published are discarded, and + * they are named. Anything else keeps its text. + */ + private void readUnknownTag(DocTree tag, DocTreePath path, ElementDoc doc) { + if (!(tag instanceof BlockTagTree named)) { + return; + } + String name = named.getTagName(); + if ("hidden".equals(name)) { + doc.hidden = true; + return; + } + // @author and @version are not published, and @since is rejected in + // sources outright by scripts/check-since-tags.sh: a guessed version is + // worse than none. Serial tags describe a mechanism this toolkit has no + // support for at all. + if (DROPPED_TAGS.contains(name)) { + return; + } + + String body = tag.toString(); + int at = body.indexOf('@'); + if (at >= 0) { + int space = body.indexOf(' ', at); + body = space < 0 ? "" : body.substring(space + 1); + } + body = body.strip(); + if (body.isEmpty()) { + return; + } + if ("warning".equals(name)) { + doc.warnings.add(body); + } else { + // Keep it visible rather than lose it; the page has no better place. + doc.description = doc.description.isBlank() + ? body + : doc.description + "\n\n" + body; + } + } + + /** Tags this generator publishes nowhere, on purpose. */ + private static final java.util.Set DROPPED_TAGS = java.util.Set.of( + "author", "version", "since", "serial", "serialData", "serialField"); + + /** + * Fills in whatever the element left to its parent. + * + *

Covers both spellings of the same intent: an explicit + * {@code {@inheritDoc}} in the description, and a description, return or + * parameter the override simply did not write. + */ + private void resolveInheritDoc(Element element, ElementDoc doc, int depth) { + boolean wantsDescription = containsInheritDoc(element) || doc.description.isBlank(); + // A marker inside a structured section is a request too. An override that + // writes "#### Returns" with {@inheritDoc} under it leaves doc.returns + // non-null, so testing only for null published the marker itself -- + // BubbleTransition.copy and FlipTransition.copy both showed a literal + // {@inheritDoc} where the parent's text belonged. + boolean wantsDetail = doc.returns == null + || isInheritDoc(doc.returns) + || hasUndocumentedParameter(element, doc); + if (!wantsDescription && !wantsDetail) { + return; + } + + ElementDoc parent = inherit(element, depth); + if (parent == null) { + return; + } + + if (wantsDescription) { + if (doc.description.isBlank()) { + doc.description = parent.description; + } else { + doc.description = doc.description.replace(INHERIT_DOC_MARKER, parent.description); + } + } + if (doc.returns == null || isInheritDoc(doc.returns)) { + doc.returns = parent.returns; + } + if (element instanceof ExecutableElement executable) { + // Paired by position, not by name. An override is free to rename a + // parameter, and looking the parent's text up under the child's name + // then finds nothing: GridBagLayout.addLayoutComponent calls its + // first parameter "constraints" where Layout calls it "value", and + // that one parameter came out undocumented while the rest inherited. + ExecutableElement overridden = findOverridden(executable); + List parameters = executable.getParameters(); + for (int i = 0; i < parameters.size(); i++) { + String name = parameters.get(i).getSimpleName().toString(); + String own = doc.parameterText(name); + if (own != null && !isInheritDoc(own)) { + continue; + } + String inherited = null; + if (overridden != null && i < overridden.getParameters().size()) { + inherited = parent.parameterText( + overridden.getParameters().get(i).getSimpleName().toString()); + } + if (inherited == null) { + inherited = parent.parameterText(name); + } + final String finalName = name; + if (inherited != null) { + doc.parameters.removeIf(existing -> existing.name().equals(finalName)); + doc.addParameter(new MarkdownSections.NamedText(name, inherited)); + } else if (own != null) { + // Nothing to inherit: drop the marker rather than publish it. + doc.parameters.removeIf(existing -> existing.name().equals(finalName)); + } + } + } + if (doc.returns != null && isInheritDoc(doc.returns)) { + doc.returns = null; + } + for (MarkdownSections.NamedText exception : parent.exceptions) { + doc.addException(exception); + } + } + + private boolean hasUndocumentedParameter(Element element, ElementDoc doc) { + if (!(element instanceof ExecutableElement executable)) { + return false; + } + for (var parameter : executable.getParameters()) { + if (doc.parameterText(parameter.getSimpleName().toString()) == null) { + return true; + } + } + return false; + } + + /** + * The rendered form of {@code {@inheritDoc}}. + * + *

{@link CommentRenderer} has no element context, so it renders the tag + * through its default branch as its own source text. That text is the marker + * this class substitutes into, which keeps the renderer free of any + * inheritance knowledge. + */ + private static final String INHERIT_DOC_MARKER = "{@inheritDoc}"; + + /** Whether a documented value is nothing but an inherit marker. */ + private static boolean isInheritDoc(String text) { + return text != null && text.strip().equals(INHERIT_DOC_MARKER); + } + + private boolean containsInheritDoc(Element element) { + DocCommentTree comment = trees.getDocCommentTree(element); + if (comment == null) { + return false; + } + for (DocTree node : comment.getFullBody()) { + if (node instanceof InheritDocTree) { + return true; + } + } + return false; + } + + /** The documentation of the method this one overrides, or null when there is none. */ + private ElementDoc inherit(Element element, int depth) { + if (depth >= MAX_INHERIT_DEPTH || !(element instanceof ExecutableElement method)) { + return null; + } + ExecutableElement overridden = findOverridden(method); + return overridden == null ? null : read(overridden, depth + 1); + } + + /** + * The first method this one overrides, searching superclasses before + * interfaces, which is the order javadoc documents. + */ + private ExecutableElement findOverridden(ExecutableElement method) { + TypeElement owner = Refs.enclosingType(method); + if (owner == null) { + return null; + } + List supertypes = new ArrayList<>(); + collectSupertypes(owner.asType(), supertypes, new ArrayList<>()); + for (TypeElement supertype : supertypes) { + for (ExecutableElement candidate : ElementFilter.methodsIn(supertype.getEnclosedElements())) { + if (elements.overrides(method, candidate, owner)) { + return candidate; + } + } + } + return null; + } + + private void collectSupertypes(TypeMirror type, List out, List seen) { + for (TypeMirror supertype : types.directSupertypes(type)) { + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement element)) { + continue; + } + String name = element.getQualifiedName().toString(); + if (seen.contains(name)) { + continue; + } + seen.add(name); + out.add(element); + collectSupertypes(supertype, out, seen); + } + } + + /** The comment's path, needed to resolve references, or null when unavailable. */ + private DocTreePath pathOf(Element element, DocCommentTree comment) { + TreePath treePath = trees.getPath(element); + return treePath == null ? null : new DocTreePath(treePath, comment); + } +} diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/ElementDoc.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/ElementDoc.java new file mode 100644 index 00000000000..7efb8146fd7 --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/ElementDoc.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import java.util.ArrayList; +import java.util.List; + +/** One element's documentation, after tags and markdown sections have been merged. */ +final class ElementDoc { + + String description = ""; + final List parameters = new ArrayList<>(); + final List exceptions = new ArrayList<>(); + final List seeAlso = new ArrayList<>(); + /** Bodies of {@code @warning}, the repository's own safety-note tag. */ + final List warnings = new ArrayList<>(); + String returns; + boolean deprecated; + String deprecatedText = ""; + boolean hidden; + + boolean isEmpty() { + return description.isBlank() && parameters.isEmpty() && exceptions.isEmpty() + && seeAlso.isEmpty() && warnings.isEmpty() && returns == null && !deprecated; + } + + /** Adds a parameter unless one of that name is already documented. */ + void addParameter(MarkdownSections.NamedText parameter) { + for (MarkdownSections.NamedText existing : parameters) { + if (existing.name().equals(parameter.name())) { + return; + } + } + parameters.add(parameter); + } + + /** Adds an exception unless one of that type is already documented. */ + void addException(MarkdownSections.NamedText exception) { + for (MarkdownSections.NamedText existing : exceptions) { + if (existing.name().equals(exception.name())) { + return; + } + } + exceptions.add(exception); + } + + /** The documented text for a parameter, or null when it is undocumented. */ + String parameterText(String name) { + for (MarkdownSections.NamedText parameter : parameters) { + if (parameter.name().equals(name)) { + return parameter.text(); + } + } + return null; + } +} diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/HugoDoclet.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/HugoDoclet.java new file mode 100644 index 00000000000..d1985bddaaa --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/HugoDoclet.java @@ -0,0 +1,1724 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.ExecutableType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import jdk.javadoc.doclet.Doclet; +import jdk.javadoc.doclet.DocletEnvironment; +import jdk.javadoc.doclet.Reporter; + +/** + * Renders the Codename One API as Hugo content instead of as a standalone HTML + * site. + * + *

The website used to embed the standard doclet's output by copying the whole + * generated tree into {@code static/}, prefixing every selector of javadoc's + * stylesheet with a scoping class, and fetching deep pages into a {@code div} + * with a script that faked {@code window.pathtoroot} and re-enabled the search + * box javadoc had disabled. Themed pages fought a stylesheet that was never + * built to be themed, so dark mode broke. + * + *

This doclet emits a page per type as a Hugo content file whose front matter + * is the API model and whose prose is markdown. The site's own templates render + * it, so the API pages are the same pages as the rest of the site -- same theme, + * same dark mode, same typography, same search. The standard doclet still runs + * alongside this one to produce the downloadable zip. + * + *

The generated URLs match the standard doclet's exactly, fragments included; + * see {@link Refs}. + */ +public final class HugoDoclet implements Doclet { + + private Reporter reporter; + private Path contentRoot; + private Path searchIndex; + + private DocletEnvironment environment; + private Elements elements; + private Types types; + private Refs refs; + private DocReader docReader; + private TypeNames typeNames; + + /** Every type we publish a page for, by qualified name. */ + private final Map documented = new LinkedHashMap<>(); + /** Direct subtypes, keyed by the qualified name of the supertype. */ + private final Map> subtypes = new LinkedHashMap<>(); + /** Rows for the search index. */ + private final List> searchRows = new ArrayList<>(); + + @Override + public void init(Locale locale, Reporter reporter) { + this.reporter = reporter; + } + + @Override + public String getName() { + return "HugoDoclet"; + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); + } + + @Override + public Set getSupportedOptions() { + return Set.of( + new SimpleOption("-d", "

", "Hugo content directory to generate into", + value -> contentRoot = Path.of(value)), + new SimpleOption("--search-index", "", "JSON search index to write", + value -> searchIndex = Path.of(value))); + } + + @Override + public boolean run(DocletEnvironment environment) { + if (contentRoot == null) { + reporter.print(javax.tools.Diagnostic.Kind.ERROR, "-d is required"); + return false; + } + this.environment = environment; + this.elements = environment.getElementUtils(); + this.types = environment.getTypeUtils(); + this.refs = new Refs(types); + + CommentRenderer.Links links = this::urlOf; + this.typeNames = new TypeNames(links); + this.docReader = new DocReader(environment.getDocTrees(), elements, types, + new CommentRenderer(environment.getDocTrees(), links)); + + try { + index(environment); + for (TypeElement type : documented.values()) { + writeTypePage(type); + } + writePackagePages(); + writeOverview(); + writeSearchIndex(); + } catch (IOException failure) { + reporter.print(javax.tools.Diagnostic.Kind.ERROR, + "failed to write Hugo content: " + failure + "\n" + stackTrace(failure)); + return false; + } + return true; + } + + // ---------------------------------------------------------------- indexing + + /** + * Collects the types that get a page, before anything is rendered. + * + *

Link resolution needs the whole set up front: a comment on the first type + * processed can reference the last, and a reference to a type we do not + * publish has to render as text rather than as a link into a 404. + */ + private void index(DocletEnvironment environment) { + for (Element element : environment.getIncludedElements()) { + if (element instanceof TypeElement type) { + collectType(type); + } + } + for (TypeElement type : documented.values()) { + recordSubtypeEdges(type); + } + } + + /** + * Records this type under its nearest published ancestors. + * + *

Stopping at a direct parent that happens to have no page breaks the + * graph: the package private AbstractVisionAnalyzer sits between + * VisionAnalyzer and all seven of its public implementations, and every one + * of them was invisible from the interface's page. An unpublished type is + * walked through rather than treated as the end of the line. + */ + private void recordSubtypeEdges(TypeElement type) { + // directSupertypes() hands an interface java.lang.Object, which the + // language does not, so seeding from it made every interface a known + // subtype of Object: the Object page listed 1406 of them, AdCallback and + // OnUserEarnedRewardListener included. allSupertypes() already skips it. + Deque queue = new ArrayDeque<>(realSupertypes(type.asType())); + Set visited = new LinkedHashSet<>(); + while (!queue.isEmpty()) { + TypeMirror supertype = queue.removeFirst(); + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement parent) + || !visited.add(parent.getQualifiedName().toString())) { + continue; + } + if (documented.containsKey(parent.getQualifiedName().toString())) { + subtypes.computeIfAbsent(parent.getQualifiedName().toString(), + key -> new ArrayList<>()).add(type); + } else { + // Filtered at every hop, not only the first. HTMLCallback reaches + // Object through the package private CSSParserCallback, so + // filtering the seed alone still left it a subtype of Object. + queue.addAll(realSupertypes(supertype)); + } + } + } + + /** + * The supertypes a type actually has. + * + *

{@code directSupertypes()} hands an interface {@code java.lang.Object}, + * which the language does not: an interface extends only its + * superinterfaces. Seeding the subtype graph from that made every interface + * a known subtype of Object, and the Object page listed 1406 of them. + * + *

Filtered at every hop rather than only at the seed, because an + * interface reaches Object through an unpublished one it is walked through: + * com.codename1.ui.html.HTMLCallback survived the first attempt that way. + * + *

There is no unit test for this. java.lang.Object comes from + * Ports/CLDC11 and cannot be part of the doclet's own fixture, so the edge + * it is about is never recorded there and any assertion passes with the fix + * removed. The evidence is the full run: Object's known subtypes went from + * 1406 to 1089, and the number of them that are interfaces from 314 to 0. + */ + private List realSupertypes(TypeMirror type) { + boolean isInterface = type instanceof DeclaredType declared + && isInterfaceLike(declared.asElement()); + // An annotation type has no supertypes worth publishing. It implicitly + // extends java.lang.annotation.Annotation, and the standard reference + // says so nowhere: no superinterface line, no inherited members, no + // mention of Annotation at all on the page. Reporting it put four + // methods under "Inherited methods" that nobody calls that way. + if (type instanceof DeclaredType annotation + && annotation.asElement().getKind() == ElementKind.ANNOTATION_TYPE) { + return List.of(); + } + List out = new ArrayList<>(); + for (TypeMirror supertype : types.directSupertypes(type)) { + if (isInterface && supertype instanceof DeclaredType declared + && declared.asElement() instanceof TypeElement element + && element.getQualifiedName().contentEquals("java.lang.Object")) { + continue; + } + out.add(supertype); + } + return out; + } + + /** + * Whether a type is an interface in the language's sense. + * + *

ANNOTATION_TYPE is its own ElementKind and is not INTERFACE, so a bare + * comparison against INTERFACE quietly excludes every annotation: they were + * still listed as known subtypes of Object, 78 of them, and their pages + * claimed to inherit clone(), wait() and the rest. An annotation interface + * inherits none of that. + */ + private static boolean isInterfaceLike(Element element) { + return element.getKind() == ElementKind.INTERFACE + || element.getKind() == ElementKind.ANNOTATION_TYPE; + } + + private void collectType(TypeElement type) { + if (isHidden(type) || !isVisible(type)) { + return; + } + documented.put(type.getQualifiedName().toString(), type); + for (TypeElement nested : ElementFilter.typesIn(type.getEnclosedElements())) { + collectType(nested); + } + } + + /** + * Whether an element carries {@code @hidden}, which removes it from the API + * entirely. + * + *

Reads the block tags directly rather than going through + * {@link DocReader}. Reading an element there renders its whole comment, + * which resolves the links in it -- and this runs during indexing, while the + * set of published types is still being filled in. The rendered text is then + * cached, so a reference to a type indexed later was frozen as an unlinked + * code span: com.codename1.annotations.AppIntent linked [IntentEntity] and + * not [IntentParam], purely because of the order the two were reached. + */ + private boolean isHidden(Element element) { + return isHiddenByTag(element); + } + + /** + * The same question asked straight off the comment's block tags. + * + *

{@link #urlOf} cannot go through {@link DocReader}: reading an element + * renders its comment, rendering resolves the links in it, and resolving a + * link calls back here. Two elements referring to each other is enough to + * recurse until the stack ends, and the whole generation dies with a + * StackOverflowError rather than a message anyone could act on. This reads + * the tags and renders nothing. + */ + private boolean isHiddenByTag(Element element) { + com.sun.source.doctree.DocCommentTree comment = + environment.getDocTrees().getDocCommentTree(element); + if (comment == null) { + return false; + } + for (com.sun.source.doctree.DocTree tag : comment.getBlockTags()) { + if (tag.getKind() == com.sun.source.doctree.DocTree.Kind.HIDDEN) { + return true; + } + } + return false; + } + + /** + * Whether an element is part of the published API. + * + *

The generator runs javadoc with {@code -protected}, so javadoc has already + * filtered the source set; this is the belt to that braces, and it also keeps + * package private nested types out of an otherwise public type's page. + */ + private static boolean isVisible(Element element) { + return element.getModifiers().contains(Modifier.PUBLIC) + || element.getModifiers().contains(Modifier.PROTECTED); + } + + /** The page URL of an element, or null when it is not something we publish. */ + private String urlOf(Element target) { + if (target == null) { + return null; + } + if (target instanceof PackageElement pkg) { + return environment.isIncluded(pkg) ? Refs.packageUrl(pkg) : null; + } + if (target instanceof TypeElement type) { + return documented.containsKey(type.getQualifiedName().toString()) + ? Refs.typeUrl(type) : null; + } + TypeElement owner = Refs.enclosingType(target); + if (owner == null || !documented.containsKey(owner.getQualifiedName().toString())) { + return null; + } + // A reference can resolve to a member the page does not render, and a + // link to an anchor that was never written is a link that goes nowhere. + // The generator runs javadoc with -protected, so a {@link #position} + // landing on a private field -- com.codename1.maps.MarkerOptions has one + // beside a public method of the same name -- produced exactly that. + if (!isVisible(target) || isHiddenByTag(target)) { + return null; + } + List anchors = refs.anchors(target); + return Refs.typeUrl(owner) + "#" + anchors.get(0); + } + + // ------------------------------------------------------------ type pages + + private void writeTypePage(TypeElement type) throws IOException { + ElementDoc doc = docReader.read(type); + Map api = new LinkedHashMap<>(); + + api.put("kind", kindOf(type)); + api.put("qualified", type.getQualifiedName().toString()); + api.put("simple", Refs.nestedDisplayName(type)); + // "public final enum E" and "public abstract annotation A" are not + // declarations Java would accept: final is implicit on an enum and + // abstract on an annotation, and the keyword is @interface. + api.put("modifiers", TypeNames.declarationModifiers(type)); + api.put("keyword", keywordOf(type)); + // @Target and @Retention define how an annotation may be used at all, and + // AppIntent published neither. Only annotations that ask to be documented + // are shown, which is the rule javadoc follows. + api.put("annotations", documentedAnnotations(type)); + api.put("typeParameters", typeNames.typeParameters(type.getTypeParameters())); + + PackageElement pkg = Refs.packageOf(type); + api.put("package", Map.of( + "name", pkg.getQualifiedName().toString(), + "url", Refs.packageUrl(pkg))); + + api.put("inheritance", inheritanceChain(type)); + api.put("interfaces", interfacesOf(type)); + api.put("subclasses", subclassesOf(type)); + + api.put("deprecated", doc.deprecated); + api.put("deprecatedText", doc.deprecatedText); + api.put("description", doc.description); + api.put("warnings", List.copyOf(doc.warnings)); + api.put("seeAlso", seeAlsoRefs(doc, type)); + // Type parameter documentation has nowhere to sit in the declaration + // string, so it was being read and then dropped. Exactly one tag in the + // tree carries any text today (VisionCameraView); the other two are + // empty. Kept anyway, because silently discarding what an author wrote is + // the defect, not the size of it. + api.put("typeParameterDocs", typeParameterDocs(doc)); + + // javadoc documents a package private supertype's members on the visible + // subclass rather than dropping them, because the supertype has no page + // to link to. com.codename1.ads.InterstitialAd is the case here: it + // declares nothing itself and inherits everything from the package + // private AbstractFullScreenAd, so treating those as merely "inherited" + // left seven documented methods with no page anywhere on the site. + List hidden = undocumentedSupertypes(type); + List owned = new ArrayList<>(type.getEnclosedElements()); + // A member is only promoted if no published ancestor already supplies it. + // A package private interface can sit behind a documented class that + // implements it, and then every descendant was being handed the + // interface's abstract methods as its own: GameSceneView declared + // "public abstract setup(GraphicsDevice)" though it is concrete and + // inherits GameView's implementation, which is what the standard page + // lists. + Set suppliedByDocumented = signaturesFromDocumentedSupertypes(type); + for (TypeElement supertype : hidden) { + for (Element member : supertype.getEnclosedElements()) { + // Only an ABSTRACT one is dropped. An abstract method promoted + // off an unpublished interface shows a concrete class declaring + // something it does not declare -- GameSceneView read + // "public abstract setup(GraphicsDevice)" while inheriting + // GameView's implementation. A concrete one is the + // implementation and has to stay: CompoundAnimation is + // unpublished and overrides flush(), so UIMutation would lose the + // method that actually runs. + if (member instanceof ExecutableElement method + && method.getModifiers().contains(Modifier.ABSTRACT) + && suppliedByDocumented.contains(signatureKey(method))) { + continue; + } + owned.add(member); + } + } + + api.put("nested", nestedRows(type)); + // An enum constant is a field in the model and not one on the page: the + // standard reference gives it its own summary and detail sections and no + // field summary at all, where this rendered "public static final + // BindAttr TEXT" under Fields. + List enumConstants = new ArrayList<>(); + List plainFields = new ArrayList<>(); + for (VariableElement field : ElementFilter.fieldsIn(owned)) { + (field.getKind() == ElementKind.ENUM_CONSTANT ? enumConstants : plainFields).add(field); + } + api.put("enumConstants", memberRows(enumConstants, type)); + api.put("fields", memberRows(plainFields, type)); + api.put("constructors", executableRows( + ElementFilter.constructorsIn(type.getEnclosedElements()), type)); + api.put("methods", executableRows(dedupeBySignature(ElementFilter.methodsIn(owned)), type)); + api.put("inherited", inheritedMembers(type, hidden)); + // javadoc renders "Fields inherited from class X" beside the methods + // block. Without it a subtype such as Label showed none of Component's + // constants -- CENTER, TOP, the cursor values -- even though this page + // links references to them. + api.put("inheritedFields", inheritedFields(type, hidden)); + // A public nested type is addressable through a subtype, and javadoc + // lists it: Dialog omitted Form.TabIterator entirely. + api.put("inheritedNested", inheritedNested(type, hidden)); + + Map frontMatter = new LinkedHashMap<>(); + frontMatter.put("title", Refs.nestedDisplayName(type)); + frontMatter.put("url", Refs.typeUrl(type)); + frontMatter.put("description", TypeNames.summary(doc.description)); + frontMatter.put("layout", "type"); + // No alias: the .html spelling redirects here on its own, and the + // directory spelling IS this page now. + frontMatter.put("aliases", List.of()); + frontMatter.put("javadoc", api); + + write(contentRoot.resolve(Refs.typeContentPath(type)), Json.write(frontMatter)); + addSearchRows(type, doc, owned); + } + +/** + * Every supertype we publish no page for, anywhere in the hierarchy. + * + *

The walk continues through documented supertypes rather than stopping + * at them, because an undocumented ancestor can sit behind a documented one: + * the constants of the package private {@code CSSParserCallback} are + * inherited by the public {@code HTMLCallback} and again by + * {@code DefaultHTMLCallback}, and javadoc documents them on both, since + * there is no page anywhere in the chain for an "inherited from" link to + * point at. Only undocumented types are collected; a documented one is + * walked through and then listed as inherited. + */ + private List undocumentedSupertypes(TypeElement type) { + List out = new ArrayList<>(); + collectUndocumented(type.asType(), new LinkedHashSet<>(), out); + return out; + } + + private void collectUndocumented(TypeMirror type, Set visited, List out) { + // Through the same filter as every other walk, so an interface cannot + // promote Object's members and an annotation promotes nothing at all. + for (TypeMirror supertype : realSupertypes(type)) { + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement element) + || !visited.add(element.getQualifiedName().toString())) { + continue; + } + if (!documented.containsKey(element.getQualifiedName().toString())) { + out.add(element); + } + collectUndocumented(supertype, visited, out); + } + } + + /** + * The method signatures a published ancestor of this type already provides. + * + *

Those are inherited, and the page says so in its inherited block. What + * they must not be is promoted a second time as the type's own members off + * some unpublished interface further up. + */ + private Set signaturesFromDocumentedSupertypes(TypeElement type) { + Set out = new LinkedHashSet<>(); + Set visited = new LinkedHashSet<>(); + for (TypeMirror supertype : allSupertypes(type.asType(), visited)) { + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement element) + || !documented.containsKey(element.getQualifiedName().toString())) { + continue; + } + // Only a published CLASS actually supplies the member. A published + // interface merely declares it, and the implementation can still be + // on the unpublished class in between: the vision analysers get + // process() from the package private AbstractVisionAnalyzer while + // VisionAnalyzer only names it, and suppressing on the interface + // took the method off all seven pages. + if (isInterfaceLike(element)) { + continue; + } + for (ExecutableElement method : ElementFilter.methodsIn(element.getEnclosedElements())) { + // And only a CONCRETE one supplies anything. ComponentAnimation + // declares flush() abstract and the package private + // CompoundAnimation implements it, so UIMutation has to keep the + // promoted implementation: an abstract declaration higher up is + // not something a reader can call. + if (method.getModifiers().contains(Modifier.ABSTRACT)) { + continue; + } + out.add(signatureKey(method)); + } + } + return out; + } + + /** Keeps the first declaration of each signature, so an override wins over what it overrides. */ + private List dedupeBySignature(List methods) { + Set seen = new LinkedHashSet<>(); + List out = new ArrayList<>(); + for (ExecutableElement method : methods) { + if (seen.add(signatureKey(method))) { + out.add(method); + } + } + return out; + } + + /** The documented type parameters, as {@code } entries lifted by the reader. */ + private List> typeParameterDocs(ElementDoc doc) { + List> out = new ArrayList<>(); + for (MarkdownSections.NamedText parameter : doc.parameters) { + if (!parameter.name().startsWith("<") || parameter.text().isBlank()) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("name", parameter.name()); + row.put("doc", parameter.text()); + out.add(row); + } + return out; + } + + /** The declaration's annotations that carry {@code @Documented}. */ + private List documentedAnnotations(TypeElement type) { + List out = new ArrayList<>(); + for (javax.lang.model.element.AnnotationMirror mirror : type.getAnnotationMirrors()) { + Element annotation = mirror.getAnnotationType().asElement(); + if (!(annotation instanceof TypeElement declared)) { + continue; + } + String name = declared.getQualifiedName().toString(); + // Deprecation already has a banner of its own on the page. + if (name.equals("java.lang.Deprecated") || !isDocumented(declared)) { + continue; + } + // javadoc writes @Retention(CLASS); the mirror's toString is fully + // qualified, which buries the name it is worth showing. + out.add(mirror.toString().replace("@" + name, "@" + declared.getSimpleName())); + } + return out; + } + + private static boolean isDocumented(TypeElement annotation) { + for (javax.lang.model.element.AnnotationMirror mirror : annotation.getAnnotationMirrors()) { + Element element = mirror.getAnnotationType().asElement(); + if (element instanceof TypeElement declared + && declared.getQualifiedName().contentEquals("java.lang.annotation.Documented")) { + return true; + } + } + return false; + } + + /** The keyword a declaration of this type would actually use. */ + private static String keywordOf(TypeElement type) { + return switch (type.getKind()) { + case INTERFACE -> "interface"; + case ENUM -> "enum"; + case ANNOTATION_TYPE -> "@interface"; + case RECORD -> "record"; + default -> "class"; + }; + } + + private String kindOf(TypeElement type) { + return switch (type.getKind()) { + case INTERFACE -> "interface"; + case ENUM -> "enum"; + case ANNOTATION_TYPE -> "annotation"; + case RECORD -> "record"; + default -> "class"; + }; + } + + /** Superclasses from the immediate parent outwards, the way javadoc stacks them. */ + private List> inheritanceChain(TypeElement type) { + List> chain = new ArrayList<>(); + TypeMirror current = type.getSuperclass(); + Set seen = new LinkedHashSet<>(); + while (current != null && current.getKind() == TypeKind.DECLARED) { + DeclaredType declared = (DeclaredType) current; + if (!(declared.asElement() instanceof TypeElement element) + || !seen.add(element.getQualifiedName().toString())) { + break; + } + // A package private implementation class cannot be named by a + // consumer and has no page, and the standard doclet leaves it out of + // the tree: PoseDetector's hierarchy is Object then PoseDetector, not + // Object then AbstractVisionAnalyzer then PoseDetector. The + // walk still goes through it, so the substitution it carries reaches + // the next visible ancestor. + if (documented.containsKey(element.getQualifiedName().toString())) { + chain.add(typeNames.reference(current)); + } + // Step through the mirror rather than the declaration. Asking the + // element for its superclass answers with the type variables as + // declared, so the substitution is lost one level up and everything + // above it: com.codename1.io.Properties extends HashMap, and its ancestry read AbstractMap, naming variables + // that mean nothing there. + current = superclassOf(current); + } + java.util.Collections.reverse(chain); + return chain; + } + + /** The superclass of a parameterized type, with its arguments substituted in. */ + private TypeMirror superclassOf(TypeMirror type) { + for (TypeMirror supertype : realSupertypes(type)) { + if (supertype instanceof DeclaredType declared + && !isInterfaceLike(declared.asElement())) { + return supertype; + } + } + return null; + } + + /** + * Every interface in the type's contract, the way the standard reference + * lists them under "All Implemented Interfaces". + * + *

Reading only the declaration hides everything a class gets from its + * superclass: CheckBox declares none of its own, so it was published with no + * interfaces at all while it really carries ActionSource, TextHolder, + * SelectableIconHolder, Animation and more. + */ + private List> interfacesOf(TypeElement type) { + List> out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>(realSupertypes(type.asType())); + while (!queue.isEmpty()) { + TypeMirror supertype = queue.removeFirst(); + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement element)) { + continue; + } + boolean isInterface = isInterfaceLike(element); + if (isInterface && !seen.add(element.getQualifiedName().toString())) { + continue; + } + // A package private interface is not part of a contract a consumer + // can use, has no page, and the standard reference leaves it out: + // GameView listed SpriteRenderer.Updatable and Ads listed + // CSSParserCallback, both unlinked. The traversal still goes through + // them, so their public superinterfaces are still found. + if (isInterface && documented.containsKey(element.getQualifiedName().toString())) { + out.add(typeNames.reference(supertype)); + } + queue.addAll(realSupertypes(supertype)); + } + out.sort(Comparator.comparing(row -> String.valueOf(row.get("label")))); + return out; + } + + /** + * The subtypes a page advertises. + * + *

Direct children for a class, which is javadoc's "Direct Known + * Subclasses", and the whole subtype graph for an interface, which is its + * "All Known Subinterfaces" and "All Known Implementing Classes". Listing + * only direct children of an interface hides most of what a reader is + * looking for: java.util.Collection named four types where the standard page + * names Deque, NavigableSet, ArrayList, Vector and the rest. + */ + private List> subclassesOf(TypeElement type) { + List children = isInterfaceLike(type) + ? transitiveSubtypes(type) + : new ArrayList<>(subtypes.getOrDefault(type.getQualifiedName().toString(), List.of())); + children.sort(Comparator.comparing(child -> child.getQualifiedName().toString())); + + List> out = new ArrayList<>(); + for (TypeElement child : children) { + out.add(Map.of("label", Refs.nestedDisplayName(child), "url", Refs.typeUrl(child))); + } + return out; + } + + private List transitiveSubtypes(TypeElement type) { + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>( + subtypes.getOrDefault(type.getQualifiedName().toString(), List.of())); + while (!queue.isEmpty()) { + TypeElement next = queue.removeFirst(); + if (!seen.add(next.getQualifiedName().toString())) { + continue; + } + out.add(next); + queue.addAll(subtypes.getOrDefault(next.getQualifiedName().toString(), List.of())); + } + return out; + } + + private List> nestedRows(TypeElement type) { + List> out = new ArrayList<>(); + for (TypeElement nested : ElementFilter.typesIn(type.getEnclosedElements())) { + if (!documented.containsKey(nested.getQualifiedName().toString())) { + continue; + } + ElementDoc doc = docReader.read(nested); + out.add(new LinkedHashMap<>(Map.of( + "name", Refs.nestedDisplayName(nested), + "url", Refs.typeUrl(nested), + "kind", kindOf(nested), + "summary", TypeNames.summary(doc.description)))); + } + return out; + } + + private List> memberRows(List fields, TypeElement owner) { + List> out = new ArrayList<>(); + for (VariableElement field : fields) { + if (!isVisible(field)) { + continue; + } + ElementDoc doc = docReader.read(field); + if (doc.hidden) { + continue; + } + Map row = baseRow(field, doc, owner); + boolean isEnumConstant = field.getKind() == ElementKind.ENUM_CONSTANT; + row.put("enumConstant", isEnumConstant); + if (isEnumConstant) { + // "public static final BindAttr TEXT" is how the model spells it + // and not how anyone writes or reads it. + row.put("modifiers", ""); + } + row.put("fieldType", isEnumConstant ? null : typeNames.reference(field.asType())); + Object constant = field.getConstantValue(); + row.put("constant", Literals.of(constant)); + out.add(row); + } + return out; + } + + private List> executableRows( + List members, TypeElement owner) { + List> out = new ArrayList<>(); + for (ExecutableElement member : members) { + if (!isVisible(member)) { + continue; + } + ElementDoc doc = docReader.read(member); + if (doc.hidden) { + continue; + } + Map row = baseRow(member, doc, owner); + // As a member of THIS type, not as declared. A promoted method comes + // from a supertype that may bind its type parameters here: + // PoseDetector promotes AbstractVisionAnalyzer.process and returns + // AsyncResource, though PoseDetector declares no T at all. + ExecutableType asMember = asMemberOf(owner, member); + row.put("typeParameters", typeNames.typeParameters(member.getTypeParameters())); + row.put("returnType", member.getKind() == ElementKind.CONSTRUCTOR + ? null + : typeNames.reference(asMember == null + ? member.getReturnType() : asMember.getReturnType())); + row.put("parameters", parameterRows(member, doc, asMember)); + row.put("throws", throwsRows(member, doc)); + // Only what the signature actually declares. The rows above also + // carry exceptions a comment documented without declaring, which + // belong in the prose but not in the declaration. + List> declaredThrows = new ArrayList<>(); + for (TypeMirror thrown : asMember == null + ? member.getThrownTypes() : asMember.getThrownTypes()) { + declaredThrows.add(typeNames.reference(thrown)); + } + row.put("declaredThrows", declaredThrows); + row.put("returns", doc.returns); + // An annotation element without its default reads as required when it + // is not: IntentParam.required() defaults to true and + // AppIntent.timeoutSeconds() to 20, and neither was shown anywhere. + javax.lang.model.element.AnnotationValue fallback = member.getDefaultValue(); + row.put("defaultValue", fallback == null ? null : Literals.of(fallback.getValue())); + out.add(row); + } + return out; + } + + private Map baseRow(Element member, ElementDoc doc, TypeElement owner) { + List anchors = refs.anchors(member); + Map row = new LinkedHashMap<>(); + row.put("name", displayName(member, owner)); + row.put("anchor", anchors.get(0)); + // Javadoc answers to both the declared and the erased spelling of a + // signature containing a type variable, and links in the wild use both. + row.put("anchors", anchors); + row.put("modifiers", TypeNames.modifiers(member)); + row.put("deprecated", doc.deprecated); + row.put("deprecatedText", doc.deprecatedText); + row.put("description", doc.description); + row.put("summary", TypeNames.summary(doc.description)); + // C: the page being written, not the member's declaring type. A member + // promoted off a package private supertype is declared somewhere that has + // no page, so resolving its references against that type produced links + // into a file the generator deliberately never writes. + row.put("seeAlso", seeAlsoRefs(doc, owner)); + row.put("warnings", List.copyOf(doc.warnings)); + return row; + } + + /** + * What a member is called on the page. + * + *

{@code getSimpleName()} answers {@code } for a constructor, which + * is the JVM's name for it and not something to show a reader: every class + * page listed its constructors as {@code (String)}. The anchor keeps + * that spelling, because javadoc's fragment really is {@code ()}. + */ + private static String displayName(Element member, TypeElement owner) { + if (member.getKind() != ElementKind.CONSTRUCTOR) { + return member.getSimpleName().toString(); + } + TypeElement declaring = Refs.enclosingType(member); + return (declaring == null ? owner : declaring).getSimpleName().toString(); + } + + /** The member's type as seen through the type whose page this is, or null. */ + private ExecutableType asMemberOf(TypeElement owner, ExecutableElement member) { + if (owner == null || !(owner.asType() instanceof DeclaredType declared)) { + return null; + } + try { + return (ExecutableType) types.asMemberOf(declared, member); + } catch (IllegalArgumentException notAMember) { + // Not reachable from this type after all; the declaration stands. + return null; + } + } + + private List> parameterRows(ExecutableElement member, ElementDoc doc, + ExecutableType asMember) { + List> out = new ArrayList<>(); + List parameters = member.getParameters(); + List substituted = + asMember == null ? null : asMember.getParameterTypes(); + for (int i = 0; i < parameters.size(); i++) { + VariableElement parameter = parameters.get(i); + String name = parameter.getSimpleName().toString(); + Map row = new LinkedHashMap<>(); + row.put("name", name); + TypeMirror parameterType = substituted != null && i < substituted.size() + ? substituted.get(i) : parameter.asType(); + Map reference = typeNames.reference(parameterType); + if (member.isVarArgs() && i == parameters.size() - 1) { + // The declared type is an array; the source spelling is an ellipsis. + String label = String.valueOf(reference.get("label")); + reference.put("label", label.endsWith("[]") + ? label.substring(0, label.length() - 2) + "..." : label); + } + row.put("type", reference); + String text = doc.parameterText(name); + row.put("doc", text == null ? "" : text); + out.add(row); + } + // Type parameter documentation has nowhere to sit in the signature table, + // so it is carried separately rather than dropped. + for (MarkdownSections.NamedText documented : doc.parameters) { + if (documented.name().startsWith("<")) { + Map row = new LinkedHashMap<>(); + row.put("name", documented.name()); + // Not Map.of: it rejects a null value and took the whole + // generation down with a NullPointerException the moment any + // method documented a type parameter. Nothing in the framework + // does today, which is the only reason this was not a crash. + Map noType = new LinkedHashMap<>(); + noType.put("label", ""); + noType.put("url", null); + row.put("type", noType); + row.put("doc", documented.text()); + out.add(row); + } + } + return out; + } + + private List> throwsRows(ExecutableElement member, ElementDoc doc) { + List> out = new ArrayList<>(); + // Matched on the simple name as well as the written one: a Throws section + // routinely names java.io.IOException where the signature, having + // imported it, declares IOException. Comparing the strings as written + // listed the same exception twice, once with prose and once without. + Set named = new LinkedHashSet<>(); + for (MarkdownSections.NamedText documented : doc.exceptions) { + named.add(documented.name()); + named.add(simpleName(documented.name())); + Map row = new LinkedHashMap<>(); + row.put("name", documented.name()); + row.put("url", exceptionUrl(member, documented.name())); + row.put("doc", documented.text()); + out.add(row); + } + // A declared exception with no prose still belongs in the throws list. + for (TypeMirror thrown : member.getThrownTypes()) { + String label = typeNames.label(thrown); + if (named.contains(label) || named.contains(simpleName(label))) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("name", label); + row.put("url", typeNames.url(thrown)); + row.put("doc", ""); + out.add(row); + } + return out; + } + + /** + * The page for an exception a Throws section names. + * + *

The method's own throws clause is consulted first, because a simple + * name is ambiguous across the API and picking the first global match sends + * the reader somewhere else entirely: {@code java.text.Format.parseObject} + * declares {@code java.text.ParseException} and documents it as + * {@code ParseException}, which was resolving to + * {@code com.codename1.l10n.ParseException}. + */ + private String exceptionUrl(ExecutableElement member, String name) { + String wanted = simpleName(name); + for (TypeMirror thrown : member.getThrownTypes()) { + if (thrown instanceof DeclaredType declared + && declared.asElement() instanceof TypeElement element + && (element.getQualifiedName().contentEquals(name) + || element.getSimpleName().contentEquals(wanted))) { + return documented.containsKey(element.getQualifiedName().toString()) + ? Refs.typeUrl(element) : null; + } + } + return urlOfTypeNamed(name); + } + + /** A signature with every parameter type reduced to its simple name. */ + private static String simplifySignature(String signature) { + int open = signature.indexOf('('); + if (open < 0 || !signature.endsWith(")")) { + return signature; + } + String name = signature.substring(0, open); + String inside = signature.substring(open + 1, signature.length() - 1); + List parts = new ArrayList<>(); + for (String part : inside.split(",")) { + String trimmed = part.strip(); + if (!trimmed.isEmpty()) { + parts.add(simpleName(trimmed)); + } + } + return name + "(" + String.join(",", parts) + ")"; + } + + /** The last segment of a dotted name. */ + private static String simpleName(String name) { + int dot = name.lastIndexOf('.'); + return dot < 0 ? name : name.substring(dot + 1); + } + + /** Resolves a bare exception name from a markdown Throws bullet to a page. */ + private String urlOfTypeNamed(String name) { + if (name.indexOf('.') > 0) { + TypeElement exact = documented.get(name); + return exact == null ? null : Refs.typeUrl(exact); + } + String suffix = "." + name; + for (Map.Entry entry : documented.entrySet()) { + if (entry.getKey().endsWith(suffix)) { + return Refs.typeUrl(entry.getValue()); + } + } + return null; + } + + /** + * See-also entries, resolved to links where they name something we publish. + * + *

These are markdown bullets rather than {@code @see} tags, so nothing has + * resolved them and {@link SeeAlsoRef} has to read the reference out of the + * text. Three shapes matter, in descending order of how often they occur: + * a local member ({@code #drawRoundRect}), a qualified member + * ({@code Display#supportsNativeImageCache()}) and a bare type name. + */ + private List> seeAlsoRefs(ElementDoc doc, TypeElement context) { + List> out = new ArrayList<>(); + for (String entry : doc.seeAlso) { + SeeAlsoRef reference = SeeAlsoRef.parse(entry); + Map row = new LinkedHashMap<>(); + if (!reference.isReference()) { + // Prose, or a markdown link that is already a link. Flagged so the + // template renders it as markdown; wrapping it in a code span + // showed the nine MDN links as literal [text](url). + row.put("label", reference.label()); + row.put("url", null); + row.put("note", ""); + row.put("prose", true); + out.add(row); + continue; + } + + TypeElement owner = reference.type().isEmpty() + ? context + : lookupType(reference.type(), context); + String url = null; + if (owner != null) { + url = reference.member().isEmpty() + ? Refs.typeUrl(owner) + : memberUrl(owner, reference); + } + row.put("label", reference.text()); + row.put("url", url); + row.put("prose", false); + // The trailing sentence a third of these carry, kept beside the link + // rather than folded into it: it is prose about the reference, not + // part of the name being linked. + row.put("note", reference.label()); + out.add(row); + } + return out; + } + + /** + * A documented type named either fully or by its simple name. + * + *

A simple name is ambiguous across the API and the first match in + * iteration order is not an answer: {@code List} is both + * {@code com.codename1.ui.List} and {@code java.util.List}, and + * {@code java.util.AbstractList} referring to {@code List#size} was being + * sent to the UI widget. The link resolves, so the internal link check + * cannot see it -- only reading the page shows it is the wrong class. + * + *

The package the reference was written in decides, which is what the + * language would do with an unqualified name. + */ + private TypeElement lookupType(String name, TypeElement context) { + TypeElement exact = documented.get(name); + if (exact != null) { + return exact; + } + // What the source file imported, before any global guess. Without this + // the answer is whichever documented type with that simple name happens + // to come first in iteration order, and there are two called Style: + // com.codename1.ui.plaf.Style and the nested + // com.codename1.charts.compat.Paint.Style. Component and + // ComponentSelector reach the right one today, but by luck rather than + // by rule, and a reader following "Style#stripMarginAndPadding()" into + // an unrelated enum would have no way to tell. + TypeElement imported = importedType(context, name); + if (imported != null) { + return imported; + } + if (context != null) { + PackageElement pkg = Refs.packageOf(context); + if (pkg != null) { + TypeElement sibling = documented.get(pkg.getQualifiedName() + "." + name); + if (sibling != null) { + return sibling; + } + } + } + String suffix = "." + name; + for (Map.Entry entry : documented.entrySet()) { + if (entry.getKey().endsWith(suffix)) { + return entry.getValue(); + } + } + return null; + } + + /** The type this simple name refers to through the context file's imports. */ + private TypeElement importedType(TypeElement context, String name) { + if (context == null) { + return null; + } + com.sun.source.util.TreePath path = environment.getDocTrees().getPath(context); + if (path == null) { + return null; + } + for (com.sun.source.tree.ImportTree imported : path.getCompilationUnit().getImports()) { + if (imported.isStatic()) { + continue; + } + String qualified = imported.getQualifiedIdentifier().toString(); + if (qualified.endsWith("." + name)) { + TypeElement found = documented.get(qualified); + if (found != null) { + return found; + } + } else if (qualified.endsWith(".*")) { + TypeElement found = + documented.get(qualified.substring(0, qualified.length() - 1) + name); + if (found != null) { + return found; + } + } + } + return null; + } + + /** + * The URL of the member a reference names, matched as precisely as the + * reference allows. + * + *

Name alone is not enough. {@code #clear(int)} against a type that + * declares {@code clear()} first would otherwise link to the wrong overload, + * which is worse than not linking at all: the reader follows it and lands on + * a method that is not the one the author meant. So an exact identifier match + * is tried first, then the argument count, and only a reference that wrote no + * parameter list at all falls back to the first member of that name. + */ + private String memberUrl(TypeElement owner, SeeAlsoRef reference) { + // A reference is written against the type the reader is looking at, but + // the member is very often declared further up: "#CENTER" on Label means + // Component.CENTER, and "#getEditingDelegate()" on Picker likewise. Over + // the framework that is roughly half of the member references that named + // something real, so searching only the enclosing type leaves them dead. + List candidates = new ArrayList<>(); + collectNamed(owner, reference.member(), candidates); + Set visited = new LinkedHashSet<>(); + for (TypeMirror supertype : allSupertypes(owner.asType(), visited)) { + if (supertype instanceof DeclaredType declared + && declared.asElement() instanceof TypeElement parent) { + collectNamed(parent, reference.member(), candidates); + } + } + if (candidates.isEmpty()) { + return null; + } + + if (reference.hasParameterList()) { + String wanted = reference.member() + "(" + String.join(",", reference.parameters()) + ")"; + for (Element candidate : candidates) { + if (refs.anchors(candidate).contains(wanted)) { + return anchorUrl(owner, candidate); + } + // A reference may write the erased spelling where the anchor + // writes it fully qualified: Collection#toArray(Object[]) against + // toArray(java.lang.Object[]). The alias is already there; it was + // only ever compared as written. + for (String anchor : refs.anchors(candidate)) { + if (simplifySignature(anchor).equals(simplifySignature(wanted))) { + return anchorUrl(owner, candidate); + } + } + } + // The reference may spell the types simply where the identifier spells + // them fully -- Component#paintShadows(Graphics, int, int) against + // paintShadows(com.codename1.ui.Graphics,int,int) -- so compare the + // types by their simple names. + // + // Arity alone is not enough, and settling for it was worse than not + // linking at all: Vector#remove(Object) resolved to remove(int), and + // Arrays#sort(Object[], int, int) to the byte[] overload. Both send + // the reader to a method the author did not mean. + for (Element candidate : candidates) { + if (candidate instanceof ExecutableElement executable + && sameParameterTypes(executable, reference.parameters())) { + return anchorUrl(owner, candidate); + } + } + // A parameter list that matches no overload is a stale reference -- + // Transform documents "#setScale()" and declares only the two and + // three argument forms. Linking to an arbitrary overload would hide + // that and send the reader to a method the author did not mean, so it + // stays unlinked, which is what the standard pages showed anyway. + return null; + } + return anchorUrl(owner, candidates.get(0)); + } + + /** + * Whether a method's parameters are the ones a reference names, compared by + * simple type name so that an imported spelling matches a qualified one. + */ + private boolean sameParameterTypes(ExecutableElement method, List written) { + List parameters = method.getParameters(); + if (parameters.size() != written.size()) { + return false; + } + for (int i = 0; i < parameters.size(); i++) { + String actual = simpleName(typeNames.label(parameters.get(i).asType())); + String wanted = simpleName(written.get(i).strip()); + if (method.isVarArgs() && i == parameters.size() - 1) { + // The declaration is an array; the reference may write either. + actual = actual.endsWith("[]") ? actual.substring(0, actual.length() - 2) : actual; + wanted = wanted.endsWith("...") ? wanted.substring(0, wanted.length() - 3) + : wanted.endsWith("[]") ? wanted.substring(0, wanted.length() - 2) : wanted; + } + if (!stripGenerics(actual).equals(stripGenerics(wanted))) { + return false; + } + } + return true; + } + + /** Drops a type argument list, which a reference may or may not write. */ + private static String stripGenerics(String label) { + int open = label.indexOf('<'); + if (open < 0) { + return label; + } + int close = label.lastIndexOf('>'); + String tail = close >= 0 && close + 1 < label.length() ? label.substring(close + 1) : ""; + return label.substring(0, open) + tail; + } + + private void collectNamed(TypeElement type, String name, List out) { + for (Element member : type.getEnclosedElements()) { + if (isVisible(member) && !(member instanceof TypeElement) + && member.getSimpleName().contentEquals(name)) { + out.add(member); + } + } + } + + /** + * The page a member is addressed on. + * + *

An inherited member lives on the page of the type that declares it, the + * way javadoc links it. The exception is a member promoted off an + * undocumented supertype: that type has no page, so the member was rendered + * onto the referring type and is addressed there. + */ + private String anchorUrl(TypeElement referring, Element member) { + TypeElement home = Refs.enclosingType(member); + String anchor = refs.anchors(member).get(0); + if (home != null && documented.containsKey(home.getQualifiedName().toString())) { + return Refs.typeUrl(home) + "#" + anchor; + } + return Refs.typeUrl(referring) + "#" + anchor; + } + + /** + * Members a type gets from its supertypes, grouped by where they come from -- + * the "Methods inherited from" blocks javadoc renders. + */ + private List> inheritedMembers(TypeElement type, List promoted) { + List> out = new ArrayList<>(); + Set declared = new LinkedHashSet<>(); + // Bucketed by simple name so the override test below only ever compares + // methods that could possibly be the same one. + Map> seenByName = new LinkedHashMap<>(); + for (ExecutableElement method : ElementFilter.methodsIn(type.getEnclosedElements())) { + declared.add(signatureKey(method)); + remember(seenByName, method); + } + Set promotedNames = new LinkedHashSet<>(); + for (TypeElement supertype : promoted) { + promotedNames.add(supertype.getQualifiedName().toString()); + for (ExecutableElement method : ElementFilter.methodsIn(supertype.getEnclosedElements())) { + declared.add(signatureKey(method)); + remember(seenByName, method); + } + } + + Set visited = new LinkedHashSet<>(); + for (TypeMirror supertype : allSupertypes(type.asType(), visited)) { + if (!(supertype instanceof DeclaredType declaredType) + || !(declaredType.asElement() instanceof TypeElement parent)) { + continue; + } + // Already rendered as this type's own members just above. + if (promotedNames.contains(parent.getQualifiedName().toString())) { + continue; + } + List> members = new ArrayList<>(); + for (ExecutableElement method : ElementFilter.methodsIn(parent.getEnclosedElements())) { + if (!isVisible(method) || !declared.add(signatureKey(method))) { + continue; + } + // The erased signature is not enough once generics are + // substituted along the hierarchy: Enum.compareTo(E) erases to + // compareTo(java.lang.Enum) and Comparable.compareTo(T) to + // compareTo(java.lang.Object), so every enum listed compareTo + // twice even though the first implements the second. + if (overridesSomethingSeen(seenByName, method, type)) { + continue; + } + remember(seenByName, method); + Map row = new LinkedHashMap<>(); + row.put("name", method.getSimpleName().toString()); + String url = urlOf(method); + row.put("url", url); + members.add(row); + } + if (members.isEmpty()) { + continue; + } + Map group = new LinkedHashMap<>(); + group.put("from", Refs.nestedDisplayName(parent)); + group.put("url", documented.containsKey(parent.getQualifiedName().toString()) + ? Refs.typeUrl(parent) : null); + group.put("members", members); + out.add(group); + } + return out; + } + + /** The same grouping as {@link #inheritedMembers}, for nested types. */ + private List> inheritedNested(TypeElement type, List promoted) { + List> out = new ArrayList<>(); + Set declared = new LinkedHashSet<>(); + for (TypeElement nested : ElementFilter.typesIn(type.getEnclosedElements())) { + declared.add(nested.getSimpleName().toString()); + } + Set promotedNames = new LinkedHashSet<>(); + for (TypeElement supertype : promoted) { + promotedNames.add(supertype.getQualifiedName().toString()); + } + + Set visited = new LinkedHashSet<>(); + for (TypeMirror supertype : allSupertypes(type.asType(), visited)) { + if (!(supertype instanceof DeclaredType declaredType) + || !(declaredType.asElement() instanceof TypeElement parent) + || promotedNames.contains(parent.getQualifiedName().toString())) { + continue; + } + List> members = new ArrayList<>(); + for (TypeElement nested : ElementFilter.typesIn(parent.getEnclosedElements())) { + if (!isVisible(nested) || !declared.add(nested.getSimpleName().toString()) + || !documented.containsKey(nested.getQualifiedName().toString())) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("name", Refs.nestedDisplayName(nested)); + row.put("url", Refs.typeUrl(nested)); + members.add(row); + } + if (members.isEmpty()) { + continue; + } + Map group = new LinkedHashMap<>(); + group.put("from", Refs.nestedDisplayName(parent)); + group.put("url", documented.containsKey(parent.getQualifiedName().toString()) + ? Refs.typeUrl(parent) : null); + group.put("members", members); + out.add(group); + } + return out; + } + + /** The same grouping as {@link #inheritedMembers}, for fields. */ + private List> inheritedFields(TypeElement type, List promoted) { + List> out = new ArrayList<>(); + Set declared = new LinkedHashSet<>(); + for (VariableElement field : ElementFilter.fieldsIn(type.getEnclosedElements())) { + declared.add(field.getSimpleName().toString()); + } + Set promotedNames = new LinkedHashSet<>(); + for (TypeElement supertype : promoted) { + promotedNames.add(supertype.getQualifiedName().toString()); + for (VariableElement field : ElementFilter.fieldsIn(supertype.getEnclosedElements())) { + declared.add(field.getSimpleName().toString()); + } + } + + Set visited = new LinkedHashSet<>(); + for (TypeMirror supertype : allSupertypes(type.asType(), visited)) { + if (!(supertype instanceof DeclaredType declaredType) + || !(declaredType.asElement() instanceof TypeElement parent) + || promotedNames.contains(parent.getQualifiedName().toString())) { + continue; + } + List> members = new ArrayList<>(); + for (VariableElement field : ElementFilter.fieldsIn(parent.getEnclosedElements())) { + if (!isVisible(field) || !declared.add(field.getSimpleName().toString())) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("name", field.getSimpleName().toString()); + row.put("url", urlOf(field)); + members.add(row); + } + if (members.isEmpty()) { + continue; + } + Map group = new LinkedHashMap<>(); + group.put("from", Refs.nestedDisplayName(parent)); + group.put("url", documented.containsKey(parent.getQualifiedName().toString()) + ? Refs.typeUrl(parent) : null); + group.put("members", members); + out.add(group); + } + return out; + } + + private List allSupertypes(TypeMirror type, Set visited) { + List out = new ArrayList<>(); + // types.directSupertypes() hands an interface java.lang.Object, which the + // language does not: an interface inherits none of its methods, and the + // standard pages list none. Without this an interface page such as + // SuccessCallback claimed ten inherited Object methods, protected + // clone() among them. + boolean isInterface = type instanceof DeclaredType declaredType + && isInterfaceLike(declaredType.asElement()); + for (TypeMirror supertype : realSupertypes(type)) { + if (!(supertype instanceof DeclaredType declared) + || !(declared.asElement() instanceof TypeElement element) + || !visited.add(element.getQualifiedName().toString())) { + continue; + } + out.add(supertype); + out.addAll(allSupertypes(supertype, visited)); + } + return out; + } + + private static void remember(Map> seen, ExecutableElement method) { + seen.computeIfAbsent(method.getSimpleName().toString(), key -> new ArrayList<>()).add(method); + } + + /** Whether a method already listed implements or overrides this one. */ + private boolean overridesSomethingSeen(Map> seen, + ExecutableElement candidate, TypeElement type) { + for (ExecutableElement earlier : seen.getOrDefault( + candidate.getSimpleName().toString(), List.of())) { + if (elements.overrides(earlier, candidate, type)) { + return true; + } + } + return false; + } + + /** Name plus erased parameter types: what makes one method the same as another. */ + private String signatureKey(ExecutableElement method) { + List anchors = refs.anchors(method); + return anchors.get(anchors.size() - 1); + } + + // --------------------------------------------------------- package pages + + private void writePackagePages() throws IOException { + Map> byPackage = new TreeMap<>(); + for (TypeElement type : documented.values()) { + // Nested types are listed on their outer type's page, exactly as javadoc + // lists them, so only top level types get a row in the package summary. + if (type.getEnclosingElement() instanceof TypeElement) { + continue; + } + byPackage.computeIfAbsent(Refs.packageOf(type).getQualifiedName().toString(), + key -> new ArrayList<>()).add(type); + } + + for (Map.Entry> entry : byPackage.entrySet()) { + PackageElement pkg = elements.getPackageElement(entry.getKey()); + if (pkg == null) { + continue; + } + ElementDoc doc = docReader.read(pkg); + List members = entry.getValue(); + members.sort(Comparator.comparing(type -> type.getSimpleName().toString())); + + List> rows = new ArrayList<>(); + for (TypeElement type : members) { + rows.add(new LinkedHashMap<>(Map.of( + "name", Refs.nestedDisplayName(type), + "url", Refs.typeUrl(type), + "kind", kindOf(type), + "summary", TypeNames.summary(docReader.read(type).description)))); + } + + Map api = new LinkedHashMap<>(); + api.put("kind", "package"); + api.put("qualified", entry.getKey()); + // A package can be deprecated, and com.codename1.ui.layouts.mig is: + // its comment warns not to rely on the integration in production. + // DocReader lifts that out of the description, so omitting the fields + // here dropped the warning off the page entirely. + api.put("deprecated", doc.deprecated); + api.put("deprecatedText", doc.deprecatedText); + api.put("description", doc.description); + api.put("types", rows); + + Map frontMatter = new LinkedHashMap<>(); + frontMatter.put("title", entry.getKey()); + frontMatter.put("url", Refs.packageUrl(pkg)); + frontMatter.put("description", TypeNames.summary(doc.description)); + frontMatter.put("layout", "package"); + // Deliberately no alias on the bare package directory. It would put an + // index.html in com/codename1/ui/list/, which is the same directory as + // the com.codename1.ui.List type page on a case insensitive filesystem, + // and one silently overwrote the other. Nothing links to a bare package + // directory anyway; the summary is what the guide and javadoc name. + frontMatter.put("aliases", List.of()); + frontMatter.put("javadoc", api); + + write(contentRoot.resolve(Refs.packageContentPath(pkg)), Json.write(frontMatter)); + } + } + + /** The API index at {@code /javadoc/}, listing every package. */ + private void writeOverview() throws IOException { + Set packageNames = new java.util.TreeSet<>(); + for (TypeElement type : documented.values()) { + packageNames.add(Refs.packageOf(type).getQualifiedName().toString()); + } + + List> rows = new ArrayList<>(); + for (String name : packageNames) { + PackageElement pkg = elements.getPackageElement(name); + if (pkg == null) { + continue; + } + rows.add(new LinkedHashMap<>(Map.of( + "name", name, + "url", Refs.packageUrl(pkg), + "summary", TypeNames.summary(docReader.read(pkg).description)))); + } + + Map api = new LinkedHashMap<>(); + api.put("kind", "overview"); + api.put("packages", rows); + api.put("typeCount", documented.size()); + + Map frontMatter = new LinkedHashMap<>(); + frontMatter.put("title", "API"); + frontMatter.put("url", "/javadoc/"); + frontMatter.put("description", "Codename One API reference"); + frontMatter.put("layout", "overview"); + // The website has linked the API from /api/ since 2015. + frontMatter.put("aliases", List.of("/api/")); + frontMatter.put("javadoc", api); + + write(contentRoot.resolve("_index.md"), Json.write(frontMatter)); + } + + // -------------------------------------------------------------- search + + private void addSearchRows(TypeElement type, ElementDoc doc, List owned) { + Map row = new LinkedHashMap<>(); + row.put("n", Refs.nestedDisplayName(type)); + row.put("p", Refs.packageOf(type).getQualifiedName().toString()); + row.put("u", Refs.typeUrl(type)); + row.put("k", kindOf(type)); + // Plain text: the results list escapes what it is given rather than + // rendering it, which is right for a value that came out of a comment, + // so markdown left here is displayed as its own source. + row.put("s", TypeNames.plainSummary(doc.description)); + + // Members are two strings each -- the label to show and the fragment to + // jump to -- nested under their type rather than repeated as standalone + // rows. The flat form, with a URL, a package and a summary per member, + // came to 9.7MB over 31519 entries, which is not a file a browser should + // download to answer one search. Grouping removes the repetition and + // dropping member summaries removes the bulk; the type summaries stay, + // because those are what a result list actually shows. + // The same list the page renders, promoted members included. Scanning + // only the type's own elements left InterstitialAd searchable by its + // constructor alone, while its page showed load(), isLoaded() and show() + // at anchors nothing could find. + List members = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (Element member : owned) { + if (!isVisible(member) || member instanceof TypeElement) { + continue; + } + // Constructors are not inherited and the page renders only its own, + // so a promoted one is a search hit pointing at an fragment + // that does not exist: ComponentAnimation.UIMutation was offering + // CompoundAnimation's two constructors. + if (member.getKind() == ElementKind.CONSTRUCTOR + && !type.equals(Refs.enclosingType(member))) { + continue; + } + if (docReader.read(member).hidden) { + continue; + } + String anchor = refs.anchors(member).get(0); + if (!seen.add(anchor)) { + continue; + } + String label = member instanceof ExecutableElement executable + ? displayName(member, type) + "(" + parameterLabels(executable) + ")" + : member.getSimpleName().toString(); + members.add(List.of(label, anchor)); + } + row.put("m", members); + searchRows.add(row); + } + + /** + * Parameter types as a reader would write them, for the search index. + * + *

The last parameter of a varargs method is an array in the model, so + * asList(T... array) was offered as asList(T[]) -- and byte[]... as + * byte[][], which says something different. 1198 labels carried the array + * spelling and none carried an ellipsis. + */ + private String parameterLabels(ExecutableElement executable) { + List parameters = new ArrayList<>(executable.getParameters()); + List out = new ArrayList<>(); + for (int i = 0; i < parameters.size(); i++) { + String label = typeNames.label(parameters.get(i).asType()); + if (executable.isVarArgs() && i == parameters.size() - 1 && label.endsWith("[]")) { + label = label.substring(0, label.length() - 2) + "..."; + } + out.add(label); + } + return String.join(", ", out); + } + + private void writeSearchIndex() throws IOException { + if (searchIndex == null) { + return; + } + Map document = new LinkedHashMap<>(); + document.put("generator", "HugoDoclet"); + document.put("types", searchRows); + write(searchIndex, Json.writeCompact(document)); + + int memberCount = 0; + for (Map row : searchRows) { + Object members = row.get("m"); + if (members instanceof List list) { + memberCount += list.size(); + } + } + reporter.print(javax.tools.Diagnostic.Kind.NOTE, + "Hugo javadoc: " + documented.size() + " types, " + + memberCount + " searchable members"); + } + + // --------------------------------------------------------------- output + + private static void write(Path target, String content) throws IOException { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + try { + Files.writeString(target, content, StandardCharsets.UTF_8); + } catch (IOException failure) { + throw new IOException("writing " + target + ": " + failure, failure); + } + } + + /** A doclet option that takes exactly one argument. */ + private record SimpleOption(String name, String parameters, String description, + java.util.function.Consumer action) implements Option { + @Override + public int getArgumentCount() { + return 1; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public Kind getKind() { + return Kind.STANDARD; + } + + @Override + public List getNames() { + return List.of(name); + } + + @Override + public String getParameters() { + return parameters; + } + + @Override + public boolean process(String option, List arguments) { + action.accept(arguments.get(0)); + return true; + } + } + + static String stackTrace(Throwable failure) { + StringWriter text = new StringWriter(); + failure.printStackTrace(new PrintWriter(text)); + return text.toString(); + } +} diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/Json.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/Json.java new file mode 100644 index 00000000000..24bc79b14c1 --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/Json.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import java.util.List; +import java.util.Map; + +/** + * A minimal JSON writer, used to emit Hugo front matter. + * + *

Hugo accepts front matter as TOML, YAML or JSON, and this generator emits + * JSON deliberately. Almost every value written here is prose lifted verbatim + * out of a documentation comment: it contains quotes, colons, backslashes, + * leading dashes, blank lines and occasionally something that reads as a YAML + * anchor. Escaping all of that correctly for YAML is a source of silent + * corruption, whereas JSON has exactly one escaping rule and no significant + * whitespace at all. + */ +final class Json { + + private Json() { + } + + static String write(Map value) { + StringBuilder out = new StringBuilder(); + writeValue(out, value, 0); + out.append('\n'); + return out.toString(); + } + + /** + * The same JSON with no indentation, for the search index. + * + *

Front matter is indented because a person reads it while debugging a + * page. The search index is only ever read by a browser, and pretty printing + * tens of thousands of nested entries costs more bytes than the entries. + */ + static String writeCompact(Map value) { + StringBuilder out = new StringBuilder(); + writeValue(out, value, COMPACT); + return out.toString(); + } + + /** Depth sentinel meaning "emit no whitespace at all". */ + private static final int COMPACT = Integer.MIN_VALUE; + + private static void writeValue(StringBuilder out, Object value, int depth) { + if (value == null) { + out.append("null"); + } else if (value instanceof Map map) { + writeObject(out, map, depth); + } else if (value instanceof List list) { + writeArray(out, list, depth); + } else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) { + out.append(value); + } else { + writeString(out, value.toString()); + } + } + + private static void writeObject(StringBuilder out, Map map, int depth) { + if (map.isEmpty()) { + out.append("{}"); + return; + } + boolean compact = depth == COMPACT; + out.append(compact ? "{" : "{\n"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + out.append(compact ? "," : ",\n"); + } + first = false; + indent(out, depth + 1); + writeString(out, entry.getKey().toString()); + out.append(compact ? ":" : ": "); + writeValue(out, entry.getValue(), compact ? COMPACT : depth + 1); + } + if (!compact) { + out.append('\n'); + indent(out, depth); + } + out.append('}'); + } + + private static void writeArray(StringBuilder out, List list, int depth) { + if (list.isEmpty()) { + out.append("[]"); + return; + } + boolean compact = depth == COMPACT; + out.append(compact ? "[" : "[\n"); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + out.append(compact ? "," : ",\n"); + } + indent(out, depth + 1); + writeValue(out, list.get(i), compact ? COMPACT : depth + 1); + } + if (!compact) { + out.append('\n'); + indent(out, depth); + } + out.append(']'); + } + + private static void indent(StringBuilder out, int depth) { + if (depth > 0) { + out.append(" ".repeat(depth)); + } + } + + private static void writeString(StringBuilder out, String value) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> { + // Control characters have to be escaped for the JSON to parse at + // all, and a stray one in a comment must never reach a generated + // file: scripts/check-control-characters.py rejects the whole + // tree over a single raw control byte. + // + // A lone surrogate is escaped for a harder reason: it has no + // UTF-8 encoding at all, so writing one out throws rather than + // producing a bad file, and one emoji cut in half by a summary + // would abort the whole generation. + if (c < 0x20 || c == 0x7f || isLoneSurrogate(value, i)) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } + + /** Whether the character at the index is a surrogate without its partner. */ + private static boolean isLoneSurrogate(String value, int index) { + char c = value.charAt(index); + if (Character.isHighSurrogate(c)) { + return index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1)); + } + if (Character.isLowSurrogate(c)) { + return index == 0 || !Character.isHighSurrogate(value.charAt(index - 1)); + } + return false; + } +} diff --git a/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/LegacyHtml.java b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/LegacyHtml.java new file mode 100644 index 00000000000..1428ed30978 --- /dev/null +++ b/maven/javadoc-hugo-doclet/src/main/java/com/codename1/doclet/hugo/LegacyHtml.java @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.doclet.hugo; + +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Deals with the HTML left in comments that were converted to markdown. + * + *

Goldmark renders with {@code unsafe = false}, so raw HTML in a comment body + * is dropped rather than rendered, and dropped silently: the build logs a + * warning per page and the content simply is not there. 13 of the 2024 core + * sources still carry some, and what disappeared included a whole {@code