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 root = document.querySelector('#post_content')!; + + upgradeArgonCodeblocks(root); + + const pre = root.querySelector('pre.wp-block-code.hljs-codeblock')!; + const rows = pre.querySelectorAll('table.hljs-ln > tbody > tr'); + const lineNumbers = pre.querySelectorAll('.hljs-ln-numbers'); + const lineNumberInners = pre.querySelectorAll('.hljs-ln-n'); + const lineCodes = pre.querySelectorAll('.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 = ` +
+
const a = 1;
+const b = a + 1;
+
+
+ `; + const root = document.querySelector('#post_content')!; + + upgradeArgonCodeblocks(root); + + const pre = root.querySelector('pre.wp-block-code.hljs-codeblock')!; + const lineCodes = pre.querySelectorAll('.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 = ` +
+
+        
+          
+            
+            
+          
const a = 1;
+
+
+
+
+
+
+ `; + const root = document.querySelector('#post_content')!; + + upgradeArgonCodeblocks(root); + upgradeArgonCodeblocks(root); + + const pre = root.querySelector('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;'); +} diff --git a/src/factories/argonCodeblockFactory.ts b/src/factories/argonCodeblockFactory.ts new file mode 100644 index 0000000..8eed923 --- /dev/null +++ b/src/factories/argonCodeblockFactory.ts @@ -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 `
` 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);
 
   /**