From 6591290739c0c7866173183946d39d615b66abb9 Mon Sep 17 00:00:00 2001 From: Bryan Stopp Date: Fri, 17 Jul 2026 14:12:10 -0400 Subject: [PATCH 1/2] Add edit link if its a valid target. --- .gitignore | 2 + .../prose/plugins/linkMenu/editorTarget.js | 69 +++++++++++++++ .../edit/prose/plugins/linkMenu/link-menu.css | 4 + .../edit/prose/plugins/linkMenu/linkMenu.js | 5 +- .../prose/plugins/linkMenu/linkMenuItems.js | 36 +++++++- .../plugins/linkMenu/editorTarget.test.js | 87 +++++++++++++++++++ .../plugins/linkMenu/linkMenu-mount.test.js | 30 +++++++ .../edit/prose/plugins/linkMenuItems.test.js | 42 +++++++++ 8 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 blocks/edit/prose/plugins/linkMenu/editorTarget.js create mode 100644 test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js diff --git a/.gitignore b/.gitignore index 0bcb05240..ca473faf3 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ typings/ .claude /.scout docs/superpowers/ +.superpowers +.nvmrc diff --git a/blocks/edit/prose/plugins/linkMenu/editorTarget.js b/blocks/edit/prose/plugins/linkMenu/editorTarget.js new file mode 100644 index 000000000..619d4e667 --- /dev/null +++ b/blocks/edit/prose/plugins/linkMenu/editorTarget.js @@ -0,0 +1,69 @@ +const DUMMY_BASE = 'https://__current__.invalid'; +const AEM_HOST_RE = /^([\w-]+)--([\w-]+)--([\w-]+)\.(?:aem|hlx)\.(?:page|live)$/; + +function parseHref(href) { + if (typeof href !== 'string' || !href) return null; + + let url; + try { + url = new URL(href, DUMMY_BASE); + } catch { + return null; + } + + const isRelative = url.origin === DUMMY_BASE; + + if (isRelative) { + if (!href.startsWith('/')) return null; + return { + org: null, site: null, branch: null, pathname: url.pathname, + }; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + + const aemMatch = url.hostname.match(AEM_HOST_RE); + if (aemMatch) { + const [, branch, site, org] = aemMatch; + return { + org, site, branch, pathname: url.pathname, + }; + } + + return null; +} + +function normalizePath(pathname) { + const path = pathname.replace(/\.html$/, ''); + if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1); + return path || '/'; +} + +export function resolveEditorTarget(href, context = {}) { + const { org: currentOrg, repo: currentRepo, ref: currentRef = 'main' } = context; + if (!currentOrg || !currentRepo) return null; + + const parsed = parseHref(href); + if (!parsed) return null; + + const path = normalizePath(parsed.pathname); + + if (parsed.org === null) { + return { + org: currentOrg, repo: currentRepo, path, branch: currentRef, + }; + } + + if (parsed.org !== currentOrg || parsed.site !== currentRepo) return null; + + return { + org: currentOrg, repo: currentRepo, path, branch: parsed.branch, + }; +} + +export function buildEditorUrl({ + org, repo, path, branch, +}) { + const refParam = branch && branch !== 'main' ? `?ref=${branch}` : ''; + return `/edit${refParam}#/${org}/${repo}${path}`; +} diff --git a/blocks/edit/prose/plugins/linkMenu/link-menu.css b/blocks/edit/prose/plugins/linkMenu/link-menu.css index 0ed7711c5..e0c0895e5 100644 --- a/blocks/edit/prose/plugins/linkMenu/link-menu.css +++ b/blocks/edit/prose/plugins/linkMenu/link-menu.css @@ -38,6 +38,10 @@ flex-shrink: 0; } +.link-menu-icon.menu-item-open-editor { + background-image: url('/blocks/edit/img/Smock_PageRule_18_N.svg'); +} + .link-menu-icon.menu-item-open-link { background-image: url('/blocks/edit/img/S2_icon_OpenIn_20_N.svg'); } diff --git a/blocks/edit/prose/plugins/linkMenu/linkMenu.js b/blocks/edit/prose/plugins/linkMenu/linkMenu.js index d85d0e07d..a6381721e 100644 --- a/blocks/edit/prose/plugins/linkMenu/linkMenu.js +++ b/blocks/edit/prose/plugins/linkMenu/linkMenu.js @@ -1,5 +1,5 @@ import { Plugin, PluginKey } from 'da-y-wrapper'; -import { getLinkMenuItems } from './linkMenuItems.js'; +import { getLinkMenuItems, findLinkAtCursor } from './linkMenuItems.js'; import './link-menu.js'; const linkMenuKey = new PluginKey('linkMenu'); @@ -52,6 +52,9 @@ class LinkMenuView { const coords = this.view.coordsAtPos($anchor.pos); const linkText = getLinkText(state); + const linkMark = findLinkAtCursor(state); + + this.menu.items = getLinkMenuItems(linkMark?.attrs?.href); const viewportCoords = { left: coords.left + window.pageXOffset, diff --git a/blocks/edit/prose/plugins/linkMenu/linkMenuItems.js b/blocks/edit/prose/plugins/linkMenu/linkMenuItems.js index b84e8bf07..25e0abb87 100644 --- a/blocks/edit/prose/plugins/linkMenu/linkMenuItems.js +++ b/blocks/edit/prose/plugins/linkMenu/linkMenuItems.js @@ -1,4 +1,6 @@ import { TextSelection } from 'da-y-wrapper'; +import getPathDetails from '../../../../shared/pathDetails.js'; +import { resolveEditorTarget, buildEditorUrl } from './editorTarget.js'; function findExistingLink(state) { const { $from } = state.selection; @@ -66,9 +68,35 @@ function removeLink(state, dispatch) { return true; } +function getCurrentContext(loc) { + const location = loc || window.location; + const details = getPathDetails(loc); + if (!details?.org || !details?.repo) return null; + const ref = new URLSearchParams(location.search).get('ref') || 'main'; + return { org: details.org, repo: details.repo, ref }; +} + +function openInEditor(target) { + window.open(buildEditorUrl(target), '_blank'); + return true; +} + /* eslint-disable import/prefer-default-export */ -export function getLinkMenuItems() { - return [ +export function getLinkMenuItems(href, loc) { + const context = getCurrentContext(loc); + const target = context ? resolveEditorTarget(href, context) : null; + + const items = []; + + if (target) { + items.push({ + title: 'Open in editor', + command: () => openInEditor(target), + class: 'menu-item-open-editor', + }); + } + + items.push( { title: 'Open link', command: openLink, @@ -89,5 +117,7 @@ export function getLinkMenuItems() { command: removeLink, class: 'menu-item-remove-link', }, - ]; + ); + + return items; } diff --git a/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js b/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js new file mode 100644 index 000000000..11426cf3a --- /dev/null +++ b/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js @@ -0,0 +1,87 @@ +import { expect } from '@esm-bundle/chai'; +import { resolveEditorTarget, buildEditorUrl } from '../../../../../../../blocks/edit/prose/plugins/linkMenu/editorTarget.js'; + +const context = { org: 'myorg', repo: 'myrepo', ref: 'main' }; + +describe('resolveEditorTarget', () => { + it('resolves a root-relative href within the current project', () => { + const result = resolveEditorTarget('/products/foo', context); + expect(result).to.deep.equal({ + org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main', + }); + }); + + it('strips a .html extension and a trailing slash', () => { + expect(resolveEditorTarget('/products/foo.html', context).path).to.equal('/products/foo'); + expect(resolveEditorTarget('/products/foo/', context).path).to.equal('/products/foo'); + }); + + it('uses the current ref for a root-relative href when the ref is not main', () => { + const result = resolveEditorTarget('/products/foo', { ...context, ref: 'feature' }); + expect(result.branch).to.equal('feature'); + }); + + it('resolves an aem.page URL matching the current org/repo, using the hostname branch', () => { + const result = resolveEditorTarget('https://feature--myrepo--myorg.aem.page/products/foo', context); + expect(result).to.deep.equal({ + org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature', + }); + }); + + it('resolves aem.live/hlx.page/hlx.live URLs the same way', () => { + expect(resolveEditorTarget('https://main--myrepo--myorg.aem.live/x', context).branch).to.equal('main'); + expect(resolveEditorTarget('https://main--myrepo--myorg.hlx.page/x', context).branch).to.equal('main'); + expect(resolveEditorTarget('https://main--myrepo--myorg.hlx.live/x', context).branch).to.equal('main'); + }); + + it('returns null for an aem.page URL from a different org or repo', () => { + expect(resolveEditorTarget('https://main--otherrepo--myorg.aem.page/x', context)).to.equal(null); + expect(resolveEditorTarget('https://main--myrepo--otherorg.aem.page/x', context)).to.equal(null); + }); + + it('returns null for an external URL', () => { + expect(resolveEditorTarget('https://example.com/products/foo', context)).to.equal(null); + }); + + it('returns null for mailto: and tel: links', () => { + expect(resolveEditorTarget('mailto:person@example.com', context)).to.equal(null); + expect(resolveEditorTarget('tel:+15551234567', context)).to.equal(null); + }); + + it('returns null for a relative link that is not root-relative', () => { + expect(resolveEditorTarget('products/foo', context)).to.equal(null); + }); + + it('returns null for an empty or missing href', () => { + expect(resolveEditorTarget('', context)).to.equal(null); + expect(resolveEditorTarget(undefined, context)).to.equal(null); + }); + + it('returns null when the current org/repo context is missing', () => { + expect(resolveEditorTarget('/products/foo', {})).to.equal(null); + expect(resolveEditorTarget('/products/foo')).to.equal(null); + }); +}); + +describe('buildEditorUrl', () => { + it('omits the ref param for the main branch', () => { + const url = buildEditorUrl({ + org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main', + }); + expect(url).to.equal('/edit#/myorg/myrepo/products/foo'); + }); + + it('includes a ref param for a non-main branch', () => { + const url = buildEditorUrl({ + org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature', + }); + expect(url).to.equal('/edit?ref=feature#/myorg/myrepo/products/foo'); + }); + + it('handles the root path', () => { + const url = buildEditorUrl({ + org: 'myorg', repo: 'myrepo', path: '/', branch: 'main', + }); + expect(url).to.equal('/edit#/myorg/myrepo/'); + }); +}); \ No newline at end of file diff --git a/test/unit/blocks/edit/prose/plugins/linkMenu/linkMenu-mount.test.js b/test/unit/blocks/edit/prose/plugins/linkMenu/linkMenu-mount.test.js index ed080f97e..47bb96dc7 100644 --- a/test/unit/blocks/edit/prose/plugins/linkMenu/linkMenu-mount.test.js +++ b/test/unit/blocks/edit/prose/plugins/linkMenu/linkMenu-mount.test.js @@ -129,4 +129,34 @@ describe('linkMenu update() logic', () => { // mount path is exercised. expect(linkMenu.items.length).to.be.greaterThan(0); }); + + it('recomputes items to include "Open in editor" for a link resolvable in the current project', async () => { + const originalUrl = window.location.href; + window.history.pushState(null, '', '/edit#/myorg/myrepo/current-page'); + try { + const { state, dispatch } = editor.view; + const { schema } = state; + const linkMark = schema.marks.link; + const tr = state.tr.replaceWith( + 0, + state.doc.content.size, + schema.nodes.paragraph.create( + null, + schema.text('linked', [linkMark.create({ href: '/target-page' })]), + ), + ); + dispatch(tr); + const tr2 = editor.view.state.tr.setSelection( + TextSelection.create(editor.view.state.doc, 2), + ); + editor.view.input.lastSelectionOrigin = 'pointer'; + dispatch(tr2); + await nextFrame(); + + const linkMenu = editor.view.dom.parentNode.querySelector('link-menu'); + expect(linkMenu.items[0].title).to.equal('Open in editor'); + } finally { + window.history.pushState(null, '', originalUrl); + } + }); }); diff --git a/test/unit/blocks/edit/prose/plugins/linkMenuItems.test.js b/test/unit/blocks/edit/prose/plugins/linkMenuItems.test.js index eff7c026e..835eadea1 100644 --- a/test/unit/blocks/edit/prose/plugins/linkMenuItems.test.js +++ b/test/unit/blocks/edit/prose/plugins/linkMenuItems.test.js @@ -139,3 +139,45 @@ describe('linkMenuItems with no link at cursor', () => { expect(() => removeItem.command(editor.view.state, () => {})).not.to.throw(); }); }); + +describe('linkMenuItems "Open in editor"', () => { + const loc = { pathname: '/edit', hash: '#/myorg/myrepo/current-page', search: '' }; + + it('is omitted when the default (no href) is used', () => { + const items = getLinkMenuItems(); + expect(items).to.have.length(4); + }); + + it('is omitted when the href does not resolve to the current project', () => { + const items = getLinkMenuItems('https://example.com', loc); + expect(items).to.have.length(4); + expect(items.map((i) => i.title)).to.not.include('Open in editor'); + }); + + it('is included first when the href resolves to the current project', () => { + const items = getLinkMenuItems('/target-page', loc); + expect(items).to.have.length(5); + expect(items[0].title).to.equal('Open in editor'); + }); + + it('command opens the resolved editor URL in a new tab', () => { + const items = getLinkMenuItems('/target-page', loc); + const openInEditorItem = items[0]; + const savedOpen = window.open; + let captured; + window.open = (url, target) => { + captured = { url, target }; + return null; + }; + try { + const result = openInEditorItem.command(); + expect(result).to.be.true; + expect(captured).to.deep.equal({ + url: '/edit#/myorg/myrepo/target-page', + target: '_blank', + }); + } finally { + window.open = savedOpen; + } + }); +}); From 2dcc1f5ed3ee2cf5e7e12ed4f72961939b9ca127 Mon Sep 17 00:00:00 2001 From: Bryan Stopp Date: Mon, 20 Jul 2026 10:16:13 -0400 Subject: [PATCH 2/2] Lint. --- .../prose/plugins/linkMenu/editorTarget.js | 20 +++++------------ .../plugins/linkMenu/editorTarget.test.js | 22 +++++-------------- 2 files changed, 11 insertions(+), 31 deletions(-) diff --git a/blocks/edit/prose/plugins/linkMenu/editorTarget.js b/blocks/edit/prose/plugins/linkMenu/editorTarget.js index 619d4e667..07d8c331c 100644 --- a/blocks/edit/prose/plugins/linkMenu/editorTarget.js +++ b/blocks/edit/prose/plugins/linkMenu/editorTarget.js @@ -15,9 +15,7 @@ function parseHref(href) { if (isRelative) { if (!href.startsWith('/')) return null; - return { - org: null, site: null, branch: null, pathname: url.pathname, - }; + return { org: null, site: null, branch: null, pathname: url.pathname }; } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; @@ -25,9 +23,7 @@ function parseHref(href) { const aemMatch = url.hostname.match(AEM_HOST_RE); if (aemMatch) { const [, branch, site, org] = aemMatch; - return { - org, site, branch, pathname: url.pathname, - }; + return { org, site, branch, pathname: url.pathname }; } return null; @@ -49,21 +45,15 @@ export function resolveEditorTarget(href, context = {}) { const path = normalizePath(parsed.pathname); if (parsed.org === null) { - return { - org: currentOrg, repo: currentRepo, path, branch: currentRef, - }; + return { org: currentOrg, repo: currentRepo, path, branch: currentRef }; } if (parsed.org !== currentOrg || parsed.site !== currentRepo) return null; - return { - org: currentOrg, repo: currentRepo, path, branch: parsed.branch, - }; + return { org: currentOrg, repo: currentRepo, path, branch: parsed.branch }; } -export function buildEditorUrl({ - org, repo, path, branch, -}) { +export function buildEditorUrl({ org, repo, path, branch }) { const refParam = branch && branch !== 'main' ? `?ref=${branch}` : ''; return `/edit${refParam}#/${org}/${repo}${path}`; } diff --git a/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js b/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js index 11426cf3a..ebb0fb7a6 100644 --- a/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js +++ b/test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js @@ -6,9 +6,7 @@ const context = { org: 'myorg', repo: 'myrepo', ref: 'main' }; describe('resolveEditorTarget', () => { it('resolves a root-relative href within the current project', () => { const result = resolveEditorTarget('/products/foo', context); - expect(result).to.deep.equal({ - org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main', - }); + expect(result).to.deep.equal({ org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main' }); }); it('strips a .html extension and a trailing slash', () => { @@ -23,9 +21,7 @@ describe('resolveEditorTarget', () => { it('resolves an aem.page URL matching the current org/repo, using the hostname branch', () => { const result = resolveEditorTarget('https://feature--myrepo--myorg.aem.page/products/foo', context); - expect(result).to.deep.equal({ - org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature', - }); + expect(result).to.deep.equal({ org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature' }); }); it('resolves aem.live/hlx.page/hlx.live URLs the same way', () => { @@ -65,23 +61,17 @@ describe('resolveEditorTarget', () => { describe('buildEditorUrl', () => { it('omits the ref param for the main branch', () => { - const url = buildEditorUrl({ - org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main', - }); + const url = buildEditorUrl({ org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'main' }); expect(url).to.equal('/edit#/myorg/myrepo/products/foo'); }); it('includes a ref param for a non-main branch', () => { - const url = buildEditorUrl({ - org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature', - }); + const url = buildEditorUrl({ org: 'myorg', repo: 'myrepo', path: '/products/foo', branch: 'feature' }); expect(url).to.equal('/edit?ref=feature#/myorg/myrepo/products/foo'); }); it('handles the root path', () => { - const url = buildEditorUrl({ - org: 'myorg', repo: 'myrepo', path: '/', branch: 'main', - }); + const url = buildEditorUrl({ org: 'myorg', repo: 'myrepo', path: '/', branch: 'main' }); expect(url).to.equal('/edit#/myorg/myrepo/'); }); -}); \ No newline at end of file +});