Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .chachalog/lnk4Pw2Hs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
# Allowed version bumps: patch, minor, major
javascript-modules: minor
---

Made the link API usable on a real site: an open `attributes` map, `asChild`, the label of a link mixin, a narrowable scheme allow-list, and an edit-mode URL that is correct wherever it is put. (#768, #769, #770, #771, #772)

`<JLink>` no longer pushes a caller off the component. `attributes` takes a record or a function of the resolved link — `({ anchor, state }) => ({ "data-element-url": anchor.href, "data-element-text": state.label })` — which is the only way to reach `data-*` and the only way to read back the URL and the label the component computed. `asChild` hands the link to the element you render, for a call to action that is not a bare `<a>`. The anchor attributes the component accepts are now derived from its own props rather than hand-listed, so a prop added later cannot silently swallow one.

A link mixin sits on a node whose `jcr:title` is the heading, not the link label; `labelProperties` and `labelFrom` say where the label really lives. `state.node` returns what the link resolved to, and the safe reference read behind it is exported as `readNodeReference`, next to `getNodeProps` — reading a `weakreference` without letting a dangling one break the render is a JCR concern, not a link one. The scheme allow-list can be narrowed, per call with `allowedSchemes` or once per module with `setLinkDefaults`; it narrows only, and says so on a development instance.

`buildNodeUrl` now emits `/cms/editframe/…` for edit mode. `/cms/edit/…` does not render a page on 8.2.3 — it redirects to the jContent UI — and only reached one because `EditModeFilter` substitutes the two for an `a[href]` and nothing else, so the same URL in an Island payload or a `data-*` attribute pointed at a second copy of jContent. The [Links guide](https://github.com/Jahia/javascript-modules/blob/main/docs/2-guides/9-links/README.md) now states which contexts core finishes a URL in and which it leaves alone.
132 changes: 127 additions & 5 deletions docs/2-guides/9-links/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ That single line builds the URL through `buildNodeUrl`, registers a render cache

Everything else you pass is a plain anchor attribute: `className`, `hreflang`, `download`, `onClick`. There is no styling of its own.

## Attributes the component does not know about

Two things a real site needs, and neither is expressible as a prop.

The first is `data-*`. React's typings do not model it on a component's props, so `attributes` takes an open map:

```tsx
<JLink node={page} attributes={{ "data-element-type": "cta" }} />
```

A static map is the easy half. The interesting form is a function, because the values an analytics layer wants are the ones the component just computed and would otherwise keep to itself — the resolved URL and the derived label:

```tsx
<JLink
content={cta}
attributes={({ anchor, state }) => ({
"data-element-url": anchor.href,
"data-element-text": state.label,
"data-element-current": state.isCurrent,
})}
/>
```

It receives exactly what `getLinkProps` returns, so the same callback works on both tiers. It is spread last, so it wins over anything else on the element, and it is not called at all when the link is not navigable. `<JImage attributes>` is the same shape, for the same reason.

The second is a wrapper that is not a bare `<a>`. A design system's call to action is usually its own component, and wrapping it in an anchor gives you two nested interactive elements. `asChild` hands the link to the element you render instead — Next.js calls the same thing `passHref`:

```tsx
<JLink node={page} asChild>
<CTA variant="primary">Read more</CTA>
</JLink>
// → <a href="…" class="cta cta--primary">Read more</a>
```

The child receives `href`, `target`, `rel`, `aria-current` and whatever `attributes` produced, and must forward them to the element it renders. It needs exactly one element child; anything else is an error naming the way out. When the link is not navigable the child is still rendered, simply without the link — `whenUnresolved="none"` is how you drop it entirely.

## A target that does not resolve is normal

This is the part that surprises people. Publishing a page does **not** publish the pages it links to: `jnt:page` is in `referencedNodeTypesToSkip`. So a perfectly ordinary editorial workflow — build a card, point it at a page that is still a draft, publish the card — leaves you with a reference that resolves to nothing in live.
Expand Down Expand Up @@ -72,7 +108,27 @@ const { anchor, state } = getLinkProps(node, {}, useServerContext());
return state.navigable ? <a {...anchor}>{state.label}</a> : <span>{state.label}</span>;
```

`anchor` is spreadable onto an `<a>` — every key is a valid anchor attribute, by construction. `state` is not: `navigable`, `isCurrent`, `isAncestor` and `label` are yours to read, never to spread.
`anchor` is spreadable onto an `<a>` — every key is a valid anchor attribute, by construction. `state` is not: `navigable`, `isCurrent`, `isAncestor`, `label` and `node` are yours to read, never to spread.

`state.node` is what the link resolved to — the node target, or the reference read off a content node. It saves the second resolution a fallback usually needs:

```tsx
const { anchor, state } = resolveContentLink(cta, {}, useServerContext()) ?? {};
const label = state?.label || state?.node?.getProperty("acme:shortName")?.getString();
```

Reading a reference yourself is the other half of that problem, and it is a JCR concern rather than a link one: an unresolvable reference reaches JavaScript as a plain falsy value, so every view that touches a `weakreference` ends up writing the same try/catch. `readNodeReference` is that try/catch, once, next to `getNodeProps`:

```tsx
import { readNodeReference } from "@jahia/javascript-modules-library";

const related = readNodeReference(currentNode, "acme:related");
// null → the property is unset
// { uuid } → it is set, and the target is not reachable (unpublished, deleted, forbidden)
// { uuid, node } → it resolved
```

It never throws. What it cannot tell you is _why_ an unreachable target is unreachable: unpublished, deleted and "you may not see it" arrive identically, and no JCR read separates them.

:::info
`getLinkProps` reads no React context of its own. Inside a view, pass `useServerContext()`. Without it you still get an `href`, but no cache dependency is registered and `isCurrent` is always false — a silent downgrade, not an error.
Expand All @@ -90,6 +146,22 @@ Pass the content node and let the library read it:

With no children, the label comes from the content: `jcr:title`, then `j:linkTitle`, then the displayable name of the target.

### When the link is a mixin, the label is somewhere else

That default is right when the link **is** the content — a `jnt:nodeLink` exists to be a link, and its `jcr:title` is the link label. It is wrong as soon as the link is a **mixin on something else**. A CTA mixin sits on a card, a panel or a hero that already has a `jcr:title`, and that title is the heading. Take it as the label and every call to action on the page is named after the section it lives in.

Say where the label really lives:

```tsx
// The mixin stores its own label
<JLink content={card} labelProperties={["acme:ctaLabel"]} />

// There is no label property: use the name of the page it points at
<JLink content={card} labelFrom="target" />
```

`labelProperties` replaces the list that is tried on the content node, in order. `labelFrom="target"` skips the content node altogether — the readable spelling of `labelProperties={[]}` — and takes precedence over `labelProperties` when both are given. An explicit `label`, or children, still wins over either.

Because the `j:linkType` convention is a module convention and at least four spellings of it exist in the wild, the discriminator is a parameter:

```tsx
Expand All @@ -115,6 +187,25 @@ Any string that the library did not build itself goes through a scheme allow-lis

This applies to an `href` you pass and to an author-supplied `j:url` alike, and it is applied at render time, so it also covers content stored before anyone thought to validate it. React alone is not enough here: it neutralises `javascript:` by substituting a throwing URL, and it matches no other scheme.

A project is often stricter than that. A "partner website" field that must be `https://` and nothing else does not want `tel:` links quietly working. Narrow the list — per call, or once for the whole module:

```ts
// src/server/links.ts, imported once from a view
import { setLinkDefaults } from "@jahia/javascript-modules-library";

setLinkDefaults({ allowedSchemes: ["http", "https"] });
```

```tsx
<JLink content={partner} allowedSchemes={["https"]} />
```

`setLinkDefaults` is keyed by the module that calls it, the same way `setImageDefaults` is: every JavaScript module in an instance shares one JavaScript context, so a module-level variable would be a policy for the whole server. Call it at the top level of a server file, not inside a render.

:::warning
The option **narrows only**. A scheme that is not on the built-in list is dropped rather than added, because a call site is not the place a project loosens its own URL policy — `javascript:` and `data:` are the reason the list exists. On a development instance the library says so once per scheme; in production the links are simply not navigable.
:::

Query parameters and a fragment are options rather than string surgery, and they land in the right order:

```tsx
Expand Down Expand Up @@ -187,18 +278,47 @@ A language switcher is the case where the computation is wrong and you know bett
`target` is validated against the four values `jmix:link` allows (`_blank`, `_parent`, `_self`, `_top`). Anything else omits the attribute rather than emitting `target=""`, which matters because the value often comes straight from content. `rel="noopener noreferrer"` is added whenever `target` resolves to `_blank`; pass `rel` yourself to replace it.

:::info
These are live and preview guarantees. In the page builder, `EditModeFilter` rewrites the anchors it delivers: it turns `/cms/edit/` into `/cms/editframe/` and either deletes `target` or staples `target="_blank"` on with no `rel`. Assert on the delivered DOM, not on what your component returned.
These are live and preview guarantees. In the page builder, `EditModeFilter` rewrites the anchors it delivers: it either deletes `target` or staples `target="_blank"` on with no `rel`. Assert on the delivered DOM, not on what your component returned.
:::

## `href` is a server-side intermediate

The `href` you get back is not the URL the visitor receives. Core finishes it after the render — vanity URLs, SEO rewriting, and the `?jsite=` parameter that live adds to a cross-site link — and it does so by walking the emitted HTML. `URLTraverser` only visits a fixed set of tag/attribute pairs (`a[href]`, `img[src]`, `form[action]`, `link[href]`, and a few more) in an `html` template type.
The `href` you get back is not the URL the visitor receives. Core finishes it after the render, and it does so by walking the emitted HTML rather than by touching the value you built. Two filters do the work, and each visits a fixed set of tag/attribute pairs in an `html` template type:

| Filter | What it adds | Where it looks |
| ---------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `URLFilter` | Vanity URLs, the SEO server name, and the `?jsite=` parameter of a cross-site link | `a[href]`, `img[src\|srcset\|data-src\|data-srcset]`, `form[action]`, `link[href]`, `source[srcset]`, `embed[src]`, `param[value]` |
| `EditModeFilter` | In the page builder: `/cms/edit/` → `/cms/editframe/`, and `target` deleted or forced | `a[href]` only |

So:
So the rule is simple, and it is about **where you put the URL**, not about how you built it:

- Put the URL anywhere else — a `data-*` attribute, an Island payload, the JSON body of an action — and it stays exactly as you built it. No vanity URL, no `?jsite=`.
- Emit it as one of those attributes and it is finished for you.
- Put it anywhere else — a `data-*` attribute of your own, an Island payload, the JSON body of an action, a `<meta>` tag, a JSON-LD block, a CSS `url()` — and it stays exactly as you built it. No vanity URL, no `?jsite=`.
- Never string-compare an `href`, and never parse it to decide something. Compare nodes, or use `state.isCurrent` and `state.isAncestor`.

There is no call that runs the finishing pass for you: it needs the assembled HTML, which does not exist yet while your view runs. What you can do is make the URL correct without it — see below — and reach for `buildNodeUrl(node, { absolute: true })` when the URL leaves the page altogether (`og:url`, an email, JSON-LD).

### The edit-mode URL, which used to need a workaround

One rewrite used to bite hard enough that projects patched it by hand:

```tsx
// Don't. This is what the library now gets right.
buildNodeUrl(target).replace("/cms/edit/", "/cms/editframe/");
```

The reason it existed: `/cms/edit/…` does not render a page. On Jahia 8.2.3 it answers `302` to the jContent UI, and it only ever reached the page because `EditModeFilter` substituted the two — for an `a[href]` and nothing else. A URL in an Island payload kept the redirecting form, so the nav rendered by that Island navigated the iframe to a whole second copy of jContent.

`buildNodeUrl` now emits `/cms/editframe/…` for edit mode directly, which is what `node.getUrl()` already returned when no `mode`, `language` or `extension` was named. The workaround is no longer needed, and neither is the branch around it:

```tsx
// The URL is correct wherever it goes, including into an Island
const { anchor, state } = getLinkProps(page, { language }, useServerContext());
<Island component={Switcher} props={{ anchor, label: state.label }} />;
```

Note that this is the URL of the page **inside** the builder's frame. Deep-linking a visitor into the jContent editor is a different URL (`/jahia/jcontent/…`) and not something this API builds.

## Links inside Islands

The library cannot be imported from a client bundle: the Vite plugin fails the build if you try. An Island therefore receives link _data_, not a link component, and renders the anchor itself:
Expand Down Expand Up @@ -229,4 +349,6 @@ If you need a policy on those anchors, it belongs in a render filter — `regist
- [`JLink`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#jlink) — the component
- [`getLinkProps`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#getlinkprops) — the props tier, for Islands and custom markup
- [`resolveContentLink`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#resolvecontentlink) — reading a link off a content node
- [`setLinkDefaults`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#setlinkdefaults) — the module-wide scheme allow-list
- [`readNodeReference`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#readnodereference) — reading a reference property safely
- [`buildNodeUrl`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#buildnodeurl) — the URL tier underneath
Loading
Loading