Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/plugin-docs-cli/src/validation/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { checkFilesystem } from './filesystem.js';
import { checkFrontmatter } from './frontmatter.js';
import { checkManifest } from './manifest.js';
import { checkMarkdown } from './markdown.js';
import { checkStubContent } from './stub-content.js';

export const allRules: RuleRunner[] = [
checkFilesystem,
checkFrontmatter,
checkAssets,
checkMarkdown,
checkStubContent,
checkCrossFile,
checkManifest,
];
61 changes: 61 additions & 0 deletions packages/plugin-docs-cli/src/validation/rules/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,42 @@ describe('checkMarkdown', () => {
expect(finding!.line).toBeDefined();
expect(finding!.line).toBeGreaterThan(1);
});

it('should not report angle-bracket placeholders inside inline code as raw HTML', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('Published at `grafana.com/grafana/plugins/<slug>/docs/<page>`.'));

const findings = await checkMarkdown(input(tmp));
expect(findings.find((f) => f.rule === Rule.NoRawHtml)).toBeUndefined();
});

it('should still report real raw HTML on a line that also contains inline code', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('Use `<placeholder>` here <div>real html</div>'));

const findings = await checkMarkdown(input(tmp));
const htmlFindings = findings.filter((f) => f.rule === Rule.NoRawHtml);
// one finding each for the opening <div> and closing </div> tag - the
// masked `<placeholder>` span must not add a third
expect(htmlFindings).toHaveLength(2);
expect(htmlFindings.every((f) => f.detail.includes('<div>'))).toBe(true);
});

it('should still flag HTML when a backtick on the line is unterminated', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('`unclosed code <div>real</div>'));

const findings = await checkMarkdown(input(tmp));
expect(findings.find((f) => f.rule === Rule.NoRawHtml)).toBeDefined();
});

it('should not report HTML-looking text inside a double-backtick code span', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('Example: ``<a> with a ` backtick inside``'));

const findings = await checkMarkdown(input(tmp));
expect(findings.find((f) => f.rule === Rule.NoRawHtml)).toBeUndefined();
});
});

// --- no-script-tags ---
Expand Down Expand Up @@ -160,6 +196,31 @@ describe('checkMarkdown', () => {
expect(scriptFindings.length).toBeGreaterThanOrEqual(1);
expect(scriptFindings[0].severity).toBe('error');
});

it('should not report a <script> tag mentioned inside inline code', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('Avoid adding a `<script>` tag directly to your page.'));

const findings = await checkMarkdown(input(tmp));
expect(findings.find((f) => f.rule === Rule.NoScriptTags)).toBeUndefined();
});

it('should not report an event handler attribute mentioned inside inline code', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('The `onclick="doSomething()"` attribute is set automatically.'));

const findings = await checkMarkdown(input(tmp));
expect(findings.find((f) => f.rule === Rule.NoScriptTags)).toBeUndefined();
});

it('should still report a real <script> tag on a line that also contains inline code', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'md-test-'));
await writeFile(join(tmp, 'index.md'), md('Use `<placeholder>` here <script>alert(1)</script>'));

const findings = await checkMarkdown(input(tmp));
const scriptFindings = findings.filter((f) => f.rule === Rule.NoScriptTags);
expect(scriptFindings.length).toBeGreaterThanOrEqual(1);
});
});

// --- image-refs-relative ---
Expand Down
18 changes: 11 additions & 7 deletions packages/plugin-docs-cli/src/validation/rules/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { readFile, readdir } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { join, relative } from 'node:path';
import { type Diagnostic, type ValidationInput, Rule } from '../types.js';
import { getCodeBlockLines, isMetaFile } from './utils.js';
import { getCodeBlockLines, isMetaFile, maskInlineCode } from './utils.js';

// matches HTML tags like <div>, <span class="x">, </p>, <br/>, <img src="..." />
const HTML_TAG_RE = /< *\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g;
Expand Down Expand Up @@ -41,7 +41,8 @@ const PATH_TRAVERSAL_RE = /(?:^|\/)\.\.\//;
function matchOutsideCode(
content: string,
re: RegExp,
codeLines: Set<number>
codeLines: Set<number>,
options?: { maskInlineCode?: boolean }
Comment thread
sunker marked this conversation as resolved.
): Array<{ match: RegExpExecArray; line: number }> {
const results: Array<{ match: RegExpExecArray; line: number }> = [];
const lines = content.split('\n');
Expand All @@ -50,9 +51,10 @@ function matchOutsideCode(
if (codeLines.has(i + 1)) {
continue;
}
const lineText = options?.maskInlineCode ? maskInlineCode(lines[i]) : lines[i];
const lineRe = new RegExp(re.source, re.flags);
let m: RegExpExecArray | null;
while ((m = lineRe.exec(lines[i])) !== null) {
while ((m = lineRe.exec(lineText)) !== null) {
results.push({ match: m, line: i + 1 });
}
}
Expand Down Expand Up @@ -93,7 +95,7 @@ export async function checkMarkdown(input: ValidationInput): Promise<Diagnostic[
const codeLines = getCodeBlockLines(content);

// no-script-tags: no <script> tags
for (const { match, line } of matchOutsideCode(content, SCRIPT_TAG_RE, codeLines)) {
for (const { match, line } of matchOutsideCode(content, SCRIPT_TAG_RE, codeLines, { maskInlineCode: true })) {
diagnostics.push({
rule: Rule.NoScriptTags,
severity: 'error',
Expand All @@ -105,7 +107,7 @@ export async function checkMarkdown(input: ValidationInput): Promise<Diagnostic[
}

// no-script-tags: no event handler attributes (onclick, onerror, etc.)
for (const { match, line } of matchOutsideCode(content, EVENT_HANDLER_RE, codeLines)) {
for (const { match, line } of matchOutsideCode(content, EVENT_HANDLER_RE, codeLines, { maskInlineCode: true })) {
diagnostics.push({
rule: Rule.NoScriptTags,
severity: 'error',
Expand All @@ -116,8 +118,10 @@ export async function checkMarkdown(input: ValidationInput): Promise<Diagnostic[
});
}

// no-raw-html: no raw HTML tags (except allowed ones)
for (const { match, line } of matchOutsideCode(content, HTML_TAG_RE, codeLines)) {
// no-raw-html: no raw HTML tags (except allowed ones). Inline code spans
// are masked first so placeholder text like `<slug>` inside backticks
// isn't mistaken for a real tag.
for (const { match, line } of matchOutsideCode(content, HTML_TAG_RE, codeLines, { maskInlineCode: true })) {
const tagName = match[1].toLowerCase();
// skip if it's a script tag (already handled above) or allowed tag
if (tagName === 'script' || ALLOWED_HTML_TAGS.has(tagName)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { join } from 'node:path';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { checkStubContent } from './stub-content.js';
import { Rule } from '../types.js';

const input = (docsPath: string, strict = true) => ({ docsPath, strict });

// helper: valid frontmatter markdown file content
const md = (body = '') => `---\ntitle: Page\ndescription: A page\n---\n${body}`;

describe('checkStubContent', () => {
it('should return empty for nonexistent path', async () => {
const findings = await checkStubContent(input('/nonexistent/path'));
expect(findings).toHaveLength(0);
});

it('should report a remaining section-brief marker', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(
join(tmp, 'index.md'),
md('## Features\n\n<!-- section-brief:start -->\n\nFill this in.\n\n<!-- section-brief:end -->\n')
);

const findings = await checkStubContent(input(tmp));
const finding = findings.find((f) => f.rule === Rule.UnfilledSectionBrief);
expect(finding).toBeDefined();
expect(finding!.severity).toBe('error');
});

it('should report as warning in non-strict mode', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(join(tmp, 'index.md'), md('<!-- section-brief:start -->\n'));

const findings = await checkStubContent(input(tmp, false));
const finding = findings.find((f) => f.rule === Rule.UnfilledSectionBrief);
expect(finding).toBeDefined();
expect(finding!.severity).toBe('warning');
});

it('should include the line number of the marker', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(join(tmp, 'index.md'), md('\n\n<!-- section-brief:start -->\n'));

const findings = await checkStubContent(input(tmp));
const finding = findings.find((f) => f.rule === Rule.UnfilledSectionBrief);
expect(finding).toBeDefined();
expect(finding!.line).toBeGreaterThan(1);
});

it('should report every remaining marker in a file with multiple sections', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(
join(tmp, 'index.md'),
md(
'<!-- section-brief:start -->\nFill this in.\n<!-- section-brief:end -->\n\n<!-- section-brief:start -->\nAnd this.\n<!-- section-brief:end -->\n'
)
);

const findings = await checkStubContent(input(tmp));
expect(findings.filter((f) => f.rule === Rule.UnfilledSectionBrief)).toHaveLength(2);
});

it('should not report a page with no section-brief markers', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(join(tmp, 'index.md'), md('## Features\n\nThis panel does real things.\n'));

const findings = await checkStubContent(input(tmp));
expect(findings.find((f) => f.rule === Rule.UnfilledSectionBrief)).toBeUndefined();
});

it('should not report meta files like README.md', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(join(tmp, 'README.md'), '# Docs\n\n<!-- section-brief:start -->\n');

const findings = await checkStubContent(input(tmp));
expect(findings.find((f) => f.rule === Rule.UnfilledSectionBrief)).toBeUndefined();
});

it('should check all markdown files', async () => {
const tmp = await mkdtemp(join(tmpdir(), 'stub-test-'));
await writeFile(join(tmp, 'index.md'), md('<!-- section-brief:start -->\n'));
await writeFile(join(tmp, 'options.md'), md('<!-- section-brief:start -->\n'));

const findings = await checkStubContent(input(tmp));
const files = findings.filter((f) => f.rule === Rule.UnfilledSectionBrief).map((f) => f.file);
expect(files).toContain('index.md');
expect(files).toContain('options.md');
});
});
64 changes: 64 additions & 0 deletions packages/plugin-docs-cli/src/validation/rules/stub-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { readFile, readdir } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { join, relative } from 'node:path';
import { type Diagnostic, type ValidationInput, Rule } from '../types.js';
import { isMetaFile } from './utils.js';

// matches the opening marker of a section-brief authoring-guidance block,
// scaffolded by `create-plugin add panel-docs`/`datasource-docs` as a
// placeholder for the author to replace with real content.
const SECTION_BRIEF_START_RE = /<!--\s*section-brief:start\s*-->/;

/**
* Checks that no page still contains an unfilled `section-brief` block. A
* remaining marker means the author never replaced the scaffolded guidance
* with real documentation, so the page shouldn't ship as-is.
*/
export async function checkStubContent(input: ValidationInput): Promise<Diagnostic[]> {
const diagnostics: Diagnostic[] = [];

let entries: Dirent[] = [];
try {
entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true });
} catch {
return diagnostics;
}

const mdFiles = entries.filter(
(e) =>
e.isFile() &&
e.name.endsWith('.md') &&
!isMetaFile(e.name) &&
!e.parentPath.includes('node_modules') &&
!e.parentPath.includes('dist')
);

for (const file of mdFiles) {
const absolutePath = join(file.parentPath, file.name);
const relativePath = relative(input.docsPath, absolutePath);
let raw: string;
try {
raw = await readFile(absolutePath, 'utf-8');
} catch {
continue;
}

const lines = raw.split('\n');
for (let i = 0; i < lines.length; i++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it any value in running these lines in the getCodeBlockLines or maskInlineCode? I'm asking since the other rules seems to use those functions to check for code.

if (!SECTION_BRIEF_START_RE.test(lines[i])) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need to verify that there is an end block? Or will this fail if it is missing?

continue;
}
diagnostics.push({
rule: Rule.UnfilledSectionBrief,
severity: input.strict ? 'error' : 'warning',
file: relativePath,
line: i + 1,
title: 'Unfilled documentation stub',
detail:
"This section still has scaffolded authoring guidance (<!-- section-brief:start -->) instead of real content. Replace it with your plugin's actual documentation and remove the marker.",
});
}
}

return diagnostics;
}
38 changes: 37 additions & 1 deletion packages/plugin-docs-cli/src/validation/rules/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isMetaFile } from './utils.js';
import { isMetaFile, maskInlineCode } from './utils.js';

describe('isMetaFile', () => {
it('matches README.md regardless of case', () => {
Expand Down Expand Up @@ -39,3 +39,39 @@ describe('isMetaFile', () => {
expect(isMetaFile('contributing-quickstart.md')).toBe(false);
});
});

describe('maskInlineCode', () => {
it('masks the contents of a single-backtick span, preserving the delimiters', () => {
expect(maskInlineCode('a `<div>` b')).toBe('a `#####` b');
});

it('preserves line length and removes tag-like text from masked spans', () => {
const line = 'text `<slug>` more `<page>` end';
const masked = maskInlineCode(line);
expect(masked).toHaveLength(line.length);
expect(masked).not.toContain('<slug>');
expect(masked).not.toContain('<page>');
});

it('returns a line with no backticks unchanged', () => {
expect(maskInlineCode('no backticks here')).toBe('no backticks here');
});

it('leaves an unterminated backtick run unmasked', () => {
expect(maskInlineCode('`unterminated <div>')).toBe('`unterminated <div>');
});

it('masks multiple independent spans on one line', () => {
expect(maskInlineCode('`<a>` and `<b>`')).toBe('`###` and `###`');
});

it('masks a double-backtick span containing a literal single backtick', () => {
const line = '``<a> ` <b>``';
const masked = maskInlineCode(line);
expect(masked.startsWith('``')).toBe(true);
expect(masked.endsWith('``')).toBe(true);
expect(masked).not.toContain('<a>');
expect(masked).not.toContain('<b>');
expect(masked).toHaveLength(line.length);
});
});
22 changes: 22 additions & 0 deletions packages/plugin-docs-cli/src/validation/rules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ export function isMetaFile(filenameOrPath: string): boolean {
return META_FILE_BASENAMES_UPPER.has(base.toUpperCase());
}

/**
* Neutralizes the contents of inline code spans (`` `code` ``, ` ``code`` `,
* etc.) on a single line by replacing the inner characters with a same-length
* run of `#` filler, leaving the backtick delimiters and everything else on
* the line untouched. Per CommonMark/GFM, inline code spans are never
* interpreted as markup - this lets regex-based checks (e.g. raw-HTML
* detection) skip over them without false-positiving on literal text like
* `` `<placeholder>` ``.
*
* Known limitations (this is a linter aid, not a full CommonMark tokenizer):
* - Only spans fully contained within a single line are recognized.
* - Backslash-escaped backticks are not specially handled.
* - An unterminated backtick run (no matching close on the line) is left
* unmasked, since it isn't a real code span - CommonMark treats it as
* literal text too.
*/
export function maskInlineCode(line: string): string {
return line.replace(/(`+)(.*?)\1(?!`)/g, (_match, delim: string, inner: string) => {
return `${delim}${'#'.repeat(inner.length)}${delim}`;
});
}

/**
* Returns a set of 1-based line numbers inside fenced code blocks.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin-docs-cli/src/validation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const Rule = {
NoH1: 'no-h1-heading',
DuplicatePosition: 'no-duplicate-sidebar-position',
DuplicateSlug: 'no-duplicate-slugs',
// content-completeness rules
UnfilledSectionBrief: 'unfilled-section-brief',
// asset rules
NoSvg: 'no-svg-files',
ReferencedImagesExist: 'referenced-images-exist',
Expand Down
Loading