mdat 3.5.0 → 3.6.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/dist/bin/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { S as version, a as expand, b as setLogger$1, d as templates_default, f as ensureArray, l as createReadme, r as collapse, s as strip, t as check, u as createReadmeInteractive, x as name, y as log } from "../lib-D3Djaa1D.js";
2
+ import { S as version, a as expand, b as setLogger$1, d as templates_default, f as ensureArray, l as createReadme, r as collapse, s as strip, t as check, u as createReadmeInteractive, x as name, y as log } from "../lib-CqQWGRqt.js";
3
3
  import { createLogger } from "lognow";
4
4
  import picocolors from "picocolors";
5
5
  import prettyMilliseconds from "pretty-ms";
@@ -179,6 +179,7 @@ declare const readmeMetadataTemplate: import("metascope").Template<{
179
179
  licenseFilePath: string | undefined;
180
180
  licenseUrl: string | undefined;
181
181
  name: string | undefined;
182
+ obsidianPluginId: string | undefined;
182
183
  operatingSystem: string[] | undefined;
183
184
  peerDependencies: {
184
185
  name: string;
@@ -186,9 +187,11 @@ declare const readmeMetadataTemplate: import("metascope").Template<{
186
187
  version: string;
187
188
  }[] | undefined;
188
189
  projectDirectory: string | undefined;
190
+ repositoryDirectory: string | undefined;
189
191
  repositoryUrl: string | undefined;
190
192
  runtimePlatform: string[] | undefined;
191
193
  usesGitLfs: boolean;
194
+ vscodeExtensionId: string | undefined;
192
195
  }>;
193
196
  type ReadmeMetadata = ReturnType<typeof readmeMetadataTemplate>;
194
197
  /**
@@ -225,6 +228,7 @@ export declare function getReadmeMetadata(): Promise<{
225
228
  licenseFilePath: string | undefined;
226
229
  licenseUrl: string | undefined;
227
230
  name: string | undefined;
231
+ obsidianPluginId: string | undefined;
228
232
  operatingSystem: string[] | undefined;
229
233
  peerDependencies: {
230
234
  name: string;
@@ -232,9 +236,11 @@ export declare function getReadmeMetadata(): Promise<{
232
236
  version: string;
233
237
  }[] | undefined;
234
238
  projectDirectory: string | undefined;
239
+ repositoryDirectory: string | undefined;
235
240
  repositoryUrl: string | undefined;
236
241
  runtimePlatform: string[] | undefined;
237
242
  usesGitLfs: boolean;
243
+ vscodeExtensionId: string | undefined;
238
244
  }>;
239
245
  /**
240
246
  * Reset all cached metadata. Call between tests or when the underlying project
package/dist/lib/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { _ as getReadmeMetadata, a as expand, b as setLogger, c as stripString, g as getContextMetadata, h as mergeConfig, i as collapseString, l as createReadme, m as loadConfig, n as checkString, o as expandString, p as defineConfig, r as collapse, s as strip, t as check, u as createReadmeInteractive, v as resetMetadataCaches } from "../lib-D3Djaa1D.js";
1
+ import { _ as getReadmeMetadata, a as expand, b as setLogger, c as stripString, g as getContextMetadata, h as mergeConfig, i as collapseString, l as createReadme, m as loadConfig, n as checkString, o as expandString, p as defineConfig, r as collapse, s as strip, t as check, u as createReadmeInteractive, v as resetMetadataCaches } from "../lib-CqQWGRqt.js";
2
2
 
3
3
  export { check, checkString, collapse, collapseString, createReadme as create, createReadmeInteractive as createInteractive, defineConfig, expand, expandString, getContextMetadata, getReadmeMetadata, loadConfig, mergeConfig, resetMetadataCaches, setLogger, strip, stripString };
@@ -26,14 +26,13 @@ import { confirm, group, intro, note, outro, select } from "@clack/prompts";
26
26
 
27
27
  //#region package.json
28
28
  var name = "mdat";
29
- var version = "3.5.0";
29
+ var version = "3.6.0";
30
30
 
31
31
  //#endregion
32
32
  //#region src/lib/deep-merge-defined.ts
33
33
  const deepmerge = deepmergeCustom({ mergeArrays: false });
34
34
  function stripUndefinedDeep(object) {
35
- if (Array.isArray(object)) return object.map((v) => v !== null && typeof v === "object" ? stripUndefinedDeep(v) : v).filter((v) => v !== void 0);
36
- return Object.entries(object).map(([k, v]) => [k, v !== null && typeof v === "object" ? stripUndefinedDeep(v) : v]).reduce((acc, [k, v]) => v === void 0 ? acc : {
35
+ return Array.isArray(object) ? object.map((v) => v !== null && typeof v === "object" ? stripUndefinedDeep(v) : v).filter((v) => v !== void 0) : Object.entries(object).map(([k, v]) => [k, v !== null && typeof v === "object" ? stripUndefinedDeep(v) : v]).reduce((acc, [k, v]) => v === void 0 ? acc : {
37
36
  ...acc,
38
37
  [k]: v
39
38
  }, {});
@@ -193,6 +192,29 @@ function getHomebrewFormula(packageName, scripts) {
193
192
  }
194
193
  }
195
194
  /**
195
+ * VS Code Marketplace item identifier for packages that are VS Code extensions,
196
+ * identified by an `engines.vscode` field.
197
+ *
198
+ * @returns E.g. `kitschpatrol.tldraw-preview`, or undefined if the package
199
+ * isn't an extension
200
+ */
201
+ function getVscodeExtensionId(nodePackage) {
202
+ return typeof nodePackage?.publisher === "string" && nodePackage.engines?.vscode !== void 0 ? `${nodePackage.publisher}.${nodePackage.name}` : void 0;
203
+ }
204
+ /**
205
+ * Obsidian community plugin identifier, from the plugin's manifest.json.
206
+ */
207
+ function getObsidianPluginId(manifests) {
208
+ return helpers.firstOf(manifests)?.data.id;
209
+ }
210
+ /**
211
+ * Package location within a monorepo, from the package.json
212
+ * `repository.directory` field.
213
+ */
214
+ function getRepositoryDirectory(nodePackage) {
215
+ return typeof nodePackage?.repository === "object" ? nodePackage.repository.directory : void 0;
216
+ }
217
+ /**
196
218
  * Reset cached context metadata. Call between tests or when the underlying
197
219
  * project files may have changed on disk.
198
220
  *
@@ -202,12 +224,12 @@ function resetContextMetadata() {
202
224
  metascopeMetadata = void 0;
203
225
  }
204
226
  const readmeMetadataTemplate = defineTemplate((context) => {
205
- const { githubActions, gitStats, licenseFile, metascope, nodePackageJson } = context;
227
+ const { githubActions, gitStats, licenseFile, metascope, nodePackageJson, obsidianPluginManifestJson } = context;
206
228
  const codemeta = templates.codemetaJson(context, {});
207
229
  const nodePackage = helpers.firstOf(nodePackageJson)?.data;
208
230
  const licenseFileData = helpers.firstOf(licenseFile);
209
231
  const ciActionFilePath = helpers.ensureArray(githubActions).find((entry) => entry.data.name.toLowerCase() === "ci")?.source;
210
- const repoUrl = codemeta.codeRepository?.replace(GIT_PREFIX_REGEX, "").replace(GIT_SUFFIX_REGEX, "").replace(TRAILING_SLASH_REGEX, "");
232
+ const repositoryUrl = codemeta.codeRepository?.replace(GIT_PREFIX_REGEX, "").replace(GIT_SUFFIX_REGEX, "").replace(TRAILING_SLASH_REGEX, "");
211
233
  const bin = (() => {
212
234
  if (nodePackage === void 0) return;
213
235
  const binField = nodePackage.bin;
@@ -257,6 +279,9 @@ const readmeMetadataTemplate = defineTemplate((context) => {
257
279
  };
258
280
  })();
259
281
  const homebrewFormula = getHomebrewFormula(nodePackage?.name, nodePackage?.scripts);
282
+ const repositoryDirectory = getRepositoryDirectory(nodePackage);
283
+ const vscodeExtensionId = getVscodeExtensionId(nodePackage);
284
+ const obsidianPluginId = getObsidianPluginId(obsidianPluginManifestJson);
260
285
  const firstAuthor = helpers.firstOf(helpers.ensureArray(codemeta.author));
261
286
  return {
262
287
  author: helpers.firstOf(helpers.mixedStringsToArray(helpers.toBasicNames(codemeta.author))),
@@ -276,12 +301,15 @@ const readmeMetadataTemplate = defineTemplate((context) => {
276
301
  licenseFilePath: licenseFileData?.source,
277
302
  licenseUrl: licenseFileData?.data.match?.spdxUrl,
278
303
  name: codemeta.name,
304
+ obsidianPluginId,
279
305
  operatingSystem: codemeta.operatingSystem ?? getSupportedOperatingSystems(nodePackage?.os),
280
306
  peerDependencies,
281
307
  projectDirectory: metascope?.data.options.path === void 0 ? void 0 : `file://${metascope.data.options.path}`,
282
- repositoryUrl: repoUrl,
308
+ repositoryDirectory,
309
+ repositoryUrl,
283
310
  runtimePlatform: codemeta.runtimePlatform,
284
- usesGitLfs: helpers.firstOf(gitStats)?.data.hasLfs === true
311
+ usesGitLfs: helpers.firstOf(gitStats)?.data.hasLfs === true,
312
+ vscodeExtensionId
285
313
  };
286
314
  });
287
315
  let readmeMetadata;
@@ -316,24 +344,159 @@ function resetMetadataCaches() {
316
344
 
317
345
  //#endregion
318
346
  //#region src/lib/readme/rules/badges.ts
347
+ const HOMEBREW_FORMULA_REGEX = /^(?<owner>[^\/]+)\/(?<tap>[^\/]+)\/(?<formula>[^\/]+)$/v;
348
+ const GITHUB_REPOSITORY_REGEX$1 = /^https:\/\/github\.com\/(?<owner>[^\/]+)\/(?<repo>[^\/]+)$/v;
349
+ const NO_NPM_PACKAGE = "no public npm package was found. Remove \"private\": true from package.json if the package is published, or list packages explicitly, e.g. badges({ npm: [\"package-name\"] })";
350
+ const optionsSchema$2 = z.object({
351
+ bundleSize: z.boolean().optional(),
352
+ ci: z.boolean().optional(),
353
+ custom: z.record(z.string(), z.object({
354
+ image: z.string(),
355
+ link: z.string()
356
+ })).default({}),
357
+ githubRelease: z.boolean().optional(),
358
+ gitLfs: z.boolean().optional(),
359
+ homebrew: z.union([z.boolean(), z.string().regex(HOMEBREW_FORMULA_REGEX, "The badges \"homebrew\" option expects a formula in the form \"owner/tap/formula\"")]).optional(),
360
+ license: z.boolean().optional(),
361
+ npm: z.union([z.boolean(), z.array(z.string()).min(1, "Pass npm: false instead of an empty array to hide the NPM version badge")]).optional(),
362
+ npmDownloads: z.boolean().optional(),
363
+ obsidianDownloads: z.boolean().optional(),
364
+ vscode: z.boolean().optional()
365
+ });
366
+ /**
367
+ * Escape text for a path segment of a shields.io static badge, where `-` and
368
+ * `_` are delimiters and must be doubled to appear literally.
369
+ */
370
+ function escapeStaticBadgeText(text) {
371
+ return encodeURIComponent(text.replaceAll("-", "--").replaceAll("_", "__"));
372
+ }
373
+ /**
374
+ * One badge per npm package, for badges that report on npm packages.
375
+ */
376
+ function mapNpmPackages(npmPackages, toBadge) {
377
+ return npmPackages.length === 0 ? { unavailable: NO_NPM_PACKAGE } : npmPackages.map((packageName) => toBadge(packageName));
378
+ }
379
+ /**
380
+ * Owner and name of the project's GitHub repository, or undefined if the
381
+ * repository isn't on GitHub.
382
+ */
383
+ function getGithubRepository(repositoryUrl = "") {
384
+ const { owner, repo } = GITHUB_REPOSITORY_REGEX$1.exec(repositoryUrl)?.groups ?? {};
385
+ return owner === void 0 || repo === void 0 ? void 0 : {
386
+ owner,
387
+ repo
388
+ };
389
+ }
390
+ /**
391
+ * Reason for badges that need a GitHub repository.
392
+ */
393
+ function getNotGithubReason(repositoryUrl) {
394
+ return `${repositoryUrl === void 0 ? "no repository URL was found in the project metadata" : `the repository URL "${repositoryUrl}" is not a GitHub repository`}. Set the "repository" field in package.json to the project's GitHub repository.`;
395
+ }
396
+ /**
397
+ * Explicit package names are useful for monorepos. Otherwise the package itself
398
+ * is used if it's public, even when the NPM version badge is hidden, so the
399
+ * download and size badges can still appear.
400
+ */
401
+ function getNpmPackages(option, { isPublicNpmPackage, name }) {
402
+ if (Array.isArray(option)) return option;
403
+ return isPublicNpmPackage && name !== void 0 ? [name] : [];
404
+ }
405
+ /**
406
+ * Badges in display order. Each appears when its metadata is available unless
407
+ * it's `optIn`, and its option can hide it (`false`) or require it (`true`).
408
+ */
409
+ const badgeDefinitions = [
410
+ {
411
+ key: "npm",
412
+ render: ({ npmPackages }) => mapNpmPackages(npmPackages, (packageName) => `[![NPM Package ${packageName}](https://img.shields.io/npm/v/${packageName}.svg)](https://www.npmjs.com/package/${packageName})`)
413
+ },
414
+ {
415
+ key: "license",
416
+ render: ({ license, licenseUrl }) => license === void 0 || licenseUrl === void 0 ? { unavailable: "no license file matching a known SPDX license was found" } : [`[![License: ${license}](https://img.shields.io/badge/License-${license.replaceAll("-", "--")}-yellow.svg)](${licenseUrl})`]
417
+ },
418
+ {
419
+ key: "ci",
420
+ render({ ciActionFileName, repositoryUrl }) {
421
+ if (ciActionFileName === void 0) return { unavailable: "no GitHub Actions workflow named \"CI\" was found in .github/workflows" };
422
+ if (repositoryUrl === void 0) return { unavailable: "no repository URL was found in the project metadata" };
423
+ return [`[![CI](${repositoryUrl}/actions/workflows/${ciActionFileName}/badge.svg)](${repositoryUrl}/actions/workflows/${ciActionFileName})`];
424
+ }
425
+ },
426
+ {
427
+ key: "gitLfs",
428
+ render: ({ usesGitLfs }) => usesGitLfs ? ["![Git LFS](https://img.shields.io/badge/Git%20LFS-enabled-F64935?logo=gitlfs&logoColor=white)"] : { unavailable: "the repository does not track any files with Git LFS" }
429
+ },
430
+ {
431
+ key: "vscode",
432
+ render({ repositoryDirectory, repositoryUrl, vscodeExtensionId }) {
433
+ if (vscodeExtensionId === void 0) return { unavailable: "package.json is missing a \"publisher\" or \"engines.vscode\" field" };
434
+ const repository = getGithubRepository(repositoryUrl);
435
+ if (repository === void 0) return { unavailable: `the extension version is read from package.json on GitHub, but ${getNotGithubReason(repositoryUrl)}` };
436
+ const packageJsonPath = [repositoryDirectory, "package.json"].filter((segment) => segment !== void 0).join("/");
437
+ const packageJsonUrl = `https://raw.githubusercontent.com/${repository.owner}/${repository.repo}/HEAD/${packageJsonPath}`;
438
+ return [`[![Visual Studio Marketplace Version](https://img.shields.io/badge/dynamic/json?url=${encodeURIComponent(packageJsonUrl)}&query=version&label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=${vscodeExtensionId})`];
439
+ }
440
+ },
441
+ {
442
+ key: "homebrew",
443
+ render({ homebrewFormula, options }) {
444
+ const formulaPath = typeof options.homebrew === "string" ? options.homebrew : homebrewFormula;
445
+ if (formulaPath === void 0) return { unavailable: "no \"brewpub --tap <owner>/<tap>\" invocation was found in the package.json scripts. Pass the formula explicitly instead, e.g. badges({ homebrew: \"owner/tap/formula\" })" };
446
+ const formulaParts = HOMEBREW_FORMULA_REGEX.exec(formulaPath)?.groups;
447
+ if (formulaParts?.owner === void 0 || formulaParts.tap === void 0 || formulaParts.formula === void 0) return { unavailable: `the detected formula "${formulaPath}" is not in the form "owner/tap/formula"` };
448
+ const { formula, owner, tap } = formulaParts;
449
+ return [`[![Homebrew](https://img.shields.io/badge/Homebrew-${escapeStaticBadgeText(formulaPath)}-FBB040?logo=homebrew&logoColor=white)](https://github.com/${owner}/homebrew-${tap}/blob/HEAD/Formula/${formula}.rb)`];
450
+ }
451
+ },
452
+ {
453
+ key: "githubRelease",
454
+ optIn: true,
455
+ render({ repositoryUrl }) {
456
+ const repository = getGithubRepository(repositoryUrl);
457
+ if (repository === void 0) return { unavailable: getNotGithubReason(repositoryUrl) };
458
+ const { owner, repo } = repository;
459
+ return [`[![GitHub Release](https://img.shields.io/github/v/release/${owner}/${repo}?label=Release)](https://github.com/${owner}/${repo}/releases/latest)`];
460
+ }
461
+ },
462
+ {
463
+ key: "npmDownloads",
464
+ optIn: true,
465
+ render: ({ npmPackages }) => mapNpmPackages(npmPackages, (packageName) => `[![NPM Downloads ${packageName}](https://img.shields.io/npm/dm/${packageName})](https://www.npmjs.com/package/${packageName})`)
466
+ },
467
+ {
468
+ key: "obsidianDownloads",
469
+ optIn: true,
470
+ render({ obsidianPluginId }) {
471
+ if (obsidianPluginId === void 0) return { unavailable: "no Obsidian plugin manifest.json was found" };
472
+ const statsUrl = "https://raw.githubusercontent.com/obsidianmd/obsidian-releases/master/community-plugin-stats.json";
473
+ const query = `$["${obsidianPluginId}"].downloads`;
474
+ return [`[![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23A88BFA&label=Downloads&query=${encodeURIComponent(query)}&url=${encodeURIComponent(statsUrl)})](https://community.obsidian.md/plugins/${obsidianPluginId})`];
475
+ }
476
+ },
477
+ {
478
+ key: "bundleSize",
479
+ optIn: true,
480
+ render: ({ npmPackages }) => mapNpmPackages(npmPackages, (packageName) => `[![Bundle Size ${packageName}](https://img.shields.io/bundlephobia/minzip/${packageName}?label=Size)](https://bundlephobia.com/package/${packageName})`)
481
+ }
482
+ ];
319
483
  var badges_default = { badges: { async content(options) {
320
- const validOptions = z.object({
321
- custom: z.record(z.string(), z.object({
322
- image: z.string(),
323
- link: z.string()
324
- })).optional(),
325
- npm: z.array(z.string()).optional()
326
- }).optional().parse(options);
484
+ const validOptions = optionsSchema$2.parse(options ?? {});
327
485
  const metadata = await getReadmeMetadata();
328
- const { ciActionFileName, license, licenseUrl, name, repositoryUrl, usesGitLfs } = metadata;
486
+ const context = {
487
+ ...metadata,
488
+ npmPackages: getNpmPackages(validOptions.npm, metadata),
489
+ options: validOptions
490
+ };
329
491
  const badges = [];
330
- if (validOptions?.npm === void 0) {
331
- if (metadata.isPublicNpmPackage) badges.push(`[![NPM Package ${name}](https://img.shields.io/npm/v/${name}.svg)](https://www.npmjs.com/package/${name})`);
332
- } else for (const packageName of validOptions.npm) badges.push(`[![NPM Package ${packageName}](https://img.shields.io/npm/v/${packageName}.svg)](https://www.npmjs.com/package/${packageName})`);
333
- if (license !== void 0 && licenseUrl !== void 0) badges.push(`[![License: ${license}](https://img.shields.io/badge/License-${license.replaceAll("-", "--")}-yellow.svg)](${licenseUrl})`);
334
- if (ciActionFileName !== void 0 && repositoryUrl !== void 0) badges.push(`[![CI](${repositoryUrl}/actions/workflows/${ciActionFileName}/badge.svg)](${repositoryUrl}/actions/workflows/${ciActionFileName})`);
335
- if (usesGitLfs) badges.push("![Git LFS](https://img.shields.io/badge/Git%20LFS-enabled-F64935?logo=gitlfs&logoColor=white)");
336
- if (validOptions?.custom !== void 0) for (const [badgeName, { image, link }] of Object.entries(validOptions.custom)) badges.push(`[![${badgeName}](${image})](${link})`);
492
+ for (const { key, optIn = false, render } of badgeDefinitions) {
493
+ const option = validOptions[key];
494
+ if (option === false || optIn && option === void 0) continue;
495
+ const result = render(context);
496
+ if (Array.isArray(result)) badges.push(...result);
497
+ else if (option !== void 0) throw new Error(`The "${key}" badge was requested, but ${result.unavailable}`);
498
+ }
499
+ for (const [badgeName, { image, link }] of Object.entries(validOptions.custom)) badges.push(`[![${badgeName}](${image})](${link})`);
337
500
  return badges.join("\n");
338
501
  } } };
339
502
 
@@ -418,29 +581,51 @@ var code_default = { code: { async content(options) {
418
581
 
419
582
  //#endregion
420
583
  //#region src/lib/readme/rules/utilities/heading.ts
584
+ const MAX_HEADING_LEVEL = 6;
421
585
  /**
422
586
  * Option schema for the Markdown heading level (1-6) at which a rule emits its
423
- * section heading. Sub-headings, if any, are nested one level deeper. Rules
424
- * default to level 3, matching their placement under a level 2 section like
425
- * "Getting started" in the house readme template.
587
+ * section heading. Sub-headings, if any, are nested one level deeper, whether
588
+ * or not the section heading itself is shown. Each rule's default matches its
589
+ * placement in the house readme template.
426
590
  */
427
- const headingLevelSchema = z.number().int().min(1).max(6).optional();
591
+ const headingLevelSchema = z.number().int().min(1).max(MAX_HEADING_LEVEL).optional();
592
+ /**
593
+ * Option schema for a rule's section heading. `true` (the default) emits the
594
+ * rule's standard heading, `false` leaves the heading out, and a string
595
+ * replaces the heading text.
596
+ */
597
+ const headingSchema = z.union([z.boolean(), z.string().trim().min(1)]).optional();
428
598
  /**
429
599
  * The `#` prefix for a Markdown heading, clamped to the maximum level of 6.
430
600
  */
431
601
  function getHeadingPrefix(level) {
432
- return "#".repeat(Math.min(level, 6));
602
+ return "#".repeat(Math.min(level, MAX_HEADING_LEVEL));
603
+ }
604
+ /**
605
+ * The lines of a rule's section heading, including the blank line separating it
606
+ * from the section body, or no lines if the heading is suppressed.
607
+ *
608
+ * @param heading - Value of the rule's `heading` option
609
+ * @param level - Markdown heading level
610
+ * @param defaultText - Heading text used unless `heading` overrides it
611
+ */
612
+ function getHeadingLines(heading, level, defaultText) {
613
+ if (heading === false) return [];
614
+ const text = typeof heading === "string" ? heading : defaultText;
615
+ return [`${getHeadingPrefix(level)} ${text}`, ""];
433
616
  }
434
617
 
435
618
  //#endregion
436
619
  //#region src/lib/readme/rules/contributing.ts
437
620
  var contributing_default = { contributing: { async content(options) {
438
- const validOptions = z.object({ headingLevel: headingLevelSchema }).optional().parse(options);
621
+ const validOptions = z.object({
622
+ heading: headingSchema,
623
+ headingLevel: headingLevelSchema
624
+ }).optional().parse(options);
439
625
  const { issuesUrl } = await getReadmeMetadata();
440
626
  if (issuesUrl === void 0) throw new Error("Could not find \"bugs.url\" entry in package.json");
441
627
  return [
442
- `${getHeadingPrefix(validOptions?.headingLevel ?? 2)} Contributing`,
443
- "",
628
+ ...getHeadingLines(validOptions?.heading, validOptions?.headingLevel ?? 2, "Contributing"),
444
629
  `[Issues](${issuesUrl}) are welcome and appreciated.`,
445
630
  "",
446
631
  "Please open an issue to discuss changes before submitting a pull request. Unsolicited PRs (especially AI-generated ones) are unlikely to be merged.",
@@ -530,7 +715,10 @@ function getPlatformItem(key, version) {
530
715
  return version === void 0 || version.trim() === "" ? `- ${display}` : `- ${display} ${describeVersionRange(version)}`;
531
716
  }
532
717
  var dependencies_default = { dependencies: { async content(options) {
533
- const validOptions = z.object({ headingLevel: headingLevelSchema }).optional().parse(options);
718
+ const validOptions = z.object({
719
+ heading: headingSchema,
720
+ headingLevel: headingLevelSchema
721
+ }).optional().parse(options);
534
722
  const { engines, operatingSystem, peerDependencies, runtimePlatform } = await getReadmeMetadata();
535
723
  const items = [];
536
724
  const engineEntries = Object.entries(engines ?? {});
@@ -551,11 +739,7 @@ var dependencies_default = { dependencies: { async content(options) {
551
739
  items.push(`- [${name}](${npmUrl}) \`${version}\` _(peer dependency)_`);
552
740
  }
553
741
  if (items.length === 0) return "";
554
- return [
555
- `${getHeadingPrefix(validOptions?.headingLevel ?? 3)} Dependencies`,
556
- "",
557
- ...items
558
- ].join("\n");
742
+ return [...getHeadingLines(validOptions?.heading, validOptions?.headingLevel ?? 3, "Dependencies"), ...items].join("\n");
559
743
  } } };
560
744
 
561
745
  //#endregion
@@ -598,7 +782,10 @@ const TOOL_INFO = {
598
782
  }
599
783
  };
600
784
  var development_dependencies_default = { "development-dependencies": { async content(options) {
601
- const validOptions = z.object({ headingLevel: headingLevelSchema }).optional().parse(options);
785
+ const validOptions = z.object({
786
+ heading: headingSchema,
787
+ headingLevel: headingLevelSchema
788
+ }).optional().parse(options);
602
789
  const { developmentDependencies } = await getReadmeMetadata();
603
790
  if (developmentDependencies === void 0) return "";
604
791
  const { packageManagers, runtimes } = developmentDependencies;
@@ -608,11 +795,7 @@ var development_dependencies_default = { "development-dependencies": { async con
608
795
  const display = info ? `[${info.display}](${info.url})` : name;
609
796
  items.push(version === void 0 || version === "" ? `- ${display}` : `- ${display} ${version}`);
610
797
  }
611
- return [
612
- `${getHeadingPrefix(validOptions?.headingLevel ?? 3)} Development dependencies`,
613
- "",
614
- ...items
615
- ].join("\n");
798
+ return [...getHeadingLines(validOptions?.heading, validOptions?.headingLevel ?? 3, "Development dependencies"), ...items].join("\n");
616
799
  } } };
617
800
 
618
801
  //#endregion
@@ -625,12 +808,15 @@ var dev_dependencies_default = { "dev-dependencies": getSoleRule(development_dep
625
808
  //#endregion
626
809
  //#region src/lib/readme/rules/license.ts
627
810
  var license_default = { license: { async content(options) {
628
- const validOptions = z.object({ headingLevel: headingLevelSchema }).optional().parse(options);
811
+ const validOptions = z.object({
812
+ heading: headingSchema,
813
+ headingLevel: headingLevelSchema
814
+ }).optional().parse(options);
629
815
  const { author, authorUrl, license, licenseFilePath } = await getReadmeMetadata();
630
816
  if (author === void 0) throw new Error("Could not find author name in project");
631
817
  if (license === void 0 || licenseFilePath === void 0) throw new Error("Could not find license for project");
632
818
  const authorDisplay = authorUrl === void 0 ? author : `[${author}](${authorUrl})`;
633
- return `${`${getHeadingPrefix(validOptions?.headingLevel ?? 2)} License`}\n[${license}](${licenseFilePath}) © ${authorDisplay}`;
819
+ return [...getHeadingLines(validOptions?.heading, validOptions?.headingLevel ?? 2, "License"), `[${license}](${licenseFilePath}) © ${authorDisplay}`].join("\n");
634
820
  } } };
635
821
 
636
822
  //#endregion
@@ -732,23 +918,23 @@ function getLibraryLines(name, bin, hasTypes, dev) {
732
918
  var install_default = { install: { async content(options) {
733
919
  const validOptions = z.object({
734
920
  dev: z.boolean().optional(),
921
+ heading: headingSchema,
735
922
  headingLevel: headingLevelSchema,
736
923
  homebrew: z.union([z.string(), z.literal(false)]).optional()
737
- }).optional().parse(options);
924
+ }).parse(options ?? {});
738
925
  const { bin, hasTypes, homebrewFormula, isLibrary, isNodePackage, isPublicNpmPackage, name, runtimePlatform } = await getReadmeMetadata();
739
926
  if (name === void 0) throw new Error("Could not find project name");
740
- const headingLevel = validOptions?.headingLevel ?? 3;
741
- const heading = `${getHeadingPrefix(headingLevel)} Installation`;
927
+ const headingLevel = validOptions.headingLevel ?? 3;
928
+ const headingLines = getHeadingLines(validOptions.heading, headingLevel, "Installation");
742
929
  const subheading = getHeadingPrefix(headingLevel + 1);
743
930
  if (isNodePackage) {
744
931
  if (!isPublicNpmPackage) throw new Error("Install instructions require a publishable package, but package.json sets \"private\": true");
745
- const homebrew = validOptions?.homebrew === void 0 ? homebrewFormula : validOptions.homebrew === false ? void 0 : validOptions.homebrew;
932
+ const homebrew = validOptions.homebrew === void 0 ? homebrewFormula : validOptions.homebrew === false ? void 0 : validOptions.homebrew;
746
933
  const hasCli = bin !== void 0 && bin.length > 0;
747
934
  const cliLines = getCliLines(name, homebrew);
748
- const libraryLines = getLibraryLines(name, bin, hasTypes, validOptions?.dev ?? false);
935
+ const libraryLines = getLibraryLines(name, bin, hasTypes, validOptions.dev ?? false);
749
936
  if (hasCli && isLibrary) return [
750
- heading,
751
- "",
937
+ ...headingLines,
752
938
  `There are several ways to install ${name} depending on how you're planning to use it:`,
753
939
  "",
754
940
  `${subheading} CLI`,
@@ -759,20 +945,12 @@ var install_default = { install: { async content(options) {
759
945
  "",
760
946
  ...libraryLines
761
947
  ].join("\n");
762
- return [
763
- heading,
764
- "",
765
- ...hasCli ? cliLines : libraryLines
766
- ].join("\n");
948
+ return [...headingLines, ...hasCli ? cliLines : libraryLines].join("\n");
767
949
  }
768
950
  const platform = (prefix) => runtimePlatform?.some((p) => p.startsWith(prefix));
769
951
  const command = platform("python") ? `pip install ${name}` : platform("rust") ? `cargo install ${name}` : platform("go") ? `go install ${name}@latest` : platform("ruby") ? `gem install ${name}` : void 0;
770
952
  if (command === void 0) throw new Error("Could not determine project ecosystem for install instructions");
771
- return [
772
- heading,
773
- "",
774
- ...codeBlock$1(command)
775
- ].join("\n");
953
+ return [...headingLines, ...codeBlock$1(command)].join("\n");
776
954
  } } };
777
955
 
778
956
  //#endregion
@@ -913,6 +1091,7 @@ const SKILLS_DIRECTORY = "skills";
913
1091
  const SKILL_FILE_NAME = "SKILL.md";
914
1092
  const GITHUB_REPOSITORY_REGEX = /^https:\/\/github\.com\/(?<slug>[^\/]+\/[^\/]+)$/v;
915
1093
  const WHITESPACE_REGEX = /\s+/gv;
1094
+ const FIRST_SENTENCE_REGEX = /^.*?(?<!\b(?:e\.g|i\.e|vs))[.!?](?=\s+\p{Lu})/v;
916
1095
  const skillFrontmatterSchema = z.object({
917
1096
  description: z.string().trim().min(1),
918
1097
  name: z.string().trim().min(1)
@@ -964,58 +1143,66 @@ async function findSkills(projectDirectory) {
964
1143
  * The argument identifying a repository to the `skills add` command, which
965
1144
  * accepts an `owner/repo` shorthand for GitHub and full URLs for other hosts.
966
1145
  */
967
- function getSkillsCliSource(repoUrl) {
968
- return GITHUB_REPOSITORY_REGEX.exec(repoUrl)?.groups?.slug ?? repoUrl;
1146
+ function getSkillsCliSource(repositoryUrl) {
1147
+ return GITHUB_REPOSITORY_REGEX.exec(repositoryUrl)?.groups?.slug ?? repositoryUrl;
1148
+ }
1149
+ /**
1150
+ * The first sentence of a skill description, which by convention says what the
1151
+ * skill does, leaving out the guidance on when to use it that follows.
1152
+ */
1153
+ function getFirstSentence(description) {
1154
+ const singleLine = description.replaceAll(WHITESPACE_REGEX, " ");
1155
+ return FIRST_SENTENCE_REGEX.exec(singleLine)?.[0] ?? singleLine;
969
1156
  }
970
1157
  var skills_default = { skills: { async content(options) {
971
- const validOptions = z.object({ headingLevel: headingLevelSchema }).optional().parse(options);
1158
+ const validOptions = z.object({
1159
+ heading: headingSchema,
1160
+ headingLevel: headingLevelSchema
1161
+ }).optional().parse(options);
972
1162
  const skills = await findSkills(process.cwd());
973
1163
  if (skills.length === 0) throw new Error(`Could not find any skills, expected at least one "${SKILLS_DIRECTORY}/<name>/${SKILL_FILE_NAME}" file in the project`);
974
1164
  const { isPublicNpmPackage, name, repositoryUrl } = await getReadmeMetadata();
975
1165
  if (name === void 0) throw new Error("Could not find project name");
976
1166
  const headingLevel = validOptions?.headingLevel ?? 2;
977
- const subheading = getHeadingPrefix(headingLevel + 1);
978
- const skillNoun = skills.length === 1 ? "skill" : "skills";
979
- const skillPronoun = skills.length === 1 ? "it" : "them";
980
- const introLines = [
981
- `${getHeadingPrefix(headingLevel)} Agent skills`,
1167
+ const skillHeading = getHeadingPrefix(headingLevel + 1);
1168
+ const skillNoun = plur("skill", skills.length);
1169
+ const agentSkillsLink = `${skills.length === 1 ? "an" : skills.length} [${plur("Agent Skill", skills.length)}](https://agentskills.io)`;
1170
+ const skillsCliLink = "Vercel's [skills CLI](https://github.com/vercel-labs/skills)";
1171
+ const headingLines = getHeadingLines(validOptions?.heading, headingLevel, "Agent skills");
1172
+ const skillLines = [`Included ${skillNoun}:`, ...skills.flatMap((skill) => [
982
1173
  "",
983
- `This project includes ${skills.length === 1 ? "an [Agent Skill](https://agentskills.io)" : "[Agent Skills](https://agentskills.io)"} that ${skills.length === 1 ? "teaches" : "teach"} coding agents like Claude Code and Codex how to work with ${name}:`,
1174
+ `${skillHeading} Skill: [\`${skill.name}\`](${skill.filePath})`,
984
1175
  "",
985
- ...skills.map((skill) => `- **[\`${skill.name}\`](${skill.filePath})**: ${skill.description.replaceAll(WHITESPACE_REGEX, " ")}`)
986
- ];
1176
+ getFirstSentence(skill.description)
1177
+ ])];
987
1178
  if (!isPublicNpmPackage) {
988
1179
  if (repositoryUrl === void 0) throw new Error("Skill install instructions require either a publishable npm package or a repository URL in the project metadata");
989
1180
  return [
990
- ...introLines,
1181
+ ...headingLines,
1182
+ `This project includes ${agentSkillsLink} to help coding agents work with ${name}.`,
1183
+ "",
1184
+ `To install the ${skillNoun}, run ${skillsCliLink}:`,
991
1185
  "",
992
- `Install ${skillPronoun} from the repository with the [\`skills\`](https://github.com/vercel-labs/skills) CLI:`,
1186
+ ...codeBlock(`npx skills add ${getSkillsCliSource(repositoryUrl)}`),
993
1187
  "",
994
- ...codeBlock(`npx skills add ${getSkillsCliSource(repositoryUrl)}`)
1188
+ ...skillLines
995
1189
  ].join("\n");
996
1190
  }
997
1191
  return [
998
- ...introLines,
1192
+ ...headingLines,
1193
+ `This project bundles ${agentSkillsLink} in its published package to help coding agents work with ${name}.`,
999
1194
  "",
1000
- `The ${skillNoun} ${skills.length === 1 ? "is" : "are"} published in the \`${SKILLS_DIRECTORY}\` directory of the \`${name}\` package. Nothing is added to your project until you install ${skillPronoun} with one of the tools below.`,
1001
- "",
1002
- `${subheading} Sync from the installed package (recommended)`,
1003
- "",
1004
- `With \`${name}\` installed as a project dependency, the [\`skills\`](https://github.com/vercel-labs/skills) CLI finds skills bundled in your dependencies and copies them into your project's agent skill directories, so they match the version of \`${name}\` you have installed:`,
1195
+ `To sync the ${skillNoun} into your project, run ${skillsCliLink} from your project root:`,
1005
1196
  "",
1006
1197
  ...codeBlock("npx skills experimental_sync"),
1007
- "",
1008
- `Run the command again after upgrading \`${name}\` to refresh the copies. The \`experimental_sync\` command is experimental and its behavior may change.`,
1009
1198
  ...repositoryUrl === void 0 ? [] : [
1010
1199
  "",
1011
- `${subheading} Install from the repository`,
1200
+ "Or install globally:",
1012
1201
  "",
1013
- `If \`${name}\` is not a dependency of your project, for example because you use a global installation, install the ${skillNoun} from the repository instead:`,
1014
- "",
1015
- ...codeBlock(`npx skills add ${getSkillsCliSource(repositoryUrl)}`),
1016
- "",
1017
- `${skills.length === 1 ? "A skill" : "Skills"} installed this way ${skills.length === 1 ? "follows" : "follow"} the repository's default branch rather than your installed version of \`${name}\`.`
1018
- ]
1202
+ ...codeBlock(`npx skills add ${getSkillsCliSource(repositoryUrl)} --global`)
1203
+ ],
1204
+ "",
1205
+ ...skillLines
1019
1206
  ].join("\n");
1020
1207
  } } };
1021
1208
 
@@ -1032,6 +1219,7 @@ var table_of_contents_default = { "table-of-contents": {
1032
1219
  z.literal(5),
1033
1220
  z.literal(6)
1034
1221
  ]).optional(),
1222
+ heading: headingSchema,
1035
1223
  headingLevel: headingLevelSchema
1036
1224
  }).optional().parse(options);
1037
1225
  const result = toc(tree, {
@@ -1040,12 +1228,12 @@ var table_of_contents_default = { "table-of-contents": {
1040
1228
  tight: true
1041
1229
  });
1042
1230
  if (result.map === void 0) throw new Error("Could not generate table of contents");
1043
- const heading = `${getHeadingPrefix(validOptions?.headingLevel ?? 2)} Table of contents`;
1044
1231
  const rootWrapper = {
1045
1232
  children: result.map.children,
1046
1233
  type: "root"
1047
1234
  };
1048
- return [heading, remark().use(remarkGfm).stringify(rootWrapper).replaceAll("\n\n", "\n")].join("\n");
1235
+ const tocString = remark().use(remarkGfm).stringify(rootWrapper).replaceAll("\n\n", "\n");
1236
+ return [...getHeadingLines(validOptions?.heading, validOptions?.headingLevel ?? 2, "Table of contents"), tocString].join("\n");
1049
1237
  },
1050
1238
  order: 1
1051
1239
  } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdat",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "CLI tool and TypeScript library implementing the Markdown Autophagic Template (MDAT) system. MDAT lets you use comments as dynamic content templates in Markdown files, making it easy to generate and update readme boilerplate.",
5
5
  "keywords": [
6
6
  "mdat",
@@ -52,7 +52,7 @@
52
52
  "gray-matter-es": "^0.2.1",
53
53
  "lognow": "^0.8.4",
54
54
  "mdast-util-toc": "^7.1.0",
55
- "metascope": "^0.12.0",
55
+ "metascope": "^0.12.1",
56
56
  "path-type": "^6.0.0",
57
57
  "picocolors": "^1.1.1",
58
58
  "plur": "^6.0.0",
@@ -71,7 +71,7 @@
71
71
  },
72
72
  "devDependencies": {
73
73
  "@arethetypeswrong/core": "^0.18.5",
74
- "@kitschpatrol/shared-config": "^8.10.1",
74
+ "@kitschpatrol/shared-config": "^8.11.0",
75
75
  "@types/mdast": "^4.0.4",
76
76
  "@types/node": "~24.13.6",
77
77
  "@types/unist": "^3.0.3",
@@ -80,7 +80,7 @@
80
80
  "brewpub": "^0.4.0",
81
81
  "bumpp": "^12.3.0",
82
82
  "execa": "^10.0.1",
83
- "mdat-plugin-cli-help": "^3.3.0",
83
+ "mdat-plugin-cli-help": "^3.4.0",
84
84
  "mdat-plugin-example": "^3.0.0",
85
85
  "mdat-plugin-tldraw": "^3.0.0",
86
86
  "nanoid": "^6.0.1",
package/readme.md CHANGED
@@ -4,22 +4,14 @@
4
4
 
5
5
  <!-- /title -->
6
6
 
7
- <!-- badges({
8
- custom: {
9
- "Homebrew": {
10
- image:
11
- "https://img.shields.io/badge/Homebrew-kitschpatrol%2Ftap%2Fmdat-FBB040?logo=homebrew&logoColor=white",
12
- link: "https://github.com/kitschpatrol/homebrew-tap/blob/main/Formula/mdat.rb",
13
- },
14
- }
15
- }) -->
7
+ <!-- // badges -->
16
8
 
17
9
  [![NPM Package mdat](https://img.shields.io/npm/v/mdat.svg)](https://www.npmjs.com/package/mdat)
18
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/license/mit)
19
11
  [![CI](https://github.com/kitschpatrol/mdat/actions/workflows/ci.yml/badge.svg)](https://github.com/kitschpatrol/mdat/actions/workflows/ci.yml)
20
- [![Homebrew](https://img.shields.io/badge/Homebrew-kitschpatrol%2Ftap%2Fmdat-FBB040?logo=homebrew&logoColor=white)](https://github.com/kitschpatrol/homebrew-tap/blob/main/Formula/mdat.rb)
12
+ [![Homebrew](https://img.shields.io/badge/Homebrew-kitschpatrol%2Ftap%2Fmdat-FBB040?logo=homebrew&logoColor=white)](https://github.com/kitschpatrol/homebrew-tap/blob/HEAD/Formula/mdat.rb)
21
13
 
22
- <!-- /badges -->
14
+ <!-- // /badges -->
23
15
 
24
16
  <!-- description -->
25
17
 
@@ -47,8 +39,8 @@
47
39
  - [Creating a rule plugin](#creating-a-rule-plugin)
48
40
  - [Available rule plugins](#available-rule-plugins)
49
41
  - [Agent skills](#agent-skills)
50
- - [Sync from the installed package (recommended)](#sync-from-the-installed-package-recommended)
51
- - [Install from the repository](#install-from-the-repository)
42
+ - [Skill: `mdat`](#skill-mdat)
43
+ - [Skill: `mdat-plugin-authoring`](#skill-mdat-plugin-authoring)
52
44
  - [Migrating from 1.x to 2.x](#migrating-from-1x-to-2x)
53
45
  - [Flat CLI commands](#flat-cli-commands)
54
46
  - [Polyglot metadata](#polyglot-metadata)
@@ -250,7 +242,9 @@ npm install mdat
250
242
 
251
243
  <!-- cli-help -->
252
244
 
253
- #### Command: `mdat`
245
+ #### Commands
246
+
247
+ ##### Command: `mdat`
254
248
 
255
249
  Work with MDAT placeholder comments in Markdown files.
256
250
 
@@ -274,7 +268,7 @@ mdat [command] [files..] [options]
274
268
 
275
269
  _See the sections below for more information on each subcommand._
276
270
 
277
- #### Subcommand: `mdat expand`
271
+ ##### Subcommand: `mdat expand`
278
272
 
279
273
  Expand MDAT placeholder comments. If no files are provided, the closest readme.md is expanded.
280
274
 
@@ -299,7 +293,7 @@ mdat expand [files..] [options]
299
293
  | `--help`<br>`-h` | Show help | `boolean` | |
300
294
  | `--version`<br>`-v` | Show version number | `boolean` | |
301
295
 
302
- #### Subcommand: `mdat collapse`
296
+ ##### Subcommand: `mdat collapse`
303
297
 
304
298
  Collapse MDAT placeholder comments. If no files are provided, the closest readme.md is collapsed.
305
299
 
@@ -323,7 +317,7 @@ mdat collapse [files..] [options]
323
317
  | `--help`<br>`-h` | Show help | `boolean` | |
324
318
  | `--version`<br>`-v` | Show version number | `boolean` | |
325
319
 
326
- #### Subcommand: `mdat strip`
320
+ ##### Subcommand: `mdat strip`
327
321
 
328
322
  Strip MDAT comments while preserving expanded content. If no files are provided, the closest readme.md is stripped.
329
323
 
@@ -347,7 +341,7 @@ mdat strip [files..] [options]
347
341
  | `--help`<br>`-h` | Show help | `boolean` | |
348
342
  | `--version`<br>`-v` | Show version number | `boolean` | |
349
343
 
350
- #### Subcommand: `mdat check`
344
+ ##### Subcommand: `mdat check`
351
345
 
352
346
  Check if MDAT placeholder comments are up to date. Exits with code 1 if any files have stale or unexpanded content.
353
347
 
@@ -369,7 +363,7 @@ mdat check [files..] [options]
369
363
  | `--help`<br>`-h` | Show help | `boolean` |
370
364
  | `--version`<br>`-v` | Show version number | `boolean` |
371
365
 
372
- #### Subcommand: `mdat create`
366
+ ##### Subcommand: `mdat create`
373
367
 
374
368
  Create a new Markdown file from a template.
375
369
 
@@ -592,7 +586,17 @@ See the [Examples section](https://github.com/kitschpatrol/remark-mdat#examples)
592
586
 
593
587
  ### Bundled rules
594
588
 
595
- Every rule that emits a section heading accepts a `headingLevel` option (`1`-`6`) to control the heading's Markdown level, with any sub-headings nested one level deeper. The defaults match each rule's placement in the bundled MDAT Readme template: `2` for top-level sections (`table-of-contents`, `contributing`, and `license`), and `3` for the rules that sit under "Getting started" (`dependencies`, `install`, and `development-dependencies`).
589
+ Every stand-alone rule that generates a section of the readme emits its own heading, and accepts two options to control it:
590
+
591
+ - `heading` is `true` by default, which emits the rule's standard heading. Pass a string to replace the heading text, or `false` to leave the heading out, e.g. when you've written your own heading above the comment, or when the same rule appears several times in one section.
592
+
593
+ - `headingLevel` (`1`-`6`) sets the heading's Markdown level. Any sub-headings are nested one level deeper, whether or not the heading itself is shown. The defaults match each rule's placement in the bundled MDAT Readme template: `2` for top-level sections (`table-of-contents`, `contributing`, `license`, and `skills`), and `3` for the rules that sit under "Getting started" (`dependencies`, `install`, and `development-dependencies`).
594
+
595
+ ```md
596
+ <!-- install({ heading: "Setup", headingLevel: 2 }) -->
597
+ ```
598
+
599
+ Rules that generate inline content, like `badges`, `code`, and `size`, have no heading. Rule plugins follow the same convention.
596
600
 
597
601
  #### Stand-alone
598
602
 
@@ -606,7 +610,28 @@ Every rule that emits a section heading accepts a `headingLevel` option (`1`-`6`
606
610
 
607
611
  - ##### `<!-- badges -->`
608
612
 
609
- Generates badges based on project metadata. Supports NPM version, license, CI status, and Git LFS badges.
613
+ Generates badges based on project metadata. Each badge has an option. Leave it out for the default behavior, pass `false` to hide the badge, or pass `true` to require it, which reports an error if the metadata the badge needs is missing. Two options also take a value that replaces the detected metadata.
614
+
615
+ | Option | Badge | Default | Value |
616
+ | ------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------- |
617
+ | `npm` | NPM version, for each package | Shown for public packages | Package names (`string[]`), useful in monorepos |
618
+ | `license` | License | Shown when a license file matches a known SPDX license | |
619
+ | `ci` | GitHub Actions CI status | Shown when a workflow is named "CI" | |
620
+ | `gitLfs` | Git LFS | Shown when the repository tracks files with Git LFS | |
621
+ | `vscode` | VS Code Marketplace version | Shown for VS Code extensions hosted on GitHub | |
622
+ | `homebrew` | Homebrew tap | Shown when a `brewpub --tap` invocation is found in the package scripts | Formula (`"owner/tap/formula"`) |
623
+ | `githubRelease` | Latest GitHub release | Hidden | |
624
+ | `npmDownloads` | Monthly NPM downloads, for each package | Hidden | |
625
+ | `obsidianDownloads` | Obsidian community plugin downloads | Hidden | |
626
+ | `bundleSize` | [Bundlephobia](https://bundlephobia.com) minified and gzipped size, for each package | Hidden | |
627
+
628
+ Badges appear in the order of the table, followed by `custom` badges, which take a `Record<string, { image: string, link: string }>` keyed by alt text.
629
+
630
+ The download and size badges cover the packages listed in `npm`, or the current package if it's public, even when `npm` is `false`. VS Code extensions are identified by the `publisher` and `engines.vscode` fields in `package.json`. Shields.io retired its Marketplace badge, so the version is read from `package.json` in the GitHub repository instead, respecting `repository.directory` in monorepos. The Homebrew formula is detected the same way as in the `install` rule. The GitHub release badge needs a GitHub repository URL, and the Obsidian downloads badge reads the plugin ID from `manifest.json`.
631
+
632
+ ```md
633
+ <!-- badges({ ci: false, npmDownloads: true }) -->
634
+ ```
610
635
 
611
636
  - ##### `<!-- description -->`
612
637
 
@@ -665,11 +690,11 @@ Every rule that emits a section heading accepts a `headingLevel` option (`1`-`6`
665
690
 
666
691
  - ##### `<!-- skills -->`
667
692
 
668
- Documents the [Agent Skills](https://agentskills.io) bundled with the project and how to install them. Detects skills from `skills/<name>/SKILL.md` files, and lists each skill's name and description from its frontmatter, linking the name to the skill's `SKILL.md` file. Publishable npm packages get instructions for syncing skills from the installed package with the [`skills`](https://github.com/vercel-labs/skills) CLI's `experimental_sync` command (recommended), and for installing them from the repository with `skills add`. Private packages and non-Node projects only get the repository instructions. Reports an error if no skills are found. Remember to include the `skills` directory in the `files` field of `package.json`. The optional `headingLevel` sets the Markdown heading level and defaults to `2`.
693
+ Documents the [Agent Skills](https://agentskills.io) bundled with the project and how to install them. Detects skills from `skills/<name>/SKILL.md` files, and gives each one a "Skill:" sub-heading linking to its `SKILL.md` file, followed by the first sentence of the description from its frontmatter. Publishable npm packages get instructions for syncing skills from the installed package with the `experimental_sync` command of Vercel's [skills CLI](https://github.com/vercel-labs/skills), and for installing them from the repository with `skills add`. Private packages and non-Node projects only get the repository instructions. Reports an error if no skills are found. Remember to include the `skills` directory in the `files` field of `package.json`. The optional `headingLevel` sets the Markdown heading level and defaults to `2`.
669
694
 
670
695
  #### Compound
671
696
 
672
- Compound rules combine several stand-alone rules under a single keyword. Options are passed as an array with one entry per stand-alone rule, in order, e.g. `<!-- footer([{ headingLevel: 3 }, { headingLevel: 3 }]) -->`.
697
+ Compound rules combine several stand-alone rules under a single keyword. Options are passed as an array with one entry per stand-alone rule, in order, e.g. `<!-- footer([{ headingLevel: 3 }, { heading: "Legal", headingLevel: 3 }]) -->`.
673
698
 
674
699
  - ##### `<!-- header -->`
675
700
 
@@ -767,44 +792,41 @@ Transform a CLI command's `--help` output into Markdown tables. Recursively call
767
792
 
768
793
  Example: `<!-- cli-help -->`
769
794
 
770
- #### [mdat-plugin-api](https://github.com/kitschpatrol/mdat-plugin-api)
795
+ #### [mdat-plugin-api-help](https://github.com/kitschpatrol/mdat-plugin-api-help)
771
796
 
772
797
  Generate API documentation for a TypeScript package's public exports. Runs [TypeDoc](https://typedoc.org) over the package's entry point and embeds signatures, JSDoc descriptions, parameter and property tables, and examples as Markdown. A compact format renders one table row per export for large or namespaced APIs, and exports can be selected by name.
773
798
 
774
- Example: `<!-- api({ format: "compact", include: ["sync*"] }) -->`
799
+ Example: `<!-- api-help({ format: "compact", include: ["sync*"] }) -->`
775
800
 
776
- <!-- //skills -->
801
+ <!-- // skills -->
777
802
 
778
803
  ## Agent skills
779
804
 
780
- This project includes [Agent Skills](https://agentskills.io) that teach coding agents like Claude Code and Codex how to work with mdat:
781
-
782
- - **[`mdat`](skills/mdat/SKILL.md)**: Use and configure mdat, the Markdown Autophagic Template CLI and library that expands HTML comment placeholders such as `<!-- title -->` in Markdown files. Use when a readme or other Markdown file contains mdat comment placeholders, when editing mdat.config.ts or an "mdat" key in package.json, when generating or updating readme boilerplate from project metadata, when `mdat check` fails, or when calling the mdat TypeScript API.
783
- - **[`mdat-plugin-authoring`](skills/mdat-plugin-authoring/SKILL.md)**: Write custom mdat expansion rules and publishable mdat rule plugins (mdat-plugin-\* packages). Use when creating or debugging rule functions for mdat comment placeholders, validating comment arguments, reading project metadata or the Markdown tree inside a rule, building compound rules, or packaging and testing rules for reuse across projects.
805
+ This project bundles 2 [Agent Skills](https://agentskills.io) in its published package to help coding agents work with mdat.
784
806
 
785
- The skills are published in the `skills` directory of the `mdat` package. Nothing is added to your project until you install them with one of the tools below.
807
+ To sync the skills into your project, run Vercel's [skills CLI](https://github.com/vercel-labs/skills) from your project root:
786
808
 
787
- ### Sync from the installed package (recommended)
809
+ ```sh
810
+ npx skills experimental_sync
811
+ ```
788
812
 
789
- With `mdat` installed as a project dependency, the [`skills`](https://github.com/vercel-labs/skills) CLI finds skills bundled in your dependencies and copies them into your project's agent skill directories, so they match the version of `mdat` you have installed:
813
+ Or install globally:
790
814
 
791
815
  ```sh
792
- npx skills experimental_sync
816
+ npx skills add kitschpatrol/mdat --global
793
817
  ```
794
818
 
795
- Run the command again after upgrading `mdat` to refresh the copies. The `experimental_sync` command is experimental and its behavior may change.
819
+ Included skills:
796
820
 
797
- ### Install from the repository
821
+ ### Skill: [`mdat`](skills/mdat/SKILL.md)
798
822
 
799
- If `mdat` is not a dependency of your project, for example because you use a global installation, install the skills from the repository instead:
823
+ Use and configure mdat, the Markdown Autophagic Template CLI and library that expands HTML comment placeholders such as `<!-- title -->` in Markdown files.
800
824
 
801
- ```sh
802
- npx skills add kitschpatrol/mdat
803
- ```
825
+ ### Skill: [`mdat-plugin-authoring`](skills/mdat-plugin-authoring/SKILL.md)
804
826
 
805
- Skills installed this way follow the repository's default branch rather than your installed version of `mdat`.
827
+ Write custom mdat expansion rules and publishable mdat rule plugins (mdat-plugin-\* packages).
806
828
 
807
- <!-- //skills -->
829
+ <!-- // /skills -->
808
830
 
809
831
  ## Migrating from 1.x to 2.x
810
832
 
@@ -61,24 +61,26 @@ Only JSON5 values are accepted. No JavaScript is evaluated. Bundled rules valida
61
61
 
62
62
  These work without any configuration. They read normalized project metadata, so most also work in Python, Rust, Go, and Ruby projects, not only Node.
63
63
 
64
- | Keyword | Output | Options |
65
- | ----------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- |
66
- | `title` | Project name as a heading. | |
67
- | `banner` | A banner image found in the project directory. | |
68
- | `badges` | npm version, license, CI status, and Git LFS badges. | `npm: string[]`, `custom: { [name]: { image, link } }` |
69
- | `description` (alias `short-description`) | Project description. | |
70
- | `table-of-contents` (alias `toc`) | Table of contents from the document headings. | `depth` (default `3`), `headingLevel` (default `2`) |
71
- | `install` (alias `installation`) | Install instructions for consumers of the package. Private packages are rejected. | `headingLevel` (default `3`), `dev`, `homebrew` |
72
- | `dependencies` | Runtime platform requirements and required peer dependencies. | `headingLevel` (default `3`) |
73
- | `development-dependencies` (alias `dev-dependencies`) | Tools needed to work on the project, from `devEngines` and `packageManager`. | `headingLevel` (default `3`) |
74
- | `contributing` | Invitation to open issues and pull requests. | `headingLevel` (default `2`) |
75
- | `license` | License section. | `headingLevel` (default `2`) |
76
- | `code` | A code block embedded from a file. | `file` (required), `language`, `trim` (default `true`) |
77
- | `size` | A file's size. | `file` (required), `compression`: `none`, `gzip`, `brotli` |
78
- | `size-table` | A table of file sizes with Gzip and Brotli columns. | `files` (required), `precision` (default `0`) |
79
- | `skills` | Agent Skills found in `skills/<name>/SKILL.md` and how to install them. | `headingLevel` (default `2`) |
80
- | `header` | Compound: `title`, `banner`, `badges`, `short-description`. | Array, one entry per sub-rule in order. |
81
- | `footer` | Compound: `contributing`, `license`. | Array, one entry per sub-rule in order. |
64
+ Rules that generate a section emit their own heading. Their `heading` option is `true` by default, takes a string to replace the heading text, and takes `false` to leave the heading out, e.g. under a hand-written heading or when a rule appears several times in one section. `headingLevel` sets the heading's level, with sub-headings nested one level deeper whether or not the heading is shown.
65
+
66
+ | Keyword | Output | Options |
67
+ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `title` | Project name as a heading. | |
69
+ | `banner` | A banner image found in the project directory. | |
70
+ | `badges` | npm version, license, CI, Git LFS, VS Code Marketplace, and Homebrew badges when detected, plus opt-in GitHub release, npm downloads, Obsidian downloads, and bundle size badges. | `npm`, `license`, `ci`, `gitLfs`, `vscode`, `homebrew`, `githubRelease`, `npmDownloads`, `obsidianDownloads`, `bundleSize`: `false` hides, `true` requires. Also `npm: string[]`, `homebrew: "owner/tap/formula"`, `custom: { [name]: { image, link } }` |
71
+ | `description` (alias `short-description`) | Project description. | |
72
+ | `table-of-contents` (alias `toc`) | Table of contents from the document headings. | `depth` (default `3`), `heading`, `headingLevel` (default `2`) |
73
+ | `install` (alias `installation`) | Install instructions for consumers of the package. Private packages are rejected. | `heading`, `headingLevel` (default `3`), `dev`, `homebrew` |
74
+ | `dependencies` | Runtime platform requirements and required peer dependencies. | `heading`, `headingLevel` (default `3`) |
75
+ | `development-dependencies` (alias `dev-dependencies`) | Tools needed to work on the project, from `devEngines` and `packageManager`. | `heading`, `headingLevel` (default `3`) |
76
+ | `contributing` | Invitation to open issues and pull requests. | `heading`, `headingLevel` (default `2`) |
77
+ | `license` | License section. | `heading`, `headingLevel` (default `2`) |
78
+ | `code` | A code block embedded from a file. | `file` (required), `language`, `trim` (default `true`) |
79
+ | `size` | A file's size. | `file` (required), `compression`: `none`, `gzip`, `brotli` |
80
+ | `size-table` | A table of file sizes with Gzip and Brotli columns. | `files` (required), `precision` (default `0`) |
81
+ | `skills` | Agent Skills found in `skills/<name>/SKILL.md` and how to install them. | `heading`, `headingLevel` (default `2`) |
82
+ | `header` | Compound: `title`, `banner`, `badges`, `short-description`. | Array, one entry per sub-rule in order. |
83
+ | `footer` | Compound: `contributing`, `license`. | Array, one entry per sub-rule in order. |
82
84
 
83
85
  File paths in `code`, `size`, and `size-table` resolve from the current working directory.
84
86
 
@@ -101,6 +101,14 @@ const metadata = await getContextMetadata()
101
101
 
102
102
  See the exported `ReadmeMetadata` type for the complete field list. Call `resetMetadataCaches()` between tests that change project files on disk.
103
103
 
104
+ ## Headings
105
+
106
+ Follow the convention of the bundled rules so that readmes stay consistent:
107
+
108
+ - A rule that generates a section emits its own heading. A rule that generates inline content, like a badge or a code block, has no heading.
109
+ - Accept a `heading` option. `true` is the default and emits the rule's standard heading, a string replaces the heading text, and `false` leaves the heading out.
110
+ - Accept a `headingLevel` option (`1`-`6`) for the heading's level. Nest any sub-headings one level deeper, whether or not the heading is shown. Default to the level that suits the section's usual place in a readme.
111
+
104
112
  ## Order and compound rules
105
113
 
106
114
  - `order` defaults to `0`. Rules with a higher order run later. Use it when a rule reads content other rules generate. The bundled `table-of-contents` rule uses `order: 1` so that it sees headings produced by other rules.