diff --git a/src/frontend/web/von_interface/static/js/utils/textDecorator.js b/src/frontend/web/von_interface/static/js/utils/textDecorator.js index 26df1482..06ef1b5e 100644 --- a/src/frontend/web/von_interface/static/js/utils/textDecorator.js +++ b/src/frontend/web/von_interface/static/js/utils/textDecorator.js @@ -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. @@ -762,6 +787,7 @@ export function linkifyVontologyTokensInElement(root, options = {}) { if (!root) { return; } + unwrapVontologyConceptLinks(root, options); const skipSelectors = Array.isArray(options.skipSelectors) ? options.skipSelectors @@ -813,6 +839,7 @@ export function cartouchifyVontologyTokensInElement(root, options = {}) { if (!root) { return; } + unwrapVontologyConceptLinks(root, options); const skipSelectors = Array.isArray(options.skipSelectors) ? options.skipSelectors @@ -890,4 +917,3 @@ try { } catch (_) { // Ignore missing window in tests. } - diff --git a/tests/browser/conceptReferenceLinks.cjs b/tests/browser/conceptReferenceLinks.cjs new file mode 100644 index 00000000..55213864 --- /dev/null +++ b/tests/browser/conceptReferenceLinks.cjs @@ -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(`
`); + } 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; }); diff --git a/tests/frontend/textDecoratorConceptLinks.test.js b/tests/frontend/textDecoratorConceptLinks.test.js new file mode 100644 index 00000000..33c0a7a4 --- /dev/null +++ b/tests/frontend/textDecoratorConceptLinks.test.js @@ -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 = '
'; }); +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: `

Task ${ids[1]}

` }) })); + 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 = `
Literal diagram link
`; + const before = root.innerHTML; + cartouchifyVontologyTokensInElement(root, {skipSelectors: ['a', '.conversation-visual']}); + expect(root.innerHTML).toBe(before); +});