Repository navigation
feat(links): store and render own-site URLs as relative paths - #792
Conversation
✅ Deploy Preview for interledger-org-v5 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
| 'secondaryButtonLink', | ||
| 'authorLink', | ||
| 'url', | ||
| 'videoUrl', |
There was a problem hiding this comment.
url, videoUrl and externalUrl now go through toSlashedPath, so a value with no scheme turns into a site path. www.youtube.com/watch?v=abc123 in a split-layout videoUrl is saved as /www.youtube.com/watch/?v=abc123. twitter.com/interledger becomes /twitter.com/interledger/.
The input was already broken, but now it looks like an internal link and the editor gets no signal. Suggest slashing only values that start with /.
There was a problem hiding this comment.
23f44e1 - Fixed: url/videoUrl/externalUrl are back in their own set (as on staging) and go through a new toStoredUrl.
Own-site and upload URLs are still made relative, and anything else is slashed only if it starts with /. Added a regression test with your two examples.
I didn't extend the rule to href/link/authorLink: turning a bare grant/apply into /grant/apply/ there is intentional since #757 and covered by tests, and the / prefix is what neutralises javascript: values.
A scheme-less host in authorLink was already slashed on staging.
| */ | ||
| function classifyUrlInText(before: string, after: string): UrlInTextContext { | ||
| if (HTML_ATTRIBUTE_VALUE_START.test(before)) return 'target' | ||
| if (CONTINUES_PRECEDING_TOKEN.test(before)) return 'leave' |
There was a problem hiding this comment.
This check runs before MARKDOWN_DESTINATION and REFERENCE_DEFINITION. In [id]:https://<cms-host>/uploads/a.png (valid CommonMark, no space), the : matches here and the URL is left as is. The old classifyUploadUrl stripped this CMS origin. Move this line below the two target checks.
| const HTML_ATTRIBUTE_VALUE_START = new RegExp( | ||
| String.raw`${HTML_TAG_OPEN}(?:\s[\w:-]+\s*=\s*["']?|,\s*)$` | ||
| ) | ||
|
|
There was a problem hiding this comment.
The ,\s* branch is for srcset, but it matches after any comma in a tag. <a title="Hello, https://interledger.org/x"> is stored as title="Hello, /x/", so the tooltip text changes. Limit the comma form to a srcset value.
| * A character a URL found in text can hold: not whitespace, a quote, a | ||
| * bracket, or a table cell's `|`. | ||
| */ | ||
| const URL_IN_TEXT_CHAR = String.raw`[^\s"'()<>[\]|]` |
There was a problem hiding this comment.
With ( and ) excluded, a bare URL is cut at the first paren. https://interledger.org/a_(b) becomes [https://interledger.org/a](/a/)_(b), which links to the wrong page. GFM keeps balanced parens in autolinks. Leaving the URL unchanged is better than this.
| ({ data: node, key, value, attribute }) => { | ||
| if (typeof value !== 'string' || CODE_FIELDS.has(key)) return | ||
| if (HREF_LIKE_FIELDS.has(key)) node[key] = toStoredHref(value) | ||
| else if (PATH_SEGMENT_FIELDS.has(key)) node[key] = toSlashedSlug(value) |
There was a problem hiding this comment.
slug and pathSlug skip the own-site strip. An absolute URL pasted into relatedArticles[].slug is stored as /https://interledger.org/.../. Eight posts already have this. I know INTORG-1346 covers it, but the fix here is one line: match SITE_URL first, then call toSlashedSlug on the path.
There was a problem hiding this comment.
Leaving this as
relatedArticles:
- '/https://interledger.org/grant/eoi-call-for-papers-reviewer/'
- '/https://interledger.org/blog/call-papers-program/'
- '/https://interledger.org/blog/taxes-on-digital-financial-services-in-africa/'
would turn into
relatedArticles:
- '/grant/eoi-call-for-papers-reviewer/'
- '/blog/call-papers-program/'
- '/blog/taxes-on-digital-financial-services-in-africa/'
which still doesn't fix the issue - related articles expect just the slug, not /blog/{slug}
Keeping it in INTORG-1346, which covers the /blog/ prefix, the validation and the existing posts.
| try { | ||
| const url = new URL(parsable) | ||
| // A leading `//` would make the path a protocol-relative link to a host | ||
| // named after its first segment. |
There was a problem hiding this comment.
This accepts any scheme and drops any port. The CMS side accepts only http(s) with no port. So http://interledger.org:8080/x stays absolute in the stored MDX but renders as /x. Check url.protocol is http: or https: and url.port is empty before you strip.
| // The CMS strips these hosts on save; Astro must treat the same set as the | ||
| // site, or a link stripped in one place stays absolute in the other. | ||
| it.each(SITE_HOSTS)('counts the CMS site host %s as the site', (host) => { | ||
| expect(isSiteHostname(host)).toBe(true) |
There was a problem hiding this comment.
This test checks CMS hosts against Astro hosts in one direction only. A host added only to SITE_HOSTNAMES still passes. This suite already imports SITE_HOSTS, so umami.ts can build its set from it and drop the second list.
There was a problem hiding this comment.
Agreed, the test only caught a host missing from Astro, not one added only on the Astro side.
I didn't have umami.ts import SITE_HOSTS, though: that would be the first runtime import from cms/ into the Astro bundle, and it only stays safe while relativeLinks.ts has no imports and reads no env.
Instead I export SITE_HOSTNAMES and the test asserts the two sets are equal, so drift in either direction fails.
d2dd737
|
@Infi-Knight thank you for reviewing this! :D |
Summary
CMS, link fields: own-site URLs from any environment (prod, staging, playground) are saved as relative, slashed paths. This covers
href,link,url,videoUrl,externalUrl, the redirectdestinationand the rest.Example:
https://staging.interledger.org/blog→/blog/.CMS, text fields: own-site URLs inside text are made relative too.
](…), an HTML attribute, a reference definition) becomes relative and slashed, in any text field.[https://interledger.org/blog](/blog/).Plain fields rendered as Markdown are made relative when the page renders, not when they are saved. Nine plain (non-CKEditor) fields are rendered as Markdown, by Marked or MDX:
quote.quote,blockquote.quote, event-cardwhen/where/apply,report-section.heading,agenda-item.time/activityandsplit-layout.quoteSource. A bare URL in these fields can't be stored as a relative path. Markdown autolinks a barehttps://…URL but not a bare/path/, and the CMS never adds Markdown to a plain field. So a stored/blog/would show as plain text, not a link. The stored value stays absolute (https://interledger.org/blog/), in Strapi and in the MDX file. Astro makes the link relative when it renders the page (next section).CMS, save middleware: moved to the new
documentLinks.ts. It now walks the document using its schema (traverseEntity), so a CKEditor field is recognised by itscustomFieldat any depth, not guessed from its name.CMS, refactor: upload-origin and site-origin stripping now share one URL-in-text rewriter in
relativeLinks.ts. Navigation hrefs go through the sametoStoredHref.Astro, render time: Markdown links, from both Marked and the rehype umami plugin, have the own-site origin stripped before the umami, new-tab and safe-scheme checks run. Staging and playground hosts now count as internal.
Content: replaced the absolute interledger.org links in 6 existing MDX files.
Docs: the CLAUDE.md redirects section now says how
destinationis stored.Tests: new
documentLinks.test.ts, plus coverage in therelativeLinks,mdx,umami,rehypeUmamiLinks,newTabLinksandnavigationLifecycletests. One test keeps the CMS and Astro host lists in sync.relatedArticlesslugs. Absolute URLs inrelatedArticles[].slug(5 posts from 2026-10-15/21) are not fixed here. We need to add field validation - tracked in a separate issue (INTORG-1346).Related Issue
Fixes INTORG-1300
Manual Test
Checks
pnpm run formatpnpm run lintPR Checklist
feat: ...,fix: ...)