@grafana/plugin-docs-cli 0.0.0 → 0.0.10-canary.2537.23286312909.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import createDebug from 'debug';
4
+
5
+ const debug = createDebug("plugin-docs-cli:utils:plugin");
6
+ async function resolveDocsPath(projectRoot) {
7
+ const root = process.cwd();
8
+ const pluginJsonPath = join(root, "src", "plugin.json");
9
+ debug("Looking for plugin.json at: %s", pluginJsonPath);
10
+ let raw;
11
+ try {
12
+ raw = await readFile(pluginJsonPath, "utf-8");
13
+ } catch {
14
+ throw new Error(`Could not find src/plugin.json in ${root}`);
15
+ }
16
+ let pluginJson;
17
+ try {
18
+ pluginJson = JSON.parse(raw);
19
+ } catch (error) {
20
+ throw new Error(`Failed to parse ${pluginJsonPath}: ${error instanceof Error ? error.message : error}`);
21
+ }
22
+ if (!pluginJson.docsPath) {
23
+ throw new Error('"docsPath" is not set in src/plugin.json');
24
+ }
25
+ const docsPath = resolve(root, pluginJson.docsPath);
26
+ debug("Resolved docsPath from plugin.json: %s -> %s", pluginJson.docsPath, docsPath);
27
+ return docsPath;
28
+ }
29
+
30
+ export { resolveDocsPath };
@@ -0,0 +1,11 @@
1
+ async function validate(input, rules) {
2
+ const diagnostics = [];
3
+ for (const run of rules) {
4
+ const results = await run(input);
5
+ diagnostics.push(...results);
6
+ }
7
+ const valid = diagnostics.every((d) => d.severity !== "error");
8
+ return { valid, diagnostics };
9
+ }
10
+
11
+ export { validate };
@@ -0,0 +1,43 @@
1
+ const SEVERITY_LABEL = {
2
+ error: "error",
3
+ warning: "warn ",
4
+ info: "info "
5
+ };
6
+ function formatResult(result) {
7
+ const lines = [];
8
+ if (result.diagnostics.length === 0) {
9
+ lines.push("\u2713 Documentation is valid");
10
+ return lines.join("\n");
11
+ }
12
+ const counts = { error: 0, warning: 0, info: 0 };
13
+ for (const d of result.diagnostics) {
14
+ counts[d.severity]++;
15
+ }
16
+ const { error: errors, warning: warnings, info: infos } = counts;
17
+ const parts = [];
18
+ if (errors > 0) {
19
+ parts.push(`${errors} error${errors !== 1 ? "s" : ""}`);
20
+ }
21
+ if (warnings > 0) {
22
+ parts.push(`${warnings} warning${warnings !== 1 ? "s" : ""}`);
23
+ }
24
+ if (infos > 0) {
25
+ parts.push(`${infos} info`);
26
+ }
27
+ const icon = errors > 0 ? "\u2717" : "\u26A0";
28
+ lines.push(`${icon} Documentation has ${parts.join(" and ")}`);
29
+ lines.push("");
30
+ for (const d of result.diagnostics) {
31
+ const label = SEVERITY_LABEL[d.severity] ?? d.severity;
32
+ const location = d.file ? d.line ? ` ${d.file}:${d.line}` : ` ${d.file}` : "";
33
+ lines.push(` ${label}${location}`);
34
+ lines.push(` ${d.title}`);
35
+ if (d.detail) {
36
+ lines.push(` ${d.detail}`);
37
+ }
38
+ lines.push("");
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ export { formatResult };
@@ -0,0 +1,167 @@
1
+ import { readdir, stat, readFile } from 'node:fs/promises';
2
+ import { extname, join, normalize, dirname, relative } from 'node:path';
3
+ import { Rule } from '../types.js';
4
+ import { ALLOWED_IMAGE_EXTENSIONS } from './filesystem.js';
5
+
6
+ const IMAGE_FILE_NAME_RE = /^[a-zA-Z0-9\-_.]+$/;
7
+ const MAX_STATIC_SIZE = 300 * 1024;
8
+ const MAX_GIF_SIZE = 1024 * 1024;
9
+ const MAX_TOTAL_SIZE = 5 * 1024 * 1024;
10
+ const MAX_DATA_URI_SIZE = 300 * 1024;
11
+ function formatBytes(bytes) {
12
+ if (bytes < 1024) {
13
+ return `${bytes}B`;
14
+ }
15
+ const kb = bytes / 1024;
16
+ if (kb < 1024) {
17
+ return `${Math.round(kb)}KB`;
18
+ }
19
+ const mb = kb / 1024;
20
+ return `${mb.toFixed(1)}MB`;
21
+ }
22
+ function findRefLine(content, ref) {
23
+ const lines = content.split("\n");
24
+ for (let i = 0; i < lines.length; i++) {
25
+ if (lines[i].includes(ref)) {
26
+ return i + 1;
27
+ }
28
+ }
29
+ return void 0;
30
+ }
31
+ async function checkAssets(input) {
32
+ const diagnostics = [];
33
+ let entries = [];
34
+ try {
35
+ entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true });
36
+ } catch {
37
+ return diagnostics;
38
+ }
39
+ const allFiles = entries.filter((e) => e.isFile());
40
+ const imageFiles = allFiles.filter((e) => ALLOWED_IMAGE_EXTENSIONS.has(extname(e.name).toLowerCase()));
41
+ const svgFiles = allFiles.filter((e) => extname(e.name).toLowerCase() === ".svg");
42
+ const mdFiles = allFiles.filter((e) => e.name.endsWith(".md"));
43
+ function rel(entry) {
44
+ return relative(input.docsPath, join(entry.parentPath, entry.name));
45
+ }
46
+ for (const svg of svgFiles) {
47
+ diagnostics.push({
48
+ rule: Rule.NoSvg,
49
+ severity: "error",
50
+ file: rel(svg),
51
+ title: "SVG files are not allowed",
52
+ detail: `"${svg.name}" is an SVG file. SVG files can contain embedded scripts and pose an XSS risk. Use PNG or WebP instead.`
53
+ });
54
+ }
55
+ for (const img of imageFiles) {
56
+ if (!IMAGE_FILE_NAME_RE.test(img.name)) {
57
+ diagnostics.push({
58
+ rule: Rule.ImageFileNaming,
59
+ severity: input.strict ? "error" : "info",
60
+ file: rel(img),
61
+ title: "Image filename contains invalid characters",
62
+ detail: `"${img.name}" should use only letters, digits, hyphens, underscores and dots.`
63
+ });
64
+ }
65
+ }
66
+ let totalSize = 0;
67
+ for (const img of imageFiles) {
68
+ const absPath = join(img.parentPath, img.name);
69
+ let size;
70
+ try {
71
+ const st = await stat(absPath);
72
+ size = st.size;
73
+ } catch {
74
+ continue;
75
+ }
76
+ totalSize += size;
77
+ const ext = extname(img.name).toLowerCase();
78
+ const isGif = ext === ".gif";
79
+ const maxSize = isGif ? MAX_GIF_SIZE : MAX_STATIC_SIZE;
80
+ const maxLabel = isGif ? "1MB" : "300KB";
81
+ if (size > maxSize) {
82
+ diagnostics.push({
83
+ rule: Rule.MaxImageSize,
84
+ severity: input.strict ? "error" : "info",
85
+ file: rel(img),
86
+ title: `Image exceeds ${maxLabel} limit`,
87
+ detail: `"${img.name}" is ${formatBytes(size)} which exceeds the ${maxLabel} limit for ${isGif ? "GIF" : "static"} images. Compress or resize the image.`
88
+ });
89
+ }
90
+ }
91
+ if (input.strict && totalSize > MAX_TOTAL_SIZE) {
92
+ diagnostics.push({
93
+ rule: Rule.MaxTotalImagesSize,
94
+ severity: "warning",
95
+ title: "Total image size exceeds 5MB",
96
+ detail: `Total image size is ${formatBytes(totalSize)} which exceeds the 5MB limit. Reduce the number or size of images.`
97
+ });
98
+ }
99
+ const allFilePaths = new Set(allFiles.map((e) => rel(e)));
100
+ const referencedPaths = /* @__PURE__ */ new Set();
101
+ for (const md of mdFiles) {
102
+ const absPath = join(md.parentPath, md.name);
103
+ const mdRelPath = rel(md);
104
+ let content;
105
+ try {
106
+ content = await readFile(absPath, "utf-8");
107
+ } catch {
108
+ continue;
109
+ }
110
+ const imageRefRe = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
111
+ let match;
112
+ while ((match = imageRefRe.exec(content)) !== null) {
113
+ const ref = match[2];
114
+ if (/^https?:\/\//i.test(ref) || /^\/\//.test(ref) || /^blob:/i.test(ref)) {
115
+ continue;
116
+ }
117
+ if (/^data:/i.test(ref)) {
118
+ const commaIdx = ref.indexOf(",");
119
+ if (commaIdx !== -1) {
120
+ const encoded = ref.slice(commaIdx + 1);
121
+ const isBase64 = /;base64$/i.test(ref.slice(0, commaIdx));
122
+ const byteSize = isBase64 ? Math.ceil(encoded.length * 3 / 4) : encoded.length;
123
+ if (byteSize > MAX_DATA_URI_SIZE) {
124
+ diagnostics.push({
125
+ rule: Rule.MaxDataUriSize,
126
+ severity: input.strict ? "error" : "info",
127
+ file: mdRelPath,
128
+ line: findRefLine(content, ref),
129
+ title: "Data URI exceeds 300KB limit",
130
+ detail: `Inline data URI is approximately ${formatBytes(byteSize)} which exceeds the 300KB limit. Use a file reference instead.`
131
+ });
132
+ }
133
+ }
134
+ continue;
135
+ }
136
+ const resolvedPath = ref.startsWith("/") ? normalize(ref.slice(1)) : normalize(join(dirname(mdRelPath), ref));
137
+ referencedPaths.add(resolvedPath);
138
+ if (!allFilePaths.has(resolvedPath)) {
139
+ diagnostics.push({
140
+ rule: Rule.ReferencedImagesExist,
141
+ severity: "error",
142
+ file: mdRelPath,
143
+ line: findRefLine(content, ref),
144
+ title: "Referenced image does not exist",
145
+ detail: `Image "${ref}" referenced in markdown does not exist on disk.`
146
+ });
147
+ }
148
+ }
149
+ }
150
+ if (input.strict) {
151
+ for (const img of imageFiles) {
152
+ const relPath = rel(img);
153
+ if (!referencedPaths.has(relPath)) {
154
+ diagnostics.push({
155
+ rule: Rule.NoOrphanedImages,
156
+ severity: "info",
157
+ file: relPath,
158
+ title: "Unreferenced image",
159
+ detail: `"${img.name}" is not referenced by any markdown file. Remove it if it is no longer needed.`
160
+ });
161
+ }
162
+ }
163
+ }
164
+ return diagnostics;
165
+ }
166
+
167
+ export { checkAssets };
@@ -0,0 +1,129 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { relative, join, normalize, dirname } from 'node:path';
3
+ import GithubSlugger from 'github-slugger';
4
+ import { Rule } from '../types.js';
5
+ import { getCodeBlockLines } from './utils.js';
6
+
7
+ const LINK_RE = /\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
8
+ const SKIP_URL_RE = /^(https?:\/\/|mailto:|tel:|data:|javascript:|vbscript:)/i;
9
+ const HEADING_RE = /^(#{2,6})\s+(.+)$/;
10
+ function extractHeadingIds(content) {
11
+ const ids = /* @__PURE__ */ new Set();
12
+ const slugger = new GithubSlugger();
13
+ const lines = content.split("\n");
14
+ const codeLines = getCodeBlockLines(content);
15
+ for (let i = 0; i < lines.length; i++) {
16
+ if (codeLines.has(i + 1)) {
17
+ continue;
18
+ }
19
+ const match = HEADING_RE.exec(lines[i]);
20
+ if (match) {
21
+ const text = match[2].replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
22
+ ids.add(slugger.slug(text));
23
+ }
24
+ }
25
+ return ids;
26
+ }
27
+ function extractLinks(content, codeLines) {
28
+ const results = [];
29
+ const lines = content.split("\n");
30
+ for (let i = 0; i < lines.length; i++) {
31
+ if (codeLines.has(i + 1)) {
32
+ continue;
33
+ }
34
+ const lineRe = new RegExp(LINK_RE.source, LINK_RE.flags);
35
+ let m;
36
+ while ((m = lineRe.exec(lines[i])) !== null) {
37
+ if (m.index > 0 && lines[i][m.index - 1] === "!") {
38
+ continue;
39
+ }
40
+ results.push({ ref: m[2], line: i + 1 });
41
+ }
42
+ }
43
+ return results;
44
+ }
45
+ async function checkCrossFile(input) {
46
+ const diagnostics = [];
47
+ let entries = [];
48
+ try {
49
+ entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true });
50
+ } catch {
51
+ return diagnostics;
52
+ }
53
+ const allFiles = entries.filter(
54
+ (e) => e.isFile() && !e.parentPath.includes("node_modules") && !e.parentPath.includes("dist")
55
+ );
56
+ const mdFiles = allFiles.filter((e) => e.name.endsWith(".md"));
57
+ const allFilePaths = new Set(allFiles.map((e) => relative(input.docsPath, join(e.parentPath, e.name))));
58
+ const fileHeadings = /* @__PURE__ */ new Map();
59
+ const fileContents = /* @__PURE__ */ new Map();
60
+ for (const md of mdFiles) {
61
+ const absPath = join(md.parentPath, md.name);
62
+ const relPath = relative(input.docsPath, absPath);
63
+ let content;
64
+ try {
65
+ content = await readFile(absPath, "utf-8");
66
+ } catch {
67
+ continue;
68
+ }
69
+ fileContents.set(relPath, content);
70
+ fileHeadings.set(relPath, extractHeadingIds(content));
71
+ }
72
+ for (const [relPath, content] of fileContents) {
73
+ const codeLines = getCodeBlockLines(content);
74
+ const links = extractLinks(content, codeLines);
75
+ for (const { ref, line } of links) {
76
+ if (SKIP_URL_RE.test(ref)) {
77
+ continue;
78
+ }
79
+ const [pathPart, anchor] = ref.split("#", 2);
80
+ if (!pathPart) {
81
+ if (anchor) {
82
+ const headings = fileHeadings.get(relPath);
83
+ if (headings && !headings.has(anchor)) {
84
+ diagnostics.push({
85
+ rule: Rule.AnchorLinksResolve,
86
+ severity: input.strict ? "error" : "warning",
87
+ file: relPath,
88
+ line,
89
+ title: "Anchor link does not resolve",
90
+ detail: `"#${anchor}" does not match any heading in this file.`
91
+ });
92
+ }
93
+ }
94
+ continue;
95
+ }
96
+ if (pathPart.startsWith("/") || /^\.\.\//.test(pathPart)) {
97
+ continue;
98
+ }
99
+ const resolvedPath = normalize(join(dirname(relPath), pathPart));
100
+ if (!allFilePaths.has(resolvedPath)) {
101
+ diagnostics.push({
102
+ rule: Rule.InternalLinksResolve,
103
+ severity: input.strict ? "error" : "warning",
104
+ file: relPath,
105
+ line,
106
+ title: "Internal link does not resolve",
107
+ detail: `"${pathPart}" does not point to an existing file.`
108
+ });
109
+ continue;
110
+ }
111
+ if (anchor) {
112
+ const targetHeadings = fileHeadings.get(resolvedPath);
113
+ if (targetHeadings && !targetHeadings.has(anchor)) {
114
+ diagnostics.push({
115
+ rule: Rule.AnchorLinksResolve,
116
+ severity: input.strict ? "error" : "warning",
117
+ file: relPath,
118
+ line,
119
+ title: "Anchor link does not resolve",
120
+ detail: `"#${anchor}" does not match any heading in "${pathPart}".`
121
+ });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ return diagnostics;
127
+ }
128
+
129
+ export { checkCrossFile };
@@ -0,0 +1,113 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { extname, join, sep } from 'node:path';
3
+ import { Rule } from '../types.js';
4
+
5
+ const SLUG_SAFE_RE = /^[a-z0-9-]+$/;
6
+ const ALLOWED_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
7
+ const ALLOWED_EXTENSIONS = /* @__PURE__ */ new Set([".md", ...ALLOWED_IMAGE_EXTENSIONS]);
8
+ async function checkFilesystem(input) {
9
+ const diagnostics = [];
10
+ let entries = [];
11
+ try {
12
+ entries = await readdir(input.docsPath, { recursive: true, withFileTypes: true });
13
+ } catch {
14
+ }
15
+ const mdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() === ".md");
16
+ const dirs = entries.filter((e) => e.isDirectory());
17
+ const symlinks = entries.filter((e) => e.isSymbolicLink());
18
+ const nonMdFiles = entries.filter((e) => e.isFile() && extname(e.name).toLowerCase() !== ".md");
19
+ for (const link of symlinks) {
20
+ diagnostics.push({
21
+ rule: Rule.NoSymlinks,
22
+ severity: "error",
23
+ file: join(link.parentPath, link.name),
24
+ title: "Symlinks are not allowed",
25
+ detail: `"${link.name}" is a symbolic link. Use actual files instead of symlinks.`
26
+ });
27
+ }
28
+ for (const file of nonMdFiles) {
29
+ const ext = extname(file.name).toLowerCase();
30
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
31
+ diagnostics.push({
32
+ rule: Rule.AllowedFileTypes,
33
+ severity: input.strict ? "error" : "info",
34
+ file: join(file.parentPath, file.name),
35
+ title: "File type not allowed",
36
+ detail: `"${file.name}" is not an allowed file type. Only .md and image files (png, jpg, jpeg, webp, gif) are permitted in the docs folder.`
37
+ });
38
+ }
39
+ }
40
+ if (mdFiles.length === 0) {
41
+ diagnostics.push({
42
+ rule: Rule.HasMarkdown,
43
+ severity: "error",
44
+ title: "Docs folder must contain at least one .md file",
45
+ detail: "The docs folder must contain at least one markdown file. Add markdown files with valid frontmatter to get started."
46
+ });
47
+ }
48
+ const hasRootIndex = mdFiles.some((e) => e.name === "index.md" && e.parentPath === input.docsPath);
49
+ if (!hasRootIndex) {
50
+ diagnostics.push({
51
+ rule: Rule.RootIndex,
52
+ severity: "error",
53
+ title: "Root index.md must exist",
54
+ detail: "The docs folder must contain an index.md file at its root. This serves as the landing page for your plugin documentation."
55
+ });
56
+ }
57
+ for (const dir of dirs) {
58
+ const dirPath = join(dir.parentPath, dir.name);
59
+ const hasAllowed = entries.some(
60
+ (e) => e.isFile() && ALLOWED_EXTENSIONS.has(extname(e.name).toLowerCase()) && (e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep))
61
+ );
62
+ if (!hasAllowed) {
63
+ diagnostics.push({
64
+ rule: Rule.NoEmptyDir,
65
+ severity: input.strict ? "error" : "warning",
66
+ file: dirPath,
67
+ title: "Directory contains no documentation files",
68
+ detail: `"${dir.name}" contains no .md or image files and serves no purpose in the documentation structure. Remove it or add documentation files.`
69
+ });
70
+ continue;
71
+ }
72
+ const hasMd = mdFiles.some((e) => e.parentPath === dirPath || e.parentPath.startsWith(dirPath + sep));
73
+ if (!hasMd) {
74
+ continue;
75
+ }
76
+ const hasIndex = mdFiles.some((e) => e.name === "index.md" && e.parentPath === dirPath);
77
+ if (!hasIndex) {
78
+ diagnostics.push({
79
+ rule: Rule.NestedDirIndex,
80
+ severity: "warning",
81
+ file: dirPath,
82
+ title: "Subdirectory is missing index.md",
83
+ detail: `"${dir.name}" has no index.md. Without one, it will appear as an unnamed category using a title-cased directory name.`
84
+ });
85
+ }
86
+ }
87
+ const namesToCheck = [
88
+ ...mdFiles.map((e) => ({ slug: e.name.slice(0, -3), label: e.name, path: join(e.parentPath, e.name) })),
89
+ ...dirs.map((e) => ({ slug: e.name, label: e.name, path: join(e.parentPath, e.name) }))
90
+ ];
91
+ for (const item of namesToCheck) {
92
+ if (/\s/.test(item.slug)) {
93
+ diagnostics.push({
94
+ rule: Rule.NoSpaces,
95
+ severity: "error",
96
+ file: item.path,
97
+ title: "Name contains spaces",
98
+ detail: `"${item.label}" contains spaces which break URL slugs. Use hyphens instead.`
99
+ });
100
+ } else if (!SLUG_SAFE_RE.test(item.slug)) {
101
+ diagnostics.push({
102
+ rule: Rule.ValidNaming,
103
+ severity: input.strict ? "error" : "warning",
104
+ file: item.path,
105
+ title: "Name contains non-slug characters",
106
+ detail: `"${item.label}" should use only lowercase letters, digits and hyphens for clean URL slugs.`
107
+ });
108
+ }
109
+ }
110
+ return diagnostics;
111
+ }
112
+
113
+ export { ALLOWED_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS, checkFilesystem };