@deskwork/core 0.9.5

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 (188) hide show
  1. package/dist/body-state.d.ts +27 -0
  2. package/dist/body-state.d.ts.map +1 -0
  3. package/dist/body-state.js +62 -0
  4. package/dist/body-state.js.map +1 -0
  5. package/dist/calendar-mutations.d.ts +124 -0
  6. package/dist/calendar-mutations.d.ts.map +1 -0
  7. package/dist/calendar-mutations.js +305 -0
  8. package/dist/calendar-mutations.js.map +1 -0
  9. package/dist/calendar.d.ts +54 -0
  10. package/dist/calendar.d.ts.map +1 -0
  11. package/dist/calendar.js +430 -0
  12. package/dist/calendar.js.map +1 -0
  13. package/dist/cli.d.ts +38 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +72 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/config.d.ts +91 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +216 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/content-index.d.ts +74 -0
  22. package/dist/content-index.d.ts.map +1 -0
  23. package/dist/content-index.js +205 -0
  24. package/dist/content-index.js.map +1 -0
  25. package/dist/content-tree-fs-walk.d.ts +54 -0
  26. package/dist/content-tree-fs-walk.d.ts.map +1 -0
  27. package/dist/content-tree-fs-walk.js +112 -0
  28. package/dist/content-tree-fs-walk.js.map +1 -0
  29. package/dist/content-tree-helpers.d.ts +52 -0
  30. package/dist/content-tree-helpers.d.ts.map +1 -0
  31. package/dist/content-tree-helpers.js +116 -0
  32. package/dist/content-tree-helpers.js.map +1 -0
  33. package/dist/content-tree-types.d.ts +175 -0
  34. package/dist/content-tree-types.d.ts.map +1 -0
  35. package/dist/content-tree-types.js +10 -0
  36. package/dist/content-tree-types.js.map +1 -0
  37. package/dist/content-tree.d.ts +93 -0
  38. package/dist/content-tree.d.ts.map +1 -0
  39. package/dist/content-tree.js +385 -0
  40. package/dist/content-tree.js.map +1 -0
  41. package/dist/doctor/index.d.ts +11 -0
  42. package/dist/doctor/index.d.ts.map +1 -0
  43. package/dist/doctor/index.js +10 -0
  44. package/dist/doctor/index.js.map +1 -0
  45. package/dist/doctor/project-rules.d.ts +59 -0
  46. package/dist/doctor/project-rules.d.ts.map +1 -0
  47. package/dist/doctor/project-rules.js +143 -0
  48. package/dist/doctor/project-rules.js.map +1 -0
  49. package/dist/doctor/rules/calendar-uuid-missing.d.ts +19 -0
  50. package/dist/doctor/rules/calendar-uuid-missing.d.ts.map +1 -0
  51. package/dist/doctor/rules/calendar-uuid-missing.js +176 -0
  52. package/dist/doctor/rules/calendar-uuid-missing.js.map +1 -0
  53. package/dist/doctor/rules/duplicate-id.d.ts +27 -0
  54. package/dist/doctor/rules/duplicate-id.d.ts.map +1 -0
  55. package/dist/doctor/rules/duplicate-id.js +157 -0
  56. package/dist/doctor/rules/duplicate-id.js.map +1 -0
  57. package/dist/doctor/rules/legacy-top-level-id-migration.d.ts +40 -0
  58. package/dist/doctor/rules/legacy-top-level-id-migration.d.ts.map +1 -0
  59. package/dist/doctor/rules/legacy-top-level-id-migration.js +232 -0
  60. package/dist/doctor/rules/legacy-top-level-id-migration.js.map +1 -0
  61. package/dist/doctor/rules/missing-frontmatter-id.d.ts +45 -0
  62. package/dist/doctor/rules/missing-frontmatter-id.d.ts.map +1 -0
  63. package/dist/doctor/rules/missing-frontmatter-id.js +283 -0
  64. package/dist/doctor/rules/missing-frontmatter-id.js.map +1 -0
  65. package/dist/doctor/rules/orphan-frontmatter-id.d.ts +18 -0
  66. package/dist/doctor/rules/orphan-frontmatter-id.d.ts.map +1 -0
  67. package/dist/doctor/rules/orphan-frontmatter-id.js +154 -0
  68. package/dist/doctor/rules/orphan-frontmatter-id.js.map +1 -0
  69. package/dist/doctor/rules/schema-rejected.d.ts +20 -0
  70. package/dist/doctor/rules/schema-rejected.d.ts.map +1 -0
  71. package/dist/doctor/rules/schema-rejected.js +44 -0
  72. package/dist/doctor/rules/schema-rejected.js.map +1 -0
  73. package/dist/doctor/rules/slug-collision.d.ts +18 -0
  74. package/dist/doctor/rules/slug-collision.d.ts.map +1 -0
  75. package/dist/doctor/rules/slug-collision.js +65 -0
  76. package/dist/doctor/rules/slug-collision.js.map +1 -0
  77. package/dist/doctor/rules/workflow-stale.d.ts +20 -0
  78. package/dist/doctor/rules/workflow-stale.d.ts.map +1 -0
  79. package/dist/doctor/rules/workflow-stale.js +136 -0
  80. package/dist/doctor/rules/workflow-stale.js.map +1 -0
  81. package/dist/doctor/runner.d.ts +75 -0
  82. package/dist/doctor/runner.d.ts.map +1 -0
  83. package/dist/doctor/runner.js +289 -0
  84. package/dist/doctor/runner.js.map +1 -0
  85. package/dist/doctor/schema-patch.d.ts +21 -0
  86. package/dist/doctor/schema-patch.d.ts.map +1 -0
  87. package/dist/doctor/schema-patch.js +92 -0
  88. package/dist/doctor/schema-patch.js.map +1 -0
  89. package/dist/doctor/types.d.ts +185 -0
  90. package/dist/doctor/types.d.ts.map +1 -0
  91. package/dist/doctor/types.js +13 -0
  92. package/dist/doctor/types.js.map +1 -0
  93. package/dist/frontmatter.d.ts +103 -0
  94. package/dist/frontmatter.d.ts.map +1 -0
  95. package/dist/frontmatter.js +306 -0
  96. package/dist/frontmatter.js.map +1 -0
  97. package/dist/index.d.ts +27 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +27 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/ingest-derive.d.ts +79 -0
  102. package/dist/ingest-derive.d.ts.map +1 -0
  103. package/dist/ingest-derive.js +299 -0
  104. package/dist/ingest-derive.js.map +1 -0
  105. package/dist/ingest-paths.d.ts +37 -0
  106. package/dist/ingest-paths.d.ts.map +1 -0
  107. package/dist/ingest-paths.js +176 -0
  108. package/dist/ingest-paths.js.map +1 -0
  109. package/dist/ingest.d.ts +162 -0
  110. package/dist/ingest.d.ts.map +1 -0
  111. package/dist/ingest.js +269 -0
  112. package/dist/ingest.js.map +1 -0
  113. package/dist/journal.d.ts +49 -0
  114. package/dist/journal.d.ts.map +1 -0
  115. package/dist/journal.js +113 -0
  116. package/dist/journal.js.map +1 -0
  117. package/dist/outline-split.d.ts +38 -0
  118. package/dist/outline-split.d.ts.map +1 -0
  119. package/dist/outline-split.js +84 -0
  120. package/dist/outline-split.js.map +1 -0
  121. package/dist/overrides.d.ts +83 -0
  122. package/dist/overrides.d.ts.map +1 -0
  123. package/dist/overrides.js +88 -0
  124. package/dist/overrides.js.map +1 -0
  125. package/dist/paths.d.ts +183 -0
  126. package/dist/paths.d.ts.map +1 -0
  127. package/dist/paths.js +266 -0
  128. package/dist/paths.js.map +1 -0
  129. package/dist/remark-image-figure.mjs +77 -0
  130. package/dist/remark-strip-first-h1.mjs +26 -0
  131. package/dist/remark-strip-outline.mjs +44 -0
  132. package/dist/rename-slug.d.ts +49 -0
  133. package/dist/rename-slug.d.ts.map +1 -0
  134. package/dist/rename-slug.js +161 -0
  135. package/dist/rename-slug.js.map +1 -0
  136. package/dist/review/handlers.d.ts +55 -0
  137. package/dist/review/handlers.d.ts.map +1 -0
  138. package/dist/review/handlers.js +307 -0
  139. package/dist/review/handlers.js.map +1 -0
  140. package/dist/review/index.d.ts +14 -0
  141. package/dist/review/index.d.ts.map +1 -0
  142. package/dist/review/index.js +13 -0
  143. package/dist/review/index.js.map +1 -0
  144. package/dist/review/journal-mappers.d.ts +35 -0
  145. package/dist/review/journal-mappers.d.ts.map +1 -0
  146. package/dist/review/journal-mappers.js +48 -0
  147. package/dist/review/journal-mappers.js.map +1 -0
  148. package/dist/review/pipeline.d.ts +79 -0
  149. package/dist/review/pipeline.d.ts.map +1 -0
  150. package/dist/review/pipeline.js +234 -0
  151. package/dist/review/pipeline.js.map +1 -0
  152. package/dist/review/render.d.ts +27 -0
  153. package/dist/review/render.d.ts.map +1 -0
  154. package/dist/review/render.js +42 -0
  155. package/dist/review/render.js.map +1 -0
  156. package/dist/review/report.d.ts +50 -0
  157. package/dist/review/report.d.ts.map +1 -0
  158. package/dist/review/report.js +164 -0
  159. package/dist/review/report.js.map +1 -0
  160. package/dist/review/result.d.ts +12 -0
  161. package/dist/review/result.d.ts.map +1 -0
  162. package/dist/review/result.js +12 -0
  163. package/dist/review/result.js.map +1 -0
  164. package/dist/review/start-handlers.d.ts +62 -0
  165. package/dist/review/start-handlers.d.ts.map +1 -0
  166. package/dist/review/start-handlers.js +223 -0
  167. package/dist/review/start-handlers.js.map +1 -0
  168. package/dist/review/types.d.ts +169 -0
  169. package/dist/review/types.d.ts.map +1 -0
  170. package/dist/review/types.js +26 -0
  171. package/dist/review/types.js.map +1 -0
  172. package/dist/review/workflow-paths.d.ts +68 -0
  173. package/dist/review/workflow-paths.d.ts.map +1 -0
  174. package/dist/review/workflow-paths.js +112 -0
  175. package/dist/review/workflow-paths.js.map +1 -0
  176. package/dist/scaffold.d.ts +67 -0
  177. package/dist/scaffold.d.ts.map +1 -0
  178. package/dist/scaffold.js +122 -0
  179. package/dist/scaffold.js.map +1 -0
  180. package/dist/scrapbook.d.ts +229 -0
  181. package/dist/scrapbook.d.ts.map +1 -0
  182. package/dist/scrapbook.js +500 -0
  183. package/dist/scrapbook.js.map +1 -0
  184. package/dist/types.d.ts +197 -0
  185. package/dist/types.d.ts.map +1 -0
  186. package/dist/types.js +120 -0
  187. package/dist/types.js.map +1 -0
  188. package/package.json +160 -0
package/dist/paths.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Path and site resolution against a DeskworkConfig.
3
+ *
4
+ * Every skill that touches disk goes through these helpers so that hardcoded
5
+ * paths — and assumptions about which sites exist — stay out of skill logic.
6
+ *
7
+ * Phase 19c — entry-to-file resolution precedence
8
+ * -----------------------------------------------
9
+ * For locating the markdown file backing a calendar entry, the canonical
10
+ * order is:
11
+ *
12
+ * 1. **Content index** — when an entry id is known, scan
13
+ * `<contentDir>/` for a markdown file whose frontmatter `id:`
14
+ * matches. This is refactor-proof: the binding moves with the file
15
+ * because the id lives inside the file.
16
+ * 2. **Slug-template fallback** — when the index has no record (entry
17
+ * not bound to frontmatter yet, e.g. pre-doctor state), fall back
18
+ * to the site's `blogFilenameTemplate` keyed by slug. This
19
+ * preserves audiocontrol-shaped flat-blog behavior unchanged.
20
+ *
21
+ * `findEntryFile` implements this precedence directly. `resolveBlogFilePath`
22
+ * remains the legacy slug-template-only entry point used by callers that
23
+ * don't have an entry id available (scaffold for new files, doctor's
24
+ * candidate-search by template, the legacy publish path). New code with
25
+ * access to a calendar entry should prefer `findEntryFile`.
26
+ */
27
+ import { dirname, join } from 'node:path';
28
+ import { buildContentIndex } from "./content-index.js";
29
+ /**
30
+ * Resolve a user-supplied site argument to a configured site slug.
31
+ *
32
+ * An empty / null / undefined value falls back to `config.defaultSite`.
33
+ * An unknown value throws with the list of configured sites.
34
+ */
35
+ export function resolveSite(config, site) {
36
+ if (site === null || site === undefined || site === '') {
37
+ return config.defaultSite;
38
+ }
39
+ if (!(site in config.sites)) {
40
+ const known = Object.keys(config.sites).join(', ');
41
+ throw new Error(`Unknown site "${site}". Configured sites: ${known}. ` +
42
+ `Default when omitted: ${config.defaultSite}.`);
43
+ }
44
+ return site;
45
+ }
46
+ /** Internal: resolve + look up the SiteConfig for a given argument. */
47
+ function siteConfig(config, site) {
48
+ const slug = resolveSite(config, site);
49
+ return config.sites[slug];
50
+ }
51
+ /** Absolute path to the site's editorial calendar file. */
52
+ export function resolveCalendarPath(projectRoot, config, site) {
53
+ return join(projectRoot, siteConfig(config, site).calendarPath);
54
+ }
55
+ /** Absolute path to the site's channels file, or undefined when the site declares none. */
56
+ export function resolveChannelsPath(projectRoot, config, site) {
57
+ const entry = siteConfig(config, site);
58
+ return entry.channelsPath === undefined
59
+ ? undefined
60
+ : join(projectRoot, entry.channelsPath);
61
+ }
62
+ /** Absolute path to the site's blog content directory. */
63
+ export function resolveContentDir(projectRoot, config, site) {
64
+ return join(projectRoot, siteConfig(config, site).contentDir);
65
+ }
66
+ /**
67
+ * Bare public hostname for the site (no protocol). Returns `undefined` for
68
+ * collections that aren't published as a website (no `host` configured).
69
+ * Callers needing a non-undefined value for display should fall back to the
70
+ * site slug; callers needing a real URL should throw if undefined.
71
+ */
72
+ export function resolveSiteHost(config, site) {
73
+ return siteConfig(config, site).host;
74
+ }
75
+ /**
76
+ * Canonical public base URL for the site, with trailing slash. Throws when
77
+ * the collection has no `host` configured (i.e. is not published as a
78
+ * website) — callers that need a URL must guarantee the collection is a
79
+ * website-rendered one before calling.
80
+ */
81
+ export function resolveSiteBaseUrl(config, site) {
82
+ const host = resolveSiteHost(config, site);
83
+ if (host === undefined) {
84
+ const slug = site ?? config.defaultSite;
85
+ throw new Error(`Cannot resolve a base URL for collection "${slug}": no "host" is configured. ` +
86
+ `Add a "host" field to .deskwork/config.json if this collection is published as a website.`);
87
+ }
88
+ return `https://${host}/`;
89
+ }
90
+ const DEFAULT_BLOG_FILENAME_TEMPLATE = '{slug}/index.md';
91
+ /**
92
+ * Absolute path to the blog post markdown for a given slug.
93
+ *
94
+ * Resolution order (first match wins):
95
+ * 1. Explicit `filePath` argument — joined with the site's `contentDir`.
96
+ * Used by the scaffolder when an explicit layout (`index` /
97
+ * `readme` / `flat`) was requested.
98
+ * 2. The site's configured `blogFilenameTemplate` (default
99
+ * `{slug}/index.md`). Audiocontrol-shaped flat blogs hit this path.
100
+ *
101
+ * Slug-only API for callers that don't have an entry id available
102
+ * (scaffold for not-yet-existent files, doctor's candidate search,
103
+ * legacy publish/iterate paths). Callers that already hold a calendar
104
+ * entry should prefer `findEntryFile` — it consults the content index
105
+ * first, falling back to this template-driven path only when no
106
+ * frontmatter binding exists yet.
107
+ */
108
+ export function resolveBlogFilePath(projectRoot, config, site, slug, filePath) {
109
+ const entry = siteConfig(config, site);
110
+ if (filePath !== undefined && filePath !== '') {
111
+ return join(projectRoot, entry.contentDir, filePath);
112
+ }
113
+ const template = entry.blogFilenameTemplate ?? DEFAULT_BLOG_FILENAME_TEMPLATE;
114
+ return join(projectRoot, entry.contentDir, template.replaceAll('{slug}', slug));
115
+ }
116
+ /**
117
+ * Absolute path to the per-post directory for a slug —
118
+ * `<contentDir>/<slug>/`. Used by features that co-locate per-post
119
+ * artifacts (scrapbook, feature images) regardless of whether the
120
+ * blog markdown lives at `<slug>/index.md` or as a flat `<slug>.md`.
121
+ *
122
+ * The directory is not guaranteed to exist. Callers that need it
123
+ * created should `mkdirSync({ recursive: true })`.
124
+ */
125
+ export function resolveBlogPostDir(projectRoot, config, site, slug) {
126
+ return join(projectRoot, siteConfig(config, site).contentDir, slug);
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Phase 19c — id-based entry → file resolution
130
+ // ---------------------------------------------------------------------------
131
+ /**
132
+ * Resolve a calendar entry's file location via the content index.
133
+ *
134
+ * Refactor-proof: the binding is whatever file currently has matching
135
+ * frontmatter `id:`. When the index has no record (entry's file hasn't
136
+ * been bound yet — pre-doctor state) AND `legacyEntryForFallback` is
137
+ * supplied, falls back to the site's `blogFilenameTemplate` to preserve
138
+ * legacy behavior. Without that fallback hint, returns `undefined` so
139
+ * callers can decide how to surface the missing binding.
140
+ *
141
+ * Note: the returned path is what the index says — its existence on
142
+ * disk is implied (the index only records files it walked). The
143
+ * template fallback path may NOT exist on disk; callers that need
144
+ * existence guarantees should `existsSync` the result.
145
+ *
146
+ * @param projectRoot Absolute path to the deskwork project root.
147
+ * @param config Loaded deskwork config.
148
+ * @param site Site slug (or null/undefined for the default site).
149
+ * @param entryId Calendar entry's stable UUID.
150
+ * @param index Pre-built content index. When omitted, this function
151
+ * builds one. The studio passes a per-request memoized
152
+ * index; the CLI typically lets it build per call.
153
+ * @param legacyEntryForFallback When supplied, allows the slug-template
154
+ * fallback for entries that haven't been bound to
155
+ * frontmatter yet. Pass `{slug}` to opt in.
156
+ * @returns absolute path, or undefined if neither index nor template resolves.
157
+ */
158
+ export function findEntryFile(projectRoot, config, site, entryId, index, legacyEntryForFallback) {
159
+ if (entryId !== '') {
160
+ const idx = index ?? buildContentIndex(projectRoot, config, resolveSite(config, site));
161
+ const hit = idx.byId.get(entryId);
162
+ if (hit !== undefined)
163
+ return hit;
164
+ }
165
+ if (legacyEntryForFallback !== undefined) {
166
+ return resolveBlogFilePath(projectRoot, config, site, legacyEntryForFallback.slug);
167
+ }
168
+ return undefined;
169
+ }
170
+ /**
171
+ * Resolve the markdown file backing a calendar entry, preferring the
172
+ * UUID frontmatter binding (refactor-proof) and falling back to the
173
+ * site's slug-template only when no binding exists.
174
+ *
175
+ * Equivalent to the studio's `resolveLongformFilePath` but exposed as a
176
+ * top-level helper from `paths.ts` so CLI commands can use it without
177
+ * pulling in `review/` infrastructure. Always returns an absolute path
178
+ * (the slug-template fallback is unconditional); callers should
179
+ * `existsSync` if they need an existence guarantee.
180
+ *
181
+ * Precedence:
182
+ * 1. Content index — when `entryId` is supplied (and non-empty), look
183
+ * up the file whose frontmatter `deskwork.id:` matches. Refactor-
184
+ * proof: the binding follows the file regardless of slug rename or
185
+ * directory relocation.
186
+ * 2. Slug-template fallback — when the index has no record (entry's
187
+ * file isn't bound to frontmatter yet, e.g. pre-doctor / pre-ingest
188
+ * state) or no `entryId` was supplied, fall back to
189
+ * `resolveBlogFilePath(slug)`.
190
+ *
191
+ * @param projectRoot Absolute path to the deskwork project root.
192
+ * @param config Loaded deskwork config.
193
+ * @param site Site slug (or null/undefined for the default site).
194
+ * @param slug Calendar entry slug — used both as the legacy fallback
195
+ * template input and as a hint for the slug-template fallback.
196
+ * @param entryId Calendar entry's stable UUID. When omitted or empty,
197
+ * resolution falls straight through to the slug template.
198
+ * @param index Pre-built content index. When omitted, this function
199
+ * builds one. Pass the per-request memoized index when
200
+ * calling from the studio; let the CLI build per call.
201
+ */
202
+ export function resolveEntryFilePath(projectRoot, config, site, slug, entryId, index) {
203
+ if (entryId !== undefined && entryId !== '') {
204
+ const idx = index ?? buildContentIndex(projectRoot, config, resolveSite(config, site));
205
+ const hit = idx.byId.get(entryId);
206
+ if (hit !== undefined)
207
+ return hit;
208
+ }
209
+ return resolveBlogFilePath(projectRoot, config, site, slug);
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Phase 21a — shortform file resolution
213
+ // ---------------------------------------------------------------------------
214
+ /**
215
+ * Channel must be a kebab-case token. Same shape as a slug segment so the
216
+ * filename remains URL-safe and matches the rest of deskwork's vocabulary.
217
+ */
218
+ const CHANNEL_RE = /^[a-z0-9][a-z0-9-]*$/;
219
+ /**
220
+ * Resolve the markdown file path for a shortform draft.
221
+ *
222
+ * <contentDir>/<entry-dir>/scrapbook/shortform/<platform>[-<channel>].md
223
+ *
224
+ * Platform is the lowercase Platform value. Channel (if present) is appended
225
+ * as `-<channel>`. Channel must validate against the kebab-case regex —
226
+ * deskwork stores channels as kebab-case strings throughout.
227
+ *
228
+ * The entry directory is resolved through `findEntryFile` (id-driven,
229
+ * refactor-proof) with slug-template fallback for legacy entries created
230
+ * pre-doctor. The slug-template fallback is intentional migration logic so
231
+ * pre-bind entries keep working.
232
+ *
233
+ * Forward-compatibility: every reference to the shortform file location
234
+ * goes through this function. Phase 20 (sandbox migration) redirects this
235
+ * single function; everything downstream (handlers, CLI, studio) works
236
+ * unchanged.
237
+ *
238
+ * @param projectRoot Absolute path to the deskwork project root.
239
+ * @param config Loaded deskwork config.
240
+ * @param site Site slug (or null/undefined for the default site).
241
+ * @param entry Calendar entry — `id` preferred, `slug` used both as the
242
+ * legacy fallback and to identify the entry directory.
243
+ * @param platform Which distribution platform.
244
+ * @param channel Optional sub-channel (e.g. `synthdiy` for r/synthdiy).
245
+ * Must be kebab-case.
246
+ * @param index Optional pre-built content index (per-request memoization).
247
+ * @returns absolute file path, or undefined when neither the index nor the
248
+ * slug-template fallback resolves the entry's directory.
249
+ */
250
+ export function resolveShortformFilePath(projectRoot, config, site, entry, platform, channel, index) {
251
+ if (channel !== undefined && channel !== '') {
252
+ if (!CHANNEL_RE.test(channel)) {
253
+ throw new Error(`Invalid shortform channel "${channel}": must match ${CHANNEL_RE} ` +
254
+ `(kebab-case, same shape as a slug segment).`);
255
+ }
256
+ }
257
+ const entryFile = findEntryFile(projectRoot, config, site, entry.id ?? '', index, { slug: entry.slug });
258
+ if (entryFile === undefined)
259
+ return undefined;
260
+ const entryDir = dirname(entryFile);
261
+ const filename = channel !== undefined && channel !== ''
262
+ ? `${platform}-${channel}.md`
263
+ : `${platform}.md`;
264
+ return join(entryDir, 'scrapbook', 'shortform', filename);
265
+ }
266
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paths.js","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * remark plugin: wrap standalone markdown images in <figure> with a
3
+ * <figcaption> sourced from the alt text.
4
+ *
5
+ * Why: authors writing `![caption](src)` expect the alt text to
6
+ * double as a visible figure caption (magazine convention). Plain
7
+ * Astro rendering emits `<p><img alt="caption"/></p>`, which hides
8
+ * the caption and nests the image inside a paragraph — wrong shape
9
+ * for a figure.
10
+ *
11
+ * What we do: find paragraphs containing exactly one image (and no
12
+ * other meaningful content), rewrite that paragraph as a `<figure>`,
13
+ * and emit a `<figcaption>` with the alt text. The alt attribute on
14
+ * the image is preserved for accessibility — screen readers still
15
+ * hear it, sighted readers now see it too.
16
+ *
17
+ * Scoped to paragraphs where the image stands alone. Inline images
18
+ * (text + `![]()` on the same line) are left as-is — those are
19
+ * intentionally mid-flow and shouldn't become block-level figures.
20
+ *
21
+ * No-op when the document has no standalone images. Safe to register
22
+ * alongside `remark-strip-outline`.
23
+ */
24
+
25
+ /**
26
+ * A paragraph "wraps" a single image iff its children are either
27
+ * (a) an image node, or (b) whitespace-only text — nothing else.
28
+ * Handles the trailing-newline case that some parsers emit.
29
+ */
30
+ function findSoloImage(paragraph) {
31
+ if (!paragraph.children || paragraph.children.length === 0) return null;
32
+ let image = null;
33
+ for (const child of paragraph.children) {
34
+ if (child.type === 'image') {
35
+ if (image) return null; // two images → not a solo figure
36
+ image = child;
37
+ continue;
38
+ }
39
+ if (child.type === 'text' && child.value.trim() === '') continue;
40
+ return null; // non-whitespace, non-image content → not a solo figure
41
+ }
42
+ return image;
43
+ }
44
+
45
+ export default function remarkImageFigure() {
46
+ return (tree) => {
47
+ for (const node of tree.children) {
48
+ if (node.type !== 'paragraph') continue;
49
+ const image = findSoloImage(node);
50
+ if (!image) continue;
51
+ const alt = typeof image.alt === 'string' ? image.alt : '';
52
+ // Convert the paragraph itself into the figure. The image node
53
+ // renders as-is via mdast-util-to-hast's default handler; we
54
+ // append a synthetic caption node that rehype emits as
55
+ // `<figcaption>`.
56
+ node.data = node.data || {};
57
+ node.data.hName = 'figure';
58
+ node.data.hProperties = { className: ['blog-figure'] };
59
+ // When alt is empty, emit the figure without a caption (rare —
60
+ // typically hero images use an empty alt to avoid double-
61
+ // captioning the visible heading above them).
62
+ if (alt.length > 0) {
63
+ node.children = [
64
+ image,
65
+ {
66
+ type: 'paragraph',
67
+ data: {
68
+ hName: 'figcaption',
69
+ hProperties: { className: ['blog-figcaption'] },
70
+ },
71
+ children: [{ type: 'text', value: alt }],
72
+ },
73
+ ];
74
+ }
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * remark plugin: strip the first H1 from rendered markdown output.
3
+ *
4
+ * Many voice patterns repeat the title above the body (a print-magazine
5
+ * convention that reorients the reader after a page turn). On a scrolling
6
+ * web document the convention reads as repetition — the BlogLayout header
7
+ * already renders the title from frontmatter, so the markdown body's
8
+ * leading `# Title` appears as a second copy right under the feature
9
+ * image. This plugin drops that first H1 from the rendered tree while
10
+ * leaving the markdown file intact (so the `.md` still validates as a
11
+ * standalone document and the operator keeps writing `# Title` at the
12
+ * top of the body per voice guidance).
13
+ *
14
+ * Stripping shape: find the first top-level H1 in the document and
15
+ * remove it. All other H1s (rare in these posts, but possible) are
16
+ * preserved. No-op when the document has no leading H1.
17
+ */
18
+
19
+ export default function remarkStripFirstH1() {
20
+ return (tree) => {
21
+ const children = tree.children;
22
+ const idx = children.findIndex((n) => n.type === 'heading' && n.depth === 1);
23
+ if (idx < 0) return;
24
+ children.splice(idx, 1);
25
+ };
26
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * remark plugin: strip the `## Outline` section from rendered
3
+ * markdown output.
4
+ *
5
+ * Applied to the host project's markdown pipeline (e.g. astro.config.mjs)
6
+ * so the outline section an operator iterates during the Outlining stage
7
+ * disappears from the public `/blog/<slug>/` render. The editorial review
8
+ * surface does NOT go through this plugin — it has its own unified
9
+ * pipeline in `review/render.ts` — so the outline stays visible there
10
+ * for annotate-and-iterate work.
11
+ *
12
+ * Stripping shape: find the first H2 whose text starts with "Outline",
13
+ * then remove that heading plus every subsequent top-level node until
14
+ * the next H1 or H2 (non-inclusive) or the end of the document. Matches
15
+ * the line-based stripper in `body-state.ts`; kept independent here
16
+ * because mdast traversal beats regex on structured content.
17
+ *
18
+ * No-op when the document has no outline section.
19
+ */
20
+
21
+ function hasOutlineHeading(node) {
22
+ if (node.type !== 'heading' || node.depth !== 2) return false;
23
+ const first = node.children?.[0];
24
+ if (!first || first.type !== 'text') return false;
25
+ return /^Outline\b/.test(first.value);
26
+ }
27
+
28
+ export default function remarkStripOutline() {
29
+ return (tree) => {
30
+ const children = tree.children;
31
+ const start = children.findIndex(hasOutlineHeading);
32
+ if (start < 0) return;
33
+
34
+ let end = children.length;
35
+ for (let i = start + 1; i < children.length; i++) {
36
+ const n = children[i];
37
+ if (n.type === 'heading' && n.depth <= 2) {
38
+ end = i;
39
+ break;
40
+ }
41
+ }
42
+ children.splice(start, end - start);
43
+ };
44
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Slug rename for blog entries.
3
+ *
4
+ * Renames the per-post directory at `<contentDir>/<slug>/` to its new
5
+ * name, updates the calendar entry's slug, syncs slug on matching
6
+ * distribution records, and (optionally) appends a 301 redirect block
7
+ * to the site's `_redirects` file. UUID identity keeps workflows,
8
+ * distribution records, and journal history joined through `entry.id`
9
+ * across the rename.
10
+ *
11
+ * Assumes the dir-based layout: blog posts live as
12
+ * `<contentDir>/<slug>/index.md` (or `<slug>/<file>.md` per
13
+ * `blogFilenameTemplate`) with assets co-located in the same dir. For
14
+ * flat-file layouts (`{slug}.md`) the directory itself doesn't exist —
15
+ * skip the dir-rename step but still update the calendar.
16
+ */
17
+ import type { DeskworkConfig } from './config.ts';
18
+ export interface RenameSlugOptions {
19
+ projectRoot: string;
20
+ config: DeskworkConfig;
21
+ site: string;
22
+ oldSlug: string;
23
+ newSlug: string;
24
+ dryRun?: boolean;
25
+ }
26
+ export interface RenameSlugPlanAction {
27
+ kind: 'dir-rename' | 'calendar-slug-change' | 'distribution-slug-sync' | 'redirect-append';
28
+ summary: string;
29
+ details?: string;
30
+ }
31
+ export interface RenameSlugResult {
32
+ entryId: string;
33
+ oldSlug: string;
34
+ newSlug: string;
35
+ actions: RenameSlugPlanAction[];
36
+ dryRun: boolean;
37
+ }
38
+ export declare function validateSlug(slug: string): void;
39
+ /**
40
+ * Build the 301 redirect block for a slug rename. Only covers the page
41
+ * URL; per-post images served as hashed `/_astro/` URLs don't embed the
42
+ * slug, so no image-path redirect is needed.
43
+ */
44
+ export declare function buildRedirectBlock(oldSlug: string, newSlug: string): string;
45
+ /**
46
+ * Execute (or dry-run) a slug rename.
47
+ */
48
+ export declare function renameSlug(options: RenameSlugOptions): RenameSlugResult;
49
+ //# sourceMappingURL=rename-slug.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rename-slug.d.ts","sourceRoot":"","sources":["../src/rename-slug.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAKlD,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EACA,YAAY,GACZ,sBAAsB,GACtB,wBAAwB,GACxB,iBAAiB,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,MAAM,EAAE,OAAO,CAAC;CACjB;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAI/C;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAS3E;AAUD;;GAEG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,gBAAgB,CA6HvE"}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Slug rename for blog entries.
3
+ *
4
+ * Renames the per-post directory at `<contentDir>/<slug>/` to its new
5
+ * name, updates the calendar entry's slug, syncs slug on matching
6
+ * distribution records, and (optionally) appends a 301 redirect block
7
+ * to the site's `_redirects` file. UUID identity keeps workflows,
8
+ * distribution records, and journal history joined through `entry.id`
9
+ * across the rename.
10
+ *
11
+ * Assumes the dir-based layout: blog posts live as
12
+ * `<contentDir>/<slug>/index.md` (or `<slug>/<file>.md` per
13
+ * `blogFilenameTemplate`) with assets co-located in the same dir. For
14
+ * flat-file layouts (`{slug}.md`) the directory itself doesn't exist —
15
+ * skip the dir-rename step but still update the calendar.
16
+ */
17
+ import { existsSync, renameSync, writeFileSync, appendFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { resolveBlogPostDir, resolveCalendarPath } from "./paths.js";
20
+ import { readCalendar, writeCalendar } from "./calendar.js";
21
+ import { effectiveContentType } from "./types.js";
22
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/;
23
+ export function validateSlug(slug) {
24
+ if (!SLUG_RE.test(slug)) {
25
+ throw new Error(`invalid slug "${slug}" — must match ${SLUG_RE}`);
26
+ }
27
+ }
28
+ /**
29
+ * Build the 301 redirect block for a slug rename. Only covers the page
30
+ * URL; per-post images served as hashed `/_astro/` URLs don't embed the
31
+ * slug, so no image-path redirect is needed.
32
+ */
33
+ export function buildRedirectBlock(oldSlug, newSlug) {
34
+ return [
35
+ '',
36
+ `# Slug rename: /blog/${oldSlug}/ → /blog/${newSlug}/`,
37
+ `/blog/${oldSlug} /blog/${newSlug}/ 301`,
38
+ `/blog/${oldSlug}/ /blog/${newSlug}/ 301`,
39
+ `/blog/${oldSlug}/* /blog/${newSlug}/:splat 301`,
40
+ '',
41
+ ].join('\n');
42
+ }
43
+ function siteEntry(config, site) {
44
+ if (!(site in config.sites)) {
45
+ const known = Object.keys(config.sites).join(', ');
46
+ throw new Error(`unknown site "${site}". Configured sites: ${known}`);
47
+ }
48
+ return config.sites[site];
49
+ }
50
+ /**
51
+ * Execute (or dry-run) a slug rename.
52
+ */
53
+ export function renameSlug(options) {
54
+ const { projectRoot, config, site, oldSlug, newSlug, dryRun = false } = options;
55
+ validateSlug(oldSlug);
56
+ validateSlug(newSlug);
57
+ if (oldSlug === newSlug) {
58
+ throw new Error('oldSlug and newSlug are identical — nothing to do');
59
+ }
60
+ const siteCfg = siteEntry(config, site);
61
+ const calendarPath = resolveCalendarPath(projectRoot, config, site);
62
+ const calendar = readCalendar(calendarPath);
63
+ const entry = calendar.entries.find((e) => e.slug === oldSlug);
64
+ if (!entry) {
65
+ throw new Error(`no calendar entry with slug "${oldSlug}" on site "${site}"`);
66
+ }
67
+ if (!entry.id) {
68
+ throw new Error(`entry "${oldSlug}" has no UUID — re-save the calendar to backfill`);
69
+ }
70
+ const collision = calendar.entries.find((e) => e.slug === newSlug && e.id !== entry.id);
71
+ if (collision) {
72
+ throw new Error(`slug "${newSlug}" is already taken by entry ${collision.id ?? '(no id)'} (${collision.title})`);
73
+ }
74
+ const actions = [];
75
+ const oldDir = resolveBlogPostDir(projectRoot, config, site, oldSlug);
76
+ const newDir = resolveBlogPostDir(projectRoot, config, site, newSlug);
77
+ // 1. Directory rename. Under the dir-based layout blog posts live as
78
+ // `<contentDir>/<slug>/` with assets co-located, so a single mv
79
+ // carries the markdown + co-located assets in one atomic
80
+ // operation. For blog entries the directory must exist when the
81
+ // layout is dir-based — if it's missing the calendar row has
82
+ // drifted from disk and the operator needs to reconcile before
83
+ // rename can proceed. Flat-file layouts (e.g.
84
+ // `blogFilenameTemplate: "{slug}.md"`) don't have a per-post
85
+ // directory; the rename is then calendar-only.
86
+ const isDirLayout = !siteCfg.blogFilenameTemplate || siteCfg.blogFilenameTemplate.includes('/');
87
+ const dirExists = existsSync(oldDir);
88
+ if (isDirLayout && !dirExists && effectiveContentType(entry) === 'blog') {
89
+ throw new Error(`calendar entry "${oldSlug}" is a blog post but no directory exists at ${oldDir}. ` +
90
+ `The calendar row has drifted from disk — reconcile the row's slug to match the actual ` +
91
+ `directory name, then re-run the rename against the real slug.`);
92
+ }
93
+ if (dirExists) {
94
+ if (existsSync(newDir)) {
95
+ throw new Error(`target directory already exists: ${newDir}`);
96
+ }
97
+ actions.push({
98
+ kind: 'dir-rename',
99
+ summary: 'rename post directory',
100
+ details: `${oldDir}\n → ${newDir}`,
101
+ });
102
+ if (!dryRun)
103
+ renameSync(oldDir, newDir);
104
+ }
105
+ // 2. Calendar entry slug change
106
+ actions.push({
107
+ kind: 'calendar-slug-change',
108
+ summary: `calendar entry.slug: "${oldSlug}" → "${newSlug}"`,
109
+ details: `entry.id ${entry.id} unchanged — all workflow/distribution joins preserved`,
110
+ });
111
+ if (!dryRun) {
112
+ entry.slug = newSlug;
113
+ }
114
+ // 3. Cosmetic slug sync on distributions with the same entryId
115
+ const matchingDistributions = calendar.distributions.filter((d) => d.entryId === entry.id);
116
+ if (matchingDistributions.length > 0) {
117
+ actions.push({
118
+ kind: 'distribution-slug-sync',
119
+ summary: `sync slug on ${matchingDistributions.length} distribution record(s)`,
120
+ details: matchingDistributions
121
+ .map((d) => ` ${d.platform}${d.channel ? `/${d.channel}` : ''} → slug "${newSlug}"`)
122
+ .join('\n'),
123
+ });
124
+ if (!dryRun) {
125
+ for (const d of matchingDistributions)
126
+ d.slug = newSlug;
127
+ }
128
+ }
129
+ if (!dryRun) {
130
+ writeCalendar(calendarPath, calendar);
131
+ }
132
+ // 4. _redirects append (when site has redirectsPath configured)
133
+ if (siteCfg.redirectsPath) {
134
+ const redirectsFile = join(projectRoot, siteCfg.redirectsPath);
135
+ const block = buildRedirectBlock(oldSlug, newSlug);
136
+ actions.push({
137
+ kind: 'redirect-append',
138
+ summary: `append 301 redirect block to _redirects`,
139
+ details: ` file: ${redirectsFile}\n${block
140
+ .split('\n')
141
+ .map((l) => ` ${l}`)
142
+ .join('\n')}`,
143
+ });
144
+ if (!dryRun) {
145
+ if (!existsSync(redirectsFile)) {
146
+ writeFileSync(redirectsFile, block, 'utf-8');
147
+ }
148
+ else {
149
+ appendFileSync(redirectsFile, block, 'utf-8');
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ entryId: entry.id,
155
+ oldSlug,
156
+ newSlug,
157
+ actions,
158
+ dryRun,
159
+ };
160
+ }
161
+ //# sourceMappingURL=rename-slug.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rename-slug.js","sourceRoot":"","sources":["../src/rename-slug.ts"],"names":[],"mappings":"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"}