@savvy-web/silk-effects 7.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +4 -1
  2. package/changesets/api/linter.js +1 -1
  3. package/changesets/api/transformer.js +10 -7
  4. package/changesets/changelog/getReleaseLine.js +114 -20
  5. package/changesets/changelog/vanilla.js +47 -0
  6. package/changesets/constants.js +7 -2
  7. package/changesets/index.js +7 -4
  8. package/changesets/markdownlint/rules/content-structure.js +2 -2
  9. package/changesets/markdownlint/rules/dependency-table-format.js +12 -11
  10. package/changesets/markdownlint/rules/heading-hierarchy.js +2 -2
  11. package/changesets/markdownlint/rules/required-sections.js +2 -2
  12. package/changesets/markdownlint/rules/uncategorized-content.js +3 -3
  13. package/changesets/markdownlint/rules/utils.js +17 -6
  14. package/changesets/remark/plugins/aggregate-dependency-tables.js +53 -1
  15. package/changesets/remark/plugins/contributor-footnotes.js +276 -64
  16. package/changesets/remark/plugins/reorder-sections.js +18 -3
  17. package/changesets/remark/presets.js +1 -1
  18. package/changesets/remark/rules/dependency-table-format.js +7 -9
  19. package/changesets/schemas/dependency-table.js +11 -3
  20. package/changesets/schemas/options.js +8 -0
  21. package/changesets/services/config-inspector.js +51 -4
  22. package/changesets/services/deps-regen.js +106 -10
  23. package/changesets/services/release-planner.js +29 -8
  24. package/changesets/utils/dep-diff.js +34 -5
  25. package/changesets/utils/dependency-section.js +34 -0
  26. package/changesets/utils/dependency-table.js +14 -9
  27. package/changesets/utils/markdown-emit.js +86 -0
  28. package/changesets/utils/remark-pipeline.js +14 -85
  29. package/changesets/utils/section-parser.js +4 -2
  30. package/index.d.ts +158 -24
  31. package/index.js +1 -1
  32. package/lint/index.js +1 -1
  33. package/package.json +4 -2
@@ -201,6 +201,85 @@ const findPureDependencyChangesets = (fs, changesetDir) => Effect.gen(function*
201
201
  return result;
202
202
  });
203
203
  /**
204
+ * Every package name a changeset's YAML frontmatter releases, parsed with the
205
+ * same lenient `"@pkg": bump` line grammar {@link isPureDependencyChangeset}
206
+ * uses. A file with no frontmatter, or one whose lines all fail the grammar,
207
+ * yields an empty list — this feeds an informational surface, so lenient
208
+ * degradation beats a typed failure.
209
+ *
210
+ * @param content - Raw `.changeset/*.md` file contents.
211
+ *
212
+ * @public
213
+ */
214
+ function parseChangesetPackages(content) {
215
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
216
+ if (!fmMatch) return [];
217
+ const packages = [];
218
+ for (const line of fmMatch[1].split(/\r?\n/)) {
219
+ if (line.trim().length === 0 || /^\s*#/.test(line)) continue;
220
+ const m = line.match(/^\s*["']?([^"':\s]+)["']?\s*:\s*([a-z]+)\s*$/);
221
+ if (m) packages.push(m[1]);
222
+ }
223
+ return packages;
224
+ }
225
+ /**
226
+ * Whether `content` carries a `## Dependencies` heading OUTSIDE fenced code
227
+ * blocks. A prose changeset that DOCUMENTS the changeset format quotes the
228
+ * heading inside a ``` fence; a fence-blind regex misreads that file as a
229
+ * dependency changeset (classified mixed, dropped from the coexisting
230
+ * bucket). Tracks CommonMark fence state: an opening run of 3+ backticks or
231
+ * tildes (a backtick fence's info string may not contain a backtick) is
232
+ * closed only by a run of the same character, at least as long, with nothing
233
+ * else on the line.
234
+ */
235
+ function containsDependenciesHeading(content) {
236
+ let fence = null;
237
+ for (const line of content.split(/\r?\n/)) {
238
+ const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
239
+ if (match) {
240
+ const run = match[1];
241
+ const char = run.charAt(0);
242
+ const trailing = match[2];
243
+ if (fence === null) {
244
+ if (char === "~" || !trailing.includes("`")) {
245
+ fence = {
246
+ char,
247
+ length: run.length
248
+ };
249
+ continue;
250
+ }
251
+ } else if (char === fence.char && run.length >= fence.length && trailing.trim() === "") {
252
+ fence = null;
253
+ continue;
254
+ }
255
+ }
256
+ if (fence === null && /^## Dependencies\b/.test(line)) return true;
257
+ }
258
+ return false;
259
+ }
260
+ /**
261
+ * Prose-only changesets (no `## Dependencies` heading at all) found in
262
+ * `changesetDir`, each with the packages its frontmatter releases. These are
263
+ * the files a regen run never touches — surfaced so the result can account
264
+ * for them instead of leaving them invisible (#279). Same
265
+ * skip-unreadable-file / loud-list-failure semantics as
266
+ * {@link findPureDependencyChangesets}.
267
+ */
268
+ const findProseChangesets = (fs, changesetDir) => Effect.gen(function* () {
269
+ const files = yield* listChangesetFiles(fs, changesetDir);
270
+ const result = [];
271
+ for (const file of files) {
272
+ const content = yield* fs.readFileString(file).pipe(Effect.option);
273
+ if (Option.isNone(content)) continue;
274
+ if (containsDependenciesHeading(content.value)) continue;
275
+ result.push({
276
+ file,
277
+ packages: parseChangesetPackages(content.value)
278
+ });
279
+ }
280
+ return result;
281
+ });
282
+ /**
204
283
  * Mixed dependency changesets (have a `## Dependencies` heading but fail
205
284
  * the strict pure-changeset test) found in `changesetDir`. Same
206
285
  * skip-unreadable-file / loud-list-failure semantics as
@@ -212,7 +291,7 @@ const findMixedDependencyChangesets = (fs, changesetDir) => Effect.gen(function*
212
291
  for (const file of files) {
213
292
  const content = yield* fs.readFileString(file).pipe(Effect.option);
214
293
  if (Option.isNone(content)) continue;
215
- if (/^## Dependencies\b/m.test(content.value) && !isPureDependencyChangeset(content.value).isPure) result.push(file);
294
+ if (containsDependenciesHeading(content.value) && !isPureDependencyChangeset(content.value).isPure) result.push(file);
216
295
  }
217
296
  return result;
218
297
  });
@@ -305,10 +384,15 @@ function makeShape(snapshots, inspector, discovery, detector, config, fs, provid
305
384
  }
306
385
  const inScopeFor = (name) => explicitTargets.size > 0 ? activeTargets.has(name) : inScope.has(name) && !excluded.has(name);
307
386
  const keepDevDeps = options.includeDevDeps === true;
387
+ const releaseNeutral = /* @__PURE__ */ new Set([
388
+ "devDependency",
389
+ "runtime",
390
+ "packageManager"
391
+ ]);
308
392
  const scoped = rawDiffs.filter((d) => inScopeFor(d.package));
309
393
  const resolved = [];
310
394
  for (const diff of scoped) {
311
- const rows = keepDevDeps ? [...diff.rows] : diff.rows.filter((r) => r.type !== "devDependency");
395
+ const rows = keepDevDeps ? [...diff.rows] : diff.rows.filter((r) => !releaseNeutral.has(r.type));
312
396
  if (rows.length > 0) resolved.push({
313
397
  ...diff,
314
398
  rows: sortDependencyRows(rows)
@@ -316,6 +400,10 @@ function makeShape(snapshots, inspector, discovery, detector, config, fs, provid
316
400
  }
317
401
  const existingPure = yield* findPureDependencyChangesets(fs, changesetDir);
318
402
  const skippedMixed = yield* findMixedDependencyChangesets(fs, changesetDir);
403
+ const coexisting = (yield* findProseChangesets(fs, changesetDir)).map((p) => ({
404
+ file: p.file,
405
+ packages: p.packages.filter(inScopeFor)
406
+ })).filter((p) => p.packages.length > 0);
319
407
  const rewrittenPackages = new Set(resolved.map((d) => d.package));
320
408
  const atMergeBase = yield* gitListChangesetFilesAtRef(resolvedCwd, fromRef).pipe(Effect.provide(provideGit));
321
409
  const authoredOnBranch = (file) => !atMergeBase.has(basename(file));
@@ -333,7 +421,8 @@ function makeShape(snapshots, inspector, discovery, detector, config, fs, provid
333
421
  return {
334
422
  toDelete,
335
423
  toWrite,
336
- skippedMixed
424
+ skippedMixed,
425
+ coexisting
337
426
  };
338
427
  });
339
428
  const execute = (plan) => Effect.gen(function* () {
@@ -351,7 +440,8 @@ function makeShape(snapshots, inspector, discovery, detector, config, fs, provid
351
440
  return {
352
441
  deleted,
353
442
  written,
354
- skippedMixed: plan.skippedMixed
443
+ skippedMixed: plan.skippedMixed,
444
+ coexisting: plan.coexisting
355
445
  };
356
446
  });
357
447
  return {
@@ -372,15 +462,21 @@ const ConfigGraph = ChangesetConfig.layer.pipe(Layer.provide(ChangesetConfigRead
372
462
  * lazily); pass an explicit `cwd` when planning against a different root
373
463
  * (fixtures, multi-repo hosts).
374
464
  *
375
- * `Workspaces.layerWithGit` mints a fresh layer reference per call, so the
376
- * graph is bound ONCE per builder call and shared across every internal
377
- * branch layer memoization by reference constructs each kit service
378
- * exactly once.
465
+ * The graph is `Workspaces.layerWithGitAndConfigDependenciesSubprocess`
466
+ * `layerWithGit`'s service set with config-dependency hook replay in catalog
467
+ * assembly, so hook-injected catalogs resolve to ranges (#539). The subprocess
468
+ * replay is chosen over the in-process one because silk's consumers (the
469
+ * `savvy` CLI, the `savvy-mcp` server) are bundled, and a bundler compiles the
470
+ * in-process replay's computed dynamic `import()` into a context module that
471
+ * cannot resolve at runtime. The kit mints a fresh layer reference per call,
472
+ * so the graph is bound ONCE per builder call and shared across every internal
473
+ * branch — layer memoization by reference constructs each kit service exactly
474
+ * once.
379
475
  *
380
476
  * @public
381
477
  */
382
478
  function makeDepsRegenDefault(options) {
383
- const kitGraph = Workspaces.layerWithGit(options);
479
+ const kitGraph = Workspaces.layerWithGitAndConfigDependenciesSubprocess(options);
384
480
  return DepsRegen.layer.pipe(Layer.provide(ConfigInspector.layer.pipe(Layer.provide(Layer.mergeAll(ChangesetConfigReader.layer, kitGraph)))), Layer.provide(SilkPublishability.layerAdaptive.pipe(Layer.provide(Layer.mergeAll(ConfigGraph, kitGraph)))), Layer.provide(ConfigGraph), Layer.provide(kitGraph));
385
481
  }
386
482
  /**
@@ -413,4 +509,4 @@ function makeDepsRegenDefault(options) {
413
509
  const DepsRegenDefault = makeDepsRegenDefault();
414
510
 
415
511
  //#endregion
416
- export { DepsRegen, DepsRegenDefault, isPureDependencyChangeset, makeDepsRegenDefault };
512
+ export { DepsRegen, DepsRegenDefault, isPureDependencyChangeset, makeDepsRegenDefault, parseChangesetPackages };
@@ -129,6 +129,19 @@ function extractVersionBlock(changelog, version) {
129
129
  }
130
130
  return lines.slice(start, end).join("\n").trim();
131
131
  }
132
+ /**
133
+ * Read the `thanks` changelog option from the changesets config tuple
134
+ * (`"changelog": ["@savvy-web/changelog", { "thanks": false }]`), if set.
135
+ * A missing or non-boolean value returns `undefined` so the transformer's
136
+ * default (`true`) applies.
137
+ */
138
+ function thanksOption(config) {
139
+ if (!Array.isArray(config.changelog)) return void 0;
140
+ const opts = config.changelog[1];
141
+ if (typeof opts !== "object" || opts === null) return void 0;
142
+ const thanks = opts.thanks;
143
+ return typeof thanks === "boolean" ? thanks : void 0;
144
+ }
132
145
  /** Maintenance reasons for every changeset-less release in the plan, keyed by package name. */
133
146
  function maintenanceReasons(plan, config) {
134
147
  const reasons = /* @__PURE__ */ new Map();
@@ -236,11 +249,15 @@ function previewEffect(root, changelogModules, fs) {
236
249
  const clPath = join(dir, "CHANGELOG.md");
237
250
  if (!(yield* fs.exists(clPath))) continue;
238
251
  const reason = reasonByName.get(r.name);
252
+ const thanks = thanksOption(config);
239
253
  yield* Effect.try({
240
- try: () => ChangelogTransformer.transformFile(clPath, reason ? { maintenance: {
241
- version: r.newVersion,
242
- reason
243
- } } : void 0),
254
+ try: () => ChangelogTransformer.transformFile(clPath, {
255
+ ...reason ? { maintenance: {
256
+ version: r.newVersion,
257
+ reason
258
+ } } : {},
259
+ ...thanks !== void 0 ? { thanks } : {}
260
+ }),
244
261
  catch: (e) => new ReleasePlanError({
245
262
  phase: "preview",
246
263
  reason: errMsg(e)
@@ -300,6 +317,7 @@ function applyEffect(root, dryRun, changelogModules, inspector, fs) {
300
317
  let touchedFiles = [];
301
318
  if (!dryRun) {
302
319
  const reasonByName = maintenanceReasons(plan, config);
320
+ const thanks = thanksOption(config);
303
321
  const versionByPkgName = new Map(plan.releases.map((r) => [r.name, r.newVersion]));
304
322
  const nameByDir = /* @__PURE__ */ new Map();
305
323
  for (const p of packages.packages) nameByDir.set(p.dir, p.packageJson.name);
@@ -312,10 +330,13 @@ function applyEffect(root, dryRun, changelogModules, inspector, fs) {
312
330
  const pkgName = nameByDir.get(dirname(f));
313
331
  const reason = pkgName ? reasonByName.get(pkgName) : void 0;
314
332
  const newVersion = pkgName ? versionByPkgName.get(pkgName) : void 0;
315
- ChangelogTransformer.transformFile(f, reason && newVersion ? { maintenance: {
316
- version: newVersion,
317
- reason
318
- } } : void 0);
333
+ ChangelogTransformer.transformFile(f, {
334
+ ...reason && newVersion ? { maintenance: {
335
+ version: newVersion,
336
+ reason
337
+ } } : {},
338
+ ...thanks !== void 0 ? { thanks } : {}
339
+ });
319
340
  }
320
341
  return touched;
321
342
  },
@@ -1,7 +1,35 @@
1
1
  import { sortDependencyRows } from "./dependency-table.js";
2
2
  import { Option } from "effect";
3
+ import { WorkspaceStateSnapshot } from "@effected/workspaces";
3
4
 
4
5
  //#region src/changesets/utils/dep-diff.ts
6
+ /**
7
+ * Compute per-workspace-package dependency-table rows from two
8
+ * {@link WorkspaceStateSnapshot}s, resolving `catalog:` / `workspace:`
9
+ * specifiers against each side's own catalogs and package versions BEFORE
10
+ * comparing.
11
+ *
12
+ * @remarks
13
+ * Operates on declared dependencies only (the `dependencies` /
14
+ * `devDependencies` / `peerDependencies` / `optionalDependencies` fields
15
+ * of each workspace's `package.json`). Lockfile-only movements
16
+ * (resolved versions changing while declared ranges stay put) are
17
+ * intentionally excluded — those happen on every `pnpm install` and
18
+ * would generate constant noise.
19
+ *
20
+ * Each side carries its own catalogs and package versions, so a specifier
21
+ * is resolved against the snapshot it belongs to: `catalog:silk` resolves
22
+ * to that ref's `silk` catalog entry, `workspace:*` to that ref's target
23
+ * package version. A row is emitted iff the two RESOLVED values differ (or
24
+ * the dependency was added/removed) — a package that merely adopted a
25
+ * `catalog:` specifier without changing the concrete version produces NO
26
+ * row. When a side cannot resolve a specifier (no matching catalog entry,
27
+ * plain range, etc.) it falls back to the raw specifier string.
28
+ *
29
+ * @see {@link DependencyTableRow} for the row schema
30
+ * @see {@link WorkspaceStateSnapshot} for the input shape
31
+ *
32
+ */
5
33
  /** The em-dash sentinel (U+2014) used for added ("from") / removed ("to") cells. */
6
34
  const EM_DASH = "—";
7
35
  const DEP_TYPE_MAP = [
@@ -69,8 +97,9 @@ const collapseFieldMoves = (rows) => {
69
97
  */
70
98
  function computeWorkspaceDependencyDiffs(before, after) {
71
99
  const result = [];
72
- for (const afterPkg of after.packages) {
73
- const beforePkg = Option.getOrNull(before.package(afterPkg.name));
100
+ const [seededBefore, seededAfter] = WorkspaceStateSnapshot.crossSeed(before, after);
101
+ for (const afterPkg of seededAfter.packages) {
102
+ const beforePkg = Option.getOrNull(seededBefore.package(afterPkg.name));
74
103
  const rows = [];
75
104
  const afterImporter = afterPkg.relativePath;
76
105
  const beforeImporter = beforePkg?.relativePath ?? afterImporter;
@@ -80,7 +109,7 @@ function computeWorkspaceDependencyDiffs(before, after) {
80
109
  const seen = /* @__PURE__ */ new Set();
81
110
  for (const [name, beforeSpec] of Object.entries(beforeRecord)) {
82
111
  seen.add(name);
83
- const from = resolveOrRaw(before, beforeImporter, name, beforeSpec);
112
+ const from = resolveOrRaw(seededBefore, beforeImporter, name, beforeSpec);
84
113
  const afterSpec = afterRecord[name];
85
114
  if (afterSpec === void 0) {
86
115
  rows.push({
@@ -92,7 +121,7 @@ function computeWorkspaceDependencyDiffs(before, after) {
92
121
  });
93
122
  continue;
94
123
  }
95
- const to = resolveOrRaw(after, afterImporter, name, afterSpec);
124
+ const to = resolveOrRaw(seededAfter, afterImporter, name, afterSpec);
96
125
  if (from !== to) rows.push({
97
126
  dependency: name,
98
127
  type,
@@ -108,7 +137,7 @@ function computeWorkspaceDependencyDiffs(before, after) {
108
137
  type,
109
138
  action: "added",
110
139
  from: EM_DASH,
111
- to: resolveOrRaw(after, afterImporter, name, afterSpec)
140
+ to: resolveOrRaw(seededAfter, afterImporter, name, afterSpec)
112
141
  });
113
142
  }
114
143
  }
@@ -0,0 +1,34 @@
1
+ //#region src/changesets/utils/dependency-section.ts
2
+ /**
3
+ * Scan a `## Dependencies` section starting at the node AFTER its heading.
4
+ *
5
+ * Collects every content block until the next heading (any depth) or end of
6
+ * input, and selects the first table found anywhere among them. See the module
7
+ * remarks for the semantics this encodes and why they live in one place.
8
+ *
9
+ * @param nodes - The sibling node list containing the section (root children
10
+ * for mdast, the top-level token list for micromark)
11
+ * @param startIndex - Index of the first node after the section heading
12
+ * @param adapter - Engine-specific node predicates
13
+ * @returns The section's blocks and its first table, if any
14
+ *
15
+ * @internal
16
+ */
17
+ function scanDependencySection(nodes, startIndex, adapter) {
18
+ const blocks = [];
19
+ let table;
20
+ for (let i = startIndex; i < nodes.length; i++) {
21
+ const node = nodes[i];
22
+ if (adapter.isSkippable?.(node)) continue;
23
+ if (adapter.isHeading(node)) break;
24
+ blocks.push(node);
25
+ if (table === void 0 && adapter.isTable(node)) table = node;
26
+ }
27
+ return {
28
+ blocks,
29
+ table
30
+ };
31
+ }
32
+
33
+ //#endregion
34
+ export { scanDependencySection };
@@ -1,5 +1,5 @@
1
1
  import { DependencyTableRowSchema } from "../schemas/dependency-table.js";
2
- import { literalText, stringifyMarkdown } from "./remark-pipeline.js";
2
+ import { stringifyMarkdown } from "./remark-pipeline.js";
3
3
  import { Schema } from "effect";
4
4
  import { toString } from "mdast-util-to-string";
5
5
 
@@ -101,10 +101,12 @@ function parseDependencyTable(table) {
101
101
  * Create a table cell with a text node.
102
102
  *
103
103
  * @remarks
104
- * The cell text is marked literal, so stringification writes it verbatim
105
- * instead of escaping characters that could open a markdown construct. See
106
- * {@link literalText} — without it `~0.2.1` is written as `\~0.2.1` and
107
- * `some_pkg` as `some\_pkg`.
104
+ * The cell holds a plain text node; the canonical stringifier escapes any
105
+ * character that could open a markdown construct (`~0.2.1` is written as
106
+ * `\~0.2.1`, `some_pkg` as `some\_pkg`, `|` as `\|`). Parsing consumes the
107
+ * escapes, so cell VALUES round-trip byte-identically through
108
+ * parse-and-reserialize — the escaping is a raw-byte spelling, never a
109
+ * value change, and it cannot compound across cycles.
108
110
  *
109
111
  * @param text - The cell text content
110
112
  * @returns An MDAST `TableCell` node
@@ -114,7 +116,10 @@ function parseDependencyTable(table) {
114
116
  function makeCell(text) {
115
117
  return {
116
118
  type: "tableCell",
117
- children: [literalText(text)]
119
+ children: [{
120
+ type: "text",
121
+ value: text
122
+ }]
118
123
  };
119
124
  }
120
125
  /**
@@ -167,9 +172,9 @@ function serializeDependencyTable(rows) {
167
172
  * Serialize dependency table rows to a markdown table string.
168
173
  *
169
174
  * @remarks
170
- * Combines {@link serializeDependencyTable} with unified/remark-gfm/remark-stringify
171
- * to produce a ready-to-use GFM markdown table string. The result is trimmed
172
- * of leading/trailing whitespace.
175
+ * Combines {@link serializeDependencyTable} with the canonical
176
+ * `@effected/markdown` emit boundary to produce a ready-to-use GFM markdown
177
+ * table string. The result is trimmed of leading/trailing whitespace.
173
178
  *
174
179
  * @param rows - Array of `DependencyTableRow` objects
175
180
  * @returns Markdown table string (GFM format)
@@ -0,0 +1,86 @@
1
+ import { Result } from "effect";
2
+ import { Markdown, Mdast } from "@effected/markdown";
3
+
4
+ //#region src/changesets/utils/markdown-emit.ts
5
+ /**
6
+ * Unwrap a `Result` from the kit's emit boundary, converting a typed failure
7
+ * into a defect.
8
+ *
9
+ * @remarks
10
+ * The trees this pipeline emits are either parsed by our own remark pipeline
11
+ * or synthesized by our own plugins, so a decode or stringify failure is a
12
+ * programmer error, not an operational condition — the sync call sites treat
13
+ * it as a defect with the typed error preserved as `cause`.
14
+ *
15
+ * @internal
16
+ */
17
+ function unwrap(result, context) {
18
+ if (Result.isFailure(result)) {
19
+ const error = result.failure;
20
+ throw new Error(`${context}: ${error.message}`, { cause: error });
21
+ }
22
+ return result.success;
23
+ }
24
+ /**
25
+ * Default `fenceChar` on language-less code nodes so they emit FENCED.
26
+ *
27
+ * @remarks
28
+ * The kit's canonical form emits a code node carrying neither `lang` nor
29
+ * `fenceChar` as an INDENTED block — and `lang: null` is exactly what
30
+ * remark-parse produces for a ``` fence with no info string, so without this
31
+ * pass every language-less snippet in changelog content would change shape.
32
+ * Worse, the indented default is neighbor-dependent: the serializer takes a
33
+ * representability escape back to a backtick fence when the indented form
34
+ * would misparse (e.g. code immediately after a list), so the same node
35
+ * spells differently depending on what precedes it — inconsistent raw
36
+ * output. House policy is fenced code everywhere, so the ONE emit boundary defaults
37
+ * the kit's own per-node fidelity field, `fenceChar`, post-decode. It has to
38
+ * happen post-decode: `Mdast.fromMdast` drops non-mdast fields from foreign
39
+ * trees, so a `fenceChar` set on the plain tree would never survive the
40
+ * boundary. Nodes are copied only where a default is applied; everything
41
+ * else is returned by reference.
42
+ *
43
+ * @internal
44
+ */
45
+ function ensureFencedCode(node) {
46
+ let result = node;
47
+ const children = node.children;
48
+ if (children !== void 0) {
49
+ const mapped = children.map(ensureFencedCode);
50
+ if (mapped.some((child, index) => child !== children[index])) result = {
51
+ ...node,
52
+ children: mapped
53
+ };
54
+ }
55
+ if (result.type === "code" && result.lang === void 0 && result.fenceChar === void 0) return {
56
+ ...result,
57
+ fenceChar: "`"
58
+ };
59
+ return result;
60
+ }
61
+ /**
62
+ * Serialize a plain MDAST tree to canonical markdown.
63
+ *
64
+ * @remarks
65
+ * The single emit chokepoint for the changesets pipeline: decodes the plain
66
+ * mdast tree into `@effected/markdown`'s node classes via
67
+ * `Mdast.fromMdastResult` and serializes it with `Markdown.stringifyResult`.
68
+ * Synchronous on purpose — every call site is a sync string pipeline — and
69
+ * failures (an undecodable node type, the nesting-depth hardening guard) are
70
+ * defects on self-built trees, so they throw rather than surface typed.
71
+ *
72
+ * The output always ends with exactly one trailing newline, matching the
73
+ * kit's document-level canonical rule.
74
+ *
75
+ * @param tree - The plain mdast root to serialize
76
+ * @returns Canonical markdown text
77
+ *
78
+ * @internal
79
+ */
80
+ function emitMarkdown(tree) {
81
+ const fenced = ensureFencedCode(unwrap(Mdast.fromMdastResult(tree), "markdown emit: mdast tree failed to decode"));
82
+ return unwrap(Markdown.stringifyResult(fenced), "markdown emit: canonical stringify failed");
83
+ }
84
+
85
+ //#endregion
86
+ export { emitMarkdown };
@@ -1,93 +1,19 @@
1
+ import { emitMarkdown } from "./markdown-emit.js";
1
2
  import remarkGfm from "remark-gfm";
2
3
  import remarkParse from "remark-parse";
3
- import remarkStringify from "remark-stringify";
4
4
  import { unified } from "unified";
5
5
 
6
6
  //#region src/changesets/utils/remark-pipeline.ts
7
7
  /**
8
- * Marker set on the `data` of a text node whose value must be written to
9
- * markdown verbatim rather than escaped.
10
- *
11
- * @remarks
12
- * `remark-stringify` escapes any character that could open a markdown
13
- * construct. Inside a dependency table that is always wrong: with
14
- * `remark-gfm` enabled `~` is the strikethrough delimiter, so the specifier
15
- * `~0.2.1` is written as `\~0.2.1`, and `_` in a package name becomes `\_`.
16
- * The corruption compounds on every re-serialization, and dependency-table
17
- * cells round-trip through consolidation and PR-body reconstruction.
18
- *
19
- * The marker is deliberately narrow. It is set only by the dependency-table
20
- * cell builder, whose cell vocabulary is closed — package name, dependency
21
- * type, action, version specifier, and the em-dash sentinel — and none of
22
- * those can legitimately carry markdown. Prose elsewhere in a changeset keeps
23
- * the default escaping.
24
- *
25
- * @internal
26
- */
27
- const LITERAL_TEXT_MARKER = "silkLiteralText";
28
- /**
29
- * Mark a text node so {@link createRemarkProcessor} writes it verbatim.
30
- *
31
- * @param value - The literal cell text
32
- * @returns An MDAST `Text` node carrying the literal marker
33
- *
34
- * @internal
35
- */
36
- function literalText(value) {
37
- return {
38
- type: "text",
39
- value,
40
- data: { [LITERAL_TEXT_MARKER]: true }
41
- };
42
- }
43
- /**
44
- * Whether a text node was marked by {@link literalText}.
45
- *
46
- * @internal
47
- */
48
- function isLiteralText(node) {
49
- return node.data?.[LITERAL_TEXT_MARKER] === true;
50
- }
51
- /**
52
- * Escape the two characters that would otherwise corrupt the table grid.
53
- *
54
- * @remarks
55
- * A `|` would close the cell early and a `\` would be read as an escape
56
- * introducer, so both are escaped to keep the written cell parseable back to
57
- * its exact input. Every other character is emitted as-is. Because parsing
58
- * consumes the backslash, a value that already carries a legacy `\~` from the
59
- * old escaping path re-parses to its clean form rather than accumulating
60
- * another layer.
61
- *
62
- * @internal
63
- */
64
- function escapeCellLiteral(value) {
65
- return value.replace(/[\\|]/g, "\\$&");
66
- }
67
- /**
68
- * `mdast-util-to-markdown` handler overrides used by the shared processor.
69
- *
70
- * @remarks
71
- * Replaces the default `text` handler — which is `state.safe(node.value, info)`
72
- * — with one that bypasses escaping for nodes marked by {@link literalText}
73
- * and delegates to the default behavior for everything else.
74
- *
75
- * @internal
76
- */
77
- const literalTextHandlers = { text: (node, _parent, state, info) => {
78
- const text = node;
79
- return isLiteralText(text) ? escapeCellLiteral(text.value) : state.safe(text.value, info);
80
- } };
81
- /**
82
- * Create a unified processor configured with remark-parse, remark-gfm,
83
- * and remark-stringify.
8
+ * Create a unified processor configured with remark-parse and remark-gfm.
84
9
  *
85
10
  * @remarks
86
11
  * Each call creates a fresh processor instance. The plugin chain is:
87
12
  * 1. `remark-parse` — markdown to MDAST
88
13
  * 2. `remark-gfm` — GitHub Flavored Markdown extensions (tables, etc.)
89
- * 3. `remark-stringify` — MDAST back to markdown, with the
90
- * {@link literalText} handler override
14
+ *
15
+ * The processor carries no compiler: stringification goes through
16
+ * {@link stringifyMarkdown}, never `processor.stringify`.
91
17
  *
92
18
  * @privateRemarks
93
19
  * Return type is intentionally inferred because the unified `Processor`
@@ -98,7 +24,7 @@ const literalTextHandlers = { text: (node, _parent, state, info) => {
98
24
  * @internal
99
25
  */
100
26
  function createRemarkProcessor() {
101
- return unified().use(remarkParse).use(remarkGfm).use(remarkStringify, { handlers: literalTextHandlers });
27
+ return unified().use(remarkParse).use(remarkGfm);
102
28
  }
103
29
  /**
104
30
  * Parse a markdown string into an MDAST AST synchronously.
@@ -126,11 +52,14 @@ function parseMarkdown(content) {
126
52
  return createRemarkProcessor().parse(content);
127
53
  }
128
54
  /**
129
- * Stringify an MDAST AST back to a markdown string synchronously.
55
+ * Stringify an MDAST AST back to markdown text synchronously.
130
56
  *
131
57
  * @remarks
132
- * Uses {@link createRemarkProcessor} internally. GFM constructs
133
- * (tables, etc.) are serialized correctly thanks to the remark-gfm plugin.
58
+ * Delegates to the canonical `@effected/markdown` emit boundary
59
+ * (`emitMarkdown`), so the output is the kit's canonical form — ATX
60
+ * headings, `-` bullets, `*`/`**` emphasis, `***` thematic breaks, one
61
+ * blank line between blocks, a single trailing newline. GFM constructs
62
+ * (tables, strikethrough) are serialized natively.
134
63
  *
135
64
  * @param tree - The MDAST root node
136
65
  * @returns The serialized markdown string
@@ -147,8 +76,8 @@ function parseMarkdown(content) {
147
76
  * @internal
148
77
  */
149
78
  function stringifyMarkdown(tree) {
150
- return createRemarkProcessor().stringify(tree);
79
+ return emitMarkdown(tree);
151
80
  }
152
81
 
153
82
  //#endregion
154
- export { LITERAL_TEXT_MARKER, createRemarkProcessor, literalText, parseMarkdown, stringifyMarkdown };
83
+ export { createRemarkProcessor, parseMarkdown, stringifyMarkdown };
@@ -60,12 +60,14 @@ function parseChangesetSections(summary) {
60
60
  const headingNode = tree.children[headingIndex];
61
61
  const headingText = toString(headingNode);
62
62
  const nextIndex = i + 1 < h2Indices.length ? h2Indices[i + 1] : tree.children.length;
63
- const content = stringifyAstSlice(tree.children.slice(headingIndex + 1, nextIndex));
63
+ const contentNodes = tree.children.slice(headingIndex + 1, nextIndex);
64
+ const content = stringifyAstSlice(contentNodes);
64
65
  const category = fromHeading(headingText);
65
66
  if (category) result.sections.push({
66
67
  category,
67
68
  heading: headingText,
68
- content
69
+ content,
70
+ contentNodes
69
71
  });
70
72
  }
71
73
  return result;