feat: 支持后端Argon代码块运行时

This commit is contained in:
sunlei 2026-07-01 16:47:15 +08:00
parent d9aa7d9cc5
commit 52cbef7d65
4 changed files with 415 additions and 1 deletions

View File

@ -1,4 +1,4 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test'; import { expect, test, type Page, type Route, type TestInfo } from '@playwright/test';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { import {
@ -9,6 +9,7 @@ import {
import { import {
captureAllRouteStates, captureAllRouteStates,
gotoLocalRoute, gotoLocalRoute,
waitForArgonPage,
writeArgonArtifact, writeArgonArtifact,
} from './argonParityHelpers'; } from './argonParityHelpers';
@ -16,6 +17,28 @@ interface PageFixture {
page: Page; page: Page;
} }
const BACKEND_BASE_CODEBLOCK_ARTICLE = {
authorName: 'KwiTsukasa',
categoriesResolved: [
{
name: 'public',
slug: 'public',
},
],
contentHtml: `<h2 class="wp-block-heading" id="header-id-1">后端代码块运行时</h2>
<pre class="wp-block-code hljs-codeblock"><code class="hljs typescript">const a = 1;
const b = a + 1;</code></pre>`,
date: '2026-07-01T16:40:00.000Z',
excerptText: '后端只生成基础 Argon 代码块Blog Web 运行时补齐行号和控制条。',
id: 909001,
modified: '2026-07-01T16:40:00.000Z',
slug: 'backend-codeblock-runtime',
tagsResolved: [],
title: {
rendered: '后端代码块运行时',
},
};
/** /**
* Registers representative Argon interaction checks against the local Vue implementation. * Registers representative Argon interaction checks against the local Vue implementation.
*/ */
@ -33,6 +56,7 @@ function registerLocalInteractionSuite() {
test('local post page exposes share, comments, and reading progress', createPostInteractionTest()); test('local post page exposes share, comments, and reading progress', createPostInteractionTest());
test('local post codeblocks match Argon hljs layout', createPostCodeblockSurfaceTest()); test('local post codeblocks match Argon hljs layout', createPostCodeblockSurfaceTest());
test('local post codeblock controls match Argon runtime toggles', createPostCodeblockControlBehaviorTest()); test('local post codeblock controls match Argon runtime toggles', createPostCodeblockControlBehaviorTest());
test('local post upgrades backend-generated base Argon codeblocks at runtime', createBackendBaseCodeblockRuntimeTest());
test('local post codeblock copy exposes Argon toast feedback', createPostCodeblockCopyFeedbackTest()); test('local post codeblock copy exposes Argon toast feedback', createPostCodeblockCopyFeedbackTest());
test('local post links match live WordPress Argon inline semantics', createPostInlineLinkSurfaceTest()); test('local post links match live WordPress Argon inline semantics', createPostInlineLinkSurfaceTest());
test('local post fancybox wrappers open live Argon lightbox shell', createPostFancyboxRuntimeTest()); test('local post fancybox wrappers open live Argon lightbox shell', createPostFancyboxRuntimeTest());
@ -764,6 +788,40 @@ function createPostCodeblockControlBehaviorTest() {
}; };
} }
/**
* @returns Playwright test body that verifies backend-generated base codeblocks are upgraded on page bind.
*/
function createBackendBaseCodeblockRuntimeTest() {
/**
* @param fixtures Playwright page fixture used to intercept the public article API and render a base codeblock.
*/
return async function runBackendBaseCodeblockRuntimeTest({ page }: PageFixture) {
await page.route('**/api/blog/article/public/list**', fulfillBackendBaseCodeblockArticleList);
await page.route('**/api/blog/article/public/detail**', fulfillBackendBaseCodeblockArticleDetail);
await page.goto('/#/post/backend-codeblock-runtime', { waitUntil: 'domcontentloaded' });
await waitForArgonPage(page);
const pre = page.locator('#post_content pre.hljs-codeblock, #post_content pre.wp-block-code.hljs-codeblock').first();
const lineCodes = pre.locator('.hljs-ln-code');
await expect(page.getByText('后端代码块运行时').first()).toBeVisible();
await expect(pre).toBeVisible();
await expect(pre.locator('table.hljs-ln')).toHaveCount(1);
await expect(pre.locator('table.hljs-ln > tbody > tr')).toHaveCount(2);
await expect(pre.locator(':scope > .hljs-control')).toHaveCount(1);
await expect(pre.locator('.hljs-control-btn')).toHaveCount(4);
await expect(lineCodes.nth(0)).toHaveText('const a = 1;');
await expect(lineCodes.nth(0)).toHaveAttribute('data-line-number', '1');
await expect(lineCodes.nth(1)).toHaveText('const b = a + 1;');
await expect(lineCodes.nth(1)).toHaveAttribute('data-line-number', '2');
await pre.locator('.hljs-control-toggle-break-line').click();
await expect(pre).toHaveClass(/hljs-break-line/);
await pre.locator('.hljs-control-toggle-linenumber').click();
await expect(pre).toHaveClass(/hljs-hide-linenumber/);
};
}
/** /**
* @returns Playwright test body that verifies code copy produces Argon's toast-style user feedback. * @returns Playwright test body that verifies code copy produces Argon's toast-style user feedback.
*/ */
@ -790,6 +848,37 @@ function createPostCodeblockCopyFeedbackTest() {
}; };
} }
/**
* @param route Intercepted Blog article list request from the local Vue app.
*/
async function fulfillBackendBaseCodeblockArticleList(route: Route) {
await route.fulfill({
contentType: 'application/json',
json: {
code: 0,
data: {
list: [BACKEND_BASE_CODEBLOCK_ARTICLE],
total: 1,
},
msg: 'ok',
},
});
}
/**
* @param route Intercepted Blog article detail request from the local Vue app.
*/
async function fulfillBackendBaseCodeblockArticleDetail(route: Route) {
await route.fulfill({
contentType: 'application/json',
json: {
code: 0,
data: BACKEND_BASE_CODEBLOCK_ARTICLE,
msg: 'ok',
},
});
}
/** /**
* @returns Playwright test body that verifies inline WordPress links inherit Argon's live color and hover underline. * @returns Playwright test body that verifies inline WordPress links inherit Argon's live color and hover underline.
*/ */

View File

@ -0,0 +1,117 @@
import { describe, expect, it, afterEach } from 'vitest';
import { upgradeArgonCodeblocks } from '@/factories/argonCodeblockFactory';
describe('upgradeArgonCodeblocks', registerArgonCodeblockFactoryTests);
/**
* Registers Argon codeblock runtime upgrade tests against jsdom-rendered post content.
*/
function registerArgonCodeblockFactoryTests() {
afterEach(resetDocumentBody);
it('upgrades backend-generated base codeblocks into Argon line tables and controls', runBaseCodeblockUpgradeTest);
it('ignores the serializer newline appended to markdown fenced code blocks', runTrailingNewlineCodeblockTest);
it('keeps existing WordPress runtime codeblocks idempotent', runExistingRuntimeCodeblockIdempotenceTest);
}
/**
* Clears article markup between tests because the factory mutates the supplied DOM root in place.
*/
function resetDocumentBody() {
document.body.innerHTML = '';
}
/**
* Verifies plain backend code text becomes the line-number table and control shell expected by Argon.
*/
function runBaseCodeblockUpgradeTest() {
document.body.innerHTML = `
<article id="post_content">
<pre class="wp-block-code hljs-codeblock"><code class="hljs typescript">const a = 1;
const b = a + 1;</code></pre>
</article>
`;
const root = document.querySelector<HTMLElement>('#post_content')!;
upgradeArgonCodeblocks(root);
const pre = root.querySelector<HTMLElement>('pre.wp-block-code.hljs-codeblock')!;
const rows = pre.querySelectorAll('table.hljs-ln > tbody > tr');
const lineNumbers = pre.querySelectorAll<HTMLElement>('.hljs-ln-numbers');
const lineNumberInners = pre.querySelectorAll<HTMLElement>('.hljs-ln-n');
const lineCodes = pre.querySelectorAll<HTMLElement>('.hljs-ln-code');
expect(rows).toHaveLength(2);
expect(lineNumbers).toHaveLength(2);
expect(lineNumberInners).toHaveLength(2);
expect(lineCodes).toHaveLength(2);
expect(lineNumbers[0]?.dataset.lineNumber).toBe('1');
expect(lineNumberInners[0]?.dataset.lineNumber).toBe('1');
expect(lineCodes[0]?.dataset.lineNumber).toBe('1');
expect(lineCodes[0]?.textContent).toBe('const a = 1;');
expect(lineNumbers[1]?.dataset.lineNumber).toBe('2');
expect(lineNumberInners[1]?.dataset.lineNumber).toBe('2');
expect(lineCodes[1]?.dataset.lineNumber).toBe('2');
expect(lineCodes[1]?.textContent).toBe('const b = a + 1;');
expect(pre.querySelectorAll(':scope > .hljs-control')).toHaveLength(1);
expect(pre.querySelector('.hljs-control-toggle-linenumber')).not.toBeNull();
expect(pre.querySelector('.hljs-control-toggle-break-line')).not.toBeNull();
expect(pre.querySelector('.hljs-control-copy')).not.toBeNull();
expect(pre.querySelector('.hljs-control-fullscreen')).not.toBeNull();
}
/**
* Verifies API-rendered fenced code blocks do not show an extra blank Argon line for serializer-only newline.
*/
function runTrailingNewlineCodeblockTest() {
document.body.innerHTML = `
<article id="post_content">
<pre class="wp-block-code hljs-codeblock"><code class="hljs typescript">const a = 1;
const b = a + 1;
</code></pre>
</article>
`;
const root = document.querySelector<HTMLElement>('#post_content')!;
upgradeArgonCodeblocks(root);
const pre = root.querySelector<HTMLElement>('pre.wp-block-code.hljs-codeblock')!;
const lineCodes = pre.querySelectorAll<HTMLElement>('.hljs-ln-code');
expect(pre.querySelectorAll('table.hljs-ln > tbody > tr')).toHaveLength(2);
expect(lineCodes[0]?.textContent).toBe('const a = 1;');
expect(lineCodes[1]?.textContent).toBe('const b = a + 1;');
}
/**
* Verifies saved WordPress runtime DOM is detected and not wrapped with duplicate tables or controls.
*/
function runExistingRuntimeCodeblockIdempotenceTest() {
document.body.innerHTML = `
<article id="post_content">
<pre class="wp-block-code hljs-codeblock">
<code class="hljs typescript" hljs-codeblock-inner="">
<table class="hljs-ln"><tbody><tr>
<td class="hljs-ln-line hljs-ln-numbers hljs"><div class="hljs-ln-n"></div></td>
<td class="hljs-ln-line hljs-ln-code">const a = 1;</td>
</tr></tbody></table>
</code>
<div class="hljs-control hljs hljs-title">
<div class="hljs-control-btn hljs-control-toggle-linenumber"><i class="fa fa-list"></i></div>
</div>
</pre>
</article>
`;
const root = document.querySelector<HTMLElement>('#post_content')!;
upgradeArgonCodeblocks(root);
upgradeArgonCodeblocks(root);
const pre = root.querySelector<HTMLElement>('pre.wp-block-code.hljs-codeblock')!;
expect(pre.querySelectorAll('table.hljs-ln')).toHaveLength(1);
expect(pre.querySelectorAll(':scope > .hljs-control')).toHaveLength(1);
expect(pre.querySelectorAll('table.hljs-ln > tbody > tr')).toHaveLength(1);
expect(pre.querySelector('.hljs-ln-code')?.textContent).toBe('const a = 1;');
}

View File

@ -0,0 +1,206 @@
const ARGON_CODEBLOCK_SELECTOR = 'pre.hljs-codeblock, pre.wp-block-code.hljs-codeblock';
/**
* @param root Rendered post content root that may contain backend-generated base Highlight.js blocks.
*
* Upgrades plain `<pre><code>` Argon codeblocks into the line-number table and control shell that the
* live WordPress Argon runtime normally creates, while preserving existing runtime DOM idempotently.
*/
export function upgradeArgonCodeblocks(root: HTMLElement) {
root.querySelectorAll<HTMLElement>(ARGON_CODEBLOCK_SELECTOR).forEach(upgradeArgonCodeblock);
}
/**
* @param codeBlock Highlight.js `<pre>` block to upgrade in place.
*/
function upgradeArgonCodeblock(codeBlock: HTMLElement) {
const codeElement = getDirectCodeElement(codeBlock);
if (!codeElement) {
return;
}
if (!hasDirectLineNumberTable(codeElement)) {
const lines = splitCodeText(codeElement.textContent ?? '');
codeElement.replaceChildren(createArgonLineTable(lines));
codeElement.setAttribute('hljs-codeblock-inner', '');
}
if (!getDirectControl(codeBlock)) {
codeBlock.append(createArgonControl());
}
}
/**
* @param codeBlock Highlight.js `<pre>` block whose immediate `<code>` child owns code content.
* @returns Direct `<code>` child, or null when the block is malformed.
*/
function getDirectCodeElement(codeBlock: HTMLElement) {
for (const child of codeBlock.children) {
if (child.tagName.toLowerCase() === 'code') {
return child as HTMLElement;
}
}
return null;
}
/**
* @param codeElement Direct code element that may already contain Argon's line-number table.
* @returns Whether the direct code child already owns a runtime `table.hljs-ln`.
*/
function hasDirectLineNumberTable(codeElement: HTMLElement) {
for (const child of codeElement.children) {
if (child.matches('table.hljs-ln')) {
return true;
}
}
return false;
}
/**
* @param codeBlock Highlight.js `<pre>` block that may already own Argon's control shell.
* @returns Direct `.hljs-control` child, or null when controls still need to be created.
*/
function getDirectControl(codeBlock: HTMLElement) {
for (const child of codeBlock.children) {
if (child.matches('.hljs-control')) {
return child as HTMLElement;
}
}
return null;
}
/**
* @param codeText Plain code text from backend-generated HTML.
* @returns Code lines preserving author-written empty lines while dropping one serializer-only trailing newline.
*/
function splitCodeText(codeText: string) {
const lines = codeText.replace(/\r\n?/g, '\n').split('\n');
if (lines.length > 1 && lines[lines.length - 1] === '') {
return lines.slice(0, -1);
}
return lines;
}
/**
* @param lines Plain code lines to render into Highlight.js line-number rows.
* @returns Argon-compatible line-number table.
*/
function createArgonLineTable(lines: string[]) {
const table = document.createElement('table');
const tbody = document.createElement('tbody');
table.className = 'hljs-ln';
for (let index = 0; index < lines.length; index += 1) {
tbody.append(createArgonLineRow(lines[index] ?? '', index + 1));
}
table.append(tbody);
return table;
}
/**
* @param line Plain code text for one rendered row.
* @param lineNumber One-based line number mirrored into Argon's `data-line-number` attributes.
* @returns Table row containing the line-number gutter and code cell.
*/
function createArgonLineRow(line: string, lineNumber: number) {
const row = document.createElement('tr');
row.append(createArgonLineNumberCell(lineNumber), createArgonLineCodeCell(line, lineNumber));
return row;
}
/**
* @param lineNumber One-based line number for the gutter cell.
* @returns Argon-compatible line-number cell with inner `.hljs-ln-n` marker.
*/
function createArgonLineNumberCell(lineNumber: number) {
const lineNumberText = String(lineNumber);
const cell = document.createElement('td');
const inner = document.createElement('div');
cell.className = 'hljs-ln-line hljs-ln-numbers hljs';
cell.dataset.lineNumber = lineNumberText;
inner.className = 'hljs-ln-n';
inner.dataset.lineNumber = lineNumberText;
cell.append(inner);
return cell;
}
/**
* @param line Plain code text for one rendered row.
* @param lineNumber One-based line number mirrored for selection/copy metadata.
* @returns Argon-compatible code cell containing only text nodes from backend source text.
*/
function createArgonLineCodeCell(line: string, lineNumber: number) {
const cell = document.createElement('td');
cell.className = 'hljs-ln-line hljs-ln-code';
cell.dataset.lineNumber = String(lineNumber);
cell.textContent = line;
return cell;
}
/**
* @returns Argon-compatible codeblock control shell used by delegated post content effects.
*/
function createArgonControl() {
const control = document.createElement('div');
control.className = 'hljs-control hljs hljs-title';
control.append(
createArgonControlButton(
'hljs-control-toggle-linenumber',
'fa fa-list',
[
['tooltip-hide-linenumber', '隐藏行号'],
['tooltip-show-linenumber', '显示行号'],
],
),
createArgonControlButton(
'hljs-control-toggle-break-line',
'fa fa-align-left',
[
['tooltip-enable-breakline', '开启折行'],
['tooltip-disable-breakline', '关闭折行'],
],
),
createArgonControlButton('hljs-control-copy', 'fa fa-clipboard', [['tooltip', '复制']]),
createArgonControlButton(
'hljs-control-fullscreen',
'fa fa-arrows-alt',
[
['tooltip-fullscreen', '全屏'],
['tooltip-exit-fullscreen', '退出全屏'],
],
),
);
return control;
}
/**
* @param modifier Argon control modifier class appended to the shared `.hljs-control-btn`.
* @param iconClass Font Awesome class used by the existing Argon codeblock styles.
* @param attributes Tooltip attributes consumed by CSS pseudo-elements.
* @returns One icon-only codeblock control button.
*/
function createArgonControlButton(modifier: string, iconClass: string, attributes: [string, string][]) {
const button = document.createElement('div');
const icon = document.createElement('i');
button.className = `hljs-control-btn ${modifier}`;
icon.className = iconClass;
for (const [name, value] of attributes) {
button.setAttribute(name, value);
}
button.append(icon);
return button;
}

View File

@ -1,4 +1,5 @@
import { BLOG_ANIMATION_TIMING_MS, runAfterBlogDelay } from '@/factories/blogAnimationFactory'; import { BLOG_ANIMATION_TIMING_MS, runAfterBlogDelay } from '@/factories/blogAnimationFactory';
import { upgradeArgonCodeblocks } from '@/factories/argonCodeblockFactory';
interface ArgonPostContentRuntimeState { interface ArgonPostContentRuntimeState {
closeFancybox?: () => void; closeFancybox?: () => void;
@ -11,6 +12,7 @@ interface ArgonPostContentRuntimeState {
export function bindArgonPostContentEffects(root: HTMLElement) { export function bindArgonPostContentEffects(root: HTMLElement) {
const runtimeState: ArgonPostContentRuntimeState = {}; const runtimeState: ArgonPostContentRuntimeState = {};
upgradeArgonCodeblocks(root);
normalizeArgonCodeblockLineNumbers(root); normalizeArgonCodeblockLineNumbers(root);
/** /**