Skip to content

feat(library): a link API for JavaScript modules - #751

Draft
romain-pm wants to merge 6 commits into
feat/image-improvementsfrom
feat/link-api
Draft

feat(library): a link API for JavaScript modules#751
romain-pm wants to merge 6 commits into
feat/image-improvementsfrom
feat/link-api

Conversation

@romain-pm

@romain-pm romain-pm commented Aug 23, 2026

Copy link
Copy Markdown

Summary

Gives the library a link concept, so a module author renders a correct, cacheable, accessible link by naming the target instead of assembling one. First implementation pass on #749. Stacked on #746 — base is feat/image-api, so the diff here is link code only; review #746 first.

<JLink node={props["j:linknode"]}>{title}</JLink>

That one line now produces an href built through buildNodeUrl, a render cache dependency on the target, aria-current="page" when the target is the page being rendered, a validated target with its rel — and, when the reference does not resolve, the children rendered without an anchor instead of a throw.

Why

buildNodeUrl throws on a falsy node (urlBuilder.ts:72), and an unresolvable reference is the normal state of a link: publishing a page does not publish the jnt:page it points at. So a live section becomes <!-- Module error : Expected a node in buildNodeUrl --> — HTTP 200, section gone. The rest was hand-written too: the none | internal | external switch appears in samples/hydrogen, Jahia/luxe-jahia-demo, Jahia/jahia.com and Jahia/se-utils under four incompatible property names, and nothing in the library read j:target, emitted rel on an author-supplied URL, or checked that a target exists in the language being linked to.

Changes

  • getLinkProps(target, options, context) — takes a node, a string, null or undefined, and never throws. Returns { anchor, state }: anchor is DOM-spreadable by construction (href, target, rel, title), state (navigable, isCurrent, isAncestor, label) never is. Registers the cache dependency, validates target against the four jmix:link values, adds rel="noopener noreferrer" to _blank, derives the label, and applies parameters / hash / language. requireTranslation (default true) makes an untranslated target non-navigable; fr_CH and fr-CH are the same locale, and language is dropped for language-neutral content so an nt:file keeps its /files/… URL instead of a /cms/render/…​.html one that does not serve the file.
  • resolveContentLink(node, options, context) — reads the link off a content node: jnt:nodeLink (j:node), jnt:externalLink (j:url), jmix:link's j:target, mix:title, plus the j:linkType convention from Jahia/default. The discriminator is only ever read for its "no link" value, and every property name is a parameter (typeProperty, noneValue, referenceProperties, urlProperty) because four spellings exist in the wild. A reference wins over the URL; referenceProperties: [] is the escape hatch for content where an earlier edit left a reference behind.
  • <JLink> — a bare <a>, no styling, every other anchor attribute passed through. A discriminated union makes node / content / href mutually exclusive at compile time, and the href shape additionally requires children or aria-label (WCAG 2.4.4, the role alt plays on <JImage>). whenUnresolved chooses between the unwrapped children and nothing. Server-only; an Island takes the data instead, <a {...anchor}>.
  • Scheme allow-list — every URL the library did not build itself (href, j:url) is checked against http/https/mailto/tel/ftp after tab, newline and control stripping, so a javascript:, data: or vbscript: URL is never rendered. A relative URL is allowed only while it names no host: //evil.example and /\evil.example are rejected, since they leave the site.
  • appendParameters fix — query parameters went after the fragment (#main?a=b, where the server never sees them) and an empty set appended a bare ?. Now fragment-aware and shared, so buildNodeUrl, buildEndpointUrl and buildModuleFileUrl all get the fix. The RFC 3986 scheme regexp is exported and reused instead of a second inline copy.
  • Docs — a links guide (docs/2-guides/9-links), and the samples/hydrogen call to action rewritten.
  • Tests — vitest, 105 cases over the URL composition, the allow-list, the resolution precedence, the language rules, the cache-dependency key forms and the rendered markup.

One behaviour change in samples/hydrogen, not a refactor: the CTA's none branch rendered <s>{title}</s> and now renders the plain title, because <JLink content> treats "no link" as not-navigable. The NavBar adoption is split out into #760 (issue #759) — it changes what the reference navigation renders and adds a caching rule, which deserves its own review.

Validation

  • tsc --noEmit clean on the library and on samples/hydrogen, plus a run with the spec files included, which tsconfig.json normally excludes. eslint clean, prettier clean on everything this branch touches, yarn build + publint clean.
  • yarn workspace @jahia/javascript-modules-library test — 132 passing (105 link, 27 inherited from the base).
  • Nothing here has run on a Jahia instance. No module was deployed, so the registered cache dependency, the edit-mode href and the page-builder behaviour (EditModeFilter rewriting target and /cms/edit/ in the delivered DOM) are unverified in situ, and there is no Cypress coverage — that is breakdown item 7. The natural next step is deploying samples/hydrogen on a local 8.2.3 and checking the two cases the unit tests cannot reach: the same nav rendered on two pages through an AbsoluteArea, and a flush after the target's title changes.

Decided, and what is left

  • The { uuid } cache dependency does not work, and this PR documents that instead of fixing it. RenderHelper.renderTag populates the tag before it sets the page context, so AddCacheDependencyTag.setUuid dereferences a null page context, the NullPointerException is swallowed by catch (Throwable), and nothing is registered. The library still picks the form — it is the only key an unresolved reference offers — but the JSDoc and the guide say it is dropped today, and point at addCacheDependency with a uuid key registers nothing #750. The fix is a one-line reorder in the engine that changes setter ordering for every tag rendered through renderTag, which is not something to land unexercised inside a library PR. Until it lands, the "publish a previously-dangling target and watch the fallback flush" case cannot pass; flushOnPathMatchingRegexp is the working substitute, and is exposed for that reason.
  • { flushOnPathMatchingRegexp } was added to the union — it is a real fourth AddCacheDependencyTag key form the spec did not list.
  • context is a parameter, not a default of useServerContext() — React's use() throws outside a render, which would break the "never throws" guarantee for callers outside one. Omitting it degrades instead: no cache dependency, no current-page state.
  • Stacked on feat(library): an image API for JavaScript modules #746, not parallel to it. Basing on feat/image-api removes the duplicate vitest bootstrap (Bootstrap a unit-test runner in the monorepo #745) and the yarn.lock tree from this diff — both now come from the base — and settles the guide numbering, since 8-images exists in the base and this takes 9-links. It also means this cannot merge before feat(library): an image API for JavaScript modules #746.
  • yarn test is still wired to nothing. The library's pom runs yarn, yarn lint and yarn build, and CI runs only the vite-plugin tests. Same hole feat(library): an image API for JavaScript modules #746 has, and the exec-maven-plugin execution that fixes it belongs in a change shared with that PR, not silently here.
  • Still to check before merge: whether Content Editor clears j:linknode when j:linkType switches to external. The escape hatch and the warning are correct either way, but the default precedence renders the stale internal target.

Part of #749 (props tier #752, component #753, content vocabulary #754, guide #757, tests #758); each commit references the epic.

@github-actions

Copy link
Copy Markdown

📝 Documentation Guidelines

Thank you for contributing to our documentation! To ensure your contributions meet our standards, please review these resources:

This comment is posted automatically when changes are detected in the docs/ folder.

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

🦜 Chachalog

javascript-modules minor

New Features

  • Added a link API to the library: the <JLink> component, and the getLinkProps / resolveContentLink functions behind it. (Improve the developer experience when rendering links #749)

    <JLink node={page}> builds the URL, registers the render cache dependency on the target, and marks the current page with aria-current="page". A target that does not resolve is treated as a result rather than an error: the children are rendered without an anchor, instead of the whole section being replaced by an error comment. That is the normal state of a link to a page that is not published yet.

    <JLink content={node}> reads a link off a content node — jnt:nodeLink, jnt:externalLink, or the j:linkType convention under whichever property names your project uses. Anchor target is validated against the four values jmix:link allows, rel="noopener noreferrer" is added to _blank, and every URL the library did not build itself goes through a scheme allow-list, so an author-supplied javascript: or data: URL is never rendered. Islands take the same data as <a {...anchor}>.

    See the new Links guide for the cache-dependency key forms, the cache.mainResource=true rule that current-page state requires, and what core rewrites after the render.

Bug Fixes

  • Fixed query string parameters being appended after the fragment in buildNodeUrl, buildEndpointUrl and buildModuleFileUrl. (Improve the developer experience when rendering links #749)

    Building a URL for #main with { a: "b" } produced #main?a=b, where the query string is part of the fragment and never reaches the server. It now produces ?a=b#main. Passing an empty set of parameters no longer appends a bare ? either.

Create a new entry online or run npx chachalog@0.5.4 prompt to create a new entry locally.

@pkg-pr-new

pkg-pr-new Bot commented Aug 23, 2026

Copy link
Copy Markdown

Open in StackBlitz

yarn add https://pkg.pr.new/@jahia/create-module@751.tgz
yarn add https://pkg.pr.new/@jahia/javascript-modules-library@751.tgz
yarn add https://pkg.pr.new/@jahia/vite-plugin@751.tgz

commit: d1656c2

appendParameters split on `?` alone, so a target carrying a fragment came
back as `#main?a=b` — a query string inside the fragment, which never
reaches the server. It now inserts the parameters before the fragment, and
returns the URL untouched when there is nothing to append.

Also exports the RFC 3986 scheme regexp so the link tier reads a scheme the
same way buildModuleFileUrl does, rather than carrying a third copy.

Refs #749
getLinkProps turns whatever names a link target — a node, an already-built
URL, or nothing — into anchor attributes plus the state around them, and
resolveContentLink reads that target off a content node first.

Not being navigable is a result rather than an error: publishing a page
does not publish the pages it links to, so an unresolved reference is the
normal state of a link, and buildNodeUrl throwing on it took the whole
fragment down. Neither function throws, and neither returns an href it
could not build.

On the way they register the render cache dependency, put every URL the
library did not build itself through a scheme allow-list, validate the
anchor target against the four values jmix:link allows, add rel to _blank,
derive the label, and answer whether the target is the page being rendered.

Refs #749
<JLink node={page}>Title</JLink> renders a bare <a>: the URL, a validated
target with its rel, aria-current on the page being rendered, and a render
cache dependency on the target. It takes one of three targets — a node, a
content node describing a link, or an already-built URL — as a
discriminated union, so naming two of them is a type error.

It never renders an <a> without an href. When the link is not navigable it
renders the children on their own, or nothing when whenUnresolved says so.

Server-side only, because it registers the cache dependency: a client
component takes getLinkProps' anchor and spreads it instead.

Refs #749
Adds vitest to the library, and one spec per tier. The nodes are hand-rolled
stubs whose getters fail the way JCR fails — an unresolvable reference
throws rather than returning null, getUrl() returns null on a repository
error — because those are the failures the props tier has to absorb.

Refs #749
Covers the one-liner, why an unresolvable reference is the normal state
rather than an edge case, the cache-dependency key forms, the
cache.mainResource=true rule that current-page state depends on, target and
rel with the page-builder carve-out, why an href is a server-side
intermediate that must never be string-compared, links inside Islands, and
what rich text puts out of reach.

Refs #749
Drops the none/internal/external switch: resolveContentLink reads the
link off the node, and JLink renders the plain title when there is none.

Refs #749
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant