Skip to content

feat(links): store and render own-site URLs as relative paths - #792

Merged
Anca2022 merged 19 commits into
stagingfrom
am/strip-link-origin
Oct 26, 2026
Merged

Anca2022 merged 19 commits into
stagingfrom
am/strip-link-origin

Conversation

@Anca2022

@Anca2022 Anca2022 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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 redirect destination and the rest.
    Example: https://staging.interledger.org/blog → /blog/.

  • CMS, text fields: own-site URLs inside text are made relative too.

    • A URL that is already a link target (](…), an HTML attribute, a reference definition) becomes relative and slashed, in any text field.
    • In CKEditor rich text, a bare URL becomes a Markdown link: [https://interledger.org/blog](/blog/).
    • In a plain field, a bare URL keeps its origin and only gets its trailing slash (see below).
    • Link text, code, emails and nested URLs are left alone.
  • 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-card when/where/apply, report-section.heading, agenda-item.time/activity and split-layout.quoteSource. A bare URL in these fields can't be stored as a relative path. Markdown autolinks a bare https://… 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 its customField at 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 same toStoredHref.

  • 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 destination is stored.

  • Tests: new documentLinks.test.ts, plus coverage in the relativeLinks, mdx, umami, rehypeUmamiLinks, newTabLinks and navigationLifecycle tests. One test keeps the CMS and Astro host lists in sync.

⚠️ Not covered: relatedArticles slugs. Absolute URLs in relatedArticles[].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 format
  • pnpm run lint

PR Checklist

  • PR title follows Conventional Commits (e.g. feat: ..., fix: ...)
  • Linked issue included
  • Scope is focused (target ~10-20 files when possible)
  • Screenshots for UI changes (if applicable)

@netlify

netlify Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for interledger-org-v5 ready!

Name Link
🔨 Latest commit 6b9fe27
🔍 Latest deploy log https://app.netlify.com/projects/interledger-org-v5/deploys/6adb342f8a9627ecef9df270
😎 Deploy Preview https://deploy-preview-792--interledger-org-v5.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@Anca2022 Anca2022 self-assigned this Oct 19, 2026
@Anca2022
Anca2022 marked this pull request as ready for review October 19, 2026 09:44
@Infi-Knight
Infi-Knight self-requested a review October 19, 2026 09:53
Comment thread cms/src/utils/documentLinks.ts Outdated
'secondaryButtonLink',
'authorLink',
'url',
'videoUrl',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /.

@Anca2022 Anca2022 Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cms/src/utils/relativeLinks.ts Outdated
*/
function classifyUrlInText(before: string, after: string): UrlInTextContext {
if (HTML_ATTRIBUTE_VALUE_START.test(before)) return 'target'
if (CONTINUES_PRECEDING_TOKEN.test(before)) return 'leave'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d63bf24 - thank you :D

const HTML_ATTRIBUTE_VALUE_START = new RegExp(
String.raw`${HTML_TAG_OPEN}(?:\s[\w:-]+\s*=\s*["']?|,\s*)$`
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 38bf195

* 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"'()<>[\]|]`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

({ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Anca2022 Anca2022 Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/utils/main/umami.ts
try {
const url = new URL(parsable)
// A leading `//` would make the path a protocol-relative link to a host
// named after its first segment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1dc6210

Comment thread src/utils/main/umami.test.ts Outdated
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Anca2022

Copy link
Copy Markdown
Contributor Author

@Infi-Knight thank you for reviewing this! :D
I've pushed fixes for your comments. Could you take another look when you have time?

@Anca2022
Anca2022 requested a review from Infi-Knight October 23, 2026 10:29
@Anca2022
Anca2022 merged commit e78079d into staging Oct 26, 2026
9 checks passed
@Anca2022
Anca2022 deleted the am/strip-link-origin branch October 26, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants