const a = 1;
+const b = a + 1;
+ diff --git a/e2e/argon-parity/interactions.spec.ts b/e2e/argon-parity/interactions.spec.ts index fbf5d84..95a4928 100644 --- a/e2e/argon-parity/interactions.spec.ts +++ b/e2e/argon-parity/interactions.spec.ts @@ -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 { @@ -9,6 +9,7 @@ import { import { captureAllRouteStates, gotoLocalRoute, + waitForArgonPage, writeArgonArtifact, } from './argonParityHelpers'; @@ -16,6 +17,28 @@ interface PageFixture { page: Page; } +const BACKEND_BASE_CODEBLOCK_ARTICLE = { + authorName: 'KwiTsukasa', + categoriesResolved: [ + { + name: 'public', + slug: 'public', + }, + ], + contentHtml: `
const a = 1;
+const b = a + 1;`,
+ 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.
*/
@@ -33,6 +56,7 @@ function registerLocalInteractionSuite() {
test('local post page exposes share, comments, and reading progress', createPostInteractionTest());
test('local post codeblocks match Argon hljs layout', createPostCodeblockSurfaceTest());
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 links match live WordPress Argon inline semantics', createPostInlineLinkSurfaceTest());
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.
*/
@@ -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.
*/
diff --git a/src/__tests__/argonCodeblockFactory.spec.ts b/src/__tests__/argonCodeblockFactory.spec.ts
new file mode 100644
index 0000000..1bbcbb3
--- /dev/null
+++ b/src/__tests__/argonCodeblockFactory.spec.ts
@@ -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 = `
+ const a = 1;
+const b = a + 1;
+ const a = 1;
+const b = a + 1;
+
+ +++++
+ + const a = 1; ++++
` 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(ARGON_CODEBLOCK_SELECTOR).forEach(upgradeArgonCodeblock);
+}
+
+/**
+ * @param codeBlock Highlight.js `` 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 `` block whose immediate `` child owns code content.
+ * @returns Direct `` 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 `` 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;
+}
diff --git a/src/hooks/useArgonPostContentEffects.ts b/src/hooks/useArgonPostContentEffects.ts
index 2444702..84084d9 100644
--- a/src/hooks/useArgonPostContentEffects.ts
+++ b/src/hooks/useArgonPostContentEffects.ts
@@ -1,4 +1,5 @@
import { BLOG_ANIMATION_TIMING_MS, runAfterBlogDelay } from '@/factories/blogAnimationFactory';
+import { upgradeArgonCodeblocks } from '@/factories/argonCodeblockFactory';
interface ArgonPostContentRuntimeState {
closeFancybox?: () => void;
@@ -11,6 +12,7 @@ interface ArgonPostContentRuntimeState {
export function bindArgonPostContentEffects(root: HTMLElement) {
const runtimeState: ArgonPostContentRuntimeState = {};
+ upgradeArgonCodeblocks(root);
normalizeArgonCodeblockLineNumbers(root);
/**