create-dox 0.3.4 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +141 -38
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -100,6 +100,7 @@ async function gatherAnswers(dirArg, useDefaults) {
100
100
  import { existsSync, readFileSync as readFileSync2, readdirSync, statSync } from "fs";
101
101
  import { join as join2, extname, relative } from "path";
102
102
  import matter from "gray-matter";
103
+ import { parse as parseYaml } from "yaml";
103
104
 
104
105
  // src/docs-json.ts
105
106
  import { readFileSync, writeFileSync } from "fs";
@@ -118,11 +119,8 @@ function writeDocsJson(projectDir, config) {
118
119
  function collectNavPageIds(groups, seen, duplicates) {
119
120
  for (const page of groups) {
120
121
  if (typeof page === "string") {
121
- if (seen.has(page)) {
122
- duplicates.add(page);
123
- } else {
124
- seen.add(page);
125
- }
122
+ if (seen.has(page)) duplicates.add(page);
123
+ else seen.add(page);
126
124
  } else if (page.pages) {
127
125
  collectNavPageIds(page.pages, seen, duplicates);
128
126
  }
@@ -139,11 +137,8 @@ function scanMdx(dir, results) {
139
137
  const fullPath = join2(dir, entry);
140
138
  try {
141
139
  const stat = statSync(fullPath);
142
- if (stat.isDirectory()) {
143
- scanMdx(fullPath, results);
144
- } else if (extname(entry).toLowerCase() === ".mdx") {
145
- results.push(fullPath);
146
- }
140
+ if (stat.isDirectory()) scanMdx(fullPath, results);
141
+ else if (extname(entry).toLowerCase() === ".mdx") results.push(fullPath);
147
142
  } catch {
148
143
  }
149
144
  }
@@ -159,7 +154,80 @@ function addOrphanToNav(projectDir, pageId) {
159
154
  writeDocsJson(projectDir, config);
160
155
  }
161
156
  }
162
- async function runCheck(projectDir, fix) {
157
+ function slugify2(text) {
158
+ return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-");
159
+ }
160
+ function extractHeadingAnchors(content) {
161
+ const anchors = /* @__PURE__ */ new Set();
162
+ for (const line of content.split("\n")) {
163
+ const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line);
164
+ if (m) anchors.add(slugify2(m[1]));
165
+ }
166
+ return anchors;
167
+ }
168
+ function extractLinks(content) {
169
+ const links = [];
170
+ const lines = content.split("\n");
171
+ let inFence = false;
172
+ for (let i = 0; i < lines.length; i++) {
173
+ if (/^\s*(```|~~~)/.test(lines[i])) {
174
+ inFence = !inFence;
175
+ continue;
176
+ }
177
+ if (inFence) continue;
178
+ const line = lines[i].replace(/`[^`]*`/g, "");
179
+ for (const m of line.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
180
+ links.push({ target: m[1], line: i + 1 });
181
+ }
182
+ for (const m of line.matchAll(/href=["']([^"']+)["']/g)) {
183
+ links.push({ target: m[1], line: i + 1 });
184
+ }
185
+ }
186
+ return links;
187
+ }
188
+ function pageIdToPath(pageId) {
189
+ return pageId === "introduction" ? "/" : `/${pageId}`;
190
+ }
191
+ function validateOpenApi(projectDir, source, issues) {
192
+ const specPath = join2(projectDir, source);
193
+ if (!existsSync(specPath)) {
194
+ issues.push({ severity: "error", message: `API reference points at "${source}" but the file does not exist`, file: source });
195
+ return;
196
+ }
197
+ let spec;
198
+ try {
199
+ const raw = readFileSync2(specPath, "utf8");
200
+ spec = source.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
201
+ } catch (err) {
202
+ issues.push({ severity: "error", message: `OpenAPI spec is not valid ${source.endsWith(".json") ? "JSON" : "YAML"}: ${err.message}`, file: source });
203
+ return;
204
+ }
205
+ const s = spec;
206
+ if (typeof s?.openapi !== "string" && typeof s?.swagger !== "string") {
207
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "openapi" (or "swagger") version field', file: source });
208
+ }
209
+ if (typeof s?.info !== "object" || s.info === null) {
210
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "info" object', file: source });
211
+ }
212
+ const paths = s?.paths;
213
+ if (typeof paths !== "object" || paths === null) {
214
+ issues.push({ severity: "error", message: 'OpenAPI spec is missing the "paths" object', file: source });
215
+ } else {
216
+ const methods = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
217
+ for (const [p, ops] of Object.entries(paths)) {
218
+ if (typeof ops !== "object" || ops === null) {
219
+ issues.push({ severity: "error", message: `OpenAPI path "${p}" is not an object`, file: source });
220
+ continue;
221
+ }
222
+ const hasOp = Object.keys(ops).some((k) => methods.has(k.toLowerCase()));
223
+ if (!hasOp) {
224
+ issues.push({ severity: "warning", message: `OpenAPI path "${p}" has no operations`, file: source });
225
+ }
226
+ }
227
+ }
228
+ }
229
+ async function runCheck(projectDir, options) {
230
+ const { fix, ci } = options;
163
231
  if (!existsSync(join2(projectDir, "docs.json"))) {
164
232
  console.error(`
165
233
  \u274C Not a Dox project: docs.json not found in ${projectDir}
@@ -172,7 +240,11 @@ async function runCheck(projectDir, fix) {
172
240
  const navPageIds = /* @__PURE__ */ new Set();
173
241
  const duplicates = /* @__PURE__ */ new Set();
174
242
  for (const tab of config.tabs) {
175
- if (tab.href || tab.api) continue;
243
+ if (tab.href) {
244
+ if (tab.href.startsWith("/")) navPageIds.add(tab.href.slice(1) || "introduction");
245
+ continue;
246
+ }
247
+ if (tab.api) continue;
176
248
  if (!tab.groups || tab.groups.length === 0) {
177
249
  issues.push({ severity: "error", message: `Tab "${tab.tab}" has no groups and no href \u2014 it will render empty` });
178
250
  continue;
@@ -183,21 +255,17 @@ async function runCheck(projectDir, fix) {
183
255
  issues.push({ severity: "error", message: `[duplicate] "${dup}" appears more than once in docs.json` });
184
256
  }
185
257
  for (const pageId of navPageIds) {
186
- const candidates = [
187
- join2(contentDir, `${pageId}.mdx`),
188
- join2(contentDir, `${pageId}/index.mdx`)
189
- ];
258
+ const candidates = [join2(contentDir, `${pageId}.mdx`), join2(contentDir, `${pageId}/index.mdx`)];
190
259
  if (!candidates.some((c) => existsSync(c))) {
191
- issues.push({
192
- severity: "error",
193
- message: `"${pageId}" is in docs.json but has no MDX file`,
194
- file: `src/content/${pageId}.mdx`
195
- });
260
+ issues.push({ severity: "error", message: `"${pageId}" is in docs.json but has no MDX file`, file: `src/content/${pageId}.mdx` });
196
261
  }
197
262
  }
198
263
  const allFiles = [];
199
264
  if (existsSync(contentDir)) scanMdx(contentDir, allFiles);
200
265
  const fixedOrphans = [];
266
+ const validPaths = /* @__PURE__ */ new Set(["/"]);
267
+ const anchorsByPath = /* @__PURE__ */ new Map();
268
+ const linksByFile = [];
201
269
  for (const filePath of allFiles) {
202
270
  const rel = filePath.slice(contentDir.length + 1).replace(/\.mdx$/, "").replace(/\\/g, "/");
203
271
  const pageId = rel.endsWith("/index") ? rel.slice(0, -6) : rel;
@@ -206,37 +274,69 @@ async function runCheck(projectDir, fix) {
206
274
  addOrphanToNav(projectDir, pageId);
207
275
  fixedOrphans.push(pageId);
208
276
  } else {
209
- issues.push({
210
- severity: "warning",
211
- message: `"${pageId}" is not in docs.json nav (orphan)`,
212
- file: relative(projectDir, filePath)
213
- });
277
+ issues.push({ severity: "warning", message: `"${pageId}" is not in docs.json nav (orphan)`, file: relative(projectDir, filePath) });
214
278
  }
215
279
  }
216
280
  let data = {};
217
281
  let content = "";
282
+ let lineOffset = 0;
218
283
  try {
219
284
  const raw = readFileSync2(filePath, "utf8");
220
285
  const parsed = matter(raw);
221
286
  data = parsed.data;
222
287
  content = parsed.content;
288
+ lineOffset = raw.slice(0, raw.indexOf(content)).split("\n").length - 1;
223
289
  } catch {
224
290
  issues.push({ severity: "error", message: `Could not parse frontmatter`, file: relative(projectDir, filePath) });
225
291
  continue;
226
292
  }
227
293
  const rel2 = relative(projectDir, filePath);
228
- if (!data.title) {
229
- issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: rel2 });
230
- }
231
- if (!data.description) {
232
- issues.push({ severity: "warning", message: `Missing "description" in frontmatter`, file: rel2 });
233
- }
234
- if (content.trim().length < 50) {
235
- issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
294
+ if (!data.title) issues.push({ severity: "warning", message: `Missing "title" in frontmatter`, file: rel2 });
295
+ if (!data.description) issues.push({ severity: "warning", message: `Missing "description" in frontmatter`, file: rel2 });
296
+ if (content.trim().length < 50) issues.push({ severity: "warning", message: `Very short body (${content.trim().length} chars) \u2014 page may be empty`, file: rel2 });
297
+ const path = pageIdToPath(pageId);
298
+ const anchors = extractHeadingAnchors(content);
299
+ validPaths.add(path);
300
+ anchorsByPath.set(path, anchors);
301
+ linksByFile.push({ file: rel2, path, anchors, links: extractLinks(content), offset: lineOffset });
302
+ }
303
+ for (const { file, anchors, links, offset } of linksByFile) {
304
+ for (const { target, line: contentLine } of links) {
305
+ const line = contentLine + offset;
306
+ if (/^(https?:|mailto:|tel:)/i.test(target)) continue;
307
+ if (target.startsWith("#")) {
308
+ const anchor2 = target.slice(1);
309
+ if (anchor2 && !anchors.has(anchor2)) {
310
+ issues.push({ severity: "warning", message: `Broken anchor: "${target}" not found on this page`, file, line });
311
+ }
312
+ continue;
313
+ }
314
+ if (!target.startsWith("/")) continue;
315
+ const [beforeHash, anchor] = target.split("#");
316
+ let path = beforeHash.split("?")[0];
317
+ if (path.length > 1) path = path.replace(/\/$/, "");
318
+ if (path.startsWith("/api") || path.startsWith("/_next") || /\.[a-z0-9]+$/i.test(path)) continue;
319
+ if (!validPaths.has(path)) {
320
+ issues.push({ severity: "error", message: `Broken link: "${target}" \u2014 no page at "${path}"`, file, line });
321
+ } else if (anchor && !anchorsByPath.get(path)?.has(anchor)) {
322
+ issues.push({ severity: "warning", message: `Broken anchor: "${target}" \u2014 no heading "#${anchor}" on that page`, file, line });
323
+ }
236
324
  }
237
325
  }
326
+ for (const tab of config.tabs) {
327
+ if (tab.api?.source) validateOpenApi(projectDir, tab.api.source, issues);
328
+ }
238
329
  const errors = issues.filter((i) => i.severity === "error");
239
330
  const warnings = issues.filter((i) => i.severity === "warning");
331
+ if (ci) {
332
+ for (const issue of issues) {
333
+ const loc = issue.file ? `file=${issue.file}${issue.line ? `,line=${issue.line}` : ""}` : "";
334
+ console.log(`::${issue.severity} ${loc}::${issue.message}`);
335
+ }
336
+ console.log(`
337
+ dox check: ${errors.length} error(s), ${warnings.length} warning(s)`);
338
+ return errors.length > 0 ? 1 : 0;
339
+ }
240
340
  console.log(`
241
341
  Linting ${projectDir}...
242
342
  `);
@@ -250,7 +350,7 @@ async function runCheck(projectDir, fix) {
250
350
  console.log(" ERRORS:");
251
351
  for (const issue of errors) {
252
352
  console.log(` ${issue.message}`);
253
- if (issue.file) console.log(` \u2192 ${issue.file}`);
353
+ if (issue.file) console.log(` \u2192 ${issue.file}${issue.line ? `:${issue.line}` : ""}`);
254
354
  }
255
355
  console.log("");
256
356
  }
@@ -258,7 +358,7 @@ async function runCheck(projectDir, fix) {
258
358
  console.log(" WARNINGS:");
259
359
  for (const issue of warnings) {
260
360
  console.log(` ${issue.message}`);
261
- if (issue.file) console.log(` \u2192 ${issue.file}`);
361
+ if (issue.file) console.log(` \u2192 ${issue.file}${issue.line ? `:${issue.line}` : ""}`);
262
362
  }
263
363
  console.log("");
264
364
  }
@@ -601,8 +701,11 @@ async function runScaffoldCommand() {
601
701
  }
602
702
  async function runCheckCommand() {
603
703
  const projectDir = resolve2(positional[1] ?? ".");
604
- const fix = flags.includes("--fix");
605
- const exitCode = await runCheck(projectDir, fix);
704
+ const exitCode = await runCheck(projectDir, {
705
+ fix: flags.includes("--fix"),
706
+ ci: flags.includes("--ci"),
707
+ external: flags.includes("--external")
708
+ });
606
709
  process.exit(exitCode);
607
710
  }
608
711
  async function runTranslateSubcommand() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-dox",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Scaffold a new Dox documentation project",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,7 +28,8 @@
28
28
  "@inquirer/prompts": "^7.0.0",
29
29
  "gray-matter": "^4.0.3",
30
30
  "p-limit": "^6.1.0",
31
- "tar": "^6.2.0"
31
+ "tar": "^6.2.0",
32
+ "yaml": "^2.6.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/node": "^22.0.0",