Skip to content

Commit 9d34044

Browse files
committed
feat: order-independent tab section mapping + FAQ synonyms (0.1.7)
1 parent fbded3e commit 9d34044

File tree

5 files changed

+100
-17
lines changed

5 files changed

+100
-17
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# Changelog
2+
## [0.1.7] - 2025-10-05
3+
### Fixed
4+
- wordpress.org tabbed theme now detects FAQ content even when the section is titled "Frequently Asked Questions" or appears out of the typical order. Sections are scanned globally and mapped to canonical tabs (description, installation, faq, changelog) irrespective of order.
5+
26

37
All notable changes to this project will be documented in this file.
48

@@ -71,6 +75,7 @@ The format is based on Keep a Changelog (https://keepachangelog.com/en/1.0.0/) a
7175
- Custom parser for WordPress readme formatting (FAQ, changelog headers, etc.)
7276

7377
[0.1.6]: https://github.com/soderlind/wordpress-readme-preview/compare/v0.1.5...v0.1.6
78+
[0.1.7]: https://github.com/soderlind/wordpress-readme-preview/compare/v0.1.6...v0.1.7
7479
[0.1.4]: https://github.com/soderlind/wordpress-readme-preview/compare/v0.1.3...v0.1.4
7580
[0.1.5]: https://github.com/soderlind/wordpress-readme-preview/compare/v0.1.4...v0.1.5
7681
[0.1.3]: https://github.com/soderlind/wordpress-readme-preview/compare/v0.1.1...v0.1.3

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "wordpress-readme-preview",
33
"displayName": "WordPress Readme",
44
"description": "Preview, validate, and edit WordPress readme.txt files with syntax highlighting, IntelliSense, and accurate rendering",
5-
"version": "0.1.6",
5+
"version": "0.1.7",
66
"publisher": "persoderlind",
77
"engines": {
88
"vscode": "^1.74.0"

src/preview/htmlGenerator.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
22
import { ParsedReadme, ReadmeHeader, ReadmeSection } from '../parser/readmeParser';
33
import { ValidationResult, ValidationError } from '../parser/validator';
44
import { WordPressMarkdownParser } from '../parser/markdownParser';
5+
import { canonicalSectionId } from './sectionMapping';
56

67
export interface HtmlGeneratorOptions {
78
resource: vscode.Uri;
@@ -107,19 +108,22 @@ export class HtmlGenerator {
107108
}
108109

109110
private generateTabbedLayout(readme: ParsedReadme, validation: ValidationResult, assets?: PluginAssets): string {
110-
// Tabs no longer include separate screenshots; gallery appended to Description panel
111+
// Desired tab order; will render placeholders for missing canonical sections
111112
const tabs = [
112113
{ id: 'description', title: 'Description' },
113114
{ id: 'installation', title: 'Installation' },
114115
{ id: 'faq', title: 'FAQ' },
115116
{ id: 'changelog', title: 'Changelog' }
116117
];
117118

118-
// Map readme sections to tab panels by simple title matching
119-
const sectionMap: { [key: string]: string } = {};
119+
// Build canonical section map (supports synonyms like Frequently Asked Questions -> faq)
120+
const sectionMap: { [canonical: string]: string } = {};
120121
for (const section of readme.sections) {
121-
const key = section.title.toLowerCase();
122-
sectionMap[key] = this.generateSection(section);
122+
const canonical = canonicalSectionId(section.title);
123+
// First occurrence wins to preserve original ordering content
124+
if (!sectionMap[canonical]) {
125+
sectionMap[canonical] = this.generateSection(section);
126+
}
123127
}
124128

125129
// Asset header
@@ -129,8 +133,8 @@ export class HtmlGenerator {
129133
const tabButtons = tabs.map((t, i) => `<button role="tab" class="wporg-tab ${i===0?'active':''}" aria-selected="${i===0}" aria-controls="panel-${t.id}" id="tab-${t.id}">${t.title}</button>`).join('');
130134
const panels = tabs.map((t, i) => {
131135
let content = '';
132-
if (sectionMap[t.title.toLowerCase()]) {
133-
let sectionHtml = sectionMap[t.title.toLowerCase()] || '';
136+
if (sectionMap[t.id]) {
137+
let sectionHtml = sectionMap[t.id] || '';
134138
if (t.id === 'description' && assets?.screenshots && assets.screenshots.length) {
135139
// Append screenshots gallery with heading and anchor for hash #screenshots
136140
sectionHtml += `<div id="screenshots" class="screenshots-anchor"></div><h2 class="section-title screenshots-title">Screenshots</h2>${this.renderScreenshots(assets)}`;
@@ -404,15 +408,8 @@ export class HtmlGenerator {
404408
private generateSection(section: ReadmeSection): string {
405409
const sectionId = this.generateSectionId(section.title);
406410
// Define canonical alias IDs that tabbed theme expects
407-
const canonicalIds: { [key: string]: string } = {
408-
'description': 'description',
409-
'installation': 'installation',
410-
'faq': 'faq',
411-
'frequently-asked-questions': 'faq',
412-
'screenshots': 'screenshots',
413-
'changelog': 'changelog'
414-
};
415-
const aliasId = canonicalIds[sectionId];
411+
const canonicalId = canonicalSectionId(section.title);
412+
const aliasId = canonicalId !== sectionId ? canonicalId : undefined;
416413

417414
// Process FAQ questions (= Question =) as H3 headers
418415
let processedContent = section.content;

src/preview/sectionMapping.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Helper for mapping arbitrary section titles to canonical IDs used in tabbed theme
2+
// Keeps logic independent of VS Code APIs for easy unit testing.
3+
4+
export function canonicalSectionId(title: string): string {
5+
const slug = title.toLowerCase()
6+
.replace(/[^a-z0-9]+/g, '-')
7+
.replace(/^-+|-+$/g, '');
8+
9+
const map: Record<string, string> = {
10+
'frequently-asked-questions': 'faq',
11+
'faq': 'faq',
12+
'description': 'description',
13+
'installation': 'installation',
14+
'changelog': 'changelog',
15+
'screenshots': 'screenshots'
16+
};
17+
return map[slug] || slug;
18+
}
19+
20+
export function isCanonicalTab(id: string): boolean {
21+
return ['description','installation','faq','changelog'].includes(id);
22+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { ReadmeParser } from '../parser/readmeParser';
3+
import { HtmlGenerator } from '../preview/htmlGenerator';
4+
import { vi } from 'vitest';
5+
6+
// Mock vscode module to satisfy htmlGenerator import in unit context
7+
vi.mock('vscode', () => {
8+
return {
9+
Uri: {
10+
joinPath: (...parts: any[]) => parts[parts.length - 1],
11+
parse: (v: string) => ({ toString: () => v })
12+
}
13+
};
14+
});
15+
16+
// Minimal stubs for VS Code types used inside HtmlGenerator
17+
// We'll monkey-patch methods that rely on webview specifics.
18+
const fakeUri: any = { toString: () => 'vscode-resource://fake' };
19+
const fakeWebview: any = {
20+
asWebviewUri: (u: any) => u,
21+
cspSource: 'vscode-resource:'
22+
};
23+
const fakeContext: any = { extensionUri: fakeUri };
24+
25+
function extractPanel(html: string, id: string): string | undefined {
26+
// Use [\s\S] to match any content including newlines greedily but stop at first closing </div> of the panel.
27+
const re = new RegExp(`<div role=\"tabpanel\" id=\"panel-${id}\"[\\s\\S]*?<\\/div>`,'i');
28+
const m = html.match(re);
29+
return m ? m[0] : undefined;
30+
}
31+
32+
describe('Tabbed theme section mapping', () => {
33+
it('maps Frequently Asked Questions to FAQ tab regardless of order', async () => {
34+
const content = `=== Plugin Name ===\nContributors: a\nTags: one\nRequires at least: 5.0\nTested up to: 6.3\nStable tag: 1.0\nLicense: GPLv2\n\n== Installation ==\nSteps here\n\n== Frequently Asked Questions ==\n= What? =\nBecause.\n\n== Description ==\nSome description text.\n`;
35+
const parsed = ReadmeParser.parse(content);
36+
const generator = new HtmlGenerator(fakeContext as any);
37+
const validation: any = { errors: [], warnings: [], score: 100 };
38+
const html = await generator.generateHtml(parsed, validation, { resource: fakeUri, webview: fakeWebview, extensionUri: fakeUri, theme: 'wordpress-org' });
39+
40+
const faqPanel = extractPanel(html, 'faq');
41+
expect(faqPanel).toBeDefined();
42+
expect(faqPanel).toMatch(/What\?/);
43+
44+
const descPanel = extractPanel(html, 'description');
45+
expect(descPanel).toBeDefined();
46+
});
47+
48+
it('supports simple FAQ heading labeled just FAQ', async () => {
49+
const content = `=== Plugin Name ===\nContributors: a\nTags: one\nRequires at least: 5.0\nTested up to: 6.3\nStable tag: 1.0\nLicense: GPLv2\n\n== FAQ ==\n= Why? =\nBecause.\n\n== Description ==\nSome description text.\n`;
50+
const parsed = ReadmeParser.parse(content);
51+
const generator = new HtmlGenerator(fakeContext as any);
52+
const validation: any = { errors: [], warnings: [], score: 100 };
53+
const html = await generator.generateHtml(parsed, validation, { resource: fakeUri, webview: fakeWebview, extensionUri: fakeUri, theme: 'wordpress-org' });
54+
55+
const faqPanel = extractPanel(html, 'faq');
56+
expect(faqPanel).toBeDefined();
57+
expect(faqPanel).toMatch(/Why\?/);
58+
});
59+
});

0 commit comments

Comments
 (0)