@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.
- package/dist/body-state.d.ts +27 -0
- package/dist/body-state.d.ts.map +1 -0
- package/dist/body-state.js +62 -0
- package/dist/body-state.js.map +1 -0
- package/dist/calendar-mutations.d.ts +124 -0
- package/dist/calendar-mutations.d.ts.map +1 -0
- package/dist/calendar-mutations.js +305 -0
- package/dist/calendar-mutations.js.map +1 -0
- package/dist/calendar.d.ts +54 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +430 -0
- package/dist/calendar.js.map +1 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +72 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +91 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +216 -0
- package/dist/config.js.map +1 -0
- package/dist/content-index.d.ts +74 -0
- package/dist/content-index.d.ts.map +1 -0
- package/dist/content-index.js +205 -0
- package/dist/content-index.js.map +1 -0
- package/dist/content-tree-fs-walk.d.ts +54 -0
- package/dist/content-tree-fs-walk.d.ts.map +1 -0
- package/dist/content-tree-fs-walk.js +112 -0
- package/dist/content-tree-fs-walk.js.map +1 -0
- package/dist/content-tree-helpers.d.ts +52 -0
- package/dist/content-tree-helpers.d.ts.map +1 -0
- package/dist/content-tree-helpers.js +116 -0
- package/dist/content-tree-helpers.js.map +1 -0
- package/dist/content-tree-types.d.ts +175 -0
- package/dist/content-tree-types.d.ts.map +1 -0
- package/dist/content-tree-types.js +10 -0
- package/dist/content-tree-types.js.map +1 -0
- package/dist/content-tree.d.ts +93 -0
- package/dist/content-tree.d.ts.map +1 -0
- package/dist/content-tree.js +385 -0
- package/dist/content-tree.js.map +1 -0
- package/dist/doctor/index.d.ts +11 -0
- package/dist/doctor/index.d.ts.map +1 -0
- package/dist/doctor/index.js +10 -0
- package/dist/doctor/index.js.map +1 -0
- package/dist/doctor/project-rules.d.ts +59 -0
- package/dist/doctor/project-rules.d.ts.map +1 -0
- package/dist/doctor/project-rules.js +143 -0
- package/dist/doctor/project-rules.js.map +1 -0
- package/dist/doctor/rules/calendar-uuid-missing.d.ts +19 -0
- package/dist/doctor/rules/calendar-uuid-missing.d.ts.map +1 -0
- package/dist/doctor/rules/calendar-uuid-missing.js +176 -0
- package/dist/doctor/rules/calendar-uuid-missing.js.map +1 -0
- package/dist/doctor/rules/duplicate-id.d.ts +27 -0
- package/dist/doctor/rules/duplicate-id.d.ts.map +1 -0
- package/dist/doctor/rules/duplicate-id.js +157 -0
- package/dist/doctor/rules/duplicate-id.js.map +1 -0
- package/dist/doctor/rules/legacy-top-level-id-migration.d.ts +40 -0
- package/dist/doctor/rules/legacy-top-level-id-migration.d.ts.map +1 -0
- package/dist/doctor/rules/legacy-top-level-id-migration.js +232 -0
- package/dist/doctor/rules/legacy-top-level-id-migration.js.map +1 -0
- package/dist/doctor/rules/missing-frontmatter-id.d.ts +45 -0
- package/dist/doctor/rules/missing-frontmatter-id.d.ts.map +1 -0
- package/dist/doctor/rules/missing-frontmatter-id.js +283 -0
- package/dist/doctor/rules/missing-frontmatter-id.js.map +1 -0
- package/dist/doctor/rules/orphan-frontmatter-id.d.ts +18 -0
- package/dist/doctor/rules/orphan-frontmatter-id.d.ts.map +1 -0
- package/dist/doctor/rules/orphan-frontmatter-id.js +154 -0
- package/dist/doctor/rules/orphan-frontmatter-id.js.map +1 -0
- package/dist/doctor/rules/schema-rejected.d.ts +20 -0
- package/dist/doctor/rules/schema-rejected.d.ts.map +1 -0
- package/dist/doctor/rules/schema-rejected.js +44 -0
- package/dist/doctor/rules/schema-rejected.js.map +1 -0
- package/dist/doctor/rules/slug-collision.d.ts +18 -0
- package/dist/doctor/rules/slug-collision.d.ts.map +1 -0
- package/dist/doctor/rules/slug-collision.js +65 -0
- package/dist/doctor/rules/slug-collision.js.map +1 -0
- package/dist/doctor/rules/workflow-stale.d.ts +20 -0
- package/dist/doctor/rules/workflow-stale.d.ts.map +1 -0
- package/dist/doctor/rules/workflow-stale.js +136 -0
- package/dist/doctor/rules/workflow-stale.js.map +1 -0
- package/dist/doctor/runner.d.ts +75 -0
- package/dist/doctor/runner.d.ts.map +1 -0
- package/dist/doctor/runner.js +289 -0
- package/dist/doctor/runner.js.map +1 -0
- package/dist/doctor/schema-patch.d.ts +21 -0
- package/dist/doctor/schema-patch.d.ts.map +1 -0
- package/dist/doctor/schema-patch.js +92 -0
- package/dist/doctor/schema-patch.js.map +1 -0
- package/dist/doctor/types.d.ts +185 -0
- package/dist/doctor/types.d.ts.map +1 -0
- package/dist/doctor/types.js +13 -0
- package/dist/doctor/types.js.map +1 -0
- package/dist/frontmatter.d.ts +103 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +306 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest-derive.d.ts +79 -0
- package/dist/ingest-derive.d.ts.map +1 -0
- package/dist/ingest-derive.js +299 -0
- package/dist/ingest-derive.js.map +1 -0
- package/dist/ingest-paths.d.ts +37 -0
- package/dist/ingest-paths.d.ts.map +1 -0
- package/dist/ingest-paths.js +176 -0
- package/dist/ingest-paths.js.map +1 -0
- package/dist/ingest.d.ts +162 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +269 -0
- package/dist/ingest.js.map +1 -0
- package/dist/journal.d.ts +49 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +113 -0
- package/dist/journal.js.map +1 -0
- package/dist/outline-split.d.ts +38 -0
- package/dist/outline-split.d.ts.map +1 -0
- package/dist/outline-split.js +84 -0
- package/dist/outline-split.js.map +1 -0
- package/dist/overrides.d.ts +83 -0
- package/dist/overrides.d.ts.map +1 -0
- package/dist/overrides.js +88 -0
- package/dist/overrides.js.map +1 -0
- package/dist/paths.d.ts +183 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +266 -0
- package/dist/paths.js.map +1 -0
- package/dist/remark-image-figure.mjs +77 -0
- package/dist/remark-strip-first-h1.mjs +26 -0
- package/dist/remark-strip-outline.mjs +44 -0
- package/dist/rename-slug.d.ts +49 -0
- package/dist/rename-slug.d.ts.map +1 -0
- package/dist/rename-slug.js +161 -0
- package/dist/rename-slug.js.map +1 -0
- package/dist/review/handlers.d.ts +55 -0
- package/dist/review/handlers.d.ts.map +1 -0
- package/dist/review/handlers.js +307 -0
- package/dist/review/handlers.js.map +1 -0
- package/dist/review/index.d.ts +14 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/index.js +13 -0
- package/dist/review/index.js.map +1 -0
- package/dist/review/journal-mappers.d.ts +35 -0
- package/dist/review/journal-mappers.d.ts.map +1 -0
- package/dist/review/journal-mappers.js +48 -0
- package/dist/review/journal-mappers.js.map +1 -0
- package/dist/review/pipeline.d.ts +79 -0
- package/dist/review/pipeline.d.ts.map +1 -0
- package/dist/review/pipeline.js +234 -0
- package/dist/review/pipeline.js.map +1 -0
- package/dist/review/render.d.ts +27 -0
- package/dist/review/render.d.ts.map +1 -0
- package/dist/review/render.js +42 -0
- package/dist/review/render.js.map +1 -0
- package/dist/review/report.d.ts +50 -0
- package/dist/review/report.d.ts.map +1 -0
- package/dist/review/report.js +164 -0
- package/dist/review/report.js.map +1 -0
- package/dist/review/result.d.ts +12 -0
- package/dist/review/result.d.ts.map +1 -0
- package/dist/review/result.js +12 -0
- package/dist/review/result.js.map +1 -0
- package/dist/review/start-handlers.d.ts +62 -0
- package/dist/review/start-handlers.d.ts.map +1 -0
- package/dist/review/start-handlers.js +223 -0
- package/dist/review/start-handlers.js.map +1 -0
- package/dist/review/types.d.ts +169 -0
- package/dist/review/types.d.ts.map +1 -0
- package/dist/review/types.js +26 -0
- package/dist/review/types.js.map +1 -0
- package/dist/review/workflow-paths.d.ts +68 -0
- package/dist/review/workflow-paths.d.ts.map +1 -0
- package/dist/review/workflow-paths.js +112 -0
- package/dist/review/workflow-paths.js.map +1 -0
- package/dist/scaffold.d.ts +67 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +122 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/scrapbook.d.ts +229 -0
- package/dist/scrapbook.d.ts.map +1 -0
- package/dist/scrapbook.js +500 -0
- package/dist/scrapbook.js.map +1 -0
- package/dist/types.d.ts +197 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +120 -0
- package/dist/types.js.map +1 -0
- 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 `` 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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"}
|