@rheged-studio/changelog-core 1.1.1
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/LICENSE +21 -0
- package/README.md +92 -0
- package/changelog-contract.md +129 -0
- package/config.json +9 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +106 -0
- package/dist/commands/add-links.d.ts +8 -0
- package/dist/commands/add-links.d.ts.map +1 -0
- package/dist/commands/add-links.js +258 -0
- package/dist/commands/backfill-commits.d.ts +19 -0
- package/dist/commands/backfill-commits.d.ts.map +1 -0
- package/dist/commands/backfill-commits.js +236 -0
- package/dist/commands/check-completeness.d.ts +13 -0
- package/dist/commands/check-completeness.d.ts.map +1 -0
- package/dist/commands/check-completeness.js +164 -0
- package/dist/commands/enrich.d.ts +17 -0
- package/dist/commands/enrich.d.ts.map +1 -0
- package/dist/commands/enrich.js +194 -0
- package/dist/commands/finalise.d.ts +25 -0
- package/dist/commands/finalise.d.ts.map +1 -0
- package/dist/commands/finalise.js +249 -0
- package/dist/commands/set-affected-packages.d.ts +13 -0
- package/dist/commands/set-affected-packages.d.ts.map +1 -0
- package/dist/commands/set-affected-packages.js +207 -0
- package/dist/commands/validate.d.ts +6 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +293 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/lib/blank.d.ts +5 -0
- package/dist/lib/blank.d.ts.map +1 -0
- package/dist/lib/blank.js +6 -0
- package/dist/lib/changelog.d.ts +6 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +47 -0
- package/dist/lib/cli-entry.d.ts +10 -0
- package/dist/lib/cli-entry.d.ts.map +1 -0
- package/dist/lib/cli-entry.js +22 -0
- package/dist/lib/commit-count.d.ts +16 -0
- package/dist/lib/commit-count.d.ts.map +1 -0
- package/dist/lib/commit-count.js +72 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +156 -0
- package/dist/lib/derive-packages.d.ts +10 -0
- package/dist/lib/derive-packages.d.ts.map +1 -0
- package/dist/lib/derive-packages.js +42 -0
- package/dist/lib/enrich.d.ts +29 -0
- package/dist/lib/enrich.d.ts.map +1 -0
- package/dist/lib/enrich.js +54 -0
- package/dist/lib/frontmatter.d.ts +11 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +355 -0
- package/dist/lib/stamp.d.ts +10 -0
- package/dist/lib/stamp.d.ts.map +1 -0
- package/dist/lib/stamp.js +35 -0
- package/dist/lib/vendor/issue-keys.d.ts +16 -0
- package/dist/lib/vendor/issue-keys.d.ts.map +1 -0
- package/dist/lib/vendor/issue-keys.js +29 -0
- package/package.json +124 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Load the consumer's changelog config.json (issue-ID prefixes, Linear workspace
|
|
2
|
+
// slug, base branch, changelog directory, monorepo package mapping).
|
|
3
|
+
//
|
|
4
|
+
// Resolved from the consumer cwd (or an explicit path) — NOT relative to this
|
|
5
|
+
// package's install location. Order:
|
|
6
|
+
// 1. options.configPath
|
|
7
|
+
// 2. process.env.CHANGELOG_CONFIG
|
|
8
|
+
// 3. <cwd>/.claude/skills/changelog/config.json
|
|
9
|
+
// 4. <cwd>/.agents/skills/changelog/config.json
|
|
10
|
+
// 5. <cwd>/config.json (committed repo-root config — stopgap until agent-skills
|
|
11
|
+
// #122 / Linear follow-up makes skill config.json reliably available in CI)
|
|
12
|
+
//
|
|
13
|
+
// Zero-deps: a plain JSON read. Identity values (`issueKeys`,
|
|
14
|
+
// `linearWorkspaceSlug`) have NO default — a foreign repo that silently inherited
|
|
15
|
+
// ACME's keys/slug would emit wrong issue links and detection, so a missing
|
|
16
|
+
// config or a missing identity key FAILS LOUDLY. Structural conventions
|
|
17
|
+
// (`baseBranch`, `changelogDir`, `packageRoots`, `fallbackPackage`) keep generic,
|
|
18
|
+
// non-ACME defaults a consumer can override; a present-but-mistyped one also fails
|
|
19
|
+
// loudly rather than surfacing as a non-actionable crash downstream.
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
// Generic, non-ACME structural defaults. Applied only when the key is absent;
|
|
23
|
+
// every one is overridable in config.json. Mirrored in derive-packages.ts so
|
|
24
|
+
// that function can run standalone — keep the two in sync.
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
// `affected_packages` only earns its keep in a genuine monorepo. Default it
|
|
27
|
+
// off so single-package repos get clean entries; monorepo consumers (and
|
|
28
|
+
// initialise-skills, when it detects a workspace config) flip it on.
|
|
29
|
+
affectedPackages: false,
|
|
30
|
+
baseBranch: "main",
|
|
31
|
+
changelogDir: "changelog",
|
|
32
|
+
fallbackPackage: "infrastructure",
|
|
33
|
+
packageRoots: ["apps", "packages", "services"],
|
|
34
|
+
};
|
|
35
|
+
let cached;
|
|
36
|
+
function fail(message, source) {
|
|
37
|
+
throw new Error(`changelog config: ${message} Set it in ${source} (commit a root config.json, or run initialise-skills for the skill-local copy).`);
|
|
38
|
+
}
|
|
39
|
+
function isNonEmptyString(value) {
|
|
40
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
41
|
+
}
|
|
42
|
+
function isStringArray(value) {
|
|
43
|
+
return Array.isArray(value) && value.every(isNonEmptyString);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validate a raw config JSON string and merge it over the structural defaults.
|
|
47
|
+
* Exported so the fail-loud contract is unit-testable without filesystem setup.
|
|
48
|
+
*/
|
|
49
|
+
export function parseConfig(raw, source = "config.json") {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
console.error(`Invalid JSON in ${source}: ${message}`);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
// JSON.parse can return null/array/primitive; the field checks below would then
|
|
60
|
+
// throw a raw TypeError instead of the actionable config error this surfaces.
|
|
61
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
62
|
+
fail("config.json must contain a JSON object.", source);
|
|
63
|
+
}
|
|
64
|
+
const record = parsed;
|
|
65
|
+
// Required identity values — no default; fail loudly so a consuming repo can't
|
|
66
|
+
// silently ship ACME's issue keys or workspace slug.
|
|
67
|
+
if (!Array.isArray(record.issueKeys) ||
|
|
68
|
+
record.issueKeys.length === 0 ||
|
|
69
|
+
!record.issueKeys.every(isNonEmptyString)) {
|
|
70
|
+
fail("`issueKeys` must be a non-empty array of strings.", source);
|
|
71
|
+
}
|
|
72
|
+
if (!isNonEmptyString(record.linearWorkspaceSlug)) {
|
|
73
|
+
fail("`linearWorkspaceSlug` must be a non-empty string.", source);
|
|
74
|
+
}
|
|
75
|
+
// Structural keys are optional, but a present value of the wrong type would
|
|
76
|
+
// otherwise crash later (e.g. `packageRoots.map`) with a non-actionable error.
|
|
77
|
+
if ("baseBranch" in record && !isNonEmptyString(record.baseBranch)) {
|
|
78
|
+
fail("`baseBranch`, when set, must be a non-empty string.", source);
|
|
79
|
+
}
|
|
80
|
+
if ("changelogDir" in record && !isNonEmptyString(record.changelogDir)) {
|
|
81
|
+
fail("`changelogDir`, when set, must be a non-empty string.", source);
|
|
82
|
+
}
|
|
83
|
+
if ("packageRoots" in record && !isStringArray(record.packageRoots)) {
|
|
84
|
+
fail("`packageRoots`, when set, must be an array of strings.", source);
|
|
85
|
+
}
|
|
86
|
+
if ("fallbackPackage" in record &&
|
|
87
|
+
!isNonEmptyString(record.fallbackPackage)) {
|
|
88
|
+
fail("`fallbackPackage`, when set, must be a non-empty string.", source);
|
|
89
|
+
}
|
|
90
|
+
if ("affectedPackages" in record &&
|
|
91
|
+
typeof record.affectedPackages !== "boolean") {
|
|
92
|
+
fail("`affectedPackages`, when set, must be a boolean.", source);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
...DEFAULTS,
|
|
96
|
+
...record,
|
|
97
|
+
issueKeys: record.issueKeys,
|
|
98
|
+
linearWorkspaceSlug: record.linearWorkspaceSlug,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function resolveConfigPath(options) {
|
|
102
|
+
if (options?.configPath) {
|
|
103
|
+
return options.configPath;
|
|
104
|
+
}
|
|
105
|
+
const fromEnvironment = process.env.CHANGELOG_CONFIG?.trim();
|
|
106
|
+
if (fromEnvironment) {
|
|
107
|
+
return fromEnvironment;
|
|
108
|
+
}
|
|
109
|
+
const cwd = process.cwd();
|
|
110
|
+
const claudePath = join(cwd, ".claude/skills/changelog/config.json");
|
|
111
|
+
if (existsSync(claudePath)) {
|
|
112
|
+
return claudePath;
|
|
113
|
+
}
|
|
114
|
+
const agentsPath = join(cwd, ".agents/skills/changelog/config.json");
|
|
115
|
+
if (existsSync(agentsPath)) {
|
|
116
|
+
return agentsPath;
|
|
117
|
+
}
|
|
118
|
+
// Last resort: committed repo-root config.json. Skill-local config.json is
|
|
119
|
+
// gitignored under the agent-skills generated-config model, so CI / fresh
|
|
120
|
+
// clones need a tracked path until that gap is fixed (A-812).
|
|
121
|
+
const rootPath = join(cwd, "config.json");
|
|
122
|
+
if (existsSync(rootPath)) {
|
|
123
|
+
return rootPath;
|
|
124
|
+
}
|
|
125
|
+
return fail("config.json not found.", `${claudePath}, ${agentsPath}, or ${rootPath}`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Load and cache the changelog config from the consumer cwd (or an explicit path).
|
|
129
|
+
*/
|
|
130
|
+
export function loadConfig(options) {
|
|
131
|
+
// An explicit path always reloads; otherwise return the cached value so
|
|
132
|
+
// repeated callers (and the CLI's env-based --config) share one parse.
|
|
133
|
+
if (cached !== undefined && options?.configPath === undefined) {
|
|
134
|
+
return cached;
|
|
135
|
+
}
|
|
136
|
+
const configPath = resolveConfigPath(options);
|
|
137
|
+
let raw;
|
|
138
|
+
try {
|
|
139
|
+
raw = readFileSync(configPath, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const nodeError = error;
|
|
143
|
+
if (nodeError.code === "ENOENT") {
|
|
144
|
+
fail("config.json not found.", configPath);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
cached = parseConfig(raw, configPath);
|
|
149
|
+
return cached;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Clear the cached config — for tests that swap config between cases.
|
|
153
|
+
*/
|
|
154
|
+
export function resetConfigCache() {
|
|
155
|
+
cached = undefined;
|
|
156
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type DerivePackagesOptions = {
|
|
2
|
+
changelogDir?: string;
|
|
3
|
+
fallbackPackage?: string;
|
|
4
|
+
packageRoots?: string[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Map changed repo-relative paths to sorted, de-duplicated package names.
|
|
8
|
+
*/
|
|
9
|
+
export declare function derivePackagesFromPaths(paths: string[], options?: DerivePackagesOptions): string[];
|
|
10
|
+
//# sourceMappingURL=derive-packages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"derive-packages.d.ts","sourceRoot":"","sources":["../../src/lib/derive-packages.ts"],"names":[],"mappings":"AAoBA,MAAM,MAAM,qBAAqB,GAAG;IAClC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB,CAAC;AAEF;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,qBAA0B,GAClC,MAAM,EAAE,CA+BV"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Map a set of changed repo-relative paths to the workspace packages they touch.
|
|
2
|
+
//
|
|
3
|
+
// Used by the merge-time path (set-affected-packages, computed from the branch
|
|
4
|
+
// diff). Kept as one implementation so the `affected_packages` value can't drift
|
|
5
|
+
// if a post-merge counterpart reuses the same rule.
|
|
6
|
+
//
|
|
7
|
+
// The rule (a conventional monorepo path→package mapping, all config-driven):
|
|
8
|
+
// <root>/<x>/... -> <x> for each root in `packageRoots`
|
|
9
|
+
// everything else -> `fallbackPackage`
|
|
10
|
+
// The changelog directory itself is skipped — it's touched by every entry and
|
|
11
|
+
// would otherwise pin `fallbackPackage` onto every package list.
|
|
12
|
+
import { escapeRegex } from "./vendor/issue-keys.js";
|
|
13
|
+
// Mirror config.ts's DEFAULTS so this function works standalone (e.g. in unit
|
|
14
|
+
// tests) without loading config; keep the two in sync if the defaults change.
|
|
15
|
+
const DEFAULT_PACKAGE_ROOTS = ["apps", "packages", "services"];
|
|
16
|
+
const DEFAULT_FALLBACK_PACKAGE = "infrastructure";
|
|
17
|
+
const DEFAULT_CHANGELOG_DIR = "changelog";
|
|
18
|
+
/**
|
|
19
|
+
* Map changed repo-relative paths to sorted, de-duplicated package names.
|
|
20
|
+
*/
|
|
21
|
+
export function derivePackagesFromPaths(paths, options = {}) {
|
|
22
|
+
const { changelogDir: changelogDirectory = DEFAULT_CHANGELOG_DIR, fallbackPackage = DEFAULT_FALLBACK_PACKAGE, packageRoots = DEFAULT_PACKAGE_ROOTS, } = options;
|
|
23
|
+
// `<root>/<x>/` → captures `<x>`, for any configured root.
|
|
24
|
+
const rootRe = packageRoots.length > 0
|
|
25
|
+
? new RegExp(`^(?:${packageRoots.map(escapeRegex).join("|")})/([^/]+)/`)
|
|
26
|
+
: null;
|
|
27
|
+
// Normalise a trailing slash so `changelogDir: "changelog/"` still skips.
|
|
28
|
+
const skipPrefix = `${changelogDirectory.replace(/\/+$/, "")}/`;
|
|
29
|
+
const out = new Set();
|
|
30
|
+
for (const changedPath of paths) {
|
|
31
|
+
const path = changedPath.trim();
|
|
32
|
+
if (!path) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (path.startsWith(skipPrefix)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const match = rootRe ? rootRe.exec(path) : null;
|
|
39
|
+
out.add(match ? match[1] : fallbackPackage);
|
|
40
|
+
}
|
|
41
|
+
return [...out].toSorted();
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type EnrichInput = {
|
|
2
|
+
additions?: null | string;
|
|
3
|
+
/**
|
|
4
|
+
* Feature branch name — the stable lookup key.
|
|
5
|
+
*/
|
|
6
|
+
branch: string;
|
|
7
|
+
changedFiles?: null | string;
|
|
8
|
+
/**
|
|
9
|
+
* Non-merge commit count as a string, or null.
|
|
10
|
+
*/
|
|
11
|
+
commits?: null | string;
|
|
12
|
+
deletions?: null | string;
|
|
13
|
+
/**
|
|
14
|
+
* PR merged_at timestamp (ISO 8601 UTC).
|
|
15
|
+
*/
|
|
16
|
+
mergedAt: string;
|
|
17
|
+
/**
|
|
18
|
+
* Merge commit SHA (full or short); only the first 7 chars are stored.
|
|
19
|
+
*/
|
|
20
|
+
mergeSha: string;
|
|
21
|
+
prNumber?: null | string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Apply enrichment to a single entry's raw markdown and return the rewritten
|
|
25
|
+
* markdown. Fill-once for merged_at/commit/pr; authoritative overwrite for
|
|
26
|
+
* stats. created_at is never touched.
|
|
27
|
+
*/
|
|
28
|
+
export declare function enrichFrontmatter(raw: string, input: EnrichInput): string;
|
|
29
|
+
//# sourceMappingURL=enrich.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enrich.d.ts","sourceRoot":"","sources":["../../src/lib/enrich.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC7B;;OAEG;IACH,OAAO,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;CAC1B,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,MAAM,CAkDzE"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure enrichment of a changelog entry's frontmatter — fills the fields that
|
|
2
|
+
// are only knowable once the PR has merged (merged_at / commit / pr) plus
|
|
3
|
+
// authoritative stats. `version` is filled separately by lib/stamp.
|
|
4
|
+
// created_at is never touched.
|
|
5
|
+
//
|
|
6
|
+
// This is a library module (no CLI): the release-time orchestrator finalise
|
|
7
|
+
// composes it with the PR data it resolves from `gh`. Kept pure so it's
|
|
8
|
+
// trivially unit-testable. Zero-dep — uses the package's frontmatter parser.
|
|
9
|
+
import { blank } from "./blank.js";
|
|
10
|
+
import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
|
|
11
|
+
/**
|
|
12
|
+
* Apply enrichment to a single entry's raw markdown and return the rewritten
|
|
13
|
+
* markdown. Fill-once for merged_at/commit/pr; authoritative overwrite for
|
|
14
|
+
* stats. created_at is never touched.
|
|
15
|
+
*/
|
|
16
|
+
export function enrichFrontmatter(raw, input) {
|
|
17
|
+
const parsed = parseFrontmatter(raw);
|
|
18
|
+
const fm = { ...parsed.data };
|
|
19
|
+
if (!fm.created_at) {
|
|
20
|
+
throw new Error("entry has no created_at; refusing to enrich");
|
|
21
|
+
}
|
|
22
|
+
const shortSha = input.mergeSha.slice(0, 7);
|
|
23
|
+
if (blank(fm.merged_at)) {
|
|
24
|
+
fm.merged_at = input.mergedAt;
|
|
25
|
+
}
|
|
26
|
+
if (blank(fm.commit)) {
|
|
27
|
+
fm.commit = shortSha;
|
|
28
|
+
}
|
|
29
|
+
if (blank(fm.pr) && input.prNumber) {
|
|
30
|
+
fm.pr = Number.parseInt(input.prNumber, 10);
|
|
31
|
+
}
|
|
32
|
+
// Authoritative overwrites from the GH API, always under stats: { ... }.
|
|
33
|
+
const stats = typeof fm.stats === "object" &&
|
|
34
|
+
fm.stats !== null &&
|
|
35
|
+
!Array.isArray(fm.stats)
|
|
36
|
+
? { ...fm.stats }
|
|
37
|
+
: {};
|
|
38
|
+
// Guard with blank() (not just null/undefined): an empty string would slip
|
|
39
|
+
// through and Number.parseInt("", 10) is NaN, which the validator rejects.
|
|
40
|
+
if (!blank(input.additions)) {
|
|
41
|
+
stats.loc_added = Number.parseInt(input.additions, 10);
|
|
42
|
+
}
|
|
43
|
+
if (!blank(input.deletions)) {
|
|
44
|
+
stats.loc_removed = Number.parseInt(input.deletions, 10);
|
|
45
|
+
}
|
|
46
|
+
if (!blank(input.changedFiles)) {
|
|
47
|
+
stats.files_changed = Number.parseInt(input.changedFiles, 10);
|
|
48
|
+
}
|
|
49
|
+
if (!blank(input.commits)) {
|
|
50
|
+
stats.commits = Number.parseInt(input.commits, 10);
|
|
51
|
+
}
|
|
52
|
+
fm.stats = stats;
|
|
53
|
+
return stringifyFrontmatter(parsed.content, fm);
|
|
54
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type FrontmatterValue = boolean | FrontmatterValue[] | null | number | string | {
|
|
2
|
+
[key: string]: FrontmatterValue;
|
|
3
|
+
};
|
|
4
|
+
export type FrontmatterData = Record<string, FrontmatterValue>;
|
|
5
|
+
export type ParsedFrontmatter = {
|
|
6
|
+
content: string;
|
|
7
|
+
data: FrontmatterData;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseFrontmatter(raw: string): ParsedFrontmatter;
|
|
10
|
+
export declare function stringifyFrontmatter(content: string, data: FrontmatterData): string;
|
|
11
|
+
//# sourceMappingURL=frontmatter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/lib/frontmatter.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,gBAAgB,GACxB,OAAO,GACP,gBAAgB,EAAE,GAClB,IAAI,GACJ,MAAM,GACN,MAAM,GACN;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAAA;CAAE,CAAC;AAExC,MAAM,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAE/D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,eAAe,CAAC;CACvB,CAAC;AA0PF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,iBAAiB,CAU/D;AA+ID,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,eAAe,GACpB,MAAM,CAOR"}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Zero-deps YAML-frontmatter parser/serialiser for the changelog corpus.
|
|
2
|
+
//
|
|
3
|
+
// Carries no third-party npm import so it can be lifted wholesale into this
|
|
4
|
+
// package. It is NOT a general YAML implementation — it handles exactly the
|
|
5
|
+
// subset the changelog frontmatter uses:
|
|
6
|
+
//
|
|
7
|
+
// - plain / single- / double-quoted string scalars
|
|
8
|
+
// - integers, booleans, the `null` literal, and bare `key:` (-> null)
|
|
9
|
+
// - one folded/literal block scalar (`>-` `>` `|` `|-`) for `release_note`
|
|
10
|
+
// - inline arrays (`[]`, `["a", b]`) and block arrays (`- item`)
|
|
11
|
+
// - one level of nested mapping (`stats:`)
|
|
12
|
+
//
|
|
13
|
+
// API mirrors gray-matter's so call sites change minimally:
|
|
14
|
+
// parseFrontmatter(raw) -> { data, content }
|
|
15
|
+
// stringifyFrontmatter(content, d) -> "---\n<yaml>\n---\n<content>"
|
|
16
|
+
const FENCE = "---";
|
|
17
|
+
// --- parsing ---------------------------------------------------------------
|
|
18
|
+
function indentOf(line) {
|
|
19
|
+
return line.length - line.trimStart().length;
|
|
20
|
+
}
|
|
21
|
+
// Parse a scalar token (the text after `key:` or an array item).
|
|
22
|
+
function parseScalar(token) {
|
|
23
|
+
const text = token.trim();
|
|
24
|
+
if (text === "" || text === "null" || text === "~") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (text === "true") {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (text === "false") {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (/^-?\d+$/.test(text)) {
|
|
34
|
+
return Number.parseInt(text, 10);
|
|
35
|
+
}
|
|
36
|
+
if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) {
|
|
37
|
+
return text.slice(1, -1).replaceAll("''", "'");
|
|
38
|
+
}
|
|
39
|
+
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
|
|
40
|
+
// Unescape in a single left-to-right pass so an escaped backslash (`\\`)
|
|
41
|
+
// can't have its trailing char re-consumed by a later rule (e.g. `\\n`
|
|
42
|
+
// must become `\n`, not a newline).
|
|
43
|
+
return text
|
|
44
|
+
.slice(1, -1)
|
|
45
|
+
.replaceAll(/\\(.)/g, (_, char) => (char === "n" ? "\n" : char));
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
// Split an inline-array body on top-level commas only — commas inside single-
|
|
50
|
+
// or double-quoted strings are preserved (e.g. `"Smith, Jr. <a@b>"` stays one
|
|
51
|
+
// item). Mirrors parseScalar's quoting: `''` is an escaped quote inside single
|
|
52
|
+
// quotes, `\` escapes inside double quotes.
|
|
53
|
+
function splitInlineItems(inner) {
|
|
54
|
+
const items = [];
|
|
55
|
+
let current = "";
|
|
56
|
+
let quote = null;
|
|
57
|
+
for (let index = 0; index < inner.length; index++) {
|
|
58
|
+
const ch = inner[index];
|
|
59
|
+
if (quote === '"') {
|
|
60
|
+
current += ch;
|
|
61
|
+
if (ch === "\\" && index + 1 < inner.length) {
|
|
62
|
+
current += inner[++index];
|
|
63
|
+
}
|
|
64
|
+
else if (ch === '"') {
|
|
65
|
+
quote = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else if (quote === "'") {
|
|
69
|
+
current += ch;
|
|
70
|
+
if (ch === "'") {
|
|
71
|
+
if (inner[index + 1] === "'") {
|
|
72
|
+
current += inner[++index];
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
quote = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (ch === '"' || ch === "'") {
|
|
80
|
+
quote = ch;
|
|
81
|
+
current += ch;
|
|
82
|
+
}
|
|
83
|
+
else if (ch === ",") {
|
|
84
|
+
items.push(current);
|
|
85
|
+
current = "";
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
current += ch;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// A non-null `quote` here means the closing quote was never seen — the array
|
|
92
|
+
// body ended mid-string. Pushing `current` would fold the dangling opening
|
|
93
|
+
// quote into the parsed value; fail loudly instead.
|
|
94
|
+
if (quote !== null) {
|
|
95
|
+
throw new Error(`Unterminated quoted item in inline array: ${inner}`);
|
|
96
|
+
}
|
|
97
|
+
items.push(current);
|
|
98
|
+
return items;
|
|
99
|
+
}
|
|
100
|
+
// Parse an inline array body (the text between the surrounding brackets).
|
|
101
|
+
function parseInlineArray(body) {
|
|
102
|
+
const inner = body.trim();
|
|
103
|
+
if (inner === "") {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
return splitInlineItems(inner).map((item) => parseScalar(item));
|
|
107
|
+
}
|
|
108
|
+
// Collect an indented block following a `key:` / block-scalar header, returning
|
|
109
|
+
// the consumed lines (those more indented than `parentIndent`) and the next index.
|
|
110
|
+
function collectBlock(lines, start, parentIndent) {
|
|
111
|
+
const block = [];
|
|
112
|
+
let index = start;
|
|
113
|
+
while (index < lines.length) {
|
|
114
|
+
const line = lines[index];
|
|
115
|
+
if (line.trim() === "") {
|
|
116
|
+
block.push(line);
|
|
117
|
+
index++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (indentOf(line) <= parentIndent) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
block.push(line);
|
|
124
|
+
index++;
|
|
125
|
+
}
|
|
126
|
+
// Drop trailing blank lines that belong to the gap before the next key.
|
|
127
|
+
while (block.length > 0 && block.at(-1).trim() === "") {
|
|
128
|
+
block.pop();
|
|
129
|
+
}
|
|
130
|
+
return { block, next: index };
|
|
131
|
+
}
|
|
132
|
+
// Fold/keep a block scalar per its indicator (`>` folds newlines to spaces,
|
|
133
|
+
// `|` keeps them; a trailing `-` strips the final newline, which we always do
|
|
134
|
+
// here since the corpus only ever uses `>-`).
|
|
135
|
+
function parseBlockScalar(indicator, block) {
|
|
136
|
+
// An empty block — or one whose lines are all whitespace — has no content to
|
|
137
|
+
// dedent. `Math.min(...[])` is `Infinity`, which would slice every line down
|
|
138
|
+
// to "" and silently collapse the block, so treat both cases as empty.
|
|
139
|
+
const nonBlank = block.filter((line) => line.trim() !== "");
|
|
140
|
+
if (nonBlank.length === 0) {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
const minIndent = Math.min(...nonBlank.map((line) => indentOf(line)));
|
|
144
|
+
const dedented = block.map((line) => line.slice(minIndent));
|
|
145
|
+
const folded = indicator.startsWith(">");
|
|
146
|
+
return folded
|
|
147
|
+
? dedented.join(" ").replaceAll(/\s+/g, " ").trim()
|
|
148
|
+
: dedented.join("\n");
|
|
149
|
+
}
|
|
150
|
+
function parseMapping(lines, startIndent) {
|
|
151
|
+
const data = {};
|
|
152
|
+
let index = 0;
|
|
153
|
+
while (index < lines.length) {
|
|
154
|
+
const line = lines[index];
|
|
155
|
+
if (line.trim() === "") {
|
|
156
|
+
index++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const colon = line.indexOf(":");
|
|
160
|
+
if (colon === -1) {
|
|
161
|
+
// No `:` means malformed input (or a mis-routed block-array item). Fail
|
|
162
|
+
// loudly: silently slicing on colon === -1 mangles the key/value and
|
|
163
|
+
// produces a confusing downstream validation error instead.
|
|
164
|
+
throw new Error(`Invalid frontmatter line (expected "key: value"): ${line}`);
|
|
165
|
+
}
|
|
166
|
+
const key = line.slice(indentOf(line), colon).trim();
|
|
167
|
+
const rest = line.slice(colon + 1).trim();
|
|
168
|
+
index++;
|
|
169
|
+
if (rest === "" ||
|
|
170
|
+
rest === ">" ||
|
|
171
|
+
rest === ">-" ||
|
|
172
|
+
rest === "|" ||
|
|
173
|
+
rest === "|-") {
|
|
174
|
+
const { block, next } = collectBlock(lines, index, startIndent);
|
|
175
|
+
index = next;
|
|
176
|
+
if (rest === "") {
|
|
177
|
+
// Could be a block array, a nested mapping, or a bare null.
|
|
178
|
+
if (block.length === 0) {
|
|
179
|
+
data[key] = null;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// Decide array-vs-mapping from the first *non-blank* line: collectBlock
|
|
183
|
+
// preserves a blank line between `key:` and the first `- item`, so
|
|
184
|
+
// keying off `block[0]` would misroute such a block into parseMapping()
|
|
185
|
+
// and throw on the `- item` lines.
|
|
186
|
+
const firstContent = block.find((blockLine) => blockLine.trim() !== "");
|
|
187
|
+
if (firstContent?.trimStart().startsWith("- ") ||
|
|
188
|
+
firstContent?.trim() === "-") {
|
|
189
|
+
// Drop interior blank lines before mapping: a block array with blank
|
|
190
|
+
// lines between items would otherwise yield spurious `null` entries
|
|
191
|
+
// (each blank line parses as the empty scalar -> `null`).
|
|
192
|
+
data[key] = block
|
|
193
|
+
.filter((blockLine) => blockLine.trim() !== "")
|
|
194
|
+
.map((blockLine) => parseScalar(blockLine.trimStart().replace(/^-\s?/, "")));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const childIndent = indentOf(firstContent ?? block[0]);
|
|
198
|
+
data[key] = parseMapping(block, childIndent);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
data[key] = parseBlockScalar(rest, block);
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (rest.startsWith("[") && rest.endsWith("]")) {
|
|
208
|
+
data[key] = parseInlineArray(rest.slice(1, -1));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Inline empty mapping. The serialiser emits `{}` for an empty object (the
|
|
212
|
+
// symmetric counterpart of `[]` for an empty array); a non-empty mapping is
|
|
213
|
+
// always written in block form, so `{}` is the only inline object to parse.
|
|
214
|
+
// Without this, `stats: {}` would fall through to parseScalar and parse back
|
|
215
|
+
// as the literal string "{}", silently corrupting an empty stats object.
|
|
216
|
+
if (rest === "{}") {
|
|
217
|
+
data[key] = {};
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
data[key] = parseScalar(rest);
|
|
221
|
+
}
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
// Matches a leading `---` fence, the frontmatter body (group 1, non-greedy up to
|
|
225
|
+
// the first closing fence on its own line), the closing fence, and its trailing
|
|
226
|
+
// newline. `content` is the exact remainder, so the markdown body is preserved
|
|
227
|
+
// byte-for-byte and round-trips are idempotent — only the frontmatter is rewritten.
|
|
228
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
|
|
229
|
+
export function parseFrontmatter(raw) {
|
|
230
|
+
const text = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
|
|
231
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
232
|
+
if (!match) {
|
|
233
|
+
return { content: text, data: {} };
|
|
234
|
+
}
|
|
235
|
+
const fmLines = match[1].split("\n");
|
|
236
|
+
const content = text.slice(match[0].length);
|
|
237
|
+
return { content, data: parseMapping(fmLines, 0) };
|
|
238
|
+
}
|
|
239
|
+
// --- serialising -----------------------------------------------------------
|
|
240
|
+
const INDICATORS = new Set([
|
|
241
|
+
"!",
|
|
242
|
+
'"',
|
|
243
|
+
"#",
|
|
244
|
+
"%",
|
|
245
|
+
"&",
|
|
246
|
+
"'",
|
|
247
|
+
"*",
|
|
248
|
+
",",
|
|
249
|
+
"-",
|
|
250
|
+
":",
|
|
251
|
+
">",
|
|
252
|
+
"?",
|
|
253
|
+
"@",
|
|
254
|
+
"[",
|
|
255
|
+
"]",
|
|
256
|
+
"`",
|
|
257
|
+
"{",
|
|
258
|
+
"|",
|
|
259
|
+
"}",
|
|
260
|
+
]);
|
|
261
|
+
function reparsesAsNonString(string_) {
|
|
262
|
+
// True when an unquoted emit of this string would parse back as something
|
|
263
|
+
// other than a string (bool/int/null) or as a date-shaped token worth quoting.
|
|
264
|
+
if (string_ === "" ||
|
|
265
|
+
string_ === "null" ||
|
|
266
|
+
string_ === "~" ||
|
|
267
|
+
string_ === "true" ||
|
|
268
|
+
string_ === "false") {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
if (/^-?\d+$/.test(string_)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return /^\d{4}-\d{2}-\d{2}/.test(string_);
|
|
275
|
+
}
|
|
276
|
+
function needsQuoting(string_) {
|
|
277
|
+
if (string_.length === 0) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (string_ !== string_.trim()) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (INDICATORS.has(string_[0]) || string_.startsWith("- ")) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
if (string_.includes(": ") ||
|
|
287
|
+
string_.includes(" #") ||
|
|
288
|
+
string_.includes("\n")) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
return reparsesAsNonString(string_);
|
|
292
|
+
}
|
|
293
|
+
function serialiseString(string_) {
|
|
294
|
+
if (!needsQuoting(string_)) {
|
|
295
|
+
return string_;
|
|
296
|
+
}
|
|
297
|
+
// Prefer double quotes (Prettier's YAML default) so enrich/finalise rewrites
|
|
298
|
+
// don't turn authored `created_at: "…"` into single-quoted scalars that fail
|
|
299
|
+
// consumer `prettier --check` (A-1308).
|
|
300
|
+
const escaped = string_
|
|
301
|
+
.replaceAll("\\", "\\\\")
|
|
302
|
+
.replaceAll('"', '\\"')
|
|
303
|
+
.replaceAll("\n", "\\n");
|
|
304
|
+
return `"${escaped}"`;
|
|
305
|
+
}
|
|
306
|
+
function serialiseScalar(value) {
|
|
307
|
+
if (value === null || value === undefined) {
|
|
308
|
+
return "";
|
|
309
|
+
}
|
|
310
|
+
if (typeof value === "boolean") {
|
|
311
|
+
return value ? "true" : "false";
|
|
312
|
+
}
|
|
313
|
+
if (typeof value === "number") {
|
|
314
|
+
return String(value);
|
|
315
|
+
}
|
|
316
|
+
return serialiseString(String(value));
|
|
317
|
+
}
|
|
318
|
+
function serialiseValue(key, value, lines) {
|
|
319
|
+
if (Array.isArray(value)) {
|
|
320
|
+
if (value.length === 0) {
|
|
321
|
+
lines.push(`${key}: []`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
lines.push(`${key}:`);
|
|
325
|
+
for (const item of value) {
|
|
326
|
+
lines.push(` - ${serialiseScalar(item)}`);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (value !== null && typeof value === "object") {
|
|
331
|
+
const childEntries = Object.entries(value);
|
|
332
|
+
// Emit `{}` for an empty mapping so it round-trips as an object, mirroring
|
|
333
|
+
// `[]` for an empty array. A bare `key:` would re-parse as null, silently
|
|
334
|
+
// turning an empty `stats: {}` into `stats: null`.
|
|
335
|
+
if (childEntries.length === 0) {
|
|
336
|
+
lines.push(`${key}: {}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
lines.push(`${key}:`);
|
|
340
|
+
for (const [childKey, childValue] of childEntries) {
|
|
341
|
+
const child = serialiseScalar(childValue);
|
|
342
|
+
lines.push(child === "" ? ` ${childKey}:` : ` ${childKey}: ${child}`);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const emitted = serialiseScalar(value);
|
|
347
|
+
lines.push(emitted === "" ? `${key}:` : `${key}: ${emitted}`);
|
|
348
|
+
}
|
|
349
|
+
export function stringifyFrontmatter(content, data) {
|
|
350
|
+
const lines = [];
|
|
351
|
+
for (const [key, value] of Object.entries(data)) {
|
|
352
|
+
serialiseValue(key, value, lines);
|
|
353
|
+
}
|
|
354
|
+
return `${FENCE}\n${lines.join("\n")}\n${FENCE}\n${content}`;
|
|
355
|
+
}
|