@textmode/tooling-scripts 0.1.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.
- package/package.json +40 -0
- package/policy.json +29 -0
- package/src/cli/check-deps.mjs +61 -0
- package/src/cli/install-husky.mjs +23 -0
- package/src/config/create-config.mjs +75 -0
- package/src/husky/commit-msg +1 -0
- package/src/husky/pre-commit +1 -0
- package/src/husky/pre-push +6 -0
- package/src/scripts/add-api-doc-links.mjs +92 -0
- package/src/scripts/check-examples.mjs +81 -0
- package/src/scripts/check-public-api-docs.mjs +128 -0
- package/src/scripts/lib/api-doc-checks.mjs +147 -0
- package/src/scripts/lib/api-doc-links-config.mjs +32 -0
- package/src/scripts/lib/api-doc-routes.mjs +118 -0
- package/src/scripts/lib/example-checks.mjs +389 -0
- package/src/scripts/lib/jsdoc-comments.mjs +78 -0
- package/src/scripts/lib/reflection-policy.mjs +106 -0
- package/src/scripts/lib/reporting.mjs +23 -0
- package/src/scripts/lib/typedoc-project.mjs +25 -0
- package/src/typedoc-plugins/accessor-docs-normalizer/README.md +45 -0
- package/src/typedoc-plugins/accessor-docs-normalizer/accessor-comments.js +53 -0
- package/src/typedoc-plugins/accessor-docs-normalizer/index.js +32 -0
- package/src/typedoc-plugins/accessor-docs-normalizer/render-accessor.js +144 -0
- package/src/typedoc-plugins/accessor-docs-normalizer/theme-patch.js +28 -0
- package/src/typedoc-plugins/all-member-pages-router/README.md +57 -0
- package/src/typedoc-plugins/all-member-pages-router/child-pages.js +65 -0
- package/src/typedoc-plugins/all-member-pages-router/constants.js +58 -0
- package/src/typedoc-plugins/all-member-pages-router/index.js +28 -0
- package/src/typedoc-plugins/all-member-pages-router/member-paths.js +48 -0
- package/src/typedoc-plugins/all-member-pages-router/member-reflections.js +164 -0
- package/src/typedoc-plugins/all-member-pages-router/namespace-paths.js +44 -0
- package/src/typedoc-plugins/all-member-pages-router/router.js +58 -0
- package/src/typedoc-plugins/api-frontmatter/README.md +66 -0
- package/src/typedoc-plugins/api-frontmatter/constants.js +52 -0
- package/src/typedoc-plugins/api-frontmatter/descriptions.js +80 -0
- package/src/typedoc-plugins/api-frontmatter/frontmatter.js +56 -0
- package/src/typedoc-plugins/api-frontmatter/index.js +38 -0
- package/src/typedoc-plugins/api-frontmatter/library-context.js +62 -0
- package/src/typedoc-plugins/api-frontmatter/reflection-metadata.js +95 -0
- package/src/typedoc-plugins/include-code-examples/README.md +40 -0
- package/src/typedoc-plugins/include-code-examples/code-fences.js +55 -0
- package/src/typedoc-plugins/include-code-examples/example-metadata.js +41 -0
- package/src/typedoc-plugins/include-code-examples/index.js +23 -0
- package/src/typedoc-plugins/include-code-examples/transform-markdown.js +33 -0
- package/src/typedoc-plugins/strip-api-self-links/index.js +126 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { hostedApiUrl, routeForReflection } from './api-doc-routes.mjs';
|
|
2
|
+
import { getPrimaryLocation, getRelevantComments } from './reflection-policy.mjs';
|
|
3
|
+
|
|
4
|
+
export const ISSUE_KIND = {
|
|
5
|
+
MISSING_DOCSTRING: 'missing-docstring',
|
|
6
|
+
MISSING_EXAMPLE: 'missing-example',
|
|
7
|
+
MISSING_API_LINK: 'missing-api-link',
|
|
8
|
+
INVALID_API_LINK: 'invalid-api-link',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function hasDocstring(reflection) {
|
|
12
|
+
return getRelevantComments(reflection).length > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasExample(reflection) {
|
|
16
|
+
return getRelevantComments(reflection).some(({ comment }) =>
|
|
17
|
+
comment.getTags('@example').some((tag) => tag.content.some((part) => (part.text ?? '').trim().length > 0))
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getApiLinkTargets(comment, apiBaseUrl) {
|
|
22
|
+
return comment
|
|
23
|
+
.getTags('@see')
|
|
24
|
+
.flatMap((tag) => tag.content)
|
|
25
|
+
.filter((part) => part.kind === 'inline-tag' && part.tag === '@link')
|
|
26
|
+
.map((part) => part.target)
|
|
27
|
+
.filter((target) => typeof target === 'string' && target.startsWith(`${apiBaseUrl}/`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getInvalidApiLinkTargets(comment, routes, apiBaseUrl) {
|
|
31
|
+
return getApiLinkTargets(comment, apiBaseUrl).filter((target) => {
|
|
32
|
+
const routeWithAnchor = target.slice(`${apiBaseUrl}/`.length);
|
|
33
|
+
if (!routeWithAnchor || routeWithAnchor === 'index') {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [route] = routeWithAnchor.split('#');
|
|
38
|
+
return !routes.has(route);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hasApiLink(comment, apiBaseUrl) {
|
|
43
|
+
return getApiLinkTargets(comment, apiBaseUrl).length > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createApiLinkTargets(reflection, routes, apiBaseUrl) {
|
|
47
|
+
const route = routeForReflection(reflection, routes);
|
|
48
|
+
if (!route) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const routeWithoutAnchor = route.split('#')[0];
|
|
53
|
+
if (!routes.has(routeWithoutAnchor)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const url = hostedApiUrl(apiBaseUrl, route);
|
|
58
|
+
const label = `${reflection.getFriendlyFullName()} API reference`;
|
|
59
|
+
const seeLine = `@see {@link ${url} | ${label}}`;
|
|
60
|
+
|
|
61
|
+
return getRelevantComments(reflection).map(({ source }) => ({
|
|
62
|
+
reflection,
|
|
63
|
+
source,
|
|
64
|
+
seeLine,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createIssue(reflection, kind) {
|
|
69
|
+
const location = getPrimaryLocation(reflection);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
kind,
|
|
73
|
+
name: reflection.getFriendlyFullName(),
|
|
74
|
+
file: location.file,
|
|
75
|
+
line: location.line,
|
|
76
|
+
column: location.column,
|
|
77
|
+
location: location.printable,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createCommentIssue(reflection, commentSource, kind, detail) {
|
|
82
|
+
const location = commentSource
|
|
83
|
+
? {
|
|
84
|
+
file: commentSource.fullFileName || getPrimaryLocation(reflection).file,
|
|
85
|
+
line: commentSource.line || 1,
|
|
86
|
+
column: (commentSource.character ?? 0) + 1,
|
|
87
|
+
}
|
|
88
|
+
: getPrimaryLocation(reflection);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
kind,
|
|
92
|
+
name: reflection.getFriendlyFullName(),
|
|
93
|
+
file: location.file,
|
|
94
|
+
line: location.line,
|
|
95
|
+
column: location.column,
|
|
96
|
+
location: `${location.file}:${location.line}:${location.column}`,
|
|
97
|
+
detail,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function collectApiDocIssues({
|
|
102
|
+
apiBaseUrl,
|
|
103
|
+
apiLinkReflections,
|
|
104
|
+
docstringReflections,
|
|
105
|
+
exampleReflections,
|
|
106
|
+
routes,
|
|
107
|
+
}) {
|
|
108
|
+
const missingDocstrings = docstringReflections
|
|
109
|
+
.filter((reflection) => !hasDocstring(reflection))
|
|
110
|
+
.map((reflection) => createIssue(reflection, ISSUE_KIND.MISSING_DOCSTRING))
|
|
111
|
+
.sort(compareIssues);
|
|
112
|
+
|
|
113
|
+
const missingExamples = exampleReflections
|
|
114
|
+
.filter((reflection) => hasDocstring(reflection) && !hasExample(reflection))
|
|
115
|
+
.map((reflection) => createIssue(reflection, ISSUE_KIND.MISSING_EXAMPLE))
|
|
116
|
+
.sort(compareIssues);
|
|
117
|
+
|
|
118
|
+
const comments = apiLinkReflections.flatMap((reflection) =>
|
|
119
|
+
getRelevantComments(reflection).map(({ comment, source }) => ({ reflection, comment, source }))
|
|
120
|
+
);
|
|
121
|
+
const missingApiLinks = comments
|
|
122
|
+
.filter(({ comment }) => !hasApiLink(comment, apiBaseUrl))
|
|
123
|
+
.map(({ reflection, source }) => createCommentIssue(reflection, source, ISSUE_KIND.MISSING_API_LINK))
|
|
124
|
+
.sort(compareIssues);
|
|
125
|
+
const invalidApiLinks = comments
|
|
126
|
+
.flatMap(({ reflection, comment, source }) =>
|
|
127
|
+
getInvalidApiLinkTargets(comment, routes, apiBaseUrl).map((target) =>
|
|
128
|
+
createCommentIssue(reflection, source, ISSUE_KIND.INVALID_API_LINK, target)
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
.sort(compareIssues);
|
|
132
|
+
|
|
133
|
+
return { missingDocstrings, missingExamples, missingApiLinks, invalidApiLinks };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function countApiDocIssues(issueGroups) {
|
|
137
|
+
return Object.values(issueGroups).reduce((total, issues) => total + issues.length, 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function compareIssues(left, right) {
|
|
141
|
+
return (
|
|
142
|
+
left.file.localeCompare(right.file) ||
|
|
143
|
+
left.line - right.line ||
|
|
144
|
+
left.column - right.column ||
|
|
145
|
+
left.name.localeCompare(right.name)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
async function loadConsumerConfig() {
|
|
5
|
+
const configPath = path.resolve(process.cwd(), 'textmode.tooling.config.mjs');
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const configModule = await import(pathToFileURL(configPath).href);
|
|
9
|
+
return configModule.default ?? configModule.config;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'ENOENT') {
|
|
12
|
+
throw new Error(`Missing textmode tooling config at ${configPath}.`);
|
|
13
|
+
}
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = await loadConsumerConfig();
|
|
19
|
+
|
|
20
|
+
export const API_BASE_URL = config.API_BASE_URL;
|
|
21
|
+
export const API_DOCS_DIR = config.API_DOCS_DIR;
|
|
22
|
+
export const CHECK_EXAMPLE_GALLERY_SOURCE_BASE = config.CHECK_EXAMPLE_GALLERY_SOURCE_BASE;
|
|
23
|
+
export const CHECK_EXAMPLE_SKETCHES = config.CHECK_EXAMPLE_SKETCHES;
|
|
24
|
+
export const DOCSTRING_TARGET_KINDS = config.DOCSTRING_TARGET_KINDS;
|
|
25
|
+
export const DOCSTRING_TARGET_KINDS_WITH_ACCESSORS = config.DOCSTRING_TARGET_KINDS_WITH_ACCESSORS;
|
|
26
|
+
export const ENTRY_POINT = config.ENTRY_POINT;
|
|
27
|
+
export const ENTRY_POINTS = config.ENTRY_POINTS;
|
|
28
|
+
export const EXAMPLE_TARGET_KINDS = config.EXAMPLE_TARGET_KINDS;
|
|
29
|
+
export const EXAMPLE_TARGET_KINDS_WITH_ACCESSORS = config.EXAMPLE_TARGET_KINDS_WITH_ACCESSORS;
|
|
30
|
+
export const PACKAGE_LABEL = config.PACKAGE_LABEL;
|
|
31
|
+
export const SOURCE_ROOTS = config.SOURCE_ROOTS;
|
|
32
|
+
export const TARGET_KINDS = config.TARGET_KINDS;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { ReflectionKind } from 'typedoc';
|
|
7
|
+
|
|
8
|
+
export const KIND_DIRECTORIES = new Map([
|
|
9
|
+
[ReflectionKind.Class, 'classes'],
|
|
10
|
+
[ReflectionKind.Interface, 'interfaces'],
|
|
11
|
+
[ReflectionKind.TypeAlias, 'type-aliases'],
|
|
12
|
+
[ReflectionKind.Enum, 'enumerations'],
|
|
13
|
+
[ReflectionKind.Variable, 'variables'],
|
|
14
|
+
[ReflectionKind.Function, 'functions'],
|
|
15
|
+
[ReflectionKind.Method, 'methods'],
|
|
16
|
+
[ReflectionKind.Accessor, 'accessors'],
|
|
17
|
+
[ReflectionKind.Property, 'properties'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function readMarkdownRoutes(apiDocsDir) {
|
|
21
|
+
const routes = new Set();
|
|
22
|
+
|
|
23
|
+
function walk(dir) {
|
|
24
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
const fullPath = path.join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
walk(fullPath);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!entry.name.endsWith('.md')) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const relative = path.relative(apiDocsDir, fullPath).replaceAll(path.sep, '/');
|
|
36
|
+
const withoutExtension = relative.replace(/\.md$/, '');
|
|
37
|
+
const route = withoutExtension.endsWith('/index')
|
|
38
|
+
? withoutExtension.slice(0, -'/index'.length)
|
|
39
|
+
: withoutExtension;
|
|
40
|
+
routes.add(route);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
walk(apiDocsDir);
|
|
45
|
+
return routes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateTemporaryMarkdownRoutes() {
|
|
49
|
+
const outputDir = fs.mkdtempSync(path.join(tmpdir(), 'textmode-api-routes-'));
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('npm', ['run', 'build:docs', '--', '--out', outputDir, '--logLevel', 'Error'], {
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
55
|
+
stdio: 'pipe',
|
|
56
|
+
});
|
|
57
|
+
return readMarkdownRoutes(outputDir);
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(outputDir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readOrGenerateMarkdownRoutes(apiDocsDir) {
|
|
64
|
+
if (fs.existsSync(apiDocsDir)) {
|
|
65
|
+
return readMarkdownRoutes(apiDocsDir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return generateTemporaryMarkdownRoutes();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function slugAnchor(name) {
|
|
72
|
+
return name
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
75
|
+
.replace(/^-+|-+$/g, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function routeForReflection(reflection, routes) {
|
|
79
|
+
if (reflection.isProject()) {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (reflection.kind === ReflectionKind.Namespace) {
|
|
84
|
+
const parentRoute =
|
|
85
|
+
reflection.parent && !reflection.parent.isProject() ? routeForReflection(reflection.parent, routes) : '';
|
|
86
|
+
return [parentRoute, 'namespaces', reflection.name].filter(Boolean).join('/');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
reflection.kind === ReflectionKind.Method ||
|
|
91
|
+
reflection.kind === ReflectionKind.Accessor ||
|
|
92
|
+
reflection.kind === ReflectionKind.Property
|
|
93
|
+
) {
|
|
94
|
+
const ownerRoute = routeForReflection(reflection.parent, routes);
|
|
95
|
+
const ownPageRoute = [ownerRoute, KIND_DIRECTORIES.get(reflection.kind), reflection.name]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join('/');
|
|
98
|
+
|
|
99
|
+
if (routes.has(ownPageRoute)) {
|
|
100
|
+
return ownPageRoute;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return `${ownerRoute}#${slugAnchor(reflection.name)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const directory = KIND_DIRECTORIES.get(reflection.kind);
|
|
107
|
+
if (!directory) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parentRoute =
|
|
112
|
+
reflection.parent && !reflection.parent.isProject() ? routeForReflection(reflection.parent, routes) : '';
|
|
113
|
+
return [parentRoute, directory, reflection.name].filter(Boolean).join('/');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function hostedApiUrl(apiBaseUrl, route) {
|
|
117
|
+
return `${apiBaseUrl}/${route}`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
|
|
5
|
+
import { CHECK_EXAMPLE_GALLERY_SOURCE_BASE } from './api-doc-links-config.mjs';
|
|
6
|
+
|
|
7
|
+
const INCLUDE_CODE_PATTERN = /\{@includeCode\s+([^}\s]+)[^}]*\}/g;
|
|
8
|
+
const MANIFEST_KEYS = new Set(['version', 'description', 'groups']);
|
|
9
|
+
const GROUP_KEYS = new Set(['name', 'description', 'examples', 'subgroups']);
|
|
10
|
+
const SUBGROUP_KEYS = new Set(['name', 'description', 'examples']);
|
|
11
|
+
const EXAMPLE_KEYS = new Set(['title', 'description', 'sourceFile']);
|
|
12
|
+
|
|
13
|
+
export const EXAMPLES_DIR = 'examples';
|
|
14
|
+
export const EXAMPLES_INDEX = path.join(EXAMPLES_DIR, 'index.html');
|
|
15
|
+
export const EXAMPLES_MANIFEST = path.join(EXAMPLES_DIR, 'manifest.json');
|
|
16
|
+
export const SOURCE_DIR = 'src';
|
|
17
|
+
|
|
18
|
+
export function isRecord(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getUnknownKeys(value, allowedKeys) {
|
|
23
|
+
return Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toRepositoryPath(filePath) {
|
|
27
|
+
return path.relative(process.cwd(), filePath).split(path.sep).join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getTypeScriptFiles(directory) {
|
|
31
|
+
const files = [];
|
|
32
|
+
|
|
33
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
34
|
+
const entryPath = path.join(directory, entry.name);
|
|
35
|
+
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
files.push(...getTypeScriptFiles(entryPath));
|
|
38
|
+
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
39
|
+
files.push(entryPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readExamplesManifest(manifestPath = EXAMPLES_MANIFEST) {
|
|
47
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readExamplesIndex(indexPath = EXAMPLES_INDEX) {
|
|
51
|
+
return fs.readFileSync(indexPath, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getSketchTitle(filePath) {
|
|
55
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
56
|
+
const docstringMatch = content.match(/^\s*\/\*\*([\s\S]*?)\*\//);
|
|
57
|
+
const titleMatch = docstringMatch?.[1].match(/@title\s+([^\r\n]+)/);
|
|
58
|
+
|
|
59
|
+
return titleMatch?.[1].trim() ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getManifestExamples(manifest) {
|
|
63
|
+
return manifest.groups.flatMap((group) => {
|
|
64
|
+
if (Array.isArray(group.subgroups)) {
|
|
65
|
+
return group.subgroups.flatMap((sg) =>
|
|
66
|
+
Array.isArray(sg.examples)
|
|
67
|
+
? sg.examples.filter(isRecord).map((example) => ({
|
|
68
|
+
...example,
|
|
69
|
+
groupName: group.name,
|
|
70
|
+
groupDescription: group.description,
|
|
71
|
+
subgroupName: sg.name,
|
|
72
|
+
}))
|
|
73
|
+
: []
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return Array.isArray(group.examples)
|
|
78
|
+
? group.examples.filter(isRecord).map((example) => ({
|
|
79
|
+
...example,
|
|
80
|
+
groupName: group.name,
|
|
81
|
+
groupDescription: group.description,
|
|
82
|
+
}))
|
|
83
|
+
: [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getTypeDocExampleIncludes(sourceDir = SOURCE_DIR, examplesDir = EXAMPLES_DIR) {
|
|
88
|
+
const includes = [];
|
|
89
|
+
|
|
90
|
+
for (const file of getTypeScriptFiles(sourceDir)) {
|
|
91
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
92
|
+
let match;
|
|
93
|
+
|
|
94
|
+
INCLUDE_CODE_PATTERN.lastIndex = 0;
|
|
95
|
+
while ((match = INCLUDE_CODE_PATTERN.exec(content)) !== null) {
|
|
96
|
+
const includeTarget = match[1];
|
|
97
|
+
const resolvedPath = path.resolve(path.dirname(file), includeTarget);
|
|
98
|
+
const sourceFile = toRepositoryPath(resolvedPath);
|
|
99
|
+
|
|
100
|
+
if (sourceFile.startsWith(`${examplesDir}/`)) {
|
|
101
|
+
includes.push({
|
|
102
|
+
file,
|
|
103
|
+
includeTarget,
|
|
104
|
+
sourceFile,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return includes;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function validateExamplesGallery(
|
|
114
|
+
indexContent,
|
|
115
|
+
examplesIndex = EXAMPLES_INDEX,
|
|
116
|
+
examplesManifest = EXAMPLES_MANIFEST
|
|
117
|
+
) {
|
|
118
|
+
const issues = [];
|
|
119
|
+
|
|
120
|
+
if (/const\s+GROUPS\s*=/.test(indexContent)) {
|
|
121
|
+
issues.push(`${examplesIndex} must not declare \`const GROUPS\`; ${examplesManifest} is authoritative.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!indexContent.includes('manifest.json')) {
|
|
125
|
+
issues.push(`${examplesIndex} must load ${examplesManifest}.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (CHECK_EXAMPLE_GALLERY_SOURCE_BASE && /data-source-base\s*=/.test(indexContent)) {
|
|
129
|
+
issues.push(
|
|
130
|
+
`${examplesIndex} must not hardcode a source base; preview links should resolve from example paths.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return issues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function validateExamplesManifest(manifest, options = {}) {
|
|
138
|
+
const examplesDir = options.examplesDir ?? EXAMPLES_DIR;
|
|
139
|
+
const examplesManifest = options.examplesManifest ?? EXAMPLES_MANIFEST;
|
|
140
|
+
const sourceDir = options.sourceDir ?? SOURCE_DIR;
|
|
141
|
+
const issues = [];
|
|
142
|
+
|
|
143
|
+
if (!isRecord(manifest)) {
|
|
144
|
+
return ['Manifest root must be an object.'];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const key of getUnknownKeys(manifest, MANIFEST_KEYS)) {
|
|
148
|
+
issues.push(`Manifest contains unknown top-level key: ${key}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (manifest.version !== 1) {
|
|
152
|
+
issues.push('Manifest `version` must be `1`.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (typeof manifest.description !== 'string' || manifest.description.length === 0) {
|
|
156
|
+
issues.push('Manifest `description` must be a non-empty string.');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!Array.isArray(manifest.groups)) {
|
|
160
|
+
return ['Manifest `groups` must be an array.'];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [groupIndex, group] of manifest.groups.entries()) {
|
|
164
|
+
if (!isRecord(group)) {
|
|
165
|
+
issues.push(`Manifest group ${groupIndex + 1} must be an object.`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const key of getUnknownKeys(group, GROUP_KEYS)) {
|
|
170
|
+
issues.push(`Manifest group "${group.name ?? groupIndex + 1}" contains unknown key: ${key}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof group.name !== 'string' || group.name.length === 0) {
|
|
174
|
+
issues.push(`Manifest group ${groupIndex + 1} is missing a name.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof group.description !== 'string' || group.description.length === 0) {
|
|
178
|
+
issues.push(`Manifest group "${group.name ?? groupIndex + 1}" is missing a description.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const hasExamples = Array.isArray(group.examples);
|
|
182
|
+
const hasSubgroups = Array.isArray(group.subgroups);
|
|
183
|
+
|
|
184
|
+
if (hasExamples && hasSubgroups) {
|
|
185
|
+
issues.push(
|
|
186
|
+
`Manifest group "${group.name}" must not have both \`examples\` and \`subgroups\`. Use one or the other.`
|
|
187
|
+
);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!hasExamples && !hasSubgroups) {
|
|
192
|
+
issues.push(
|
|
193
|
+
`Manifest group "${group.name}" must have either an \`examples\` array or a \`subgroups\` array.`
|
|
194
|
+
);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (hasSubgroups) {
|
|
199
|
+
for (const [sgIndex, sg] of group.subgroups.entries()) {
|
|
200
|
+
if (!isRecord(sg)) {
|
|
201
|
+
issues.push(`Manifest subgroup ${sgIndex + 1} in group "${group.name}" must be an object.`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const key of getUnknownKeys(sg, SUBGROUP_KEYS)) {
|
|
206
|
+
issues.push(
|
|
207
|
+
`Manifest subgroup "${sg.name ?? sgIndex + 1}" in group "${group.name}" contains unknown key: ${key}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (typeof sg.name !== 'string' || sg.name.length === 0) {
|
|
212
|
+
issues.push(`Manifest subgroup ${sgIndex + 1} in group "${group.name}" is missing a name.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!Array.isArray(sg.examples)) {
|
|
216
|
+
issues.push(
|
|
217
|
+
`Manifest subgroup "${sg.name ?? sgIndex + 1}" in group "${group.name}" examples must be an array.`
|
|
218
|
+
);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const [exampleIndex, example] of sg.examples.entries()) {
|
|
223
|
+
if (!isRecord(example)) {
|
|
224
|
+
issues.push(
|
|
225
|
+
`Manifest example ${exampleIndex + 1} in subgroup "${sg.name}" of group "${group.name}" must be an object.`
|
|
226
|
+
);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const key of getUnknownKeys(example, EXAMPLE_KEYS)) {
|
|
231
|
+
issues.push(
|
|
232
|
+
`Manifest example "${example.sourceFile ?? `${group.name}.${sg.name}.${exampleIndex + 1}`}" contains unknown key: ${key}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
for (const [exampleIndex, example] of group.examples.entries()) {
|
|
239
|
+
if (!isRecord(example)) {
|
|
240
|
+
issues.push(`Manifest example ${exampleIndex + 1} in group "${group.name}" must be an object.`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const key of getUnknownKeys(example, EXAMPLE_KEYS)) {
|
|
245
|
+
issues.push(
|
|
246
|
+
`Manifest example "${example.sourceFile ?? `${group.name}.${exampleIndex + 1}`}" contains unknown key: ${key}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const manifestExamples = getManifestExamples(manifest);
|
|
254
|
+
const manifestFiles = manifestExamples.map((example) => example.sourceFile);
|
|
255
|
+
|
|
256
|
+
if (manifestFiles.length === 0) {
|
|
257
|
+
issues.push('Manifest must include at least one example.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
for (const example of manifestExamples) {
|
|
262
|
+
if (typeof example.sourceFile !== 'string') {
|
|
263
|
+
issues.push('Manifest examples must include a string sourceFile.');
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (seen.has(example.sourceFile)) {
|
|
268
|
+
issues.push(`Manifest contains duplicate source file: ${example.sourceFile}`);
|
|
269
|
+
}
|
|
270
|
+
seen.add(example.sourceFile);
|
|
271
|
+
|
|
272
|
+
if (typeof example.title !== 'string' || example.title.length === 0) {
|
|
273
|
+
issues.push(`Manifest example "${example.sourceFile}" is missing a title.`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!example.sourceFile.startsWith(`${examplesDir}/`) || !example.sourceFile.endsWith('/sketch.js')) {
|
|
277
|
+
issues.push(`Manifest example "${example.sourceFile}" sourceFile must be an examples/**/sketch.js path.`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!fs.existsSync(example.sourceFile)) {
|
|
281
|
+
issues.push(`Manifest example is missing its sketch file: ${example.sourceFile}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const sketchTitle = getSketchTitle(example.sourceFile);
|
|
286
|
+
|
|
287
|
+
if (!sketchTitle) {
|
|
288
|
+
issues.push(`Manifest example "${example.sourceFile}" sketch is missing an @title tag.`);
|
|
289
|
+
} else if (sketchTitle !== example.title) {
|
|
290
|
+
issues.push(
|
|
291
|
+
`Manifest example "${example.sourceFile}" title "${example.title}" must match sketch @title "${sketchTitle}".`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const manifestFileSet = new Set(manifestFiles);
|
|
297
|
+
for (const include of getTypeDocExampleIncludes(sourceDir, examplesDir)) {
|
|
298
|
+
if (!manifestFileSet.has(include.sourceFile)) {
|
|
299
|
+
issues.push(
|
|
300
|
+
`TypeDoc includeCode in "${include.file}" references "${include.includeTarget}" (${include.sourceFile}), but that sketch is not in ${examplesManifest}.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return issues;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function checkSketch(filePath) {
|
|
309
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
310
|
+
const issues = [];
|
|
311
|
+
|
|
312
|
+
if (content.includes('import ') || content.includes('export ')) {
|
|
313
|
+
return { skipped: true, issues };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const docstringMatch = content.match(/^\s*\/\*\*([\s\S]*?)\*\//);
|
|
317
|
+
if (!docstringMatch) {
|
|
318
|
+
issues.push('Missing JSDoc-style block comment header at the top of the file.');
|
|
319
|
+
} else {
|
|
320
|
+
const docstring = docstringMatch[1];
|
|
321
|
+
if (!docstring.includes('@title')) {
|
|
322
|
+
issues.push('Docstring header is missing `@title` tag.');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const createMatch = content.match(/textmode\.create\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
327
|
+
if (!createMatch) {
|
|
328
|
+
issues.push('Could not find `textmode.create({ ... })` call.');
|
|
329
|
+
} else {
|
|
330
|
+
const options = createMatch[1];
|
|
331
|
+
|
|
332
|
+
if (!options.match(/width:\s*window\.innerWidth/)) {
|
|
333
|
+
issues.push('`width` must be set to `window.innerWidth` in create options.');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!options.match(/height:\s*window\.innerHeight/)) {
|
|
337
|
+
issues.push('`height` must be set to `window.innerHeight` in create options.');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const fontSizeMatch = options.match(/fontSize:\s*(\d+)/);
|
|
341
|
+
if (fontSizeMatch) {
|
|
342
|
+
const size = parseInt(fontSizeMatch[1], 10);
|
|
343
|
+
if (size !== 8 && size !== 16 && size !== 32) {
|
|
344
|
+
issues.push(`\`fontSize\` is set to \`${size}\`, but must be exactly \`8\`, \`16\`, or \`32\`.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!content.includes('windowResized')) {
|
|
350
|
+
issues.push('Missing `windowResized` callback registration.');
|
|
351
|
+
}
|
|
352
|
+
if (!content.match(/resizeCanvas\(\s*window\.innerWidth\s*,\s*window\.innerHeight\s*\)/)) {
|
|
353
|
+
issues.push(
|
|
354
|
+
'`resizeCanvas` must be called with `window.innerWidth` and `window.innerHeight` inside the sketch.'
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (/\bvar\b/.test(content)) {
|
|
359
|
+
issues.push('Do not use `var`; prefer `const` or `let`.');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const lines = content.split('\n');
|
|
363
|
+
if (lines.length > 100) {
|
|
364
|
+
issues.push(`Sketch file has ${lines.length} lines, but must not exceed 100 lines.`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!content.includes('layers.add(')) {
|
|
368
|
+
issues.push('Missing dedicated label layer registration (`t.layers.add()`).');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const leftMatch = content.includes('grid.cols / 2') || content.includes('grid.cols/2');
|
|
372
|
+
const topMatch = content.includes('grid.rows / 2') || content.includes('grid.rows/2');
|
|
373
|
+
if (!leftMatch || !topMatch) {
|
|
374
|
+
issues.push(
|
|
375
|
+
'Missing top-left labeling alignment coordinates calculation using grid center offset (`-Math.floor(t.grid.cols / 2)` and `-Math.floor(t.grid.rows / 2)`).'
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const textDrawingRegex = /(?:drawText|drawCenteredText|t\.char)\s*\(\s*(['"`])(.*?)\1/g;
|
|
380
|
+
let match;
|
|
381
|
+
while ((match = textDrawingRegex.exec(content)) !== null) {
|
|
382
|
+
const text = match[2];
|
|
383
|
+
if (text.length > 40) {
|
|
384
|
+
issues.push(`Row of text exceeds 40 characters limit: "${text}" (${text.length} chars).`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { skipped: false, issues };
|
|
389
|
+
}
|