Skip to content
Merged
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
28 changes: 27 additions & 1 deletion src/frontend/web/von_interface/static/js/utils/textDecorator.js
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,31 @@ function cartouchifyStandaloneVontologyCodeSpans(root) {
}
}

function unwrapVontologyConceptLinks(root, options) {
// Older responses invented /von/#V# URLs. Recover their exact IDs before
// annotation, which deliberately skips ordinary links. Do not interpret
// concept-looking fragments on external source pages as Von navigation.
const exclusions = Array.isArray(options.skipSelectors)
? options.skipSelectors.filter(selector => selector !== 'a')
: [];
for (const anchor of root.querySelectorAll('a[href]')) {
if (anchor.closest('pre, code')) continue;
if (shouldSkipTextNode(anchor.firstChild, exclusions)) continue;
try {
const url = new URL(anchor.getAttribute('href'), window.location.href);
const isVonOrigin = url.origin === window.location.origin
|| url.origin === 'https://von.curiouscat.cc';
if (!isVonOrigin || url.username || url.password || url.search) continue;
if (url.pathname !== '/von/' && url.pathname !== '/von') continue;
const conceptId = decodeURIComponent(url.hash);
if (!/^#V#[-A-Za-z0-9_./:–—]+$/.test(conceptId)) continue;
anchor.replaceWith(document.createTextNode(conceptId));
} catch (_) {
// Malformed URLs/escapes remain ordinary links.
}
}
}

/**
* Traverse existing DOM content and replace raw #V# tokens in text nodes with
* clickable anchors. This is useful after Markdown rendering.
Expand All @@ -762,6 +787,7 @@ export function linkifyVontologyTokensInElement(root, options = {}) {
if (!root) {
return;
}
unwrapVontologyConceptLinks(root, options);

const skipSelectors = Array.isArray(options.skipSelectors)
? options.skipSelectors
Expand Down Expand Up @@ -813,6 +839,7 @@ export function cartouchifyVontologyTokensInElement(root, options = {}) {
if (!root) {
return;
}
unwrapVontologyConceptLinks(root, options);

const skipSelectors = Array.isArray(options.skipSelectors)
? options.skipSelectors
Expand Down Expand Up @@ -890,4 +917,3 @@ try {
} catch (_) {
// Ignore missing window in tests.
}

73 changes: 73 additions & 0 deletions tests/browser/conceptReferenceLinks.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Isolated browser fixture: real renderer, annotator and navigation handler;
// concept metadata and terminal tab/panel dependencies are fixture-backed.
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '../../src/frontend/web/von_interface/static');
const ids = ['#V#task_agent_65d772a220b888b99f69da12723eafab', '#V#masataro_asai'];
const output = path.resolve(process.argv[2] || '.run/concept-reference-links');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/von/') {
res.setHeader('Content-Type', 'text/html');
res.end(`<!doctype html><link rel="stylesheet" href="/styles.css"><div class="chat-markdown markdown-rendered" id="chat"></div><output id="opened"></output><script type="module">
import {simpleMarkdownToHtml} from '/js/markdownUtils.js';
import {cartouchifyVontologyTokensInElement} from '/js/utils/textDecorator.js';
import {handleSelectConceptByIdDetail} from '/js/utils/selectConceptByIdHandler.js';
const ids = ${JSON.stringify(ids)};
const chat = document.querySelector('#chat');
chat.innerHTML = simpleMarkdownToHtml(ids.map(id => '[Concept](https://von.curiouscat.cc/von/' + id + ')').join('\\n') + '\\n[External source](https://example.com/paper#V#masataro_asai)');
cartouchifyVontologyTokensInElement(chat);
window.requests = [];
document.addEventListener('von:selectConceptById', async event => {
await handleSelectConceptByIdDetail(event.detail, {
fetchFn: async url => {
const id = new URL(url, location.href).searchParams.get('identifier');
window.requests.push(id);
if (!ids.includes(id)) throw Error('Unexpected identity');
return {ok: true, json: async () => ({display_name: id === ids[1] ? 'Masataro Asai' : 'Task fixture', kind: 'individual', raw_doc: {relationships: {is_an_instance_of: id === ids[0] ? ['#V#task_specification'] : ['#V#person']}}})};
},
createOrActivateConceptTab: (id, name) => { document.querySelector('#opened').textContent = 'concept:' + id + ':' + name; },
closeDynamicConceptTab: () => {},
openTaskPanel: async id => { document.querySelector('#opened').textContent = 'task:' + id; }
});
});
</script>`);
} else {
const file = path.resolve(root, '.' + url.pathname);
if (!file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); res.end(); return; }
res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript');
res.end(fs.readFileSync(file));
}
});
(async () => {
let browser;
try {
fs.mkdirSync(output, {recursive: true});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
browser = await chromium.launch({headless: true});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.address().port}/von/`);
await page.locator('.vontology-cartouche').first().waitFor();
for (const [index, id] of ids.entries()) {
await page.locator('.vontology-cartouche').nth(index).click();
const expected = index === 0 ? 'task:' + id : 'concept:' + id + ':Masataro Asai';
await page.waitForFunction(text => document.querySelector('#opened').textContent === text, expected);
}
assert.equal(await page.locator('a').getAttribute('href'), 'https://example.com/paper#V#masataro_asai');
assert.equal(await page.locator('a').getAttribute('target'), '_blank');
assert.deepEqual(await page.evaluate(() => window.requests), [ids[0], ids[0], ids[1], ids[1]]);
assert.deepEqual(errors, []);
await page.screenshot({path: path.join(output, 'concept-links.png')});
const receipt = {fixture: true, canonicalDataVerified: false, ids, navigation: ['task panel', 'concept tab'], externalLinkPreserved: true, pageErrors: errors};
fs.writeFileSync(path.join(output, 'receipt.json'), JSON.stringify(receipt, null, 2));
console.log(JSON.stringify(receipt));
} finally {
if (browser) await browser.close();
server.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });
62 changes: 62 additions & 0 deletions tests/frontend/textDecoratorConceptLinks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** @jest-environment jsdom */
const { simpleMarkdownToHtml, renderMarkdownViaServer } = require('../../src/frontend/web/von_interface/static/js/markdownUtils.js');
const { cartouchifyVontologyTokensInElement, linkifyVontologyTokensInElement } = require('../../src/frontend/web/von_interface/static/js/utils/textDecorator.js');

const ids = ['#V#task_agent_65d772a220b888b99f69da12723eafab', '#V#masataro_asai'];

beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; });
afterEach(() => { delete global.fetch; });

test.each(ids)('Markdown concept URL navigates with the exact identity %s', (id) => {
const root = document.getElementById('root');
root.innerHTML = simpleMarkdownToHtml(`[Readable label](https://von.curiouscat.cc/von/${id})`);
cartouchifyVontologyTokensInElement(root);
const button = root.querySelector('button');
expect(button.dataset.fullConceptId).toBe(id);
const selected = jest.fn();
root.addEventListener('von:selectConceptById', selected);
button.click();
expect(selected.mock.calls[0][0].detail).toMatchObject({ conceptId: id.slice(3), createConceptTab: true });
expect(root.querySelector('a')).toBeNull();
});

test('server-rendered anchors, bare URLs, relative URLs and bare IDs share annotation', async () => {
const root = document.getElementById('root');
global.fetch = jest.fn(async () => ({ ok: true, json: async () => ({ html: `<p><a href="/von/#${encodeURIComponent(ids[0].slice(1))}">Task</a> ${ids[1]}</p>` }) }));
root.innerHTML = await renderMarkdownViaServer('server fixture');
cartouchifyVontologyTokensInElement(root);
expect([...root.querySelectorAll('button')].map(el => el.dataset.fullConceptId)).toEqual(ids);
root.innerHTML = simpleMarkdownToHtml(`https://von.curiouscat.cc/von/${ids[1]}`);
linkifyVontologyTokensInElement(root);
const selected = jest.fn();
root.addEventListener('von:selectConceptById', selected);
root.querySelector('a').click();
expect(selected.mock.calls[0][0].detail.conceptId).toBe(ids[1].slice(3));
});

test('external sources, unrelated routes, malformed fragments and literal code stay untouched', () => {
const root = document.getElementById('root');
const hrefs = [
'https://example.com/paper',
`https://example.com/von/${ids[1]}`,
`https://von.curiouscat.cc.example.com/von/${ids[1]}`,
`https://von.curiouscat.cc/source/${ids[1]}`,
`https://von.curiouscat.cc/von/?task=other${ids[1]}`,
'https://von.curiouscat.cc/von/#V#bad%ZZ',
'https://von.curiouscat.cc/von/#V#bad%20id'
];
root.innerHTML = simpleMarkdownToHtml(hrefs.map(href => `[Source](${href})`).join('\n') + `\n\n\`https://von.curiouscat.cc/von/${ids[1]}\``);
const before = root.innerHTML;
cartouchifyVontologyTokensInElement(root);
expect(root.innerHTML).toBe(before);
expect([...root.querySelectorAll('a')].map(el => el.getAttribute('href'))).toEqual(hrefs);
expect([...root.querySelectorAll('a')].every(el => el.target === '_blank')).toBe(true);
});

test('caller-excluded content is not normalised', () => {
const root = document.getElementById('root');
root.innerHTML = `<div class="conversation-visual"><a href="https://von.curiouscat.cc/von/${ids[1]}">Literal diagram link</a></div>`;
const before = root.innerHTML;
cartouchifyVontologyTokensInElement(root, {skipSelectors: ['a', '.conversation-visual']});
expect(root.innerHTML).toBe(before);
});
Loading