Skip to content

Commit effccbb

Browse files
committed
docs(links): what the component now lets you do, and which URLs core finishes
1 parent 02f59e0 commit effccbb

1 file changed

Lines changed: 127 additions & 5 deletions

File tree

docs/2-guides/9-links/README.md

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,42 @@ That single line builds the URL through `buildNodeUrl`, registers a render cache
3636

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

39+
## Attributes the component does not know about
40+
41+
Two things a real site needs, and neither is expressible as a prop.
42+
43+
The first is `data-*`. React's typings do not model it on a component's props, so `attributes` takes an open map:
44+
45+
```tsx
46+
<JLink node={page} attributes={{ "data-element-type": "cta" }} />
47+
```
48+
49+
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:
50+
51+
```tsx
52+
<JLink
53+
content={cta}
54+
attributes={({ anchor, state }) => ({
55+
"data-element-url": anchor.href,
56+
"data-element-text": state.label,
57+
"data-element-current": state.isCurrent,
58+
})}
59+
/>
60+
```
61+
62+
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.
63+
64+
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`:
65+
66+
```tsx
67+
<JLink node={page} asChild>
68+
<CTA variant="primary">Read more</CTA>
69+
</JLink>
70+
// → <a href="…" class="cta cta--primary">Read more</a>
71+
```
72+
73+
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.
74+
3975
## A target that does not resolve is normal
4076

4177
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.
@@ -72,7 +108,27 @@ const { anchor, state } = getLinkProps(node, {}, useServerContext());
72108
return state.navigable ? <a {...anchor}>{state.label}</a> : <span>{state.label}</span>;
73109
```
74110

75-
`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.
111+
`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.
112+
113+
`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:
114+
115+
```tsx
116+
const { anchor, state } = resolveContentLink(cta, {}, useServerContext()) ?? {};
117+
const label = state?.label || state?.node?.getProperty("acme:shortName")?.getString();
118+
```
119+
120+
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`:
121+
122+
```tsx
123+
import { readNodeReference } from "@jahia/javascript-modules-library";
124+
125+
const related = readNodeReference(currentNode, "acme:related");
126+
// null → the property is unset
127+
// { uuid } → it is set, and the target is not reachable (unpublished, deleted, forbidden)
128+
// { uuid, node } → it resolved
129+
```
130+
131+
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.
76132

77133
:::info
78134
`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.
@@ -90,6 +146,22 @@ Pass the content node and let the library read it:
90146

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

149+
### When the link is a mixin, the label is somewhere else
150+
151+
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.
152+
153+
Say where the label really lives:
154+
155+
```tsx
156+
// The mixin stores its own label
157+
<JLink content={card} labelProperties={["acme:ctaLabel"]} />
158+
159+
// There is no label property: use the name of the page it points at
160+
<JLink content={card} labelFrom="target" />
161+
```
162+
163+
`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.
164+
93165
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:
94166

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

116188
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.
117189

190+
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:
191+
192+
```ts
193+
// src/server/links.ts, imported once from a view
194+
import { setLinkDefaults } from "@jahia/javascript-modules-library";
195+
196+
setLinkDefaults({ allowedSchemes: ["http", "https"] });
197+
```
198+
199+
```tsx
200+
<JLink content={partner} allowedSchemes={["https"]} />
201+
```
202+
203+
`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.
204+
205+
:::warning
206+
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.
207+
:::
208+
118209
Query parameters and a fragment are options rather than string surgery, and they land in the right order:
119210

120211
```tsx
@@ -187,18 +278,47 @@ A language switcher is the case where the computation is wrong and you know bett
187278
`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.
188279

189280
:::info
190-
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.
281+
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.
191282
:::
192283

193284
## `href` is a server-side intermediate
194285

195-
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.
286+
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:
287+
288+
| Filter | What it adds | Where it looks |
289+
| ---------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
290+
| `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]` |
291+
| `EditModeFilter` | In the page builder: `/cms/edit/``/cms/editframe/`, and `target` deleted or forced | `a[href]` only |
196292

197-
So:
293+
So the rule is simple, and it is about **where you put the URL**, not about how you built it:
198294

199-
- 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=`.
295+
- Emit it as one of those attributes and it is finished for you.
296+
- 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=`.
200297
- Never string-compare an `href`, and never parse it to decide something. Compare nodes, or use `state.isCurrent` and `state.isAncestor`.
201298

299+
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).
300+
301+
### The edit-mode URL, which used to need a workaround
302+
303+
One rewrite used to bite hard enough that projects patched it by hand:
304+
305+
```tsx
306+
// Don't. This is what the library now gets right.
307+
buildNodeUrl(target).replace("/cms/edit/", "/cms/editframe/");
308+
```
309+
310+
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.
311+
312+
`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:
313+
314+
```tsx
315+
// The URL is correct wherever it goes, including into an Island
316+
const { anchor, state } = getLinkProps(page, { language }, useServerContext());
317+
<Island component={Switcher} props={{ anchor, label: state.label }} />;
318+
```
319+
320+
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.
321+
202322
## Links inside Islands
203323

204324
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:
@@ -229,4 +349,6 @@ If you need a policy on those anchors, it belongs in a render filter — `regist
229349
- [`JLink`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#jlink) — the component
230350
- [`getLinkProps`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#getlinkprops) — the props tier, for Islands and custom markup
231351
- [`resolveContentLink`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#resolvecontentlink) — reading a link off a content node
352+
- [`setLinkDefaults`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#setlinkdefaults) — the module-wide scheme allow-list
353+
- [`readNodeReference`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#readnodereference) — reading a reference property safely
232354
- [`buildNodeUrl`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#buildnodeurl) — the URL tier underneath

0 commit comments

Comments
 (0)