import { StringUtils, RoutePaths, LayoutTopology } from '@teleporthq/teleport-shared' import { GeneratorOptions, UIDLLinkNode, UIDLNavLinkNode, UIDLElementNode, UIDLAttributeValue, UIDLPropDefinition, UIDLDynamicReference, UIDLDataSourceItemNode, UIDLDataSourceListNode, } from '@teleporthq/teleport-types' type NavlinkDifferentiatorValue = NonNullable type NavlinkCanonicalValue = NonNullable // Whitelist of attributes that are safe to transfer to anchor tags const ANCHOR_SAFE_ATTRIBUTES = new Set([ // Standard HTML attributes 'class', 'id', 'style', 'title', 'lang', 'dir', 'tabindex', 'accesskey', 'contenteditable', 'draggable', 'hidden', 'spellcheck', 'translate', ]) // ARIA attributes safe for anchor tags (link-specific) const ANCHOR_SAFE_ARIA_ATTRIBUTES = new Set([ 'aria-describedby', // Describes the link purpose (valid transfer) 'aria-labelledby', // References label for the link 'aria-expanded', // For dropdown/collapsible links 'aria-haspopup', // For links that open menus/dialogs 'aria-current', // For navigation state 'aria-disabled', // For disabled links ]) /* A listing card's anatomy — `data-tq-card-slot`, `-size`, `-tone` — describes the card's own box: its styles answer it with rules that need the class and the attribute on one element (`.thq-listing-card[data-tq-card-size="wide"]`) and the picture as a child of it (`[data-tq-card-size] > [data-tq-card-slot]`). Moved onto the wrapper of a card that is one link, no such rule matches. */ const BOX_ATTRIBUTE_PREFIX = 'data-tq-card-' const isBoxAttribute = (attrName: string): boolean => attrName.startsWith(BOX_ATTRIBUTE_PREFIX) /* Attributes a per-element runtime reads (scroll-scene lanes, snap points, scroll rails, chapter bookkeeping) stay on the styled element. The link wrapper is display:contents whenever the parent lays out with flex/grid, and a box-less element ignores opacity/transform/scroll properties — a lane parked on the wrapper animates nothing, so a linked button stayed visible in every chapter of a scene. The static HTML export carries a scene's and a motion element's whole configuration as such attributes (data-scene-*, data-motion-*), read by its runtime off the element that draws the box. */ const RUNTIME_DATA_ATTRIBUTE_PREFIXES = [ 'data-scroll-', 'data-snap-', 'data-chapter-', 'data-scene-', 'data-motion-', 'data-tq-motion', ] export const isRuntimeDataAttribute = (attrName: string): boolean => RUNTIME_DATA_ATTRIBUTE_PREFIXES.some((prefix) => attrName.startsWith(prefix)) const isAttributeSafeForAnchor = (attrName: string): boolean => { if (isRuntimeDataAttribute(attrName) || isBoxAttribute(attrName)) { return false } return ( ANCHOR_SAFE_ATTRIBUTES.has(attrName) || attrName.startsWith('data-') || ANCHOR_SAFE_ARIA_ATTRIBUTES.has(attrName) ) } const isBareContainer = (node: UIDLElementNode): boolean => { const { elementType, semanticType, style = {}, referencedStyles = {}, dynamicStyleBindings = {}, events = {}, attrs = {}, } = node.content return ( elementType === 'container' && (!semanticType || semanticType === 'div') && Object.keys(style).length === 0 && Object.keys(referencedStyles).length === 0 && Object.keys(dynamicStyleBindings).length === 0 && Object.keys(events).length === 0 && Object.keys(attrs).every(isAttributeSafeForAnchor) ) } /* A
that means nothing but "a box": it can be an instead without the page losing anything. Runtime attributes are fine here (unlike on a wrapper): the anchor this element becomes is the very box they act on. */ const isPlainContainer = (node: UIDLElementNode): boolean => { const { elementType, semanticType, events = {}, attrs = {} } = node.content return ( elementType === 'container' && (!semanticType || semanticType === 'div') && Object.keys(events).length === 0 && Object.keys(attrs).every( (attrName) => isAttributeSafeForAnchor(attrName) || isRuntimeDataAttribute(attrName) || isBoxAttribute(attrName) ) ) } /* When a styled element also carries a link, the link is realised as a wrapper /navlink around it. If the layout parent is a flex/grid container, that wrapper would become the flex/grid item instead of the styled child, dropping the child's sizing (e.g. a `flex: 0 0 …` card width). Detecting that here lets the caller mark the wrapper `display: contents` so the styled child stays the real flex/grid item. The container test lives in LayoutTopology because this is not the only place that inserts a wrapper between a container and its items. */ const parentIsFlexOrGridContainer = ( parentNode: UIDLElementNode | undefined, options: GeneratorOptions ): boolean => LayoutTopology.isFlexOrGridContainer(parentNode, options.projectStyleSet?.styleSetDefinitions) /* A box-less element cannot draw an outline, so a visitor tabbing through the page lands on the wrapper and sees nothing: the link is focused and no ring is painted anywhere (measured on a generated store: 4 of 5 wrapped links). The wrapper says what it is, and the platform's reset stylesheet paints the ring on the element it wraps while the wrapper holds keyboard focus. */ export const BOXLESS_LINK_WRAPPER_ATTR = 'data-thq-link-wrapper' /* A
written as its own link stops being a block: a link is laid out in line with text unless a rule says otherwise, while the canvas keeps drawing the
. Measured on 0.43.71: three bare linked boxes in a block parent sat on one line, and a menu whose row turns into a plain block on phones kept its items side by side there. The element says what it is, and the reset stylesheet gives it the block layout at zero weight, so any rule of the page's own still decides. */ export const LINKED_BOX_ATTR = 'data-thq-link-box' const becomeTheLink = ( node: UIDLElementNode, link: UIDLLinkNode, options: GeneratorOptions ): UIDLElementNode => { node.content.elementType = getLinkElementType(link) node.content.semanticType = '' node.content.attrs = { ...node.content.attrs, ...createLinkAttributes(link, options), [LINKED_BOX_ATTR]: { type: 'static', content: 'true' }, } return node } const markBoxless = (linkNode: UIDLElementNode): void => { linkNode.content.style = { ...linkNode.content.style, display: { type: 'static', content: 'contents' }, } linkNode.content.attrs = { ...linkNode.content.attrs, [BOXLESS_LINK_WRAPPER_ATTR]: { type: 'static', content: 'true' }, } } /** Resolves the links in the nodes a data provider renders for each of its states. */ const insertLinksInDataSourceNodes = ( dataSourceNode: UIDLDataSourceItemNode | UIDLDataSourceListNode, options: GeneratorOptions, layoutParent: UIDLElementNode | undefined, propDefinitions?: Record ): void => { const { nodes } = dataSourceNode.content if (!nodes) { return } if (nodes.success) { nodes.success = insertLinks(nodes.success, options, false, layoutParent, propDefinitions) } if (nodes.error) { nodes.error = insertLinks(nodes.error, options, false, layoutParent, propDefinitions) } if (nodes.loading) { nodes.loading = insertLinks(nodes.loading, options, false, layoutParent, propDefinitions) } } export const insertLinks = ( node: UIDLElementNode, options: GeneratorOptions, linkInParent: boolean = false, parentNode?: UIDLElementNode, propDefinitions?: Record ): UIDLElementNode => { const { abilities, children, elementType, semanticType, attrs = {} } = node.content const linkInNode = linkInParent || !!abilities?.link /* What this node's descendants should treat as their layout parent. A node that draws no box (a fragment) passes its OWN layout parent through, so a card sitting inside `grid > data-provider > fragment > repeater` still sees the grid. Passing `node` blindly here is what let a link wrapper become the flex item of a card grid and collapse every card to content width. */ const layoutParent = LayoutTopology.resolveLayoutParent(node, parentNode) node.content.children = children?.map((child) => { if (child.type === 'element') { // The editor wraps an element bound to a data source as an element whose // content IS the provider; every other generator pass reads it that way. // Walked as a plain element it looked empty, so a link inside the // provider — a details-page link reading its record off it — was dropped. const wrappedDataSource = child.content as unknown as { type?: string } if ( wrappedDataSource.type === 'data-source-item' || wrappedDataSource.type === 'data-source-list' ) { insertLinksInDataSourceNodes( child.content as unknown as UIDLDataSourceItemNode | UIDLDataSourceListNode, options, layoutParent, propDefinitions ) return child } return insertLinks(child, options, linkInNode, layoutParent, propDefinitions) } if (child.type === 'repeat') { child.content.node = insertLinks( child.content.node, options, linkInNode, layoutParent, propDefinitions ) } if (child.type === 'conditional' && child.content.node.type === 'element') { child.content.node = insertLinks( child.content.node, options, linkInNode, layoutParent, propDefinitions ) } if (child.type === 'slot' && child.content.fallback?.type === 'element') { child.content.fallback = insertLinks( child.content.fallback, options, linkInNode, layoutParent, propDefinitions ) } if (child.type === 'cms-list') { const { nodes: { success, error, loading }, } = child.content if (success) { child.content.nodes.success = insertLinks( success, options, false, layoutParent, propDefinitions ) } if (error) { child.content.nodes.error = insertLinks( error, options, false, layoutParent, propDefinitions ) } if (loading) { child.content.nodes.loading = insertLinks( loading, options, false, layoutParent, propDefinitions ) } } if (child.type === 'cms-list-repeater') { const { nodes: { list, empty, loading }, } = child.content if (list) { child.content.nodes.list = insertLinks(list, options, false, layoutParent, propDefinitions) } if (empty) { child.content.nodes.empty = insertLinks( empty, options, false, layoutParent, propDefinitions ) } if (loading) { child.content.nodes.loading = insertLinks( loading, options, false, layoutParent, propDefinitions ) } } if (child.type === 'cms-mixed-type') { if (child.content.mappings) { Object.keys(child.content.mappings).forEach((key) => { child.content.mappings[key] = insertLinks( child.content.mappings[key], options, false, layoutParent, propDefinitions ) }) } const { nodes: { fallback, error }, } = child.content if (fallback) { child.content.nodes.fallback = insertLinks( fallback, options, false, layoutParent, propDefinitions ) } if (error) { child.content.nodes.error = insertLinks( error, options, false, layoutParent, propDefinitions ) } } if (child.type === 'cms-item') { const { nodes: { success, error, loading }, } = child.content if (success) { child.content.nodes.success = insertLinks( success, options, false, layoutParent, propDefinitions ) } if (error) { child.content.nodes.error = insertLinks( error, options, false, layoutParent, propDefinitions ) } if (loading) { child.content.nodes.loading = insertLinks( loading, options, false, layoutParent, propDefinitions ) } } if (child.type === 'data-source-list' || child.type === 'data-source-item') { insertLinksInDataSourceNodes(child, options, layoutParent, propDefinitions) } return child }) for (const attrKey of Object.keys(attrs)) { const attr = attrs[attrKey] if (attr.type === 'element') { node.content.attrs[attrKey] = insertLinks( attr as UIDLElementNode, options, false, node, propDefinitions ) } } if (abilities?.link) { if (linkInParent) { console.warn('parent node has a link capability, nesting links is illegal') return node } /* type attribute is not valid for `anchor` tags */ if (node.content?.attrs?.type) { delete node.content.attrs.type } /* Handle link-type prop references: when the link ability is a dynamic reference to a prop with type 'link', route it through the navlink path so frameworks can map it to their link component (e.g. Next.js ) */ if ( abilities.link.type === 'dynamic' && abilities.link.content.referenceType === 'prop' && propDefinitions?.[abilities.link.content.id]?.type === 'link' ) { return handleLinkTypeProp(node, abilities.link as UIDLDynamicReference, options, parentNode) } /* We repalce buttons with link to use tag's, to make the generated code to be semantically correct. */ if (elementType === 'button') { node.content.elementType = getLinkElementType(abilities.link) node.content.semanticType = '' node.content.style = { textAlign: { type: 'static', content: 'center' }, ...node.content.style, } node.content.attrs = { ...node.content.attrs, ...createLinkAttributes(abilities.link, options), } return node } /* a text node (span) on which we added a link gets transformed into an the rest of the text elements get wrapped with an tag */ if (elementType === 'text' && semanticType === 'span') { node.content.elementType = getLinkElementType(abilities.link) node.content.semanticType = '' node.content.attrs = { ...node.content.attrs, ...createLinkAttributes(abilities.link, options), } return node } /* A container that is NOTHING BUT the link — no style, no class, no event, no runtime attribute — becomes the anchor itself. Imported markup writes ``, and stylesheets size that image by its place in the tree (`.surface-media > a > img`). Wrapping the bare container produced `
`: the extra
broke the child chain, the image fell back to its natural size, and the canvas — which inserts no wrapper — showed a page the generated site did not. An element with a box of its own keeps the wrapper below, so its sizing stays on it. */ if (isBareContainer(node)) { return becomeTheLink(node, abilities.link, options) } /* A plain
that is a flex or grid ITEM becomes the anchor itself. Its wrapper would have to be `display: contents` (see below), and Chrome cannot focus a box-less link: Tab skips it and focus() fails (measured on Chrome 151 and 153), so a keyboard visitor could not reach any linked card in a row or a grid. A flex or grid item is laid out as a block whatever its tag, and at a width where the parent stops being a row or a grid the reset keeps it one (LINKED_BOX_ATTR); its id, classes and runtime attributes stay where the stylesheet and the scene runtime look for them; and the tree is the one the canvas draws, which adds no link element either. Measured on a generated store, 216 links on 8 pages: each renders the same as its wrapped twin. Everything else keeps the wrapper: an element with a meaning of its own (heading, list item, section, image), one that listens to events, and one whose parent is not known to lay out its children as items. */ if ( parentNode !== undefined && parentIsFlexOrGridContainer(parentNode, options) && isPlainContainer(node) ) { return becomeTheLink(node, abilities.link, options) } const linkNode = createLinkNode(abilities.link, options) if (node.type === 'element' && node.content.attrs) { // Filter attributes to only transfer those safe for anchor tags const safeAttrs: Record = {} Object.keys(node.content.attrs).forEach((attrName) => { if (isAttributeSafeForAnchor(attrName)) { safeAttrs[attrName] = { ...node.content.attrs[attrName] } } }) linkNode.content.attrs = { ...linkNode.content.attrs, ...safeAttrs, } // Remove only the transferred attributes from the original node Object.keys(safeAttrs).forEach((attrName) => { delete node.content.attrs[attrName] }) } linkNode.content.children.push(node) if (parentNode === undefined || parentIsFlexOrGridContainer(parentNode, options)) { markBoxless(linkNode) } return linkNode } return node } const handleLinkTypeProp = ( node: UIDLElementNode, linkRef: UIDLDynamicReference, options: GeneratorOptions, parentNode?: UIDLElementNode ): UIDLElementNode => { const { elementType, semanticType } = node.content const propId = linkRef.content.id const linkAttrs: Record = { // A `link` prop is normally a `{ url, newTab }` object, but the prop can be // bound to a plain string field (e.g. an Airtable URL column), in which case // `props..url` is `undefined` and Next's throws // "The prop `href` expects a string or object … but got undefined". Resolve // the href tolerantly: use the string directly when the prop is a string, // otherwise read `.url`. url: { type: 'expr', content: `typeof props.${propId} === 'string' ? props.${propId} : props.${propId}?.['url']`, }, target: { type: 'expr', content: `props.${propId}?.['newTab'] ? '_blank' : undefined`, }, rel: { type: 'expr', content: `props.${propId}?.['newTab'] ? 'noreferrer noopener' : undefined`, }, } // For buttons: replace element type directly if (elementType === 'button') { node.content.elementType = 'prop-link' node.content.semanticType = '' node.content.style = { textAlign: { type: 'static', content: 'center' }, ...node.content.style, } node.content.attrs = { ...node.content.attrs, ...linkAttrs } return node } // For text spans: replace element type directly if (elementType === 'text' && semanticType === 'span') { node.content.elementType = 'prop-link' node.content.semanticType = '' node.content.attrs = { ...node.content.attrs, ...linkAttrs } return node } // For other elements: wrap with a link node const linkNode: UIDLElementNode = { type: 'element', content: { elementType: 'prop-link', attrs: { ...linkAttrs }, children: [], }, } // Transfer safe attributes to the wrapper if (node.type === 'element' && node.content.attrs) { const safeAttrs: Record = {} Object.keys(node.content.attrs).forEach((attrName) => { if (isAttributeSafeForAnchor(attrName)) { safeAttrs[attrName] = { ...node.content.attrs[attrName] } } }) linkNode.content.attrs = { ...linkNode.content.attrs, ...safeAttrs, } Object.keys(safeAttrs).forEach((attrName) => { delete node.content.attrs[attrName] }) } linkNode.content.children.push(node) if (parentNode === undefined || parentIsFlexOrGridContainer(parentNode, options)) { markBoxless(linkNode) } return linkNode } export const createLinkNode = (link: UIDLLinkNode, options: GeneratorOptions): UIDLElementNode => { return { type: 'element', content: { elementType: getLinkElementType(link), attrs: createLinkAttributes(link, options), children: [], }, } } const getLinkElementType = (link: UIDLLinkNode): string => { // for now I'm making all dynamic links local. // Maybe navlinks could have a dynamic reference, // not just a staic on in the future, but for now // (for the CMS demo) the navlink was too robust // to change return link.type === 'navlink' || link.type === 'dynamic' ? 'navlink' : 'link' } const createLinkAttributes = ( link: UIDLLinkNode, options: GeneratorOptions ): Record => { switch (link.type) { case 'url': { return { url: link.content.url, ...(link.content.newTab ? { target: { type: 'static', content: '_blank', }, rel: { type: 'static', content: 'noreferrer noopener', }, } : {}), } } case 'section': { if (link.content.section.type === 'static') { return { url: { type: 'static', content: `#${link.content.section.content}`, }, } } if (link.content.section.type === 'expr') { return { url: { type: 'expr', content: '`#${' + link.content.section.content + '}`', }, } } return } case 'dynamic': return { transitionTo: link, } case 'navlink': { const baseRoute = resolveNavlink(link.content.routeName, options) const differentiatorValue = link.content.differentiatorValue if (differentiatorValue) { return { transitionTo: buildDifferentiatorTransitionTo( baseRoute, differentiatorValue, link.content.canonicalValue ), } } // No differentiator + a dynamic destination = nothing navigable. See // UNRESOLVABLE_NAVLINK_HREF. Checked HERE rather than inside // `resolveNavlink` so the differentiator branch above still receives the // real `/prefix/[id]` template it needs to substitute the row id into. if ( baseRoute.type === 'static' && RoutePaths.pathHasDynamicSegment(String(baseRoute.content)) ) { return { transitionTo: { type: 'static', content: UNRESOLVABLE_NAVLINK_HREF }, } } return { transitionTo: baseRoute, } } case 'mail': { let mailUrl = `mailto:${link.content.mail}?subject=${link.content.subject ?? ''}` if (link.content.body) { mailUrl = mailUrl + `&body=${link.content.body}` } return { url: { type: 'static', content: mailUrl }, } } case 'phone': { return { url: { type: 'static', content: `tel:${link.content.phone}` }, } } default: throw new Error( `createLinkNode called with invalid link type '${(link as UIDLLinkNode).type}'` ) } } /** * A route whose `value` is folder-qualified (`add-character/Add-Character`) * still answers to its bare page name in a navlink's `routeName` * (`Add-Character`) — the folder prefix is part of the ROUTE, not of the page's * identity. Matching only on the whole value made every such lookup miss and * fall through to the fabricated `/${friendlyURL}` below. * * Resolved only when the last segment identifies the route UNAMBIGUOUSLY; with * two pages of the same name in different folders there is no way to tell which * one was meant, so we keep the old miss rather than guess. */ const findRouteByName = ( values: GeneratorOptions['projectRouteDefinition']['values'], routeName: string ) => { const exact = values.find((routeItem) => routeItem.value === routeName) if (exact) { return exact } // `value` is typed `string | number | boolean` — only a string route can carry // a folder prefix, so anything else can never match by last segment. const byLastSegment = values.filter( (routeItem) => typeof routeItem.value === 'string' && routeItem.value.split('/').pop() === routeName ) return byLastSegment.length === 1 ? byLastSegment[0] : undefined } /** * A DYNAMIC route (`/rsvp-event/[id]`) is not a URL — it is a template that * needs one record's id. A navlink with no `differentiatorValue` has no id to * put there, so neither spelling is navigable: the template ships a literal * `[id]` in the address bar, and the bare prefix (`/rsvp-event`) matches no file * and 404s. `#` is the platform's existing representation for a link that * cannot be resolved — the element still renders, it just does not navigate, * which is strictly better than a link that provably fails. * * This is the last line of defence, not the fix: a nav or CTA link pointing at a * single-record page is a planning defect, and the generator that produced the * UIDL is where it should be prevented. */ const UNRESOLVABLE_NAVLINK_HREF = '#' const resolveNavlink = ( route: UIDLAttributeValue, options: GeneratorOptions ): UIDLAttributeValue => { if (options.skipNavlinkResolver) { return route } const { type, content: routeName } = route if (type !== 'static') { return route } if (routeName.toString().startsWith('/')) { // attribute was explicitly set as a custom navlink return route } const friendlyURL = StringUtils.camelCaseToDashCase( StringUtils.removeIllegalCharacters(routeName.toString()) ) const transitionRoute = options.projectRouteDefinition ? findRouteByName(options.projectRouteDefinition.values, routeName.toString()) : null if (!transitionRoute) { return { type: 'static', content: `/${friendlyURL}`, } } if (transitionRoute?.pageOptions?.navLink === '/') { return { type: 'static', content: transitionRoute.pageOptions.navLink, } } const { pageOptions } = transitionRoute return { type: 'static', content: pageOptions.navLink ?? `/${friendlyURL}`, } } // Keep in sync with GLOBAL_REF_ID_MAP in teleport-plugin-common/ast-utils — // inlined here so the resolver has no dependency on plugin-common. const NAVLINK_GLOBAL_REF_ID_MAP: Record = { 'E-commerce': 'ecommerce', Cart: 'cart', 'Current User': 'currentUser', } const escapeTemplateLiteralText = (value: string): string => value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${') // Converts a UIDL differentiatorValue into a JS expression string suitable // for embedding in a template literal: `${baseRoute}/${}`. const differentiatorToJsExpression = (value: NavlinkDifferentiatorValue): string => { if (value.type === 'static') { return escapeTemplateLiteralText(String(value.content)) } if (value.type === 'expr') { return String(value.content) } // dynamic reference const content = value.content as { referenceType?: string id?: string refPath?: string[] } const refPath = content.refPath || [] if (content.referenceType === 'global') { const hasId = !!content.id const rootName = hasId ? (content.id as string) : NAVLINK_GLOBAL_REF_ID_MAP[refPath[0]] || refPath[0] const tailPath = hasId ? refPath : refPath.slice(1) const chain = tailPath.map((seg: string) => `?.${seg}`).join('') return `${rootName}${chain}` } if (content.referenceType === 'state' || content.referenceType === 'prop') { const prefix = content.referenceType === 'prop' ? 'props.' : '' const head = content.id || '' const chain = refPath.map((seg: string) => `?.${seg}`).join('') return `${prefix}${head}${chain}` } if (content.referenceType === 'local') { const head = refPath[0] || content.id || '' const tail = refPath.slice(1) const chain = tail.map((seg: string) => `?.${seg}`).join('') return `${head}${chain}` } return String(content.id || '') } const buildDifferentiatorTransitionTo = ( baseRoute: UIDLAttributeValue, differentiatorValue: NavlinkDifferentiatorValue, canonicalValue?: NavlinkCanonicalValue ): UIDLAttributeValue => { // If the base route is a static value we can inline it into the template // literal; otherwise fall back to leaving the base untouched and emitting // a raw expression that references the original (uncommon path). if (baseRoute.type !== 'static') { return baseRoute } const baseText = escapeTemplateLiteralText(String(baseRoute.content)) // A static differentiator resolves to literal path text; concatenate it as // a quasi segment rather than wrapping it in `${...}` (which would parse as // a bare identifier). if (differentiatorValue.type === 'static') { const staticPath = encodeURIComponent(String(differentiatorValue.content)) const staticHref = `${String(baseRoute.content)}/${staticPath}` return canonicalValue ? { type: 'expr', content: `(${differentiatorToJsExpression(canonicalValue)}) || ${JSON.stringify( staticHref )}`, } : { type: 'static', content: staticHref } } const diffExpr = differentiatorToJsExpression(differentiatorValue) const routedHref = `\`${baseText}/\${${diffExpr}}\`` // The record's own canonical address wins whenever it has one (a product on // a custom product page); every other row keeps the route + differentiator. return { type: 'expr', content: canonicalValue ? `(${differentiatorToJsExpression(canonicalValue)}) || ${routedHref}` : routedHref, } }