Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,5 @@ typings/
.claude
/.scout
docs/superpowers/
.superpowers
.nvmrc
59 changes: 59 additions & 0 deletions blocks/edit/prose/plugins/linkMenu/editorTarget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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}`;
}
4 changes: 4 additions & 0 deletions blocks/edit/prose/plugins/linkMenu/link-menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
5 changes: 4 additions & 1 deletion blocks/edit/prose/plugins/linkMenu/linkMenu.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 33 additions & 3 deletions blocks/edit/prose/plugins/linkMenu/linkMenuItems.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -89,5 +117,7 @@ export function getLinkMenuItems() {
command: removeLink,
class: 'menu-item-remove-link',
},
];
);

return items;
}
77 changes: 77 additions & 0 deletions test/unit/blocks/edit/prose/plugins/linkMenu/editorTarget.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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/');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
42 changes: 42 additions & 0 deletions test/unit/blocks/edit/prose/plugins/linkMenuItems.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
Loading