@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.
Files changed (52) hide show
  1. package/changesets/api/dependency-table.js +12 -12
  2. package/changesets/api/linter.js +23 -19
  3. package/changesets/api/transformer.js +38 -33
  4. package/changesets/categories/index.js +1 -1
  5. package/changesets/categories/types.js +2 -2
  6. package/changesets/changelog/getDependencyReleaseLine.js +1 -1
  7. package/changesets/changelog/index.js +2 -2
  8. package/changesets/constants.js +1 -1
  9. package/changesets/errors.js +1 -1
  10. package/changesets/index.js +11 -40
  11. package/changesets/markdownlint/rules/content-structure.js +2 -2
  12. package/changesets/markdownlint/rules/heading-hierarchy.js +2 -2
  13. package/changesets/markdownlint/rules/required-sections.js +2 -2
  14. package/changesets/markdownlint/rules/uncategorized-content.js +2 -2
  15. package/changesets/markdownlint/rules/utils.js +6 -6
  16. package/changesets/rules/content-structure.js +44 -0
  17. package/changesets/rules/dependency-table-format.js +54 -0
  18. package/changesets/rules/heading-hierarchy.js +37 -0
  19. package/changesets/rules/required-sections.js +34 -0
  20. package/changesets/rules/types.js +20 -0
  21. package/changesets/rules/uncategorized-content.js +30 -0
  22. package/changesets/schemas/changeset.js +1 -1
  23. package/changesets/transforms/aggregate-dependency-tables.js +96 -0
  24. package/changesets/transforms/contributor-footnotes.js +290 -0
  25. package/changesets/transforms/deduplicate-items.js +47 -0
  26. package/changesets/transforms/issue-link-refs.js +47 -0
  27. package/changesets/transforms/maintenance-note.js +65 -0
  28. package/changesets/transforms/merge-sections.js +56 -0
  29. package/changesets/transforms/normalize-format.js +53 -0
  30. package/changesets/transforms/reorder-sections.js +53 -0
  31. package/changesets/utils/blocks.js +67 -0
  32. package/changesets/utils/dependency-table.js +29 -36
  33. package/changesets/utils/markdown-emit.js +11 -36
  34. package/index.d.ts +54 -1199
  35. package/package.json +11 -18
  36. package/tsdoc-metadata.json +1 -1
  37. package/changesets/remark/plugins/aggregate-dependency-tables.js +0 -98
  38. package/changesets/remark/plugins/contributor-footnotes.js +0 -334
  39. package/changesets/remark/plugins/deduplicate-items.js +0 -31
  40. package/changesets/remark/plugins/issue-link-refs.js +0 -57
  41. package/changesets/remark/plugins/maintenance-note.js +0 -74
  42. package/changesets/remark/plugins/merge-sections.js +0 -42
  43. package/changesets/remark/plugins/normalize-format.js +0 -46
  44. package/changesets/remark/plugins/reorder-sections.js +0 -48
  45. package/changesets/remark/presets.js +0 -118
  46. package/changesets/remark/rules/content-structure.js +0 -21
  47. package/changesets/remark/rules/dependency-table-format.js +0 -37
  48. package/changesets/remark/rules/heading-hierarchy.js +0 -18
  49. package/changesets/remark/rules/required-sections.js +0 -16
  50. package/changesets/remark/rules/uncategorized-content.js +0 -30
  51. package/changesets/utils/remark-pipeline.js +0 -35
  52. 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
- * remark-lint pre-validation layer (Layer 1). They enforce constraints
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 };