diff --git a/app/components/AsciidocBlocks/Footnotes.tsx b/app/components/AsciidocBlocks/Footnotes.tsx index e9bd15e..db89d84 100644 --- a/app/components/AsciidocBlocks/Footnotes.tsx +++ b/app/components/AsciidocBlocks/Footnotes.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { type DocumentBlock } from '@oxide/react-asciidoc' +import { RenderInline, type DocumentBlock } from '@oxide/react-asciidoc' import { Link } from 'react-router' import Container from '../Container' @@ -36,10 +36,9 @@ const Footnotes = ({ doc }: { doc: DocumentBlock }) => { {footnote.index}
-

{' '} +

+ +

{ - const documentAttrs = node.getDocument().getAttributes() - - let target = '' - if (nodeIsInline(node)) { - target = node.getTarget() || '' // Getting target on inline nodes - } else { - target = node.getAttribute('target') // Getting target on block nodes - } - - const uri = node.getImageUri(target) - let url = '' +const InlineImage = ({ node }: { node: Inline.ImageNode }) => { + const { document } = useConverterContext() + const docAttrs = document.attributes || {} - url = `/rfd/image/${documentAttrs.rfdnumber}/${uri}` + const url = `/rfd/image/${docAttrs.rfdnumber}/${node.target}` - let img = ( - {node.getAttribute('alt')} - ) + let img = {node.alt} - if (node.hasAttribute('link')) { + if (node.link) { img = ( - + {img} ) } return ( -
+ {img} -
+ ) } diff --git a/app/components/AsciidocBlocks/RfdLink.tsx b/app/components/AsciidocBlocks/RfdLink.tsx new file mode 100644 index 0000000..cf4fa58 --- /dev/null +++ b/app/components/AsciidocBlocks/RfdLink.tsx @@ -0,0 +1,193 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { inlineHtml, parse, useConverterContext, type Inline } from '@oxide/react-asciidoc' +import cn from 'classnames' +import { isValidElement, useEffect, useRef, type ReactNode } from 'react' +import { Link, useNavigation, useRouteLoaderData } from 'react-router' + +import Icon from '~/components/Icon' +import { calcOffset, extractRfdNumber } from '~/components/rfd/RfdPreview' +import type { RfdListItem } from '~/services/rfd.server' +import { closeRfdPreview, openRfdPreview } from '~/stores/rfd-preview' + +type AnchorProps = { + href?: string + id?: string + className?: string + target?: string +} + +/** + * Inline override for anchors. RFDs referenced from the body — whether as a + * link, a `[bibliography]` cross-reference resolving to an RFD URL, or a classic + * `<>` anchor — become relative react-router ``s with a hover + * preview. Other internal links navigate client-side, bibliography xrefs to + * external resources link straight out, and everything else (in-page xrefs, + * refs, external links) renders exactly as the default renderer would — we + * round-trip the node through `inlineHtml`/`parse` so there's no drift from + * stock output. + */ +const RfdLink = ({ node, children }: { node: Inline.AnchorNode; children: ReactNode }) => { + const parsed = parse(inlineHtml([node]).__html) + // Bibrefs serialise to multiple nodes (`[]`); anything that isn't a + // single anchor element just renders stock. + const props = isValidElement(parsed) ? (parsed.props as AnchorProps) : null + + // A bibliography xref carries its resolved URL on `externalHref`; it takes + // priority over the stock in-page (`#id`) anchor href. + const externalHref = node.subtype === 'xref' ? node.externalHref : undefined + + // The target used to detect RFD references: a link's decoded href, a + // bibliography xref's resolved URL, or a classic `<>`'s `#` anchor. + const navHref = externalHref ?? props?.href + + const rfdNumber = navHref ? extractRfdNumber(navHref) : null + if (rfdNumber !== null) { + return ( + + {children} + + ) + } + + // Bibliography xref to a non-RFD external resource links straight out. + if (externalHref) { + return ( + + {children} + + ) + } + + // Same-origin relative links navigate client-side. Absolute URLs (including + // in-page `#` anchors and `target="_blank"` links) keep the stock anchor. + if (node.subtype === 'link' && props?.href?.startsWith('/') && !props.target) { + return ( + + {children} + + ) + } + + return <>{parsed} +} + +/** + * A link to another RFD: navigates client-side and opens a hover preview after + * a short delay. The delay and cleanup live in this component, so there's no + * document-wide event delegation. + */ +const RfdHoverLink = ({ + rfdNumber, + id, + className, + children, +}: { + rfdNumber: number + children: ReactNode +} & AnchorProps) => { + const navigation = useNavigation() + // Read the root loader directly rather than importing `useRootLoaderData`: + // this component sits in the server-side asciidoctor import chain, and + // importing `~/root` there would create a circular import. + const data = useRouteLoaderData('root') as + | { rfds: RfdListItem[]; user: unknown } + | undefined + const rfds = data?.rfds ?? [] + const { document } = useConverterContext() + const currentRfd = Number(document.attributes?.rfdnumber) + + const ref = useRef(null) + const timeoutRef = useRef(null) + + const clearHoverTimeout = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + + useEffect(() => clearHoverTimeout, []) + + const matchedRfd = rfds.find((rfd) => rfd.number === rfdNumber) + + // The linked RFD isn't in the set this reader can see. We can't (and + // shouldn't) tell "private" apart from "doesn't exist" — that mirrors the RFD + // loader, which deliberately 404s rather than leak a private RFD's existence. + // Surface it as a restricted reference instead of a link that dead-ends. + if (!matchedRfd && rfdNumber !== currentRfd) { + const formattedNumber = rfdNumber.toString().padStart(4, '0') + const lock = ( + + ) + // Signed-out readers can gain access by logging in, so point them there; + // signed-in readers simply don't have access, so it's non-interactive. + return data?.user ? ( + + {children} + {lock} + + ) : ( + + {children} + {lock} + + ) + } + + const handleMouseEnter = () => { + if (navigation.state !== 'idle') return + if (!matchedRfd || rfdNumber === currentRfd) return + if (timeoutRef.current) return + + timeoutRef.current = setTimeout(() => { + const anchor = ref.current + if (anchor) { + openRfdPreview({ rfd: matchedRfd, position: calcOffset(anchor), anchor }) + } + timeoutRef.current = null + }, 125) + } + + const handleMouseLeave = () => clearHoverTimeout() + + return ( + + {children} + + ) +} + +export default RfdLink diff --git a/app/components/AsciidocBlocks/index.ts b/app/components/AsciidocBlocks/index.ts index 132c40c..c3cbd97 100644 --- a/app/components/AsciidocBlocks/index.ts +++ b/app/components/AsciidocBlocks/index.ts @@ -9,6 +9,8 @@ import { AsciiDocBlocks } from '@oxide/design-system/asciidoc' import { type Options } from '@oxide/react-asciidoc' +import { inlineOverrides } from '~/utils/asciidoctor' + import { CustomDocument } from './Document' import { Image } from './Image' import Listing from './Listing' @@ -21,5 +23,6 @@ export const opts: Options = { section: AsciiDocBlocks.Section, listing: Listing, }, + inlineOverrides, customDocument: CustomDocument, } diff --git a/app/components/rfd/RfdPreview.tsx b/app/components/rfd/RfdPreview.tsx index b287b02..12d6305 100644 --- a/app/components/rfd/RfdPreview.tsx +++ b/app/components/rfd/RfdPreview.tsx @@ -9,11 +9,10 @@ import cn from 'classnames' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { Fragment, useCallback, useEffect, useRef, useState } from 'react' -import { Link, useNavigate, useNavigation } from 'react-router' +import { Fragment, useEffect, useRef } from 'react' +import { Link } from 'react-router' -import { useRootLoaderData } from '~/root' -import type { RfdListItem } from '~/services/rfd.server' +import { closeRfdPreview, useRfdPreviewStore } from '~/stores/rfd-preview' dayjs.extend(relativeTime) @@ -54,122 +53,12 @@ export function calcOffset(element: HTMLAnchorElement | HTMLElement) { return { left: x, top: y } } -interface RfdPreviewState { - rfd: RfdListItem - position: { left: number; top: number } - anchor: HTMLAnchorElement -} - -interface RfdPreviewProps { - currentRfd: number - nodeRef: React.RefObject -} - -const RfdPreview = ({ currentRfd, nodeRef }: RfdPreviewProps) => { - const navigate = useNavigate() - const navigation = useNavigation() - const isNavigating = navigation.state !== 'idle' - const { rfds } = useRootLoaderData() - const [preview, setPreview] = useState(null) - const timeoutRef = useRef(null) +const RfdPreview = ({ currentRfd }: { currentRfd: number }) => { + const preview = useRfdPreviewStore((state) => state.preview) const previewRef = useRef(null) - const clearHoverTimeout = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null - } - }, []) - - useEffect(() => { - setPreview(null) - }, [currentRfd]) - - // Based on https://github.com/remix-run/react-router-website/blob/main/app/ui/delegate-markdown-links.ts - // Converts regular AsciiDoc a tags and makes them React Routery - useEffect(() => { - const node = nodeRef.current - if (!node) return - - const handleClick = (event: MouseEvent) => { - if (!(event.target instanceof HTMLElement)) return - - const a = event.target.closest('a') - - if ( - a && // is anchor or has anchor parent - a.hasAttribute('href') && // has an href - a.host === window.location.host && // is internal - event.button === 0 && // left click - (!a.target || a.target === '_self') && // Let browser handle "target=_blank" etc. - !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // not modified - ) { - const rfdNum = extractRfdNumber(a.getAttribute('href') || '') - - if (rfdNum !== null) { - event.preventDefault() - clearHoverTimeout() - setPreview(null) - const formattedNumber = rfdNum.toString().padStart(4, '0') - navigate(`/rfd/${formattedNumber}`) - return - } - - if (a.host === window.location.host) { - event.preventDefault() - const { pathname, search, hash } = a - navigate({ pathname, search, hash }) - } - } - } - - const handleMouseOver = (event: MouseEvent) => { - if (isNavigating) return - if (!(event.target instanceof HTMLElement)) return - - const anchor = event.target.closest('a') - if (!anchor) return - - const rfdNum = extractRfdNumber(anchor.getAttribute('href') || '') - - if (rfdNum === null || rfdNum === currentRfd) return - - const matchedRfd = rfds.find((rfd) => rfd.number === rfdNum) - if (!matchedRfd) return - - if (timeoutRef.current) return - - timeoutRef.current = setTimeout(() => { - const offset = calcOffset(anchor) - setPreview({ - rfd: matchedRfd, - position: offset, - anchor, - }) - timeoutRef.current = null - }, 125) - } - - const handleMouseOut = (event: MouseEvent) => { - if (!(event.target instanceof HTMLElement)) return - - const anchor = event.target.closest('a') - if (anchor) { - clearHoverTimeout() - } - } - - node.addEventListener('click', handleClick) - node.addEventListener('mouseover', handleMouseOver) - node.addEventListener('mouseout', handleMouseOut) - - return () => { - node.removeEventListener('click', handleClick) - node.removeEventListener('mouseover', handleMouseOver) - node.removeEventListener('mouseout', handleMouseOut) - clearHoverTimeout() - } - }, [navigate, nodeRef, currentRfd, rfds, clearHoverTimeout, isNavigating]) + // Dismiss any open preview when navigating to a different RFD + useEffect(() => closeRfdPreview, [currentRfd]) useEffect(() => { if (!preview) return @@ -237,7 +126,7 @@ const RfdPreview = ({ currentRfd, nodeRef }: RfdPreviewProps) => { const isInside = isPointInPolygon(cursor, polygon) if (!isInside) { - setPreview(null) + closeRfdPreview() } } diff --git a/app/routes/rfd.$slug.tsx b/app/routes/rfd.$slug.tsx index ea8dc4c..0bc4754 100644 --- a/app/routes/rfd.$slug.tsx +++ b/app/routes/rfd.$slug.tsx @@ -24,6 +24,7 @@ import { useState, } from 'react' import { + Link, redirect, useLoaderData, useLocation, @@ -225,17 +226,15 @@ export default function Rfd() { const bodyRef = useRef(null) - const containerRef = useRef(null) - return ( -
+
{/* key makes the search dialog close on selection */}
{inlineComments && user && pullNumber && ( )} - + {state && (
@@ -279,18 +278,18 @@ export default function Rfd() {
{authors.map((author, index) => ( - {author.name} {index < authors.length - 1 && ', '} - {' '} + {' '} ))}
@@ -301,13 +300,13 @@ export default function Rfd() {
{labels.map((label, index) => ( - {label.trim()} {index < labels.length - 1 && ', '} - {' '} + {' '} ))}
diff --git a/app/stores/rfd-preview.ts b/app/stores/rfd-preview.ts new file mode 100644 index 0000000..f851574 --- /dev/null +++ b/app/stores/rfd-preview.ts @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { create } from 'zustand' + +import type { RfdListItem } from '~/services/rfd.server' + +export interface RfdPreviewState { + rfd: RfdListItem + position: { left: number; top: number } + anchor: HTMLAnchorElement +} + +/** + * Holds the RFD link that is currently being previewed on hover. Anchors + * rendered inside the document (see the `anchor` inline override) write to + * this store, and the single `RfdPreview` overlay subscribes to it. Using a + * store rather than DOM event delegation means each link manages its own hover + * state through ordinary React handlers. + */ +export const useRfdPreviewStore = create<{ preview: RfdPreviewState | null }>(() => ({ + preview: null, +})) + +export const openRfdPreview = (preview: RfdPreviewState) => + useRfdPreviewStore.setState({ preview }) + +export const closeRfdPreview = () => useRfdPreviewStore.setState({ preview: null }) diff --git a/app/utils/asciidoctor.tsx b/app/utils/asciidoctor.tsx index ff89270..f7ac118 100644 --- a/app/utils/asciidoctor.tsx +++ b/app/utils/asciidoctor.tsx @@ -5,11 +5,14 @@ * * Copyright Oxide Computer Company */ -import { type Block, type Html5Converter } from '@asciidoctor/core' -import { InlineConverter, loadAsciidoctor } from '@oxide/design-system/asciidoc' -import { renderToString } from 'react-dom/server' +import { + inlineOverrides as baseInlineOverrides, + loadAsciidoctor, +} from '@oxide/design-system/asciidoc' +import { type InlineOverrides } from '@oxide/react-asciidoc' import { InlineImage } from '~/components/AsciidocBlocks/Image' +import RfdLink from '~/components/AsciidocBlocks/RfdLink' const attrs = { sectlinks: 'true', @@ -19,27 +22,10 @@ const attrs = { const ad = loadAsciidoctor({}) -class CustomInlineConverter { - baseConverter: Html5Converter - - constructor() { - this.baseConverter = new InlineConverter() - } - - convert(node: Block, transform: string) { - switch (node.getNodeName()) { - case 'inline_image': - return renderToString() - case 'image': - return renderToString() - default: - break - } - - return this.baseConverter.convert(node, transform) - } +const inlineOverrides: InlineOverrides = { + ...baseInlineOverrides, + image: InlineImage, + anchor: RfdLink, } -ad.ConverterFactory.register(new CustomInlineConverter(), ['html5']) - -export { ad, attrs } +export { ad, attrs, inlineOverrides } diff --git a/package-lock.json b/package-lock.json index e5ff5b1..e8f4e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@floating-ui/react": "^0.17.0", "@leeoniya/ufuzzy": "^1.0.19", "@meilisearch/instant-meilisearch": "^0.28.0", - "@oxide/design-system": "^6.2.1", - "@oxide/react-asciidoc": "^1.3.0", + "@oxide/design-system": "^6.4.1-canary.fec0981", + "@oxide/react-asciidoc": "^2.0.0--canary.54.26817409416.0", "@oxide/remix-auth-rfd": "^0.1.3", "@oxide/rfd.ts": "^0.14.1", "@radix-ui/react-accordion": "^1.2.12", @@ -2416,9 +2416,9 @@ } }, "node_modules/@oxide/design-system": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.2.1.tgz", - "integrity": "sha512-1QBu1CVLx20AI9QHOo/m7yFslghpYfHhHxa7r/74NV6Ff/RawkUQjl7X+NgFxLyf3jJehWgWBhcycxM+Bw1q3A==", + "version": "6.4.1-canary.fec0981", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.4.1-canary.fec0981.tgz", + "integrity": "sha512-cdlP8hsIotQP597m4Y2ukBCEa9A/8B/EpA8tSWrabTBKuMM/1qaiRRQRZdx1MgqfyGCPROzYUq7dTDDW3R7Ubg==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.16", @@ -2431,7 +2431,7 @@ }, "peerDependencies": { "@asciidoctor/core": "^3.0.0", - "@oxide/react-asciidoc": "^1.3.0", + "@oxide/react-asciidoc": "^2.0.0--canary.54.26752676803.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } @@ -2452,11 +2452,12 @@ } }, "node_modules/@oxide/react-asciidoc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@oxide/react-asciidoc/-/react-asciidoc-1.3.0.tgz", - "integrity": "sha512-0QNKE03z3nh82AjmJD7Gx7uxpEa0Io4bb/3Zlqh7b87c6iyQ99nV1bdiIel/Ja7FfSdC/qqIfmcedxutvKuD7Q==", + "version": "2.0.0--canary.54.26817409416.0", + "resolved": "https://registry.npmjs.org/@oxide/react-asciidoc/-/react-asciidoc-2.0.0--canary.54.26817409416.0.tgz", + "integrity": "sha512-+XXxkZonYG2OFMYixA5aX0BhTMMzEfebs03BOCLEs9WDEIj8zqeVOHSlSc92RFS0aXHp8VDw5fSlWKO7+yrPfw==", "license": "MPL 2.0", "dependencies": { + "entities": "^6.0.1", "html-react-parser": "^5.2.6" }, "peerDependencies": { @@ -2465,6 +2466,18 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@oxide/react-asciidoc/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/@oxide/remix-auth-rfd": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@oxide/remix-auth-rfd/-/remix-auth-rfd-0.1.5.tgz", diff --git a/package.json b/package.json index 15fca74..fe82d91 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "@floating-ui/react": "^0.17.0", "@leeoniya/ufuzzy": "^1.0.19", "@meilisearch/instant-meilisearch": "^0.28.0", - "@oxide/design-system": "^6.2.1", - "@oxide/react-asciidoc": "^1.3.0", + "@oxide/design-system": "^6.4.1-canary.fec0981", + "@oxide/react-asciidoc": "^2.0.0--canary.54.26817409416.0", "@oxide/remix-auth-rfd": "^0.1.3", "@oxide/rfd.ts": "^0.14.1", "@radix-ui/react-accordion": "^1.2.12",