From 7ac70b343e219f2d16acf095cb76fa6d050fe0ed Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:49:46 +0200 Subject: [PATCH 1/6] fix: keep query parameters ahead of the URL fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .chachalog/KcD0XPWv.md | 8 +++++++ .../src/utils/urlBuilder/urlBuilder.ts | 21 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 .chachalog/KcD0XPWv.md diff --git a/.chachalog/KcD0XPWv.md b/.chachalog/KcD0XPWv.md new file mode 100644 index 00000000..2aeba208 --- /dev/null +++ b/.chachalog/KcD0XPWv.md @@ -0,0 +1,8 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: patch +--- + +Fixed query string parameters being appended after the fragment in `buildNodeUrl`, `buildEndpointUrl` and `buildModuleFileUrl`. (#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. diff --git a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts index 020deeb6..8dda53b7 100644 --- a/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts +++ b/javascript-modules-library/src/utils/urlBuilder/urlBuilder.ts @@ -8,12 +8,25 @@ const absoluteUrlRegExp = /^(?:[a-z+]+:)?\/\//i; export { toAbsoluteUrl, type AbsoluteUrlOption } from "./absoluteUrl.js"; -/** URLSearchParams is not supported by Graal, this is our polyfill in the meantime */ -function appendParameters(url: string, parameters: Record): string { +/** An RFC 3986 scheme, up to and including its colon. Group 1 is the scheme itself. */ +export const schemeRegExp = /^([a-z][a-z0-9+.-]*):/i; + +/** + * URLSearchParams is not supported by Graal, this is our polyfill in the meantime. + * + * The parameters go before the fragment, which is where a query string belongs: appending them to + * `/page.html#main` gives `/page.html?a=b#main`, not `/page.html#main?a=b`. + */ +export function appendParameters(url: string, parameters: Record): string { const querystring = Object.entries(parameters) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); - return `${url}${url.includes("?") ? "&" : "?"}${querystring}`; + if (!querystring) return url; + + const fragmentIndex = url.indexOf("#"); + const path = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex); + const fragment = fragmentIndex === -1 ? "" : url.slice(fragmentIndex); + return `${path}${path.includes("?") ? "&" : "?"}${querystring}${fragment}`; } /** @@ -171,7 +184,7 @@ export function buildModuleFileUrl( renderContext?: RenderContext; } = useServerContext(), ): string { - if (/^[a-zA-Z0-9.+-]+:/.test(filePath)) { + if (schemeRegExp.test(filePath)) { // If path has a protocol (e.g. data: URI), return it as is. return filePath; } From 68af5ed995ea1df947497fcc0023b1916ef23a27 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:49:57 +0200 Subject: [PATCH 2/6] feat: add the link props tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- javascript-modules-library/src/index.ts | 13 + .../src/utils/link/getLinkProps.ts | 264 ++++++++++++++++++ .../src/utils/link/resolveContentLink.ts | 154 ++++++++++ .../src/utils/link/types.ts | 135 +++++++++ 4 files changed, 566 insertions(+) create mode 100644 javascript-modules-library/src/utils/link/getLinkProps.ts create mode 100644 javascript-modules-library/src/utils/link/resolveContentLink.ts create mode 100644 javascript-modules-library/src/utils/link/types.ts diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 80287107..39c3a33a 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -68,6 +68,19 @@ export { } from "./utils/image/imageDefaults.js"; export { readImageMeta, type ImageMeta } from "./utils/image/imageMeta.js"; +// Links +export { getLinkProps } from "./utils/link/getLinkProps.js"; +export { resolveContentLink } from "./utils/link/resolveContentLink.js"; +export type { + AnchorProps, + LinkContext, + LinkOptions, + LinkProps, + LinkState, + LinkTarget, + LinkTargetAttribute, +} from "./utils/link/types.js"; + // I18n export { getSiteLocales } from "./utils/i18n.js"; diff --git a/javascript-modules-library/src/utils/link/getLinkProps.ts b/javascript-modules-library/src/utils/link/getLinkProps.ts new file mode 100644 index 00000000..5273ca4c --- /dev/null +++ b/javascript-modules-library/src/utils/link/getLinkProps.ts @@ -0,0 +1,264 @@ +import type { Locale } from "java.util"; +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { RenderContext } from "org.jahia.services.render"; +import { appendParameters, buildNodeUrl, schemeRegExp } from "../urlBuilder/urlBuilder.js"; +import type { AnchorProps, LinkContext, LinkOptions, LinkProps, LinkTarget } from "./types.js"; + +/** The values `jmix:link`'s `j:target` allows. Anything else omits the attribute. */ +const TARGET_ATTRIBUTES: readonly string[] = ["_blank", "_parent", "_self", "_top"]; + +/** + * Schemes a link may use, for every URL the library did not build itself — an author-supplied + * `j:url` included. + * + * React neutralises `javascript:` alone, by substituting a throwing URL rather than removing the + * attribute; `data:`, `blob:` and `vbscript:` are covered by this list and by nothing else. + */ +const ALLOWED_SCHEMES: readonly string[] = ["http", "https", "mailto", "tel", "ftp"]; + +/** + * Reproduces what a URL parser removes before it reads the scheme: ASCII tab and newline anywhere + * in the URL, then leading and trailing C0 controls and spaces. + * + * The string the allow-list judges has to be the string the browser will act on. A tab inserted in + * the middle of a scheme, or a control character in front of it, otherwise reads as an unrecognised + * scheme here and as `javascript:` there. + */ +function normalizeUrl(raw: string): string { + const stripped = raw.replaceAll("\t", "").replaceAll("\r", "").replaceAll("\n", ""); + let start = 0; + let end = stripped.length; + while (start < end && stripped.charCodeAt(start) <= 0x20) start++; + while (end > start && stripped.charCodeAt(end - 1) <= 0x20) end--; + return stripped.slice(start, end); +} + +/** The URL to navigate to, or `undefined` when its scheme is not allow-listed. */ +function allowedHref(raw: string): string | undefined { + const url = normalizeUrl(raw); + if (!url) return undefined; + + // A same-document URL names neither a scheme nor a host + if (url.startsWith("#")) return url; + + // A site-relative URL names no scheme either — but only as long as it names no host. A relative + // URL whose second character is `/` or `\` is parsed as `//host`, which leaves the site under + // whatever scheme the page itself was served with, and so has to go through the allow-list. + if (url.startsWith("/")) return url[1] === "/" || url[1] === "\\" ? undefined : url; + + const scheme = schemeRegExp.exec(url)?.[1].toLowerCase(); + return scheme && ALLOWED_SCHEMES.includes(scheme) ? url : undefined; +} + +/** + * Applies the requested fragment, then the query string. + * + * A `hash` replaces the fragment the target already carries, and the empty string removes it. + * Keeping the query ahead of whichever fragment survives is `appendParameters`' job. + */ +function composeUrl(base: string, parameters?: Record, hash?: string): string { + const fragmentIndex = base.indexOf("#"); + const path = fragmentIndex === -1 ? base : base.slice(0, fragmentIndex); + + const url = + hash === undefined + ? base + : `${path}${hash ? `#${hash.startsWith("#") ? hash.slice(1) : hash}` : ""}`; + + return parameters ? appendParameters(url, parameters) : url; +} + +/** A JCR read that must never break a render: the node may be gone by the time it is called. */ +const read = (node: JCRNodeWrapper, accessor: (node: JCRNodeWrapper) => T): T | undefined => { + try { + return accessor(node) ?? undefined; + } catch { + return undefined; + } +}; + +/** + * Two `JCRNodeWrapper` proxies for the same node are not guaranteed to be the same object, so + * identity comparison is a bug even where it happens to work today. + */ +const isSameNode = (a: JCRNodeWrapper | undefined, b: JCRNodeWrapper | undefined): boolean => { + if (!a || !b) return false; + const identifier = read(a, (node) => node.getIdentifier()); + return identifier !== undefined && identifier === read(b, (node) => node.getIdentifier()); +}; + +/** + * Java writes a locale `fr_CH` and BCP 47 writes it `fr-CH`; both spellings name one language here, + * because refusing the one the caller happens to have typed silently removes a working link. + */ +const normalizeLanguage = (language: string): string => language.replaceAll("-", "_"); + +/** A bare language accepts any region of it, so `"fr"` matches a site running `fr_CH`. */ +const localeMatches = (locale: Locale, language: string): boolean => { + const tag = normalizeLanguage(locale.toString()); + const requested = normalizeLanguage(language); + return tag === requested || (!requested.includes("_") && tag.split("_")[0] === requested); +}; + +/** + * Whether the node is translated at all. Content that is not — a file, a folder — is + * language-neutral: it has no translation to point at and none to be missing. + * + * A node that cannot answer is treated as translated, which is the conservative reading: it leaves + * the language the caller asked for in place rather than quietly ignoring it. + */ +function hasTranslations(node: JCRNodeWrapper): boolean { + try { + return node.hasTranslations(); + } catch { + return true; + } +} + +/** + * Whether the target exists in the requested language. + * + * Language-neutral content stays navigable. A check that cannot run leaves the link alone: a false + * negative here removes a link that works. + */ +function hasTranslation(node: JCRNodeWrapper, language: string): boolean { + try { + const invalidLanguages = node.hasProperty("j:invalidLanguages") + ? node + .getProperty("j:invalidLanguages") + .getValues() + .map((value) => value.getString()) + : []; + if (invalidLanguages.includes(normalizeLanguage(language))) return false; + + if (!hasTranslations(node)) return true; + return node.getExistingLocales().some((locale) => localeMatches(locale, language)); + } catch { + return true; + } +} + +/** + * The URL of a node target, or `undefined` when there is none. + * + * `buildNodeUrl` throws on a falsy node and on a mode it cannot infer, and `getUrl()` returns null + * on a repository error. An unusable URL is the ordinary not-navigable outcome of this API, not an + * error to propagate: a thrown error replaces the whole fragment with an HTML comment. + * + * The language is dropped for language-neutral content. Passing one takes `buildNodeUrl` down its + * manual branch, which concatenates `/cms/render//.html` and so loses + * the `/files//` form `getUrl()` gives an `nt:file` — a URL that does not serve + * the file. Content with no translations has no language to be pointed at anyway. + */ +function buildTargetUrl( + node: JCRNodeWrapper, + language: string | undefined, + context: LinkContext | undefined, +): string | undefined { + try { + const config = language && hasTranslations(node) ? { language } : {}; + return buildNodeUrl(node, config, context) || undefined; + } catch { + return undefined; + } +} + +function registerCacheDependency( + cacheDependency: LinkOptions["cacheDependency"], + node: JCRNodeWrapper | undefined, + renderContext: RenderContext | undefined, +): void { + if (cacheDependency === false || !renderContext) return; + + const key = + typeof cacheDependency === "object" && cacheDependency !== null + ? cacheDependency + : node + ? { node } + : undefined; + if (!key) return; + + server.render.addCacheDependency(key, renderContext); +} + +/** + * Builds the props of a link from whatever names its target: a node, a URL, or nothing. + * + * Not being navigable is a result, not an error. A reference that does not resolve is the normal + * state of a link to an unpublished page — publishing a page does not publish the pages it links to + * — so this function never throws and never returns an `href` it could not build. The caller + * renders the children without an anchor; `state.navigable` says which case it is in. + * + * On the way it registers the render cache dependency, validates the anchor `target`, adds `rel` to + * `_blank`, derives the label, and answers whether the target is the page being rendered. + * + * @example + * ```tsx + * const { anchor, state } = getLinkProps(props["j:linknode"], {}, useServerContext()); + * return state.navigable ? {state.label} : <>{state.label}; + * ```; + * + * @param target - The node to link to, an already-built URL, or nothing. + * @param options - Query string, fragment, language, anchor attributes and cache dependency. + * @param context - What the link is resolved against. Pass `useServerContext()`: this function + * reads no React context of its own, so omitting it inside a render does not fall back to one. It + * degrades instead — without `renderContext` no cache dependency is registered, and without + * `mainNode` `isCurrent` and `isAncestor` are always false. Omit it only outside a render, where + * there is nothing to read. + * @returns The anchor attributes and the state of the link. Never throws. + * @see {@link resolveContentLink} to read the target off a content node first. + */ +export function getLinkProps( + target: LinkTarget, + options: LinkOptions = {}, + context?: LinkContext, +): LinkProps { + const node = typeof target === "string" ? undefined : (target ?? undefined); + + let href: string | undefined; + if (!target) { + href = undefined; + } else if (typeof target === "string") { + href = allowedHref(target); + } else if ( + !options.language || + options.requireTranslation === false || + hasTranslation(target, options.language) + ) { + href = buildTargetUrl(target, options.language, context); + } + + if (href !== undefined) href = composeUrl(href, options.parameters, options.hash); + + // Registered even when the link is not navigable, which is the case the { uuid } form addresses + // — see LinkOptions.cacheDependency for the engine limitation that form currently hits + registerCacheDependency(options.cacheDependency, node, context?.renderContext); + + const mainNode = context?.mainNode; + const path = node && read(node, (linked) => linked.getPath()); + const mainPath = mainNode && read(mainNode, (main) => main.getPath()); + + const state = { + navigable: href !== undefined, + isCurrent: options.isCurrent ?? isSameNode(node, mainNode), + isAncestor: + path !== undefined && + mainPath !== undefined && + (mainPath === path || mainPath.startsWith(`${path}/`)), + label: + options.label ?? (node ? (read(node, (linked) => linked.getDisplayableName()) ?? "") : ""), + }; + + if (href === undefined) return { anchor: {}, state }; + + const targetAttribute = + options.target && TARGET_ATTRIBUTES.includes(options.target) ? options.target : undefined; + const rel = options.rel ?? (targetAttribute === "_blank" ? "noopener noreferrer" : undefined); + + const anchor: AnchorProps = { href }; + if (targetAttribute) anchor.target = targetAttribute; + if (rel) anchor.rel = rel; + if (options.title) anchor.title = options.title; + + return { anchor, state }; +} diff --git a/javascript-modules-library/src/utils/link/resolveContentLink.ts b/javascript-modules-library/src/utils/link/resolveContentLink.ts new file mode 100644 index 00000000..97a71b20 --- /dev/null +++ b/javascript-modules-library/src/utils/link/resolveContentLink.ts @@ -0,0 +1,154 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import { getLinkProps } from "./getLinkProps.js"; +import type { LinkContext, LinkOptions, LinkProps, LinkTargetAttribute } from "./types.js"; + +/** + * Reference properties that carry an internal link, in lookup order: core's `jnt:nodeLink`, then + * the `jmix:internalLink` of the Jahia/default module. + */ +const REFERENCE_PROPERTIES: readonly string[] = ["j:node", "j:linknode"]; + +/** The label of the link itself: `mix:title`, then `jmix:externalLink`. */ +const TITLE_PROPERTIES: readonly string[] = ["jcr:title", "j:linkTitle"]; + +/** A property read as a string, or `undefined` when it is absent, empty, or unreadable. */ +function readString(node: JCRNodeWrapper, property: string): string | undefined { + try { + if (!node.hasProperty(property)) return undefined; + const value = node.getPropertyAsString(property); + return value && value.trim() ? value : undefined; + } catch { + return undefined; + } +} + +/** The first of these properties the node carries. */ +const readFirst = (node: JCRNodeWrapper, properties: readonly string[]): string | undefined => + properties.reduce( + (found, property) => found ?? readString(node, property), + undefined, + ); + +/** + * A reference, as both ends of it: the node it resolves to, and the raw value it stores. + * + * The raw value is the target's UUID, and it is only ever a cache-dependency key. A node the + * visitor may not read still yields it, so putting it in an `href` or a label would both leak a + * target's existence and render a URL that leads nowhere. + */ +function readReference( + node: JCRNodeWrapper, + property: string, +): { target?: JCRNodeWrapper; uuid?: string } | undefined { + let uuid: string | undefined; + let target: JCRNodeWrapper | undefined; + + try { + if (!node.hasProperty(property)) return undefined; + uuid = readString(node, property); + target = node.getProperty(property).getValue().getNode() ?? undefined; + } catch { + // An unresolvable reference throws rather than returning null, and is the case this API exists + // for: the property is set, the target is not there yet. + target = undefined; + } + + return target || uuid ? { target, uuid } : undefined; +} + +/** + * Reads a link off a content node, and turns it into the props of an anchor. + * + * It handles core's own link types — `jnt:nodeLink` (`j:node`) and `jnt:externalLink` (`j:url`) — + * plus `jmix:link`'s `j:target` and `mix:title`'s `jcr:title`. It also handles the `j:linkType` + * convention, which is not core: it ships in the Jahia/default module, with `jmix:internalLink` + * (`j:linknode`) and `jmix:externalLink` (`j:url`, `j:linkTitle`). Because that is a module + * convention, and three other spellings of it exist in the wild, the discriminator is a parameter. + * + * The discriminator only ever says "no link": every vocabulary spells that value differently but + * agrees on having one, whereas their other values are incompatible. Which link to read is decided + * by which property is filled — every reference property in `referenceProperties`, in order, then + * the external URL. + * + * That precedence has a hazard worth knowing: an editor who switches a link from internal to + * external leaves the reference property behind if the definition does not clear it, and this + * function then renders the abandoned internal target rather than the URL the editor typed. Name + * the properties to read when it matters — `referenceProperties: []` forces the external branch. + * + * The external URL goes through the same scheme allow-list as any other author-supplied string. An + * internal reference that does not resolve returns a link that is not navigable, carrying a cache + * dependency on the raw reference, rather than nothing at all. + * + * @example + * ```tsx + * const link = resolveContentLink(currentNode, {}, useServerContext()); + * return link?.state.navigable ? {link.state.label} : null; + * ```; + * + * @param node - The content node carrying the link. + * @param options - Everything {@link getLinkProps} takes, plus the properties to read. + * @param context - What the link is resolved against. Pass `useServerContext()`; see + * {@link getLinkProps} for what omitting it costs. + * @returns The link props, or `null` when the node carries no link at all. Never throws. + * @see {@link getLinkProps} for the semantics of the props it returns. + */ +export function resolveContentLink( + node: JCRNodeWrapper, + options: LinkOptions & { + /** Discriminator property. luxe uses `"ctaType"`, se-utils `"seu:linkType"`. */ + typeProperty?: string; + /** The discriminator value that means "no link". */ + noneValue?: string; + /** + * Reference properties holding an internal link, tried in order and ahead of `urlProperty`. The + * empty array reads the external URL only, which is how a caller that knows the link is + * external steps around a reference property an earlier edit left behind. + * + * @default ["j:node", "j:linknode"] + */ + referenceProperties?: readonly string[]; + /** + * Property holding an external URL. + * + * @default "j:url" + */ + urlProperty?: string; + } = {}, + context?: LinkContext, +): LinkProps | null { + if (!node) return null; + + const { + typeProperty = "j:linkType", + noneValue = "none", + referenceProperties = REFERENCE_PROPERTIES, + urlProperty = "j:url", + ...linkOptions + } = options; + if (readString(node, typeProperty)?.trim() === noneValue) return null; + + const shared: LinkOptions = { + ...linkOptions, + label: linkOptions.label ?? readFirst(node, TITLE_PROPERTIES), + // A content value, so it may be anything; getLinkProps drops what is not a jmix:link target + target: linkOptions.target ?? (readString(node, "j:target") as LinkTargetAttribute | undefined), + }; + + for (const property of referenceProperties) { + const reference = readReference(node, property); + if (!reference) continue; + + // `true` and the default both mean "pick the key form", and the UUID of an unresolved + // reference is only in hand here: the props tier receives nothing it could derive it from + const automatic = shared.cacheDependency === undefined || shared.cacheDependency === true; + const cacheDependency = + automatic && !reference.target && reference.uuid + ? { uuid: reference.uuid } + : shared.cacheDependency; + + return getLinkProps(reference.target, { ...shared, cacheDependency }, context); + } + + const url = readString(node, urlProperty); + return url ? getLinkProps(url, shared, context) : null; +} diff --git a/javascript-modules-library/src/utils/link/types.ts b/javascript-modules-library/src/utils/link/types.ts new file mode 100644 index 00000000..5a9e7a8a --- /dev/null +++ b/javascript-modules-library/src/utils/link/types.ts @@ -0,0 +1,135 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { RenderContext, Resource } from "org.jahia.services.render"; + +/** + * What a link can point at: a node, an already-built URL, or nothing. + * + * Both `null` and `undefined` occur, and neither is an error: an unresolved reference yields the + * JCR `null`, an unset property yields `undefined`. An unpublished target, a deleted one and one + * the visitor may not read all collapse to the same value here, so a link cannot tell them apart. + */ +export type LinkTarget = JCRNodeWrapper | string | null | undefined; + +/** The four values `jmix:link`'s `j:target` allows. */ +export type LinkTargetAttribute = "_blank" | "_parent" | "_self" | "_top"; + +/** + * Anchor attributes, spreadable onto an `` as they are — every key is a valid one, by + * construction, the same contract `ImgProps` has for ``. This is also the shape to hand to an + * Island, where a React element cannot travel: ``. + * + * An empty object means the link is not navigable. There is no `href` to render, and the other + * attributes have nothing to hang on. + */ +export interface AnchorProps { + /** + * A server-side intermediate, not the URL the visitor receives. Core completes it after the + * render, and only where it is emitted as one of `URLTraverser`'s tag/attribute pairs — `a[href]` + * among them — in an `html` template type. Copy it into a `data-*` attribute or an Island payload + * and it stays un-rewritten: no vanity URL, no `?jsite=` cross-site parameter. Never + * string-compare it. + */ + href?: string; + target?: LinkTargetAttribute; + /** `"noopener noreferrer"` whenever `target` resolves to `_blank`. */ + rel?: string; + title?: string; +} + +/** + * What the caller needs to know about the link that is not an anchor attribute. + * + * Never spread onto an ``: none of these keys is a DOM attribute. + */ +export interface LinkState { + /** False when there is no `href`. Render the children without an anchor. */ + navigable: boolean; + /** + * The target is the page being rendered. `` turns it into `aria-current="page"`. + * + * A view that reads it must declare `cache.mainResource=true`, otherwise its fragment is cached + * once and replayed on every page that shares it. + */ + isCurrent: boolean; + /** The page being rendered is the target or one of its descendants. Style with it. */ + isAncestor: boolean; + /** The label to render when the caller provides no children. Empty when nothing supplies one. */ + label: string; +} + +/** Everything a link needs to be rendered: the anchor attributes, and the rest. */ +export interface LinkProps { + anchor: AnchorProps; + state: LinkState; +} + +export interface LinkOptions { + /** Query string parameters. Inserted before any fragment, on node and string targets alike. */ + parameters?: Record; + /** Fragment, without the leading `#`. Appended last, replacing a fragment the target carries. */ + hash?: string; + /** + * Language of the target. Also selects the language the URL points at. + * + * Both spellings of a locale are accepted: `"fr"`, `"fr_CH"` and `"fr-CH"` all name the same + * language. Language-neutral content — a file, a folder — ignores it, since it has no translation + * to point at. + */ + language?: string; + /** + * With `language` set, require the target to exist in that language; a target that does not is + * not navigable. Turn it off to link to the untranslated page anyway. + * + * @default true + */ + requireTranslation?: boolean; + /** Anchor `target`. Anything but the four `jmix:link` values omits the attribute. */ + target?: LinkTargetAttribute; + /** Overrides the automatic `"noopener noreferrer"`. */ + rel?: string; + title?: string; + /** Overrides the derived label. */ + label?: string; + /** + * Overrides the computed value. A language switcher sets it: every entry points at the same page, + * so identifier equality marks them all current. + */ + isCurrent?: boolean; + /** + * Register a render cache dependency, so that changing the target flushes the fragments that link + * to it. + * + * `true` (the default) picks the key form: `{ node }` when the target resolves, `{ uuid }` on the + * raw reference when it does not, and nothing at all for a string target. + * + * Pass an explicit form to override. A loop over JCR query hits wants the `path` form, since it + * holds paths rather than nodes; `flushOnPathMatchingRegexp` covers a set of paths at once. + * + * The `{ uuid }` form is meant to flush the fragment that fell back once its target is published, + * but the engine drops it today: the tag it feeds resolves the UUID against a page context it has + * not been given yet, and the failure is swallowed, so nothing is registered + * ({@link https://github.com/Jahia/javascript-modules/issues/750}). The other three forms work. + * Until that is fixed, a fragment that fell back is flushed by whatever else it depends on, or by + * `flushOnPathMatchingRegexp` on the section the target will land in. + * + * @default true + */ + cacheDependency?: + | boolean + | { node: JCRNodeWrapper } + | { path: string } + | { uuid: string } + | { flushOnPathMatchingRegexp: string }; +} + +/** + * The Jahia objects a link is resolved against. Pass `useServerContext()`; every field is optional + * because each one only removes a capability when it is missing — no `renderContext` means no cache + * dependency, no `mainNode` means no current-page state. + */ +export interface LinkContext { + renderContext?: RenderContext; + currentResource?: Resource; + /** The node of the main resource — the page being rendered, not the node being rendered. */ + mainNode?: JCRNodeWrapper; +} From 41b75be89c38c0a895916f41c0ba4cb5c8b61a04 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:50:06 +0200 Subject: [PATCH 3/6] feat: add the JLink component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title renders a bare : 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 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 --- .../src/components/JLink.tsx | 183 ++++++++++++++++++ javascript-modules-library/src/index.ts | 1 + 2 files changed, 184 insertions(+) create mode 100644 javascript-modules-library/src/components/JLink.tsx diff --git a/javascript-modules-library/src/components/JLink.tsx b/javascript-modules-library/src/components/JLink.tsx new file mode 100644 index 00000000..70814098 --- /dev/null +++ b/javascript-modules-library/src/components/JLink.tsx @@ -0,0 +1,183 @@ +import type { AnchorHTMLAttributes, JSX, ReactNode } from "react"; +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import { useServerContext } from "../hooks/useServerContext.js"; +import { getLinkProps } from "../utils/link/getLinkProps.js"; +import { resolveContentLink } from "../utils/link/resolveContentLink.js"; +import type { LinkOptions } from "../utils/link/types.js"; + +/** Everything the three shapes of `` have in common. */ +type JLinkCommon = { + /** The content of the anchor. With none, the derived label is rendered instead. */ + children?: ReactNode; + /** + * What to render when the link is not navigable — an unresolved reference, a rejected URL, a + * missing translation. `"children"` renders the children without an anchor, `"none"` renders + * nothing at all. + * + * @default "children" + */ + whenUnresolved?: "children" | "none"; +} & Pick< + LinkOptions, + | "parameters" + | "hash" + | "language" + | "requireTranslation" + | "target" + | "rel" + | "title" + | "isCurrent" + | "cacheDependency" +> & + // `content` is React's RDFa attribute, and it is this component's own prop name + Omit, "href" | "target" | "rel" | "title" | "content">; + +/** How {@link resolveContentLink} reads the link off the `content` node. */ +type JLinkDiscriminator = { + /** + * Property saying which kind of link the content node carries. Only its "no link" value is read. + * luxe uses `"ctaType"`, se-utils `"seu:linkType"`. + * + * @default "j:linkType" + */ + typeProperty?: string; + /** + * The value of `typeProperty` that means "no link". + * + * @default "none" + */ + noneValue?: string; + /** + * Reference properties holding an internal link, tried in order and ahead of `urlProperty`. The + * empty array reads the external URL only. + * + * @default ["j:node", "j:linknode"] + */ + referenceProperties?: readonly string[]; + /** + * Property holding an external URL. + * + * @default "j:url" + */ + urlProperty?: string; +}; + +/** + * A discriminated union, so that naming two targets, or none, is a type error rather than prose. + * + * The `href` shape additionally requires `children` or `aria-label`: an anchor with no accessible + * name is a WCAG 2.4.4 failure, and it is what `alt` protects against on ``. The other two + * shapes derive a label from the content, so they cannot end up nameless. + */ +export type JLinkProps = + | ({ + /** The node to link to. `null` and `undefined` are ordinary: the link is not navigable. */ + node: JCRNodeWrapper | null | undefined; + content?: never; + href?: never; + } & JLinkCommon & { [K in keyof JLinkDiscriminator]?: never }) + | ({ + /** A content node carrying a link — a `jnt:nodeLink`, a `jnt:externalLink`, a CTA mixin. */ + content: JCRNodeWrapper; + node?: never; + href?: never; + } & JLinkCommon & + JLinkDiscriminator) + | ({ + /** An already-built URL. Goes through the scheme allow-list like any other string. */ + href: string; + node?: never; + content?: never; + } & JLinkCommon & { [K in keyof JLinkDiscriminator]?: never } & ( + | { children: ReactNode } + | { "aria-label": string } + )); + +/** + * Renders a link as a bare ``: the URL, the validated `target` and its `rel`, `aria-current` on + * the page being rendered, and a render cache dependency on the target. + * + * A target that does not resolve is the normal state of a link — publishing a page does not publish + * the pages it links to — so this component never renders an `` without an `href`. It renders + * the children on their own instead, or nothing when `whenUnresolved` says so. + * + * The element carries no styling of its own: pass a `className`. Every other anchor attribute — + * `onClick`, `hreflang`, `download` — is passed through. + * + * Server-side only, because it registers the cache dependency. A client component receives link + * data instead: build it with {@link getLinkProps} and spread it, ``. + * + * @example + * ```tsx + * {label} + * + * + * ```; + * + * @returns The `` element, or the unwrapped children when the link is not navigable. + * @see {@link getLinkProps} for the semantics of every option. + */ +export function JLink(props: JLinkProps): JSX.Element | null { + const { + node, + content, + href, + children, + whenUnresolved = "children", + parameters, + hash, + language, + requireTranslation, + target, + rel, + title, + isCurrent, + cacheDependency, + typeProperty, + noneValue, + referenceProperties, + urlProperty, + // Whatever is left is an anchor attribute: everything this component consumes is named above, + // so that a link option added later cannot reach the DOM. + ...anchorAttributes + } = props; + + const context = useServerContext(); + const options: LinkOptions = { + parameters, + hash, + language, + requireTranslation, + target, + rel, + title, + isCurrent, + cacheDependency, + }; + + const link = content + ? resolveContentLink( + content, + { ...options, typeProperty, noneValue, referenceProperties, urlProperty }, + context, + ) + : getLinkProps(href ?? node, options, context); + + // Children win over the derived label, and a link with neither renders as nothing + const body = children ?? link?.state.label; + + if (!link?.state.navigable) { + return whenUnresolved === "none" ? null : <>{body}; + } + + return ( + // `state` is never spread: none of its keys is an anchor attribute + + {body} + + ); +} diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 39c3a33a..b1ea5353 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -16,6 +16,7 @@ export { type JImageProps, type MarkupBox, } from "./components/JImage.js"; +export { JLink, type JLinkProps } from "./components/JLink.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; From 83f441496491c64512c022d36e9266f5e336d9e9 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:50:12 +0200 Subject: [PATCH 4/6] test: cover the link API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/JLink.spec.tsx | 154 ++++ .../src/utils/link/link.spec.ts | 723 ++++++++++++++++++ 2 files changed, 877 insertions(+) create mode 100644 javascript-modules-library/src/components/JLink.spec.tsx create mode 100644 javascript-modules-library/src/utils/link/link.spec.ts diff --git a/javascript-modules-library/src/components/JLink.spec.tsx b/javascript-modules-library/src/components/JLink.spec.tsx new file mode 100644 index 00000000..1e20492c --- /dev/null +++ b/javascript-modules-library/src/components/JLink.spec.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { RenderContext } from "org.jahia.services.render"; +import { ServerContextProvider, type ServerContext } from "../hooks/useServerContext.js"; + +vi.mock("../utils/urlBuilder/urlBuilder.js", async (importOriginal) => ({ + ...(await importOriginal()), + buildNodeUrl: (node: { getUrl: () => string | null }) => node.getUrl(), +})); + +const { JLink } = await import("./JLink.js"); +const { getLinkProps } = await import("../utils/link/getLinkProps.js"); + +/** A JCR node with just the surface the link code touches. */ +const jcrNode = ({ + identifier = "u-1", + path = "/sites/test/home", + url, + displayableName = "Home", + strings = {}, +}: { + identifier?: string; + path?: string; + url?: string | null; + displayableName?: string; + strings?: Record; +} = {}) => + ({ + getIdentifier: () => identifier, + getPath: () => path, + getDisplayableName: () => displayableName, + getUrl: () => (url === undefined ? `${path}.html` : url), + hasProperty: (property: string) => property in strings, + getPropertyAsString: (property: string) => strings[property] ?? null, + getProperty: (property: string) => { + throw new Error(`no such property: ${property}`); + }, + hasTranslations: () => false, + getExistingLocales: () => [], + }) as unknown as JCRNodeWrapper; + +const renderContext = {} as RenderContext; +const mainNode = jcrNode({ identifier: "u-home", path: "/sites/test/home" }); + +/** Renders inside the server context the component reads, the way the engine provides it. */ +const render = (element: ReactElement, context: Partial = {}) => + renderToStaticMarkup( + + {element} + , + ); + +beforeEach(() => { + Reflect.set(globalThis, "server", { render: { addCacheDependency: vi.fn() } }); +}); + +afterEach(() => { + vi.clearAllMocks(); + Reflect.deleteProperty(globalThis, "server"); +}); + +describe("the props JLink puts on the DOM", () => { + it("leaks no link state onto the anchor, whichever shape produced it", () => { + const node = jcrNode({ identifier: "u-home", displayableName: "Home" }); + // Every key of the state object, read off the real thing so a field added later is covered + const stateKeys = Object.keys(getLinkProps(node, {}, { mainNode }).state); + expect(stateKeys).not.toHaveLength(0); + + const markups = [ + render(), + render(), + render(Partner), + render(), + ]; + + for (const markup of markups) { + for (const key of stateKeys) { + // An attribute React rendered would appear lower-cased, and always followed by "=" + expect(markup.toLowerCase()).not.toContain(`${key.toLowerCase()}=`); + } + } + }); + + it("turns the current-page state into aria-current, and nothing else", () => { + const onThisPage = render(Home); + expect(onThisPage).toBe('Home'); + + const elsewhere = render( + Other, + ); + expect(elsewhere).toBe('Other'); + }); + + it("passes every other anchor attribute through untouched", () => { + const markup = render( + + Report + , + ); + expect(markup).toBe( + 'Report', + ); + }); +}); + +describe("a link that is not navigable", () => { + const unresolved = jcrNode({ url: null, displayableName: "Coming soon" }); + + it("renders the children without an anchor rather than an anchor without an href", () => { + const markup = render(Read the story); + expect(markup).toBe("Read the story"); + expect(markup).not.toContain(" { + expect(render()).toBe(""); + }); + + it("falls back to the derived label when the caller renders no children", () => { + expect(render()).toBe("Coming soon"); + }); + + it("covers a content node whose discriminator says there is no link", () => { + const none = jcrNode({ strings: { "ctaType": "none", "j:url": "https://example.com" } }); + expect( + render( + + Nothing to see + , + ), + ).toBe("Nothing to see"); + // Under the default discriminator name nothing says so, and the link is rendered + expect(render(Partner)).toBe( + 'Partner', + ); + }); + + it("drops the anchor attributes with it", () => { + const markup = render( + + Later + , + ); + expect(markup).toBe("Later"); + }); +}); diff --git a/javascript-modules-library/src/utils/link/link.spec.ts b/javascript-modules-library/src/utils/link/link.spec.ts new file mode 100644 index 00000000..443f7429 --- /dev/null +++ b/javascript-modules-library/src/utils/link/link.spec.ts @@ -0,0 +1,723 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { RenderContext } from "org.jahia.services.render"; +import type { LinkTargetAttribute } from "./types.js"; + +// `buildNodeUrl` reaches into the Jahia render context, which only exists inside the engine. The +// mock reproduces the shape of its two branches, and the three ways it fails to produce a URL: a +// falsy node and an un-inferrable mode both throw, and `getUrl()` returns null on a repository +// error. Those are the failures the props tier has to absorb rather than propagate. +vi.mock("../urlBuilder/urlBuilder.js", async (importOriginal) => ({ + // The rest of the URL tier is pure string work, and the link code shares it rather than + // reimplementing it: keep the real thing + ...(await importOriginal()), + buildNodeUrl: ( + node: { getUrl: () => string | null; getPath: () => string }, + config: { language?: string } = {}, + context: { renderContext?: unknown } = {}, + ) => { + if (!node) throw new Error("Expected a node in buildNodeUrl, received undefined"); + + // Passing a language takes the manual branch, which needs a mode it can only get from the + // render context + if (config.language) { + if (!context.renderContext) { + throw new Error("buildNodeUrl: mode is not defined and cannot be inferred."); + } + return `/cms/render/live/${config.language}${node.getPath()}.html`; + } + + return node.getUrl(); + }, +})); + +const { getLinkProps } = await import("./getLinkProps.js"); +const { resolveContentLink } = await import("./resolveContentLink.js"); + +/** A reference property: the UUID it stores, and the node it resolves to — when it does. */ +interface Reference { + uuid: string; + target?: JCRNodeWrapper; +} + +/** + * A JCR node with just the surface the link code touches. + * + * `locales: undefined` models language-neutral content — a file, a folder — which reports no + * translations at all, as opposed to content translated into some languages and not others. + */ +const jcrNode = ({ + identifier = "u-1", + path = "/sites/test/home", + url, + displayableName = "Home", + strings = {}, + multiple = {}, + references = {}, + locales, + unreadable = false, +}: { + identifier?: string; + path?: string; + url?: string | null; + displayableName?: string; + strings?: Record; + multiple?: Record; + references?: Record; + locales?: string[]; + unreadable?: boolean; +} = {}) => + ({ + getIdentifier: () => { + if (unreadable) throw new Error("RepositoryException"); + return identifier; + }, + getPath: () => path, + getCanonicalPath: () => path, + getDisplayableName: () => displayableName, + // getUrl() returns null rather than throwing when the repository cannot answer + getUrl: () => (url === undefined ? `${path}.html` : url), + hasProperty: (property: string) => + property in strings || property in multiple || property in references, + getPropertyAsString: (property: string) => + // A reference reports its target's UUID, whether or not the visitor may read that target + property in references ? references[property].uuid : (strings[property] ?? null), + getProperty: (property: string) => { + if (property in multiple) { + return { getValues: () => multiple[property].map((value) => ({ getString: () => value })) }; + } + + if (property in references) { + const { target } = references[property]; + return { + getValue: () => ({ + getNode: () => { + // An unresolvable reference throws, it does not return null + if (!target) throw new Error("ItemNotFoundException"); + return target; + }, + }), + }; + } + + // A JCR node throws PathNotFoundException on a property it does not have + throw new Error(`no such property: ${property}`); + }, + hasTranslations: () => locales !== undefined, + getExistingLocales: () => (locales ?? []).map((tag) => ({ toString: () => tag })), + }) as unknown as JCRNodeWrapper; + +const addCacheDependency = vi.fn(); +/** Only ever an opaque token here: the props tier passes it on, it never reads it. */ +const renderContext = {} as RenderContext; + +beforeEach(() => { + /** The engine injects `server` as a global; a test provides only the part under test. */ + Reflect.set(globalThis, "server", { render: { addCacheDependency } }); +}); + +afterEach(() => { + vi.clearAllMocks(); + Reflect.deleteProperty(globalThis, "server"); +}); + +describe("a target that cannot be linked to", () => { + it("treats an absent target as a result, not an error", () => { + for (const target of [null, undefined]) { + const { anchor, state } = getLinkProps(target); + expect(state.navigable).toBe(false); + expect(anchor).toEqual({}); + } + }); + + it("rejects the empty and whitespace-only strings a content property yields", () => { + for (const target of ["", " ", " ", "\t", "\n", " \t\r\n "]) { + expect(getLinkProps(target).state.navigable).toBe(false); + } + }); + + it("survives a node whose URL the repository cannot produce", () => { + expect(getLinkProps(jcrNode({ url: null })).state.navigable).toBe(false); + expect(getLinkProps(jcrNode({ url: "" })).state.navigable).toBe(false); + }); + + it("absorbs a node that cannot answer anything at all", () => { + const dead = new Proxy({} as JCRNodeWrapper, { + get: () => () => { + throw new Error("RepositoryException"); + }, + }); + expect(() => getLinkProps(dead, {}, { mainNode: dead })).not.toThrow(); + const { anchor, state } = getLinkProps(dead, {}, { mainNode: dead }); + expect(anchor).toEqual({}); + expect(state).toEqual({ navigable: false, isCurrent: false, isAncestor: false, label: "" }); + }); + + it("never emits an anchor attribute with nothing to hang on", () => { + const { anchor } = getLinkProps(null, { + target: "_blank", + rel: "nofollow", + title: "Read more", + }); + expect(anchor).toEqual({}); + }); +}); + +describe("the scheme allow-list", () => { + it.each([ + "https://example.com/a", + "http://example.com/a", + "mailto:someone@example.com", + "tel:+33123456789", + "ftp://files.example.com/a.txt", + "/search", + "#main", + ])("navigates to %s", (url) => { + expect(getLinkProps(url).anchor.href).toBe(url); + }); + + it.each([ + "javascript:alert(1)", + "data:text/html,", + "blob:https://example.com/1234", + "vbscript:msgbox(1)", + "file:///etc/passwd", + "relative/path", + // A leading `//` or `/\` is an authority, not a path: both leave the site under whatever + // scheme the page was served with, and neither carries a scheme the allow-list could judge + "//evil.example/phish", + String.raw`/\evil.example/phish`, + "//evil.example", + String.raw`\\evil.example/phish`, + ])("refuses %s", (url) => { + const { anchor, state } = getLinkProps(url); + expect(state.navigable).toBe(false); + expect(anchor.href).toBeUndefined(); + }); + + it.each([ + ["leading whitespace", " javascript:alert(1)"], + ["a leading control character", "\u0001javascript:alert(1)"], + ["a leading newline", "\njavascript:alert(1)"], + ["mixed case", "JaVaScRiPt:alert(1)"], + ["an embedded tab", "java\tscript:alert(1)"], + ["an embedded newline", "java\nscript:alert(1)"], + ["an embedded carriage return", "java\rscript:alert(1)"], + ["all of them at once", " \u0002Ja\tVa\nScRiPt:alert(1)"], + ])("refuses a scheme hidden behind %s", (_, url) => { + expect(getLinkProps(url).state.navigable).toBe(false); + }); + + it("judges the URL a browser would act on, not the one the author typed", () => { + // The same normalisation that unmasks javascript: also has to keep a valid URL valid + expect(getLinkProps(" https://example.com/a ").anchor.href).toBe("https://example.com/a"); + }); +}); + +describe("target and rel", () => { + it("adds rel to a link that opens a new browsing context", () => { + expect(getLinkProps("https://example.com", { target: "_blank" }).anchor).toEqual({ + href: "https://example.com", + target: "_blank", + rel: "noopener noreferrer", + }); + }); + + it("leaves the other three browsing contexts without a rel", () => { + for (const target of ["_self", "_parent", "_top"] as const) { + const { anchor } = getLinkProps("https://example.com", { target }); + expect(anchor.target).toBe(target); + expect(anchor).not.toHaveProperty("rel"); + } + }); + + it("omits the attribute entirely for a value jmix:link does not allow", () => { + // The value comes from content, so it can be anything an editor or an import put there + const { anchor } = getLinkProps("https://example.com", { + target: "popup" as LinkTargetAttribute, + }); + expect(anchor).not.toHaveProperty("target"); + expect(anchor).not.toHaveProperty("rel"); + }); + + it("omits the attribute when no target is asked for, rather than emitting an empty one", () => { + expect(getLinkProps("https://example.com").anchor).toEqual({ href: "https://example.com" }); + }); + + it("lets an explicit rel replace the automatic one", () => { + expect( + getLinkProps("https://example.com", { target: "_blank", rel: "nofollow" }).anchor.rel, + ).toBe("nofollow"); + }); + + it("keeps a title only when there is one", () => { + expect(getLinkProps("https://example.com", { title: "Docs" }).anchor.title).toBe("Docs"); + expect(getLinkProps("https://example.com", { title: "" }).anchor).not.toHaveProperty("title"); + }); +}); + +describe("current-page state", () => { + const mainNode = jcrNode({ identifier: "u-home", path: "/sites/test/home" }); + + it("compares nodes by identifier, because two proxies of one node are two objects", () => { + const sameNodeAgain = jcrNode({ identifier: "u-home", path: "/sites/test/home" }); + expect(sameNodeAgain).not.toBe(mainNode); + expect(getLinkProps(sameNodeAgain, {}, { mainNode }).state.isCurrent).toBe(true); + }); + + it("is not current when the identifier differs, whatever the path suggests", () => { + const other = jcrNode({ identifier: "u-other", path: "/sites/test/home" }); + expect(getLinkProps(other, {}, { mainNode }).state.isCurrent).toBe(false); + }); + + it("lets the caller override it, which is what a language switcher needs", () => { + const other = jcrNode({ identifier: "u-other", path: "/sites/test/other" }); + expect(getLinkProps(other, { isCurrent: true }, { mainNode }).state.isCurrent).toBe(true); + // An override of false has to survive too: the entry for the language already displayed + expect(getLinkProps(mainNode, { isCurrent: false }, { mainNode }).state.isCurrent).toBe(false); + }); + + it("is never current without a main node to compare against", () => { + expect(getLinkProps(mainNode).state.isCurrent).toBe(false); + expect(getLinkProps("/sites/test/home.html", {}, { mainNode }).state.isCurrent).toBe(false); + }); + + it("says nothing rather than guessing when a node cannot be read", () => { + const gone = jcrNode({ identifier: "u-home", unreadable: true }); + expect(getLinkProps(gone, {}, { mainNode }).state.isCurrent).toBe(false); + }); + + it("tests ancestry by path segment, so /home/news does not swallow /home/newsletter", () => { + const news = jcrNode({ identifier: "u-news", path: "/sites/test/home/news" }); + const onNewsletter = jcrNode({ + identifier: "u-newsletter", + path: "/sites/test/home/newsletter", + }); + const onArticle = jcrNode({ + identifier: "u-article", + path: "/sites/test/home/news/2026/a-story", + }); + + expect(getLinkProps(news, {}, { mainNode: onNewsletter }).state.isAncestor).toBe(false); + expect(getLinkProps(news, {}, { mainNode: onArticle }).state.isAncestor).toBe(true); + // A page is its own ancestor for this purpose: the nav entry is in path either way + expect(getLinkProps(news, {}, { mainNode: news }).state.isAncestor).toBe(true); + }); + + it("is not an ancestor of anything when the target is a bare URL", () => { + expect(getLinkProps("/sites/test/home.html", {}, { mainNode }).state.isAncestor).toBe(false); + }); +}); + +describe("query string and fragment", () => { + it("puts the query before the fragment on a node target", () => { + const { anchor } = getLinkProps(jcrNode({ path: "/sites/test/home" }), { + parameters: { page: "2", sort: "date" }, + hash: "results", + }); + expect(anchor.href).toBe("/sites/test/home.html?page=2&sort=date#results"); + }); + + it("puts the query before the fragment on a string target that is only a fragment", () => { + // The URL tier splits on "?" alone, which would turn a skip link into "#main?utm=a" + expect(getLinkProps("#main", { parameters: { utm: "a" } }).anchor.href).toBe("?utm=a#main"); + }); + + it("keeps the query ahead of a fragment the target already carries", () => { + expect(getLinkProps("/docs#install", { parameters: { v: "2" } }).anchor.href).toBe( + "/docs?v=2#install", + ); + }); + + it("replaces a fragment the target carries when one is asked for", () => { + expect(getLinkProps("/docs#install", { hash: "upgrade" }).anchor.href).toBe("/docs#upgrade"); + expect(getLinkProps("/docs#install", { hash: "#upgrade" }).anchor.href).toBe("/docs#upgrade"); + }); + + it("joins onto a query the URL already has", () => { + expect( + getLinkProps(jcrNode({ url: "/home.html?jsite=abc" }), { parameters: { page: "2" } }).anchor + .href, + ).toBe("/home.html?jsite=abc&page=2"); + }); + + it("encodes both halves of a parameter", () => { + expect(getLinkProps("/search", { parameters: { "q term": "a&b=c" } }).anchor.href).toBe( + "/search?q%20term=a%26b%3Dc", + ); + }); + + it("adds nothing when there is nothing to add", () => { + expect(getLinkProps("/docs", { parameters: {} }).anchor.href).toBe("/docs"); + }); +}); + +describe("the label", () => { + it("falls back to the displayable name, which already knows the JCR title rules", () => { + expect(getLinkProps(jcrNode({ displayableName: "Latest news" })).state.label).toBe( + "Latest news", + ); + }); + + it("prefers an explicit label", () => { + expect( + getLinkProps(jcrNode({ displayableName: "Latest news" }), { label: "Read more" }).state.label, + ).toBe("Read more"); + }); + + it("is empty rather than undefined when nothing supplies one", () => { + expect(getLinkProps("https://example.com").state.label).toBe(""); + expect(getLinkProps(null).state.label).toBe(""); + }); + + it("is still available on a link that is not navigable", () => { + const { state } = getLinkProps(jcrNode({ url: null, displayableName: "Latest news" })); + expect(state.navigable).toBe(false); + expect(state.label).toBe("Latest news"); + }); +}); + +describe("linking into a language", () => { + const context = { renderContext }; + + it("points at the requested language", () => { + const node = jcrNode({ path: "/sites/test/home", locales: ["en", "fr"] }); + expect(getLinkProps(node, { language: "fr" }, context).anchor.href).toBe( + "/cms/render/live/fr/sites/test/home.html", + ); + }); + + it("refuses to link to a page that does not exist in that language", () => { + const node = jcrNode({ locales: ["en"] }); + expect(getLinkProps(node, { language: "fr" }, context).state.navigable).toBe(false); + }); + + it("links anyway when the caller turns the requirement off", () => { + const node = jcrNode({ locales: ["en"] }); + const { state } = getLinkProps(node, { language: "fr", requireTranslation: false }, context); + expect(state.navigable).toBe(true); + }); + + it("leaves language-neutral content alone, since a file has no translations to have", () => { + const file = jcrNode({ path: "/sites/test/files/a.pdf" }); + expect(getLinkProps(file, { language: "fr" }, context).state.navigable).toBe(true); + }); + + it("keeps the URL the repository gives a file, which a language would replace with a page URL", () => { + // Asking for a language takes the URL tier down its manual branch, which hand-builds a + // `/cms/render/...` page URL and loses the `/files/...` form that actually serves the file + const file = jcrNode({ path: "/sites/test/files/a.pdf", url: "/files/live/a.pdf" }); + expect(getLinkProps(file, { language: "fr" }, context).anchor.href).toBe("/files/live/a.pdf"); + }); + + it("honours j:invalidLanguages, which marks a translation the editor disabled", () => { + const node = jcrNode({ locales: ["en", "fr"], multiple: { "j:invalidLanguages": ["fr"] } }); + expect(getLinkProps(node, { language: "fr" }, context).state.navigable).toBe(false); + expect(getLinkProps(node, { language: "en" }, context).state.navigable).toBe(true); + }); + + it("accepts any region of a bare language, so fr reaches a site running fr_CH", () => { + const node = jcrNode({ locales: ["fr_CH"] }); + expect(getLinkProps(node, { language: "fr" }, context).state.navigable).toBe(true); + // A request for one region is not satisfied by another + expect(getLinkProps(node, { language: "fr_BE" }, context).state.navigable).toBe(false); + }); + + it("reads a locale in either spelling, since a JS caller writes fr-CH and Java writes fr_CH", () => { + const node = jcrNode({ locales: ["fr_CH"] }); + expect(getLinkProps(node, { language: "fr-CH" }, context).state.navigable).toBe(true); + expect(getLinkProps(node, { language: "fr-BE" }, context).state.navigable).toBe(false); + }); + + it("matches j:invalidLanguages in either spelling too", () => { + const node = jcrNode({ locales: ["fr_CH"], multiple: { "j:invalidLanguages": ["fr_CH"] } }); + expect(getLinkProps(node, { language: "fr-CH" }, context).state.navigable).toBe(false); + }); + + it("does not throw when the URL cannot be built for that language", () => { + const node = jcrNode({ locales: ["fr"] }); + // No render context, so the mode cannot be inferred and the URL tier throws + expect(() => getLinkProps(node, { language: "fr" })).not.toThrow(); + expect(getLinkProps(node, { language: "fr" }).state.navigable).toBe(false); + }); +}); + +describe("the render cache dependency", () => { + it("registers on the node the link resolved to", () => { + const node = jcrNode(); + getLinkProps(node, {}, { renderContext }); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith({ node }, renderContext); + }); + + it("registers nothing without a render context to register against", () => { + getLinkProps(jcrNode(), {}, {}); + getLinkProps(jcrNode()); + expect(addCacheDependency).not.toHaveBeenCalled(); + }); + + it("registers nothing for a string target, which names no node to depend on", () => { + getLinkProps("https://example.com", { cacheDependency: true }, { renderContext }); + expect(addCacheDependency).not.toHaveBeenCalled(); + }); + + it("can be turned off", () => { + getLinkProps(jcrNode(), { cacheDependency: false }, { renderContext }); + expect(addCacheDependency).not.toHaveBeenCalled(); + }); + + it("takes the path form a JCR query loop has, rather than a node", () => { + getLinkProps( + "/sites/test/home.html", + { cacheDependency: { path: "/sites/test/home" } }, + { renderContext }, + ); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith( + { path: "/sites/test/home" }, + renderContext, + ); + }); + + it("takes the uuid form even when the target did not resolve", () => { + getLinkProps(null, { cacheDependency: { uuid: "u-missing" } }, { renderContext }); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith( + { uuid: "u-missing" }, + renderContext, + ); + }); + + it("registers even when the link is not navigable, which is when it matters most", () => { + const node = jcrNode({ url: null }); + const { state } = getLinkProps(node, {}, { renderContext }); + expect(state.navigable).toBe(false); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith({ node }, renderContext); + }); +}); + +describe("resolveContentLink", () => { + const context = { renderContext }; + const target = jcrNode({ + identifier: "u-target", + path: "/sites/test/home/news", + displayableName: "News", + }); + + it("reads core's jnt:nodeLink", () => { + const link = resolveContentLink( + jcrNode({ references: { "j:node": { uuid: "u-target", target } } }), + {}, + context, + ); + expect(link?.anchor.href).toBe("/sites/test/home/news.html"); + expect(link?.state.label).toBe("News"); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith({ node: target }, renderContext); + }); + + it("reads core's jnt:externalLink", () => { + const link = resolveContentLink( + jcrNode({ strings: { "j:url": "https://example.com/a" } }), + {}, + context, + ); + expect(link?.anchor.href).toBe("https://example.com/a"); + }); + + it("reads the jmix:internalLink of the Jahia/default module", () => { + const link = resolveContentLink( + jcrNode({ references: { "j:linknode": { uuid: "u-target", target } } }), + {}, + context, + ); + expect(link?.anchor.href).toBe("/sites/test/home/news.html"); + }); + + it("returns nothing when the discriminator says there is no link", () => { + const node = jcrNode({ + strings: { "j:linkType": "none", "j:url": "https://example.com" }, + }); + expect(resolveContentLink(node, {}, context)).toBeNull(); + }); + + it("reads the discriminator the project actually uses", () => { + const node = jcrNode({ + strings: { "ctaType": "none", "j:url": "https://example.com" }, + }); + // Under its own name it means "no link" + expect(resolveContentLink(node, { typeProperty: "ctaType" }, context)).toBeNull(); + // Under the default name nothing says so, and the link is read as usual + expect(resolveContentLink(node, {}, context)?.anchor.href).toBe("https://example.com"); + }); + + it("takes the value that means no link as a parameter too", () => { + const node = jcrNode({ + strings: { "seu:linkType": "self", "j:url": "https://example.com" }, + }); + expect( + resolveContentLink(node, { typeProperty: "seu:linkType", noneValue: "self" }, context), + ).toBeNull(); + }); + + it("puts an author-supplied j:url through the same allow-list as any other string", () => { + const node = jcrNode({ strings: { "j:url": "javascript:alert(1)" } }); + const link = resolveContentLink(node, {}, context); + expect(link?.state.navigable).toBe(false); + expect(link?.anchor.href).toBeUndefined(); + }); + + it("returns nothing at all when the node carries no link", () => { + expect(resolveContentLink(jcrNode({ strings: { "jcr:title": "A card" } }), {}, context)).toBe( + null, + ); + }); + + it("reads the anchor target and title off the content", () => { + const node = jcrNode({ + strings: { "j:url": "https://example.com", "j:target": "_blank", "jcr:title": "Our partner" }, + }); + const link = resolveContentLink(node, {}, context); + expect(link?.anchor).toEqual({ + href: "https://example.com", + target: "_blank", + rel: "noopener noreferrer", + }); + expect(link?.state.label).toBe("Our partner"); + }); + + it("drops a j:target an editor or an import left as something else", () => { + const node = jcrNode({ strings: { "j:url": "https://example.com", "j:target": "new" } }); + expect(resolveContentLink(node, {}, context)?.anchor).not.toHaveProperty("target"); + }); + + describe("a reference that does not resolve", () => { + const dangling = () => + jcrNode({ + strings: { "jcr:title": "Coming soon" }, + references: { "j:linknode": { uuid: "u-unpublished" } }, + }); + + it("is not navigable, and never falls back to the raw UUID", () => { + const link = resolveContentLink(dangling(), {}, context); + expect(link).not.toBeNull(); + expect(link?.state.navigable).toBe(false); + expect(link?.anchor.href).toBeUndefined(); + expect(link?.state.label).toBe("Coming soon"); + expect(JSON.stringify(link)).not.toContain("u-unpublished"); + }); + + // What the engine does with the { uuid } key is out of this tier's hands, and today it drops + // it — see LinkOptions.cacheDependency. These tests pin the key the library hands over. + it("depends on the UUID, which is all the unresolved reference gives it", () => { + resolveContentLink(dangling(), {}, context); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith( + { uuid: "u-unpublished" }, + renderContext, + ); + }); + + it("still depends on the UUID when the default is spelled out", () => { + // `true` means "pick the key form automatically", which for an unresolved reference is + // the UUID — passing the documented default must not silently register nothing + resolveContentLink(dangling(), { cacheDependency: true }, context); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith( + { uuid: "u-unpublished" }, + renderContext, + ); + }); + + it("registers nothing when the caller turns the dependency off", () => { + resolveContentLink(dangling(), { cacheDependency: false }, context); + expect(addCacheDependency).not.toHaveBeenCalled(); + }); + + it("lets an explicit key form win", () => { + resolveContentLink(dangling(), { cacheDependency: { path: "/sites/test/soon" } }, context); + expect(addCacheDependency).toHaveBeenCalledExactlyOnceWith( + { path: "/sites/test/soon" }, + renderContext, + ); + }); + }); + + it("puts an author-supplied j:url through the host check too", () => { + const node = jcrNode({ strings: { "j:url": "//evil.example" } }); + expect(resolveContentLink(node, {}, context)?.state.navigable).toBe(false); + }); + + it("prefers the internal reference when the node carries both kinds of link", () => { + const node = jcrNode({ + strings: { "j:url": "https://example.com" }, + references: { "j:node": { uuid: "u-target", target } }, + }); + expect(resolveContentLink(node, {}, context)?.anchor.href).toBe("/sites/test/home/news.html"); + }); + + describe("a node that carries a reference the editor has moved on from", () => { + // Switching a link from internal to external does not necessarily clear the reference the + // editor filled in first, and the discriminator is never read as anything but "no link" + const stale = () => + jcrNode({ + strings: { "j:linkType": "external", "j:url": "https://example.com" }, + references: { "j:node": { uuid: "u-target", target } }, + }); + + it("still reads the reference, because the property being filled is what decides", () => { + expect(resolveContentLink(stale(), {}, context)?.anchor.href).toBe( + "/sites/test/home/news.html", + ); + }); + + it("reads the URL when the caller says which properties hold a reference", () => { + const link = resolveContentLink(stale(), { referenceProperties: [] }, context); + expect(link?.anchor.href).toBe("https://example.com"); + }); + + it("reads the URL out of the property the caller names", () => { + const node = jcrNode({ strings: { "cta:href": "https://example.com" } }); + const link = resolveContentLink( + node, + { referenceProperties: [], urlProperty: "cta:href" }, + context, + ); + expect(link?.anchor.href).toBe("https://example.com"); + }); + }); + + it("labels an untitled link node with the name of what it points at", () => { + const node = jcrNode({ references: { "j:node": { uuid: "u-target", target } } }); + expect(resolveContentLink(node, {}, context)?.state.label).toBe("News"); + }); + + it("prefers the title carried by the link itself", () => { + const node = jcrNode({ + strings: { "jcr:title": "Read the announcement" }, + references: { "j:node": { uuid: "u-target", target } }, + }); + expect(resolveContentLink(node, {}, context)?.state.label).toBe("Read the announcement"); + }); + + it("reads the j:linkTitle of the Jahia/default module", () => { + const node = jcrNode({ + strings: { "j:url": "https://example.com", "j:linkTitle": "Our partner" }, + }); + expect(resolveContentLink(node, {}, context)?.state.label).toBe("Our partner"); + }); + + it("answers the current-page question about the target, not about the link node", () => { + const node = jcrNode({ + identifier: "u-cta", + references: { "j:node": { uuid: "u-target", target } }, + }); + const link = resolveContentLink(node, {}, { renderContext, mainNode: target }); + expect(link?.state.isCurrent).toBe(true); + }); + + it("passes its options down to the props tier", () => { + const node = jcrNode({ strings: { "j:url": "/search", "jcr:title": "Search" } }); + const link = resolveContentLink( + node, + { parameters: { q: "jahia" }, hash: "results", label: "Find" }, + context, + ); + expect(link?.anchor.href).toBe("/search?q=jahia#results"); + expect(link?.state.label).toBe("Find"); + }); +}); From 31c344b9655d809e23417e4879ba19d877289160 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:50:21 +0200 Subject: [PATCH 5/6] docs: add the links guide 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 --- .chachalog/0k0OonEj.md | 12 ++ docs/2-guides/9-links/README.md | 232 ++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 .chachalog/0k0OonEj.md create mode 100644 docs/2-guides/9-links/README.md diff --git a/.chachalog/0k0OonEj.md b/.chachalog/0k0OonEj.md new file mode 100644 index 00000000..a4bf77f6 --- /dev/null +++ b/.chachalog/0k0OonEj.md @@ -0,0 +1,12 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +Added a link API to the library: the `` component, and the `getLinkProps` / `resolveContentLink` functions behind it. (#749) + +`` 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. + +`` 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 ``. + +See the new [Links guide](https://github.com/Jahia/javascript-modules/blob/main/docs/2-guides/9-links/README.md) for the cache-dependency key forms, the `cache.mainResource=true` rule that current-page state requires, and what core rewrites after the render. diff --git a/docs/2-guides/9-links/README.md b/docs/2-guides/9-links/README.md new file mode 100644 index 00000000..49598cd5 --- /dev/null +++ b/docs/2-guides/9-links/README.md @@ -0,0 +1,232 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/links + jcr:title: Links + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Almost every component ends up rendering a link, and a link in a CMS is not just an ``: the target may not exist yet, the URL depends on the mode and the language, the fragment that contains the link is cached, and the page builder rewrites what you emit. This guide covers the `` component and the two functions behind it. + +## The one-liner + +Name the target, and you get a correct link: + +```tsx +import { JLink, jahiaComponent } from "@jahia/javascript-modules-library"; +import type { JCRNodeWrapper } from "org.jahia.services.content"; + +type Props = { "title": string; "j:linknode": JCRNodeWrapper }; + +jahiaComponent({ componentType: "view", nodeType: "example:card" }, (props: Props) => ( + {props.title} +)); +``` + +That single line builds the URL through `buildNodeUrl`, registers a render cache dependency on the target, adds `aria-current="page"` when the target is the page being rendered, and — when the target cannot be linked to — renders `title` without an anchor. + +`` accepts exactly one of three targets: + +| Prop | Use it for | +| --------- | --------------------------------------------------------------------------------------------- | +| `node` | A JCR node you already have, from a property or a query. | +| `content` | A content node that _describes_ a link — a `jnt:nodeLink`, a `jnt:externalLink`, a CTA mixin. | +| `href` | A URL you built yourself, or one that comes from outside Jahia. | + +Everything else you pass is a plain anchor attribute: `className`, `hreflang`, `download`, `onClick`. There is no styling of its own. + +## 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. + +Before ``, that case ended the render of the whole fragment: + +```tsx +// Don't: buildNodeUrl throws when the node is undefined, and the section disappears +{title} +``` + +The visitor gets HTTP 200 with the section replaced by an HTML comment. Unpublished, deleted and "you are not allowed to see it" all arrive as the same falsy value at the JCR boundary, so no component can tell them apart. + +`` treats it as a result rather than an error. It never throws, and it never renders an `` without an `href`. When the link is not navigable it renders the children on their own; pass `whenUnresolved="none"` to render nothing at all: + +```tsx +{title} +// → Title or just Title + + + {title} + +// → Title or nothing +``` + +The same applies to a rejected URL and to a target that is missing in the language you asked for. + +If you need to know which case you are in — to render a different fallback, for instance — use the props tier directly: + +```tsx +import { getLinkProps, useServerContext } from "@jahia/javascript-modules-library"; + +const { anchor, state } = getLinkProps(node, {}, useServerContext()); +return state.navigable ? {state.label} : {state.label}; +``` + +`anchor` is spreadable onto an `` — every key is a valid anchor attribute, by construction. `state` is not: `navigable`, `isCurrent`, `isAncestor` and `label` are yours to read, never to spread. + +:::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. +::: + +## Reading a link off a content node + +Editors rarely fill in a single reference. They pick a link _type_ and then fill in either an internal reference or an external URL. Core has `jnt:nodeLink` (`j:node`) and `jnt:externalLink` (`j:url`); the Jahia/default module adds the `j:linkType` convention with `jmix:internalLink` (`j:linknode`) and `jmix:externalLink` (`j:url`, `j:linkTitle`). + +Pass the content node and let the library read it: + +```tsx + +``` + +With no children, the label comes from the content: `jcr:title`, then `j:linkTitle`, then the displayable name of the target. + +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 + +``` + +Only the "no link" value of the discriminator is read — every vocabulary agrees on having one, while their other values are incompatible. Which link to render is decided by which property is filled: the reference properties first, then the URL. + +:::warning +That precedence has a consequence. An editor who first picks "internal", chooses a page, then switches to "external" and types a URL may leave the reference property behind, and the reference wins. When you know the shape of your own content type, say so: + + +```tsx +// This CTA is external: ignore any reference an earlier edit left behind + +``` + +::: + +## URLs you did not build + +Any string that the library did not build itself goes through a scheme allow-list: `http`, `https`, `mailto`, `tel` and `ftp`. Anything else — `javascript:`, `data:`, `blob:`, `vbscript:` — is not navigable. Site-relative paths (`/search`) and same-document fragments (`#main`) pass, but a protocol-relative `//host` does not: it leaves the site, so it has to name a scheme. + +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. + +Query parameters and a fragment are options rather than string surgery, and they land in the right order: + +```tsx + +// → /sites/example/search.html?q=jahia#results +``` + +## Cache dependencies + +A rendered fragment is cached. If it contains a link to a page whose title just changed, the fragment has to be flushed — otherwise the visitor keeps the old label. `` registers that dependency for you, on the node it resolved to. + +You only touch this when the automatic choice is wrong. Pass a key form explicitly: + +| Form | When | +| --------------------------------------------------- | ---------------------------------------------------------------------------- | +| `{ node }` | The default when the target resolved. | +| `{ path: "/sites/x/home" }` | You are looping over JCR query hits, which give you paths rather than nodes. | +| `{ flushOnPathMatchingRegexp: "/sites/x/news/.*" }` | The fragment depends on a whole subtree. | + +```tsx + + +``` + +There is a fourth form, `{ uuid }`, which the library picks by itself when a reference does not resolve — the fallback fragment has no node to depend on, only the raw reference. It is meant to flush that fragment once the target is finally published. + +:::warning +The engine drops the `{ uuid }` form today: the tag it feeds resolves the UUID against a page context it has not been given yet, and the failure is swallowed ([issue #750](https://github.com/Jahia/javascript-modules/issues/750)). The other three forms work. Until that is fixed, a fragment that fell back is flushed by whatever else it depends on, or by a `flushOnPathMatchingRegexp` on the section the target will land in. +::: + +## Current-page state, and the property you must declare + +`` emits `aria-current="page"` when the target is the page being rendered, and `state.isCurrent` / `state.isAncestor` are there for styling a navigation: + +```tsx +const { state } = getLinkProps(page, {}, useServerContext()); +; +``` + +Nodes are compared by identifier, never by identity: two `JCRNodeWrapper` proxies for the same node are not the same object, so `page === mainNode` is a bug even where it appears to work. + +:::warning +A view that reads `isCurrent` or `isAncestor` — or that simply lets `` emit `aria-current` — **must** declare `cache.mainResource=true`: + +```tsx +jahiaComponent( + { + componentType: "view", + nodeType: "example:navBar", + // Without this, the fragment is cached once and replayed on every page + properties: { "cache.mainResource": "true" }, + }, + () => , +); +``` + +The fragment cache key does not include the main resource unless the view opts in. Without it, a shared fragment — a navigation in an `AbsoluteArea`, for instance — is rendered once, with `aria-current` on whichever page happened to be rendered first, and replayed on every other page. +::: + +A language switcher is the case where the computation is wrong and you know better: every entry points at the same page, so mark them all current with `isCurrent`. + +```tsx + +``` + +`language` also selects the language the URL points at. By default a target that has no translation in that language is not navigable; `requireTranslation={false}` links to it anyway. Both `fr_CH` and `fr-CH` are understood, and language-neutral content — a file, a folder — ignores the option entirely. + +## `target` and `rel` + +`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. +::: + +## `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. + +So: + +- 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=`. +- Never string-compare an `href`, and never parse it to decide something. Compare nodes, or use `state.isCurrent` and `state.isAncestor`. + +## 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: + +```tsx +// Server view +const { anchor, state } = getLinkProps(page, {}, useServerContext()); +return ; +``` + +```tsx +// Client component +export default function Menu({ anchor, label }: { anchor: AnchorProps; label: string }) { + return {label}; +} +``` + +Server-render the anchor whenever you can. An anchor created on the client after hydration is invisible to `URLTraverser`, and so loses the vanity URL and the cross-site parameter, exactly as above. + +## What links in rich text do + +Rich text reaches the page through `dangerouslySetInnerHTML`, which is outside a link component's reach. Core does resolve the internal references an editor inserted there, but nothing applies the scheme allow-list, adds `rel` to a `target="_blank"`, or sanitises an author-pasted `javascript:` href. + +If you need a policy on those anchors, it belongs in a render filter — `registerRenderFilter` above priority 21, so that it runs on the assembled HTML — not in a component. + +## Reference + +- [`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 +- [`buildNodeUrl`](https://github.com/Jahia/javascript-modules/blob/main/javascript-modules-library/README.md#buildnodeurl) — the URL tier underneath From d1656c2150dcdb588d5c25a5bca1c1bbc7704242 Mon Sep 17 00:00:00 2001 From: Romain Gauthier Date: Sun, 23 Aug 2026 03:50:21 +0200 Subject: [PATCH 6/6] docs(hydrogen): render the sample call to action with JLink 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 --- .../Hero/CallToAction/default.server.tsx | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/samples/hydrogen/src/components/Hero/CallToAction/default.server.tsx b/samples/hydrogen/src/components/Hero/CallToAction/default.server.tsx index 980aa2d6..b3d275bf 100644 --- a/samples/hydrogen/src/components/Hero/CallToAction/default.server.tsx +++ b/samples/hydrogen/src/components/Hero/CallToAction/default.server.tsx @@ -1,14 +1,11 @@ -import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library"; -import type { JCRNodeWrapper } from "org.jahia.services.content"; +import { jahiaComponent, JLink } from "@jahia/javascript-modules-library"; import classes from "./component.module.css"; type Props = { - title: string; -} & ( // Reflect the three possible values of j:linkType - | { "j:linkType": "none" } - | { "j:linkType": "external"; "j:url": string; "j:linkTitle": string } - | { "j:linkType": "internal"; "j:linknode": JCRNodeWrapper } -); + "title": string; + /** Only set on an external link, where it is the tooltip of the anchor. */ + "j:linkTitle"?: string; +}; jahiaComponent( { @@ -16,24 +13,17 @@ jahiaComponent( nodeType: "hydrogen:heroCallToAction", displayName: "Call To Action", }, - (props: Props) => { - switch (props["j:linkType"]) { - case "external": - return ( - - {props.title} - - ); - - case "internal": - return ( - - {props.title} - - ); - - case "none": - return {props.title}; - } - }, + (props: Props, { currentNode }) => ( + // `content` reads j:linkType, j:linknode and j:url off the node, so there is no switch to + // write. A reference that does not resolve — a page that is not published yet — renders the + // title without an anchor instead of breaking the section. + + {props.title} + + ), );