From 95e89026ce3df3bc08cc5fb5149281a90ad728e0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 16 Aug 2026 10:57:04 +1200 Subject: [PATCH 1/6] Preserve markup during automatic upgrade --- Syntax/CodeElement.js | 6 ++++++ test/Syntax/CodeElement.js | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/Syntax/CodeElement.js b/Syntax/CodeElement.js index 5fc8615..e01d1fc 100644 --- a/Syntax/CodeElement.js +++ b/Syntax/CodeElement.js @@ -312,6 +312,12 @@ export function upgradeAll(selector, syntax = null) { const elements = document.querySelectorAll(selector); for (const element of elements) { + // Existing markup may contain links or other semantics which cannot be + // reconstructed from text content after highlighting. + if (element.childElementCount > 0) { + continue; + } + // Create a syntax-code wrapper const wrapper = document.createElement('syntax-code'); if (syntax) { diff --git a/test/Syntax/CodeElement.js b/test/Syntax/CodeElement.js index aa6cbe2..32f8b22 100644 --- a/test/Syntax/CodeElement.js +++ b/test/Syntax/CodeElement.js @@ -242,6 +242,23 @@ test('upgradeAll can handle standalone blocks with custom selector', asyn ); }); +test('upgradeAll preserves code containing markup', async () => { + const {upgradeAll} = await import('../../Syntax/CodeElement.js'); + + document.body.innerHTML = ` + class Foo + `; + + upgradeAll('code[class*="language-"]'); + + const code = document.querySelector('code'); + const link = code.querySelector('a'); + + assert.equal(document.querySelector('syntax-code'), null); + assert.equal(code.textContent, 'class Foo'); + assert.equal(link.getAttribute('href'), '/source/Foo'); +}); + test('syntax-code behaves semantically like when inline', async () => { const {CodeElement} = await import('../../Syntax/CodeElement.js'); From 25556dfc44283a3e3fbaeaf776f8968109890d7f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 16 Aug 2026 11:05:28 +1200 Subject: [PATCH 2/6] Restore markup annotations during highlighting --- Syntax/CodeElement.js | 83 ++++++++++++++++++++++++++++++++------ Syntax/Language.js | 7 +++- Syntax/Match.js | 9 ++++- test/Syntax/CodeElement.js | 42 ++++++++++++++++--- 4 files changed, 118 insertions(+), 23 deletions(-) diff --git a/Syntax/CodeElement.js b/Syntax/CodeElement.js index e01d1fc..4664d83 100644 --- a/Syntax/CodeElement.js +++ b/Syntax/CodeElement.js @@ -1,9 +1,64 @@ import Syntax from '../Syntax.js'; +import {Match} from './Match.js'; const supportsAdopted = typeof CSSStyleSheet !== 'undefined' && 'adoptedStyleSheets' in Document.prototype; +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const CDATA_SECTION_NODE = 4; + +/** + * Extract the source text and existing markup as source-aligned matches. + * + * The highlighting pipeline can then insert these matches into the syntax + * tree, preserving elements such as links while allowing their contents to + * receive syntax highlighting. + */ +function extractCode(root) { + let text = ''; + const matches = []; + + function extract(node) { + if (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) { + text += node.nodeValue.replace(/\r/g, ''); + return; + } + + if (node.nodeType !== ELEMENT_NODE) { + return; + } + + if (node.tagName === 'BR') { + text += '\n'; + return; + } + + const offset = text.length; + let match = null; + + if (node !== root) { + match = new Match(offset, 0, {element: node, force: true, allow: '*'}, ''); + matches.push(match); + } + + for (const child of node.childNodes) { + extract(child); + } + + if (match) { + match.length = text.length - offset; + match.endOffset = text.length; + match.value = text.slice(offset); + } + } + + extract(root); + + return {text, matches: matches.filter(match => match.length > 0)}; +} + /** * CodeElement - Web Component for syntax highlighting with isolated styles * @@ -178,16 +233,16 @@ export class CodeElement extends HTMLElement { } /** - * Get the code content to highlight + * Get the source text and existing markup to highlight. */ #getCodeContent() { // Check if there's a child element const codeElement = this.querySelector('code'); if (codeElement) { - return codeElement.textContent; + return extractCode(codeElement); } - return this.textContent; + return extractCode(this); } /** @@ -249,7 +304,7 @@ export class CodeElement extends HTMLElement { async #render() { try { const languageName = this.language; - const code = this.#getCodeContent(); + const {text: code, matches} = this.#getCodeContent(); if (!languageName) { console.warn(': No language specified'); @@ -271,7 +326,12 @@ export class CodeElement extends HTMLElement { // Highlight off-DOM so the original source remains visible while all // asynchronous work is in progress: - const highlighted = await language.process(this.syntax, code); + const highlighted = await language.process( + this.syntax, + code, + undefined, + matches + ); // Swap the completed rendering in synchronously. On the first render, // the slot keeps the light-DOM source visible. On subsequent renders, @@ -312,12 +372,6 @@ export function upgradeAll(selector, syntax = null) { const elements = document.querySelectorAll(selector); for (const element of elements) { - // Existing markup may contain links or other semantics which cannot be - // reconstructed from text content after highlighting. - if (element.childElementCount > 0) { - continue; - } - // Create a syntax-code wrapper const wrapper = document.createElement('syntax-code'); if (syntax) { @@ -343,8 +397,11 @@ export function upgradeAll(selector, syntax = null) { wrapper.setAttribute('language', language); } - // Copy the code content into the wrapper - wrapper.textContent = element.textContent; + // Move the source content into the wrapper so existing markup remains + // available for extraction and re-rendering. + while (element.firstChild) { + wrapper.appendChild(element.firstChild); + } // Replace with , leaving
 parent in place
 		const parent = element.parentElement;
diff --git a/Syntax/Language.js b/Syntax/Language.js
index 9693340..9a460d9 100644
--- a/Syntax/Language.js
+++ b/Syntax/Language.js
@@ -239,9 +239,12 @@ export class Language {
 
 	/**
 	 * Build a syntax tree and process it into HTML.
+	 *
+	 * Additional matches can preserve source annotations, such as links, by
+	 * inserting them into the syntax tree before it is reduced to HTML.
 	 */
-	async process(syntax, text, options) {
-		const top = await this.buildTree(syntax, text, 0);
+	async process(syntax, text, options, additionalMatches) {
+		const top = await this.buildTree(syntax, text, 0, additionalMatches);
 
 		const lines = top.splitLines();
 
diff --git a/Syntax/Match.js b/Syntax/Match.js
index 9c92f59..2133f74 100644
--- a/Syntax/Match.js
+++ b/Syntax/Match.js
@@ -284,8 +284,13 @@ export class Match {
 		if (parts[1]) {
 			match.children = [];
 
-			// Update the match's expression based on the current position in the tree:
-			if (this.expression && this.expression.owner) {
+			// Element-backed matches describe authored markup and must retain their
+			// original expression so reduction can recreate that element.
+			if (
+				this.expression &&
+				this.expression.owner &&
+				!match.expression.element
+			) {
 				match.expression =
 					this.expression.owner.getRuleForType(match.expression.type) ||
 					match.expression;
diff --git a/test/Syntax/CodeElement.js b/test/Syntax/CodeElement.js
index 32f8b22..ba6595a 100644
--- a/test/Syntax/CodeElement.js
+++ b/test/Syntax/CodeElement.js
@@ -242,7 +242,7 @@ test('upgradeAll can handle standalone  blocks with custom selector', asyn
 	);
 });
 
-test('upgradeAll preserves code containing markup', async () => {
+test('upgradeAll preserves and highlights code containing markup', async () => {
 	const {upgradeAll} = await import('../../Syntax/CodeElement.js');
 
 	document.body.innerHTML = `
@@ -251,12 +251,42 @@ test('upgradeAll preserves code containing markup', async () => {
 
 	upgradeAll('code[class*="language-"]');
 
-	const code = document.querySelector('code');
-	const link = code.querySelector('a');
+	const element = document.querySelector('syntax-code');
+	await element.ready;
+
+	const sourceLink = element.querySelector('a');
+	const renderedLink = element.shadowRoot.querySelector('a');
+
+	assert.equal(element.textContent, 'class Foo');
+	assert.equal(sourceLink.getAttribute('href'), '/source/Foo');
+	assert.equal(renderedLink.getAttribute('href'), '/source/Foo');
+	assert.equal(renderedLink.textContent, 'Foo');
+	assert.ok(
+		renderedLink.closest('.type'),
+		'linked source should still receive syntax highlighting'
+	);
+});
+
+test('upgradeAll preserves nested markup structure', async () => {
+	const {upgradeAll} = await import('../../Syntax/CodeElement.js');
+
+	document.body.innerHTML = `
+		Foo::Bar
+	`;
+
+	upgradeAll('code[class*="language-"]');
+
+	const element = document.querySelector('syntax-code');
+	await element.ready;
+
+	const renderedLink = element.shadowRoot.querySelector('a');
+	const renderedStrong = renderedLink.querySelector('strong');
 
-	assert.equal(document.querySelector('syntax-code'), null);
-	assert.equal(code.textContent, 'class Foo');
-	assert.equal(link.getAttribute('href'), '/source/Foo');
+	assert.equal(renderedLink.getAttribute('href'), '/source/Foo');
+	assert.equal(renderedLink.textContent, 'Foo::Bar');
+	assert.equal(renderedStrong.textContent, 'Foo');
+	assert.ok(renderedStrong.closest('.type'));
+	assert.equal(renderedLink.querySelectorAll('.type').length, 2);
 });
 
 test('syntax-code behaves semantically like  when inline', async () => {

From 9c225e8ec17d12c907bde71238aaaf93da999c3d Mon Sep 17 00:00:00 2001
From: Samuel Williams 
Date: Sun, 16 Aug 2026 11:08:45 +1200
Subject: [PATCH 3/6] Preserve authored links during generated link processing

---
 Syntax/Match.js            |  1 +
 Syntax/Rule.js             | 16 +++++++++++++
 test/Syntax/CodeElement.js | 46 ++++++++++++++++++++++++++++++++++++++
 test/Syntax/Rule.js        | 35 +++++++++++++++++++++++++++++
 4 files changed, 98 insertions(+)

diff --git a/Syntax/Match.js b/Syntax/Match.js
index 2133f74..5b01f5f 100644
--- a/Syntax/Match.js
+++ b/Syntax/Match.js
@@ -108,6 +108,7 @@ export class Match {
 
 		for (const child of this.children) {
 			const end = child.offset;
+			child.parent = this;
 
 			if (child.offset < this.offset) {
 				console.warn(
diff --git a/Syntax/Rule.js b/Syntax/Rule.js
index ada0da2..65619b6 100644
--- a/Syntax/Rule.js
+++ b/Syntax/Rule.js
@@ -288,6 +288,22 @@ export class Rule {
 	 */
 	static webLinkProcess(baseUrl) {
 		return function (container, match, options) {
+			// Authored links take precedence over generated documentation links.
+			// Depending on the source ranges, the authored link may be either an
+			// ancestor or a descendant of this syntax match.
+			let current = match;
+			while (current) {
+				if (current.expression?.element?.tagName === 'A') {
+					return container;
+				}
+
+				current = current.parent;
+			}
+
+			if (container.matches('a') || container.querySelector('a')) {
+				return container;
+			}
+
 			// Replace the span with an anchor element
 			const anchor = document.createElement('a');
 
diff --git a/test/Syntax/CodeElement.js b/test/Syntax/CodeElement.js
index ba6595a..7c3efef 100644
--- a/test/Syntax/CodeElement.js
+++ b/test/Syntax/CodeElement.js
@@ -289,6 +289,52 @@ test('upgradeAll preserves nested markup structure', async () => {
 	assert.equal(renderedLink.querySelectorAll('.type').length, 2);
 });
 
+test('upgradeAll preserves markup spanning multiple lines', async () => {
+	const {upgradeAll} = await import('../../Syntax/CodeElement.js');
+
+	document.body.innerHTML =
+		'Foo\nBar';
+
+	upgradeAll('code[class*="language-"]');
+
+	const element = document.querySelector('syntax-code');
+	await element.ready;
+
+	const renderedLinks = [...element.shadowRoot.querySelectorAll('a')];
+
+	assert.equal(element.lineCount, 2);
+	assert.deepEqual(
+		renderedLinks.map(link => link.textContent),
+		['Foo\n', 'Bar']
+	);
+	assert.ok(
+		renderedLinks.every(link => link.getAttribute('href') === '/source/Foo')
+	);
+});
+
+test('syntax-code preserves markup when re-rendering', async () => {
+	await import('../../Syntax/CodeElement.js');
+
+	document.body.innerHTML =
+		'Foo';
+
+	const element = document.querySelector('syntax-code');
+	await element.ready;
+
+	const firstLink = element.shadowRoot.querySelector('a');
+	assert.equal(firstLink.getAttribute('href'), '/source/Foo');
+	assert.equal(firstLink.dataset.kind, 'class');
+
+	element.language = 'python';
+	await element.ready;
+
+	const secondLink = element.shadowRoot.querySelector('a');
+	assert.notEqual(secondLink, firstLink);
+	assert.equal(secondLink.getAttribute('href'), '/source/Foo');
+	assert.equal(secondLink.dataset.kind, 'class');
+	assert.equal(secondLink.textContent, 'Foo');
+});
+
 test('syntax-code behaves semantically like  when inline', async () => {
 	const {CodeElement} = await import('../../Syntax/CodeElement.js');
 
diff --git a/test/Syntax/Rule.js b/test/Syntax/Rule.js
index 3215905..adc3fdf 100644
--- a/test/Syntax/Rule.js
+++ b/test/Syntax/Rule.js
@@ -249,3 +249,38 @@ test('webLinkProcess preserves nested HTML', () => {
 	);
 	assert.strictEqual(result.className, 'function');
 });
+
+test('webLinkProcess preserves a nested authored link', () => {
+	const process = Rule.webLinkProcess('http://docs.example.com/');
+	const container = document.createElement('span');
+	container.innerHTML = 'Foo';
+
+	const match = {
+		value: 'Foo',
+		expression: {type: 'type'}
+	};
+
+	assert.strictEqual(process(container, match, {}), container);
+	assert.strictEqual(
+		container.querySelector('a').getAttribute('href'),
+		'/source/Foo'
+	);
+});
+
+test('webLinkProcess preserves an authored link from a parent match', () => {
+	const process = Rule.webLinkProcess('http://docs.example.com/');
+	const container = document.createElement('span');
+	container.textContent = 'Foo';
+
+	const sourceLink = document.createElement('a');
+	const match = {
+		value: 'Foo',
+		expression: {type: 'type'},
+		parent: {
+			expression: {element: sourceLink},
+			parent: null
+		}
+	};
+
+	assert.strictEqual(process(container, match, {}), container);
+});

From c43c8f1be6c66f9f057360a0680ddb16f8a94701 Mon Sep 17 00:00:00 2001
From: Samuel Williams 
Date: Sun, 16 Aug 2026 11:13:11 +1200
Subject: [PATCH 4/6] Document DOM node constants

---
 Syntax/CodeElement.js | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/Syntax/CodeElement.js b/Syntax/CodeElement.js
index 4664d83..57c5176 100644
--- a/Syntax/CodeElement.js
+++ b/Syntax/CodeElement.js
@@ -5,6 +5,9 @@ const supportsAdopted =
 	typeof CSSStyleSheet !== 'undefined' &&
 	'adoptedStyleSheets' in Document.prototype;
 
+// These values are defined by the DOM standard. Keep them local so this code
+// does not depend on a global `Node`, which may be unavailable in non-browser
+// DOM implementations.
 const ELEMENT_NODE = 1;
 const TEXT_NODE = 3;
 const CDATA_SECTION_NODE = 4;

From d5afad0ba489ccafda5e4183249959cb455d40f7 Mon Sep 17 00:00:00 2001
From: Samuel Williams 
Date: Sun, 16 Aug 2026 11:14:51 +1200
Subject: [PATCH 5/6] Soft wrap link precedence comment

---
 Syntax/Rule.js | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/Syntax/Rule.js b/Syntax/Rule.js
index 65619b6..980d479 100644
--- a/Syntax/Rule.js
+++ b/Syntax/Rule.js
@@ -288,9 +288,7 @@ export class Rule {
 	 */
 	static webLinkProcess(baseUrl) {
 		return function (container, match, options) {
-			// Authored links take precedence over generated documentation links.
-			// Depending on the source ranges, the authored link may be either an
-			// ancestor or a descendant of this syntax match.
+			// Authored links take precedence over generated documentation links. Depending on the source ranges, the authored link may be either an ancestor or a descendant of this syntax match.
 			let current = match;
 			while (current) {
 				if (current.expression?.element?.tagName === 'A') {

From 091ec31999114db710a40bc6a8505ec9e22dd50c Mon Sep 17 00:00:00 2001
From: Samuel Williams 
Date: Sun, 16 Aug 2026 11:16:15 +1200
Subject: [PATCH 6/6] Revert "Soft wrap link precedence comment"

This reverts commit d5afad0ba489ccafda5e4183249959cb455d40f7.
---
 Syntax/Rule.js | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/Syntax/Rule.js b/Syntax/Rule.js
index 980d479..65619b6 100644
--- a/Syntax/Rule.js
+++ b/Syntax/Rule.js
@@ -288,7 +288,9 @@ export class Rule {
 	 */
 	static webLinkProcess(baseUrl) {
 		return function (container, match, options) {
-			// Authored links take precedence over generated documentation links. Depending on the source ranges, the authored link may be either an ancestor or a descendant of this syntax match.
+			// Authored links take precedence over generated documentation links.
+			// Depending on the source ranges, the authored link may be either an
+			// ancestor or a descendant of this syntax match.
 			let current = match;
 			while (current) {
 				if (current.expression?.element?.tagName === 'A') {