@savvy-web/silk-effects 9.6.0 → 10.0.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/changesets/api/dependency-table.js +12 -12
- package/changesets/api/linter.js +23 -19
- package/changesets/api/transformer.js +38 -33
- package/changesets/categories/index.js +1 -1
- package/changesets/categories/types.js +2 -2
- package/changesets/changelog/getDependencyReleaseLine.js +1 -1
- package/changesets/changelog/index.js +2 -2
- package/changesets/constants.js +1 -1
- package/changesets/errors.js +1 -1
- package/changesets/index.js +11 -40
- package/changesets/markdownlint/rules/content-structure.js +2 -2
- package/changesets/markdownlint/rules/heading-hierarchy.js +2 -2
- package/changesets/markdownlint/rules/required-sections.js +2 -2
- package/changesets/markdownlint/rules/uncategorized-content.js +2 -2
- package/changesets/markdownlint/rules/utils.js +6 -6
- package/changesets/rules/content-structure.js +44 -0
- package/changesets/rules/dependency-table-format.js +54 -0
- package/changesets/rules/heading-hierarchy.js +37 -0
- package/changesets/rules/required-sections.js +34 -0
- package/changesets/rules/types.js +20 -0
- package/changesets/rules/uncategorized-content.js +30 -0
- package/changesets/schemas/changeset.js +1 -1
- package/changesets/transforms/aggregate-dependency-tables.js +96 -0
- package/changesets/transforms/contributor-footnotes.js +290 -0
- package/changesets/transforms/deduplicate-items.js +47 -0
- package/changesets/transforms/issue-link-refs.js +47 -0
- package/changesets/transforms/maintenance-note.js +65 -0
- package/changesets/transforms/merge-sections.js +56 -0
- package/changesets/transforms/normalize-format.js +53 -0
- package/changesets/transforms/reorder-sections.js +53 -0
- package/changesets/utils/blocks.js +67 -0
- package/changesets/utils/dependency-table.js +29 -36
- package/changesets/utils/markdown-emit.js +11 -36
- package/index.d.ts +54 -1199
- package/package.json +11 -18
- package/tsdoc-metadata.json +1 -1
- package/changesets/remark/plugins/aggregate-dependency-tables.js +0 -98
- package/changesets/remark/plugins/contributor-footnotes.js +0 -334
- package/changesets/remark/plugins/deduplicate-items.js +0 -31
- package/changesets/remark/plugins/issue-link-refs.js +0 -57
- package/changesets/remark/plugins/maintenance-note.js +0 -74
- package/changesets/remark/plugins/merge-sections.js +0 -42
- package/changesets/remark/plugins/normalize-format.js +0 -46
- package/changesets/remark/plugins/reorder-sections.js +0 -48
- package/changesets/remark/presets.js +0 -118
- package/changesets/remark/rules/content-structure.js +0 -21
- package/changesets/remark/rules/dependency-table-format.js +0 -37
- package/changesets/remark/rules/heading-hierarchy.js +0 -18
- package/changesets/remark/rules/required-sections.js +0 -16
- package/changesets/remark/rules/uncategorized-content.js +0 -30
- package/changesets/utils/remark-pipeline.js +0 -35
- package/changesets/utils/version-blocks.js +0 -107
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parseDependencyTable } from "../utils/dependency-table.js";
|
|
2
|
+
import { RULE_DOCS } from "../constants.js";
|
|
3
|
+
import { finding } from "./types.js";
|
|
4
|
+
import { scanDependencySection } from "../utils/dependency-section.js";
|
|
5
|
+
import { Mdast } from "@effected/markdown";
|
|
6
|
+
|
|
7
|
+
// src/changesets/rules/dependency-table-format.ts
|
|
8
|
+
/**
|
|
9
|
+
* Lint rule: changeset-dependency-table-format (CSH005).
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Kit rule. A `## Dependencies` section must
|
|
13
|
+
* contain a table anywhere before the next heading (prose may precede or
|
|
14
|
+
* follow it); a section without one reports at the heading. The table must
|
|
15
|
+
* parse through `parseDependencyTable` (columns, types, actions), and the
|
|
16
|
+
* em-dash rules apply: `from` is `—` when `added`, `to` is `—` when
|
|
17
|
+
* `removed`. Table-level findings anchor at the table.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const RULE = "changeset-dependency-table-format";
|
|
22
|
+
const EM_DASH = "—";
|
|
23
|
+
/**
|
|
24
|
+
* Validate the structure and semantics of `## Dependencies` tables.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
const dependencyTableFormat = (root) => {
|
|
29
|
+
const findings = [];
|
|
30
|
+
root.children.forEach((node, index) => {
|
|
31
|
+
if (node.type !== "heading" || node.depth !== 2) return;
|
|
32
|
+
if (Mdast.toText(node).toLowerCase() !== "dependencies") return;
|
|
33
|
+
const table = scanDependencySection(root.children, index + 1, {
|
|
34
|
+
isHeading: (n) => n.type === "heading",
|
|
35
|
+
isTable: (n) => n.type === "table"
|
|
36
|
+
}).table;
|
|
37
|
+
if (table === void 0 || table.type !== "table") {
|
|
38
|
+
findings.push(finding(RULE, `Dependencies section must contain a table, not a list or paragraph. See: ${RULE_DOCS.CSH005}`, node));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const rows = parseDependencyTable(table);
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (row.action === "added" && row.from !== EM_DASH) findings.push(finding(RULE, `'from' must be '—' when action is 'added' (got '${row.from}'). See: ${RULE_DOCS.CSH005}`, table));
|
|
45
|
+
if (row.action === "removed" && row.to !== EM_DASH) findings.push(finding(RULE, `'to' must be '—' when action is 'removed' (got '${row.to}'). See: ${RULE_DOCS.CSH005}`, table));
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
findings.push(finding(RULE, `${error instanceof Error ? error.message : String(error)}. See: ${RULE_DOCS.CSH005}`, table));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return findings;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export { dependencyTableFormat };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RULE_DOCS } from "../constants.js";
|
|
2
|
+
import { finding } from "./types.js";
|
|
3
|
+
import { MarkdownVisitor } from "@effected/markdown";
|
|
4
|
+
|
|
5
|
+
// src/changesets/rules/heading-hierarchy.ts
|
|
6
|
+
/**
|
|
7
|
+
* Lint rule: changeset-heading-hierarchy (CSH001).
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Kit rule. Forbids h1 headings and sequential
|
|
11
|
+
* depth skips.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const RULE = "changeset-heading-hierarchy";
|
|
16
|
+
/**
|
|
17
|
+
* Validate heading depth structure.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
const headingHierarchy = (root) => {
|
|
22
|
+
const findings = [];
|
|
23
|
+
let prevDepth = 0;
|
|
24
|
+
for (const event of MarkdownVisitor.walk(root)) {
|
|
25
|
+
if (event._tag !== "Enter" || event.node.type !== "heading") continue;
|
|
26
|
+
const node = event.node;
|
|
27
|
+
if (node.depth === 1) {
|
|
28
|
+
findings.push(finding(RULE, `h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`, node));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (prevDepth > 0 && node.depth > prevDepth + 1) findings.push(finding(RULE, `Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`, node));
|
|
32
|
+
prevDepth = node.depth;
|
|
33
|
+
}
|
|
34
|
+
return findings;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { headingHierarchy };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { allHeadings, isValidHeading } from "../categories/index.js";
|
|
2
|
+
import { RULE_DOCS } from "../constants.js";
|
|
3
|
+
import { finding } from "./types.js";
|
|
4
|
+
import { MarkdownVisitor, Mdast } from "@effected/markdown";
|
|
5
|
+
|
|
6
|
+
// src/changesets/rules/required-sections.ts
|
|
7
|
+
/**
|
|
8
|
+
* Lint rule: changeset-required-sections (CSH002).
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Kit rule. Every h2 must match a known category
|
|
12
|
+
* heading (case-insensitive).
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
const RULE = "changeset-required-sections";
|
|
17
|
+
/**
|
|
18
|
+
* Validate that h2 headings are known categories.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
const requiredSections = (root) => {
|
|
23
|
+
const findings = [];
|
|
24
|
+
for (const event of MarkdownVisitor.walk(root)) {
|
|
25
|
+
if (event._tag !== "Enter" || event.node.type !== "heading") continue;
|
|
26
|
+
const node = event.node;
|
|
27
|
+
if (node.depth !== 2) continue;
|
|
28
|
+
const text = Mdast.toText(node);
|
|
29
|
+
if (!isValidHeading(text)) findings.push(finding(RULE, `Unknown section "${text}". Valid h2 headings are: ${allHeadings().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`, node));
|
|
30
|
+
}
|
|
31
|
+
return findings;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { requiredSections };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/changesets/rules/types.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build a {@link RuleFinding} positioned on a node.
|
|
4
|
+
*
|
|
5
|
+
* @param rule - The rule id
|
|
6
|
+
* @param message - The finding message
|
|
7
|
+
* @param node - The offending node
|
|
8
|
+
* @returns The finding
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
function finding(rule, message, node) {
|
|
13
|
+
return {
|
|
14
|
+
rule,
|
|
15
|
+
message,
|
|
16
|
+
position: node.position
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { finding };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RULE_DOCS } from "../constants.js";
|
|
2
|
+
import { finding } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// src/changesets/rules/uncategorized-content.ts
|
|
5
|
+
/**
|
|
6
|
+
* Lint rule: changeset-uncategorized-content (CSH004).
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Kit rule. Flags substantive content before
|
|
10
|
+
* the first h2; html nodes (comments) are ignored.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
const RULE = "changeset-uncategorized-content";
|
|
15
|
+
/**
|
|
16
|
+
* Detect content that appears before the first h2 heading.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
const uncategorizedContent = (root) => {
|
|
21
|
+
const findings = [];
|
|
22
|
+
for (const node of root.children) {
|
|
23
|
+
if (node.type === "heading" && node.depth === 2) break;
|
|
24
|
+
if (node.type === "heading" || node.type === "html") continue;
|
|
25
|
+
findings.push(finding(RULE, `Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`, node));
|
|
26
|
+
}
|
|
27
|
+
return findings;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export { uncategorizedContent };
|
|
@@ -8,7 +8,7 @@ import { Schema } from "effect";
|
|
|
8
8
|
* @remarks
|
|
9
9
|
* These schemas define the canonical representation of changeset data
|
|
10
10
|
* consumed by the changelog formatter (Layer 2) and validated by the
|
|
11
|
-
*
|
|
11
|
+
* lint pre-validation layer (Layer 1). They enforce constraints
|
|
12
12
|
* such as summary length limits and valid dependency types at system
|
|
13
13
|
* boundaries.
|
|
14
14
|
*
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { collapseDependencyRows, parseDependencyTable, serializeDependencyTable, sortDependencyRows } from "../utils/dependency-table.js";
|
|
2
|
+
import { getBlockSections, getHeadingText, getVersionBlocks } from "../utils/blocks.js";
|
|
3
|
+
import { Heading, List, ListItem, Root, Text } from "@effected/markdown";
|
|
4
|
+
|
|
5
|
+
// src/changesets/transforms/aggregate-dependency-tables.ts
|
|
6
|
+
/**
|
|
7
|
+
* Unwrap dependency tables that an earlier (broken) formatter nested inside
|
|
8
|
+
* list items.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Only tables that parse as dependency tables leave the list; every other
|
|
12
|
+
* item child keeps its bullet structure, surviving as a rebuilt `List` in the
|
|
13
|
+
* legacy content. When no table parses the caller keeps the whole list.
|
|
14
|
+
*
|
|
15
|
+
* @param list - A list node found inside a `### Dependencies` section
|
|
16
|
+
* @returns Parsed rows plus the remaining non-table content
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
function extractTablesFromList(list) {
|
|
21
|
+
const rows = [];
|
|
22
|
+
const keptItems = [];
|
|
23
|
+
for (const item of list.children) {
|
|
24
|
+
const keptChildren = [];
|
|
25
|
+
for (const child of item.children) {
|
|
26
|
+
if (child.type === "table") try {
|
|
27
|
+
rows.push(...parseDependencyTable(child));
|
|
28
|
+
continue;
|
|
29
|
+
} catch {}
|
|
30
|
+
keptChildren.push(child);
|
|
31
|
+
}
|
|
32
|
+
if (keptChildren.length > 0) keptItems.push(new ListItem({
|
|
33
|
+
...item,
|
|
34
|
+
children: keptChildren
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
rows,
|
|
39
|
+
rest: keptItems.length > 0 ? [new List({
|
|
40
|
+
...list,
|
|
41
|
+
children: keptItems
|
|
42
|
+
})] : []
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Merge every `### Dependencies` section of each version block into one.
|
|
47
|
+
*
|
|
48
|
+
* @param root - The changelog tree
|
|
49
|
+
* @returns A tree with at most one aggregated dependencies section per block
|
|
50
|
+
*
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
const aggregateDependencyTables = (root) => {
|
|
54
|
+
const children = [...root.children];
|
|
55
|
+
const blocks = getVersionBlocks(children);
|
|
56
|
+
for (let b = blocks.length - 1; b >= 0; b--) {
|
|
57
|
+
const depSections = getBlockSections(children, blocks[b]).filter((s) => getHeadingText(s.heading).toLowerCase() === "dependencies");
|
|
58
|
+
if (depSections.length === 0) continue;
|
|
59
|
+
const allRows = [];
|
|
60
|
+
const legacyContent = [];
|
|
61
|
+
for (const section of depSections) for (const node of section.contentNodes) if (node.type === "table") try {
|
|
62
|
+
allRows.push(...parseDependencyTable(node));
|
|
63
|
+
} catch {
|
|
64
|
+
legacyContent.push(node);
|
|
65
|
+
}
|
|
66
|
+
else if (node.type === "list") {
|
|
67
|
+
const extracted = extractTablesFromList(node);
|
|
68
|
+
if (extracted.rows.length > 0) {
|
|
69
|
+
allRows.push(...extracted.rows);
|
|
70
|
+
legacyContent.push(...extracted.rest);
|
|
71
|
+
} else legacyContent.push(node);
|
|
72
|
+
} else legacyContent.push(node);
|
|
73
|
+
const collapsed = sortDependencyRows(collapseDependencyRows(allRows));
|
|
74
|
+
const indicesToRemove = [];
|
|
75
|
+
for (const section of depSections) {
|
|
76
|
+
indicesToRemove.push(section.headingIndex);
|
|
77
|
+
for (let c = 0; c < section.contentNodes.length; c++) indicesToRemove.push(section.headingIndex + 1 + c);
|
|
78
|
+
}
|
|
79
|
+
indicesToRemove.sort((x, y) => y - x);
|
|
80
|
+
for (const idx of indicesToRemove) children.splice(idx, 1);
|
|
81
|
+
if (collapsed.length === 0 && legacyContent.length === 0) continue;
|
|
82
|
+
const newNodes = [new Heading({
|
|
83
|
+
depth: 3,
|
|
84
|
+
children: [new Text({ value: "Dependencies" })]
|
|
85
|
+
})];
|
|
86
|
+
if (collapsed.length > 0) newNodes.push(serializeDependencyTable(collapsed));
|
|
87
|
+
newNodes.push(...legacyContent);
|
|
88
|
+
children.splice(depSections[0].headingIndex, 0, ...newNodes);
|
|
89
|
+
}
|
|
90
|
+
return new Root({
|
|
91
|
+
...root,
|
|
92
|
+
children
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export { aggregateDependencyTables };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { getVersionBlocks, isHeadingOfDepth } from "../utils/blocks.js";
|
|
2
|
+
import { Heading, Link, List, ListItem, MarkdownTransform, MarkdownVisitor, Paragraph, Root, Text } from "@effected/markdown";
|
|
3
|
+
|
|
4
|
+
// src/changesets/transforms/contributor-footnotes.ts
|
|
5
|
+
/** Pattern matching `Thanks @user!` at end of text. */
|
|
6
|
+
const ATTRIBUTION_PLAIN_RE = /\s*Thanks @(\w[\w-]*)!$/;
|
|
7
|
+
/** Pattern matching the `Thanks ` text suffix that precedes a linked attribution. */
|
|
8
|
+
const LINKED_PREFIX_RE = /\s*Thanks $/;
|
|
9
|
+
/** Pattern matching `@user` mentions inside text. */
|
|
10
|
+
const MENTION_RE = /@(\w[\w-]*)/g;
|
|
11
|
+
/**
|
|
12
|
+
* Pattern matching the transform's own merged summary paragraph, e.g.
|
|
13
|
+
* `Thanks to @alice, @bob, and @carol for their contributions!`.
|
|
14
|
+
*/
|
|
15
|
+
const MERGED_SUMMARY_RE = /^Thanks to @\w[\w-]*(?:(?:, | and |, and |,and )@\w[\w-]*)* for their contributions!$/;
|
|
16
|
+
/** Record a contributor unless one with the same lowercase username is already known. */
|
|
17
|
+
function addContributor(contributors, contributor) {
|
|
18
|
+
const key = contributor.username.toLowerCase();
|
|
19
|
+
if (!contributors.has(key)) contributors.set(key, contributor);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Match a linked attribution at the end of a paragraph's children:
|
|
23
|
+
* text `"...Thanks "` + link `"@user"` + text `"!"`.
|
|
24
|
+
*/
|
|
25
|
+
function extractLinkedAttribution(children) {
|
|
26
|
+
if (children.length < 3) return void 0;
|
|
27
|
+
const last = children[children.length - 1];
|
|
28
|
+
const link = children[children.length - 2];
|
|
29
|
+
const prefix = children[children.length - 3];
|
|
30
|
+
if (last.type !== "text" || last.value !== "!") return void 0;
|
|
31
|
+
if (link.type !== "link" || link.children.length !== 1) return void 0;
|
|
32
|
+
const [label] = link.children;
|
|
33
|
+
if (label.type !== "text" || !label.value.startsWith("@")) return void 0;
|
|
34
|
+
if (prefix.type !== "text" || !LINKED_PREFIX_RE.test(prefix.value)) return void 0;
|
|
35
|
+
return {
|
|
36
|
+
contributor: {
|
|
37
|
+
username: label.value.slice(1),
|
|
38
|
+
url: link.url
|
|
39
|
+
},
|
|
40
|
+
removeFrom: children.length - 3,
|
|
41
|
+
prefix
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Strip one trailing attribution (linked or plain) from a paragraph, recording
|
|
46
|
+
* the contributor.
|
|
47
|
+
*
|
|
48
|
+
* @param para - The paragraph to inspect
|
|
49
|
+
* @param contributors - The per-block contributor accumulator
|
|
50
|
+
* @returns The rebuilt paragraph, or `undefined` when nothing was stripped
|
|
51
|
+
*/
|
|
52
|
+
function stripAttribution(para, contributors) {
|
|
53
|
+
const linked = extractLinkedAttribution(para.children);
|
|
54
|
+
if (linked) {
|
|
55
|
+
addContributor(contributors, linked.contributor);
|
|
56
|
+
const value = linked.prefix.value.replace(LINKED_PREFIX_RE, "");
|
|
57
|
+
const kept = para.children.slice(0, linked.removeFrom);
|
|
58
|
+
const children = value === "" ? kept : [...kept, new Text({
|
|
59
|
+
...linked.prefix,
|
|
60
|
+
value
|
|
61
|
+
})];
|
|
62
|
+
return new Paragraph({
|
|
63
|
+
...para,
|
|
64
|
+
children
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const last = para.children[para.children.length - 1];
|
|
68
|
+
if (last?.type === "text") {
|
|
69
|
+
const match = last.value.match(ATTRIBUTION_PLAIN_RE);
|
|
70
|
+
if (match) {
|
|
71
|
+
addContributor(contributors, {
|
|
72
|
+
username: match[1],
|
|
73
|
+
url: void 0
|
|
74
|
+
});
|
|
75
|
+
const value = last.value.replace(ATTRIBUTION_PLAIN_RE, "");
|
|
76
|
+
const kept = para.children.slice(0, -1);
|
|
77
|
+
const children = value === "" ? kept : [...kept, new Text({
|
|
78
|
+
...last,
|
|
79
|
+
value
|
|
80
|
+
})];
|
|
81
|
+
return new Paragraph({
|
|
82
|
+
...para,
|
|
83
|
+
children
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Harvest every `@user` mention (linked or plain) from a node subtree.
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* A mention link is entered before its own label text, so the link's URL wins
|
|
93
|
+
* and the label's plain re-match is a no-op.
|
|
94
|
+
*/
|
|
95
|
+
function harvestMentions(node, contributors) {
|
|
96
|
+
for (const event of MarkdownVisitor.walk(new Root({ children: [node] }))) {
|
|
97
|
+
if (event._tag !== "Enter") continue;
|
|
98
|
+
const child = event.node;
|
|
99
|
+
if (child.type === "link") {
|
|
100
|
+
const [only] = child.children;
|
|
101
|
+
if (child.children.length === 1 && only.type === "text" && only.value.startsWith("@")) addContributor(contributors, {
|
|
102
|
+
username: only.value.slice(1),
|
|
103
|
+
url: child.url
|
|
104
|
+
});
|
|
105
|
+
} else if (child.type === "text") for (const match of child.value.matchAll(MENTION_RE)) addContributor(contributors, {
|
|
106
|
+
username: match[1],
|
|
107
|
+
url: void 0
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Build the `Thanks to ... for their contributions!` summary paragraph. */
|
|
112
|
+
function buildSummaryParagraph(contributors) {
|
|
113
|
+
const sorted = [...contributors.values()].sort((a, b) => a.username.toLowerCase().localeCompare(b.username.toLowerCase()));
|
|
114
|
+
const children = [new Text({ value: "Thanks to " })];
|
|
115
|
+
sorted.forEach((contrib, i) => {
|
|
116
|
+
if (i > 0 && sorted.length > 2) children.push(new Text({ value: ", " }));
|
|
117
|
+
if (i > 0 && i === sorted.length - 1) children.push(new Text({ value: sorted.length === 2 ? " and " : "and " }));
|
|
118
|
+
const mention = `@${contrib.username}`;
|
|
119
|
+
children.push(contrib.url ? new Link({
|
|
120
|
+
url: contrib.url,
|
|
121
|
+
children: [new Text({ value: mention })]
|
|
122
|
+
}) : new Text({ value: mention }));
|
|
123
|
+
});
|
|
124
|
+
children.push(new Text({ value: " for their contributions!" }));
|
|
125
|
+
return new Paragraph({ children });
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Remove list items (recursively) emptied by attribution stripping, so a
|
|
129
|
+
* bullet that held only `Thanks @user!` does not survive as a bare `- `.
|
|
130
|
+
*
|
|
131
|
+
* @param list - The list to prune
|
|
132
|
+
* @returns The rebuilt list
|
|
133
|
+
*/
|
|
134
|
+
function pruneEmptiedListItems(list) {
|
|
135
|
+
const items = [];
|
|
136
|
+
for (const item of list.children) {
|
|
137
|
+
const kept = [];
|
|
138
|
+
for (const child of item.children) if (child.type === "paragraph") {
|
|
139
|
+
if (child.children.length > 0) kept.push(child);
|
|
140
|
+
} else if (child.type === "list") {
|
|
141
|
+
const nested = pruneEmptiedListItems(child);
|
|
142
|
+
if (nested.children.length > 0) kept.push(nested);
|
|
143
|
+
} else kept.push(child);
|
|
144
|
+
if (kept.length > 0) items.push(new ListItem({
|
|
145
|
+
...item,
|
|
146
|
+
children: kept
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
return new List({
|
|
150
|
+
...list,
|
|
151
|
+
children: items
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Text form of a paragraph, with single-text links flattened to their label;
|
|
156
|
+
* `undefined` when the paragraph holds anything else.
|
|
157
|
+
*/
|
|
158
|
+
function flattenToText(para) {
|
|
159
|
+
let out = "";
|
|
160
|
+
for (const child of para.children) {
|
|
161
|
+
if (child.type === "text") {
|
|
162
|
+
out += child.value;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (child.type === "link" && child.children.length === 1) {
|
|
166
|
+
const [only] = child.children;
|
|
167
|
+
if (only.type === "text") {
|
|
168
|
+
out += only.value;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether a paragraph consists ENTIRELY of attribution content the transform
|
|
178
|
+
* owns: its own merged summary, or one or more trailing attributions with
|
|
179
|
+
* nothing else.
|
|
180
|
+
*/
|
|
181
|
+
function isPureAttributionParagraph(para) {
|
|
182
|
+
const text = flattenToText(para);
|
|
183
|
+
if (text !== void 0 && MERGED_SUMMARY_RE.test(text)) return true;
|
|
184
|
+
const scratch = /* @__PURE__ */ new Map();
|
|
185
|
+
let current = para;
|
|
186
|
+
while (current.children.length > 0) {
|
|
187
|
+
const next = stripAttribution(current, scratch);
|
|
188
|
+
if (next === void 0) break;
|
|
189
|
+
current = next;
|
|
190
|
+
}
|
|
191
|
+
return current.children.length === 0;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether a Thanks-section body node is a pure attribution shape: an
|
|
195
|
+
* attribution-only paragraph, or a non-empty list whose every item holds only
|
|
196
|
+
* attribution-only paragraphs.
|
|
197
|
+
*/
|
|
198
|
+
function isPureAttributionNode(node) {
|
|
199
|
+
if (node.type === "paragraph") return isPureAttributionParagraph(node);
|
|
200
|
+
if (node.type === "list") return node.children.length > 0 && node.children.every((item) => item.children.length > 0 && item.children.every((child) => child.type === "paragraph" && isPureAttributionParagraph(child)));
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
/** Whether a node is a depth-3 heading whose text is `Thanks`. */
|
|
204
|
+
function isThanksHeading(node) {
|
|
205
|
+
if (!isHeadingOfDepth(node, [3]) || node.children.length !== 1) return false;
|
|
206
|
+
const [only] = node.children;
|
|
207
|
+
return only.type === "text" && only.value.trim().toLowerCase() === "thanks";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Create the contributor-footnotes transform.
|
|
211
|
+
*
|
|
212
|
+
* @param options - Whether to emit the aggregated Thanks section
|
|
213
|
+
* @returns A transform that strips attributions and aggregates them per version block
|
|
214
|
+
*
|
|
215
|
+
* @internal
|
|
216
|
+
*/
|
|
217
|
+
function contributorFootnotes(options) {
|
|
218
|
+
const emitThanks = options?.thanks !== false;
|
|
219
|
+
return (root) => {
|
|
220
|
+
const children = [...root.children];
|
|
221
|
+
const blocks = getVersionBlocks(children);
|
|
222
|
+
for (let b = blocks.length - 1; b >= 0; b--) {
|
|
223
|
+
const block = blocks[b];
|
|
224
|
+
const contributors = /* @__PURE__ */ new Map();
|
|
225
|
+
const indicesToRemove = [];
|
|
226
|
+
let preservedThanksAnchor;
|
|
227
|
+
for (let i = block.startIndex; i < block.endIndex; i++) {
|
|
228
|
+
const node = children[i];
|
|
229
|
+
if (node.type === "list") {
|
|
230
|
+
const pruned = pruneEmptiedListItems(MarkdownTransform.map(node, { paragraph: (para) => stripAttribution(para, contributors) ?? para }));
|
|
231
|
+
children[i] = pruned;
|
|
232
|
+
if (pruned.children.length === 0) indicesToRemove.push(i);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (node.type === "paragraph") {
|
|
236
|
+
const stripped = stripAttribution(node, contributors);
|
|
237
|
+
if (stripped !== void 0) {
|
|
238
|
+
children[i] = stripped;
|
|
239
|
+
if (stripped.children.length === 0) indicesToRemove.push(i);
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (isThanksHeading(node)) {
|
|
244
|
+
const headingIndex = i;
|
|
245
|
+
const harvested = [];
|
|
246
|
+
let lastPreserved;
|
|
247
|
+
let j = i + 1;
|
|
248
|
+
for (; j < block.endIndex; j++) {
|
|
249
|
+
const contentNode = children[j];
|
|
250
|
+
if (isHeadingOfDepth(contentNode, [2, 3])) break;
|
|
251
|
+
if (contentNode.type === "definition") continue;
|
|
252
|
+
if (isPureAttributionNode(contentNode)) {
|
|
253
|
+
harvestMentions(contentNode, contributors);
|
|
254
|
+
harvested.push(j);
|
|
255
|
+
} else lastPreserved = contentNode;
|
|
256
|
+
}
|
|
257
|
+
if (lastPreserved === void 0) indicesToRemove.push(headingIndex, ...harvested);
|
|
258
|
+
else {
|
|
259
|
+
indicesToRemove.push(...harvested);
|
|
260
|
+
preservedThanksAnchor ??= lastPreserved;
|
|
261
|
+
}
|
|
262
|
+
i = j - 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const uniqueIndices = [...new Set(indicesToRemove)].sort((x, y) => y - x);
|
|
266
|
+
for (const idx of uniqueIndices) children.splice(idx, 1);
|
|
267
|
+
if (!emitThanks || contributors.size === 0) continue;
|
|
268
|
+
if (preservedThanksAnchor !== void 0) {
|
|
269
|
+
const anchorIndex = children.indexOf(preservedThanksAnchor);
|
|
270
|
+
if (anchorIndex !== -1) {
|
|
271
|
+
children.splice(anchorIndex + 1, 0, buildSummaryParagraph(contributors));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let insertAt = block.endIndex - uniqueIndices.length;
|
|
276
|
+
while (insertAt > block.startIndex && children[insertAt - 1]?.type === "definition") insertAt--;
|
|
277
|
+
const heading = new Heading({
|
|
278
|
+
depth: 3,
|
|
279
|
+
children: [new Text({ value: "Thanks" })]
|
|
280
|
+
});
|
|
281
|
+
children.splice(insertAt, 0, heading, buildSummaryParagraph(contributors));
|
|
282
|
+
}
|
|
283
|
+
return new Root({
|
|
284
|
+
...root,
|
|
285
|
+
children
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { contributorFootnotes };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getBlockSections, getVersionBlocks } from "../utils/blocks.js";
|
|
2
|
+
import { List, Mdast, Root } from "@effected/markdown";
|
|
3
|
+
|
|
4
|
+
// src/changesets/transforms/deduplicate-items.ts
|
|
5
|
+
/**
|
|
6
|
+
* Changelog transform: drop duplicate list items within a section.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Kit transform. Seen item text is tracked across every
|
|
10
|
+
* list in a section, not per list, because merging sections can leave
|
|
11
|
+
* duplicate content as sibling lists. Lists left empty are removed.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Remove duplicate list items within each section of each version block.
|
|
17
|
+
*
|
|
18
|
+
* @param root - The changelog tree
|
|
19
|
+
* @returns A tree with duplicates (and resulting empty lists) removed
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
const deduplicateItems = (root) => {
|
|
24
|
+
const children = [...root.children];
|
|
25
|
+
for (const block of getVersionBlocks(children)) for (const section of getBlockSections(children, block)) {
|
|
26
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
|
+
section.contentNodes.forEach((node, offset) => {
|
|
28
|
+
if (node.type !== "list") return;
|
|
29
|
+
const filtered = node.children.filter((item) => {
|
|
30
|
+
const text = Mdast.toText(item);
|
|
31
|
+
if (seen.has(text)) return false;
|
|
32
|
+
seen.add(text);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
children[section.headingIndex + 1 + offset] = new List({
|
|
36
|
+
...node,
|
|
37
|
+
children: filtered
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new Root({
|
|
42
|
+
...root,
|
|
43
|
+
children: children.filter((node) => node.type !== "list" || node.children.length > 0)
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { deduplicateItems };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getVersionBlocks } from "../utils/blocks.js";
|
|
2
|
+
import { Definition, LinkReference, MarkdownTransform, Root, Text } from "@effected/markdown";
|
|
3
|
+
|
|
4
|
+
// src/changesets/transforms/issue-link-refs.ts
|
|
5
|
+
/** Pattern matching issue numbers like `#123`. */
|
|
6
|
+
const ISSUE_RE = /^#\d+$/;
|
|
7
|
+
/**
|
|
8
|
+
* Convert issue links to reference-style links with per-block definitions.
|
|
9
|
+
*
|
|
10
|
+
* @param root - The changelog tree
|
|
11
|
+
* @returns A tree with reference links and appended definitions
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
const issueLinkRefs = (root) => {
|
|
16
|
+
const children = [...root.children];
|
|
17
|
+
const blocks = getVersionBlocks(children);
|
|
18
|
+
for (let b = blocks.length - 1; b >= 0; b--) {
|
|
19
|
+
const block = blocks[b];
|
|
20
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
21
|
+
for (let i = block.startIndex; i < block.endIndex; i++) children[i] = MarkdownTransform.map(children[i], { link: (link) => {
|
|
22
|
+
const [only] = link.children;
|
|
23
|
+
if (link.children.length !== 1 || only.type !== "text" || !ISSUE_RE.test(only.value)) return link;
|
|
24
|
+
const label = only.value;
|
|
25
|
+
if (!definitions.has(label)) definitions.set(label, link.url);
|
|
26
|
+
return new LinkReference({
|
|
27
|
+
identifier: label,
|
|
28
|
+
label,
|
|
29
|
+
referenceType: "full",
|
|
30
|
+
children: [new Text({ value: label })]
|
|
31
|
+
});
|
|
32
|
+
} });
|
|
33
|
+
if (definitions.size === 0) continue;
|
|
34
|
+
const defNodes = [...definitions.entries()].sort(([x], [y]) => Number.parseInt(x.slice(1), 10) - Number.parseInt(y.slice(1), 10)).map(([label, url]) => new Definition({
|
|
35
|
+
identifier: label,
|
|
36
|
+
label,
|
|
37
|
+
url
|
|
38
|
+
}));
|
|
39
|
+
children.splice(block.endIndex, 0, ...defNodes);
|
|
40
|
+
}
|
|
41
|
+
return new Root({
|
|
42
|
+
...root,
|
|
43
|
+
children
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { issueLinkRefs };
|