@minamocms/framework 0.0.0-stage → 0.10.2

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 (286) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +94 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. package/package.json +64 -4
@@ -0,0 +1,169 @@
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ import { GitOperationError, commitPaths } from "./git.js";
5
+ import { validateContent } from "./validation.js";
6
+ import { enqueue } from "./write-queue.js";
7
+ export class MigrationError extends Error {
8
+ reason;
9
+ constructor(reason, message, options) {
10
+ super(message, options);
11
+ this.name = 'MigrationError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ // Walks the migration chain one step at a time until currentVersion is
16
+ // reached. Throws if a step is missing, or if a migration doesn't
17
+ // actually advance the version (a defensive check against a broken
18
+ // migration function).
19
+ function applyMigrationChain(content, migrations, currentVersion, path) {
20
+ let current = content;
21
+ const initialVersion = current.schemaVersion;
22
+ if (typeof initialVersion !== 'number') {
23
+ throw new MigrationError('migration-failed', `"${path}" has no numeric schemaVersion`);
24
+ }
25
+ let version = initialVersion;
26
+ while (version < currentVersion) {
27
+ const migrate = migrations[version];
28
+ if (!migrate) {
29
+ throw new MigrationError('migration-failed', `No migration registered for schemaVersion ${version} ("${path}")`);
30
+ }
31
+ const next = migrate(current);
32
+ const nextVersion = next.schemaVersion;
33
+ if (typeof nextVersion !== 'number' || nextVersion <= version) {
34
+ throw new MigrationError('migration-failed', `Migration from schemaVersion ${version} did not advance schemaVersion ("${path}")`);
35
+ }
36
+ current = next;
37
+ version = nextVersion;
38
+ }
39
+ return current;
40
+ }
41
+ // Hand-rolled fs snapshot/restore, matching publish.ts's established
42
+ // shape - but simpler: every file the write phase touches already
43
+ // existed with real prior bytes (F3's skip logic guarantees this), so
44
+ // there is no "did this file exist before" branch to carry. A
45
+ // relocated file (targetPath !== path) is undone by removing whatever
46
+ // landed at targetPath and restoring the original at its original path.
47
+ function rollback(files) {
48
+ const failures = [];
49
+ for (const file of files) {
50
+ try {
51
+ if (file.targetPath !== file.path) {
52
+ try {
53
+ unlinkSync(file.targetPath);
54
+ }
55
+ catch (error) {
56
+ if (error.code !== 'ENOENT') {
57
+ throw error;
58
+ }
59
+ }
60
+ }
61
+ writeFileSync(file.path, file.originalBytes);
62
+ }
63
+ catch (error) {
64
+ failures.push(error);
65
+ }
66
+ }
67
+ return failures;
68
+ }
69
+ async function runMigrationsJob(config, themeSchemas, migrations, currentVersion, author, options) {
70
+ // content/posts/ is a legacy root: posts were folded into pages, and
71
+ // no code walks a postsRoot for anything else any more (see
72
+ // config.ts) - it's computed locally, here only, purely to find any
73
+ // leftover legacy files this migration needs to relocate. A site
74
+ // that never had posts has no such directory; listFilesRecursively
75
+ // already returns [] for a missing root, same as every other caller.
76
+ const legacyPostsRoot = join(config.contentRoot, 'posts');
77
+ // Named walks (pages/menus/legacy posts), not one broad walk of
78
+ // contentRoot - see content-read.ts's listContent for the identical
79
+ // reasoning (Group N nested draftsRoot/redirectsPath inside
80
+ // contentRoot; a broad walk would double-migrate drafts and try to
81
+ // validate redirects.json as a page). base stays config.contentRoot
82
+ // for pages/menus so relativePath matches what validateContent
83
+ // expects. Legacy posts get their own targetPath: content/posts/
84
+ // was always flat (no nested slugs), so relocating each file to
85
+ // content/pages/blog/<slug>.json preserves its old /blog/<slug> URL
86
+ // as an ordinary nested page - no reserved namespace needed any more.
87
+ const files = [
88
+ ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json').map((rel) => ({
89
+ path: join(config.contentRoot, rel),
90
+ targetPath: join(config.contentRoot, rel),
91
+ relativePath: rel,
92
+ })),
93
+ ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json').map((rel) => ({
94
+ path: join(config.contentRoot, rel),
95
+ targetPath: join(config.contentRoot, rel),
96
+ relativePath: rel,
97
+ })),
98
+ ...listFilesRecursively(legacyPostsRoot, legacyPostsRoot, '.json').map((rel) => ({
99
+ path: join(legacyPostsRoot, rel),
100
+ targetPath: join(config.contentRoot, 'pages', 'blog', rel),
101
+ relativePath: join('pages', 'blog', rel),
102
+ })),
103
+ ...listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json').map((rel) => ({
104
+ path: join(config.draftsRoot, rel),
105
+ targetPath: join(config.draftsRoot, rel),
106
+ relativePath: rel,
107
+ })),
108
+ ];
109
+ // Compute every migration before writing anything. If any file's
110
+ // migration or validation throws, everything computed for other
111
+ // files so far is simply discarded - a mid-list failure aborts with
112
+ // literally zero writes having happened (F4's primary scenario).
113
+ const toMigrate = [];
114
+ for (const { path, targetPath, relativePath } of files) {
115
+ const originalBytes = readFileSync(path);
116
+ const parsed = JSON.parse(originalBytes.toString('utf-8'));
117
+ const schemaVersion = parsed.schemaVersion;
118
+ // A legacy posts/ file can never already be at currentVersion: the
119
+ // posts/ directory only ever held content written before this very
120
+ // migration existed, so this skip and the relocation above never
121
+ // conflict in practice.
122
+ if (typeof schemaVersion === 'number' && schemaVersion >= currentVersion) {
123
+ continue;
124
+ }
125
+ const migrated = applyMigrationChain(parsed, migrations, currentVersion, path);
126
+ const result = validateContent(relativePath, migrated, themeSchemas);
127
+ if (!result.valid) {
128
+ throw new MigrationError('validation-failed', `Migrated content at "${path}" failed validation: ${JSON.stringify(result.errors)}`);
129
+ }
130
+ const migratedBytes = Buffer.from(JSON.stringify(migrated, null, 2));
131
+ toMigrate.push({ path, targetPath, originalBytes, migratedBytes });
132
+ }
133
+ if (toMigrate.length === 0) {
134
+ return [];
135
+ }
136
+ try {
137
+ for (const file of toMigrate) {
138
+ if (file.targetPath !== file.path) {
139
+ mkdirSync(dirname(file.targetPath), { recursive: true });
140
+ }
141
+ writeFileSync(file.targetPath, file.migratedBytes);
142
+ if (file.targetPath !== file.path) {
143
+ unlinkSync(file.path);
144
+ }
145
+ }
146
+ const committedPaths = new Set();
147
+ for (const file of toMigrate) {
148
+ committedPaths.add(file.path);
149
+ committedPaths.add(file.targetPath);
150
+ }
151
+ if (options.commit !== false) {
152
+ commitPaths(config.siteRoot, [...committedPaths], `chore: migrate content to schema version ${currentVersion}`, author);
153
+ }
154
+ }
155
+ catch (error) {
156
+ const failures = rollback(toMigrate);
157
+ if (failures.length > 0) {
158
+ throw new MigrationError('rollback-failed', 'Migration run failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
159
+ }
160
+ const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
161
+ const detail = error instanceof Error ? error.message : String(error);
162
+ throw new MigrationError(reason, `Migration run failed: ${detail}`, { cause: error });
163
+ }
164
+ return toMigrate.map((file) => file.path);
165
+ }
166
+ // Resolves to the files migrated (their original paths).
167
+ export function runMigrations(config, themeSchemas, migrations, currentVersion, author, options = {}) {
168
+ return enqueue(() => runMigrationsJob(config, themeSchemas, migrations, currentVersion, author, options));
169
+ }
@@ -0,0 +1,3 @@
1
+ export declare const MIME_TYPES: Record<string, string>;
2
+ export declare const DEFAULT_MIME_TYPE = "application/octet-stream";
3
+ export declare function mimeTypeFor(path: string): string;
@@ -0,0 +1,43 @@
1
+ import { extname } from 'node:path';
2
+ // A small hand-rolled lookup, not a dependency (@fastify/static would
3
+ // be the obvious ecosystem package, but this is a handful of lines and
4
+ // matches this codebase's existing minimal-dependency posture). Only
5
+ // the common web asset types - nothing here needs to be exhaustive.
6
+ //
7
+ // Shared by both routes/assets.ts (theme CSS/JS/images/fonts) and
8
+ // routes/public.ts's theme/root/ mirror (robots.txt, .well-known/*,
9
+ // site-verification HTML files) via services/static-file.ts - the
10
+ // text/xml/html entries below exist specifically for that second,
11
+ // broader use case. Found live: robots.txt fell back to
12
+ // DEFAULT_MIME_TYPE (application/octet-stream), which every browser
13
+ // treats as "download this" rather than displaying it inline, even
14
+ // though the actual content is plain text.
15
+ export const MIME_TYPES = {
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.js': 'text/javascript; charset=utf-8',
18
+ '.json': 'application/json; charset=utf-8',
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.jpeg': 'image/jpeg',
22
+ '.gif': 'image/gif',
23
+ '.svg': 'image/svg+xml',
24
+ '.webp': 'image/webp',
25
+ // Video: without these an uploaded .mp4 serves as the
26
+ // DEFAULT_MIME_TYPE below (application/octet-stream), which every
27
+ // browser treats as "download this" rather than playing it - and
28
+ // media-public.ts sets X-Content-Type-Options: nosniff, so nothing
29
+ // rescues it by sniffing. Same failure robots.txt already hit.
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
32
+ '.ico': 'image/x-icon',
33
+ '.woff': 'font/woff',
34
+ '.woff2': 'font/woff2',
35
+ '.txt': 'text/plain; charset=utf-8',
36
+ '.xml': 'application/xml; charset=utf-8',
37
+ '.html': 'text/html; charset=utf-8',
38
+ '.htm': 'text/html; charset=utf-8',
39
+ };
40
+ export const DEFAULT_MIME_TYPE = 'application/octet-stream';
41
+ export function mimeTypeFor(path) {
42
+ return MIME_TYPES[extname(path).toLowerCase()] ?? DEFAULT_MIME_TYPE;
43
+ }
@@ -0,0 +1,16 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { PreparedOperation } from './prepared-operation.ts';
4
+ export type MoveReason = 'source-not-found' | 'destination-exists' | 'write-failed' | 'commit-failed' | 'rollback-failed';
5
+ export declare class MoveError extends Error {
6
+ readonly reason: MoveReason;
7
+ constructor(reason: MoveReason, message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export declare function prepareMovePage(config: SiteConfig, fromUrl: string, toUrl: string, options?: {
12
+ createRedirect?: boolean;
13
+ }): PreparedOperation;
14
+ export declare function movePage(config: SiteConfig, fromUrl: string, toUrl: string, message: string, author: CommitAuthor, options?: {
15
+ createRedirect?: boolean;
16
+ }): Promise<void>;
@@ -0,0 +1,236 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ import { commitPaths } from "./git.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { linkSources, rewriteLinks } from "./links.js";
7
+ import { addRedirect, loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
8
+ import { pagePathToUrl, urlToPagePath } from "./urls.js";
9
+ import { enqueue } from "./write-queue.js";
10
+ import { reindexInBackground } from "./reindex-on-write.js";
11
+ export class MoveError extends Error {
12
+ reason;
13
+ constructor(reason, message, options) {
14
+ super(message, options);
15
+ this.name = 'MoveError';
16
+ this.reason = reason;
17
+ }
18
+ }
19
+ // Validates and writes everything a move needs, stopping short of the
20
+ // commit itself - used both by the standalone movePageJob (which
21
+ // commits immediately after) and by batch.ts (which accumulates this
22
+ // alongside other operations and commits once for the whole batch). A
23
+ // write-phase failure is caught and rolled back here, inside prepare
24
+ // itself, so prepare either fully succeeds or fully undoes itself
25
+ // before throwing.
26
+ //
27
+ // options.createRedirect defaults true - existing callers (and
28
+ // batch.ts) keep today's behaviour unchanged. false skips only
29
+ // addRedirect (the "old URL now redirects to the new one" step);
30
+ // removeRedirectForPath still always runs regardless, since that's a
31
+ // correctness cleanup (the destination URL can't stay claimed by a
32
+ // stale redirect once a real page exists there), not a new redirect
33
+ // being created.
34
+ export function prepareMovePage(config, fromUrl, toUrl, options = {}) {
35
+ const createRedirect = options.createRedirect ?? true;
36
+ const fromRelative = urlToPagePath(fromUrl);
37
+ const toRelative = urlToPagePath(toUrl);
38
+ const fromPageFile = sanitisePath(config.pagesRoot, fromRelative);
39
+ const toPageFile = sanitisePath(config.pagesRoot, toRelative);
40
+ if (!existsSync(fromPageFile)) {
41
+ throw new MoveError('source-not-found', `No page found at "${fromUrl}"`);
42
+ }
43
+ if (existsSync(toPageFile)) {
44
+ throw new MoveError('destination-exists', `A page already exists at "${toUrl}"`);
45
+ }
46
+ const fromDirRelative = fromRelative.replace(/\.json$/, '');
47
+ const toDirRelative = toRelative.replace(/\.json$/, '');
48
+ const fromDir = sanitisePath(config.pagesRoot, fromDirRelative);
49
+ const toDir = sanitisePath(config.pagesRoot, toDirRelative);
50
+ const fromDirExists = existsSync(fromDir) && statSync(fromDir).isDirectory();
51
+ if (fromDirExists && existsSync(toDir)) {
52
+ throw new MoveError('destination-exists', `A directory already exists at the destination for "${toUrl}"`);
53
+ }
54
+ // A draft-only page (never published) already at the destination
55
+ // would be overwritten by this page's own draft moving there.
56
+ const hasDrafts = existsSync(config.draftsRoot);
57
+ if (hasDrafts && existsSync(sanitisePath(config.draftsRoot, join('pages', toRelative)))) {
58
+ throw new MoveError('destination-exists', `A draft page already exists at "${toUrl}"`);
59
+ }
60
+ // Enumerate every affected page (the page itself, plus every
61
+ // descendant if it has any) before touching disk. This list drives
62
+ // both the redirect entries (E3: one per affected page) and the git
63
+ // staging list.
64
+ const affected = [
65
+ { oldRelativePath: fromRelative, newRelativePath: toRelative, oldUrl: fromUrl, newUrl: toUrl },
66
+ ];
67
+ if (fromDirExists) {
68
+ for (const rel of listFilesRecursively(fromDir, fromDir, '.json')) {
69
+ const oldRelativePath = join(fromDirRelative, rel);
70
+ const newRelativePath = join(toDirRelative, rel);
71
+ affected.push({
72
+ oldRelativePath,
73
+ newRelativePath,
74
+ oldUrl: pagePathToUrl(oldRelativePath),
75
+ newUrl: pagePathToUrl(newRelativePath),
76
+ });
77
+ }
78
+ }
79
+ const performedRenames = [];
80
+ // Files whose links were rewritten, with what they held before.
81
+ const rewrittenFiles = [];
82
+ let redirectsBefore = null;
83
+ let redirectsChanged = false;
84
+ // Reverses every rename (in reverse order) and restores/removes
85
+ // redirects.json. Attempts everything even if one step fails,
86
+ // reporting every failure - matches publish.ts's rollback()
87
+ // philosophy exactly (a filesystem cannot give a true atomicity
88
+ // guarantee, so the honest contract is "restores everything it can,
89
+ // and is loud if it can't").
90
+ function undoMoveWrites() {
91
+ const restoreFailures = [];
92
+ // Links first: they were rewritten after the renames, in the files'
93
+ // new places.
94
+ for (const rewritten of [...rewrittenFiles].reverse()) {
95
+ try {
96
+ writeFileSync(rewritten.file, rewritten.before);
97
+ }
98
+ catch (restoreError) {
99
+ restoreFailures.push(restoreError);
100
+ }
101
+ }
102
+ for (const rename of [...performedRenames].reverse()) {
103
+ try {
104
+ renameSync(rename.to, rename.from);
105
+ }
106
+ catch (restoreError) {
107
+ restoreFailures.push(restoreError);
108
+ }
109
+ }
110
+ if (redirectsChanged) {
111
+ try {
112
+ if (redirectsBefore === null) {
113
+ rmSync(config.redirectsPath, { force: true });
114
+ }
115
+ else {
116
+ writeFileSync(config.redirectsPath, redirectsBefore);
117
+ }
118
+ }
119
+ catch (restoreError) {
120
+ restoreFailures.push(restoreError);
121
+ }
122
+ }
123
+ return restoreFailures;
124
+ }
125
+ try {
126
+ mkdirSync(dirname(toPageFile), { recursive: true });
127
+ renameSync(fromPageFile, toPageFile);
128
+ performedRenames.push({ from: fromPageFile, to: toPageFile });
129
+ if (fromDirExists) {
130
+ mkdirSync(dirname(toDir), { recursive: true });
131
+ renameSync(fromDir, toDir);
132
+ performedRenames.push({ from: fromDir, to: toDir });
133
+ }
134
+ // Each affected page's draft moves with it: left behind, it became
135
+ // a draft-only page at the old address (found while adding link
136
+ // rewriting). Drafts aren't committed, so they're renamed but never
137
+ // staged; undo still puts them back.
138
+ for (const page of hasDrafts ? affected : []) {
139
+ const draftFrom = sanitisePath(config.draftsRoot, join('pages', page.oldRelativePath));
140
+ if (existsSync(draftFrom)) {
141
+ const draftTo = sanitisePath(config.draftsRoot, join('pages', page.newRelativePath));
142
+ mkdirSync(dirname(draftTo), { recursive: true });
143
+ renameSync(draftFrom, draftTo);
144
+ performedRenames.push({ from: draftFrom, to: draftTo });
145
+ }
146
+ }
147
+ redirectsBefore = existsSync(config.redirectsPath)
148
+ ? readFileSync(config.redirectsPath, 'utf-8')
149
+ : null;
150
+ let entries = loadRedirects(config).entries;
151
+ for (const page of affected) {
152
+ // E5 (move half) must run BEFORE addRedirect, not after: if the
153
+ // destination held a stale redirect (page.newUrl was itself a
154
+ // key), addRedirect's chain-walk would otherwise treat it as a
155
+ // legitimate hop and silently collapse straight through it,
156
+ // rather than the new page superseding it.
157
+ entries = removeRedirectForPath(entries, page.newUrl);
158
+ if (createRedirect) {
159
+ entries = addRedirect(entries, page.oldUrl, page.newUrl).entries;
160
+ }
161
+ }
162
+ const redirectsAfter = serialiseRedirects(entries);
163
+ if (redirectsAfter !== (redirectsBefore ?? '')) {
164
+ writeFileSync(config.redirectsPath, redirectsAfter);
165
+ redirectsChanged = true;
166
+ }
167
+ // Every link to the moved page (or a page under it) now points at
168
+ // its new address - in live pages, drafts, menus and the site
169
+ // settings - in this same commit, so links never lean on the
170
+ // redirect above. The redirect stays for links from elsewhere.
171
+ for (const source of linkSources(config)) {
172
+ const before = readFileSync(source.file, 'utf-8');
173
+ let parsed;
174
+ try {
175
+ parsed = JSON.parse(before);
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ const rewritten = rewriteLinks(parsed, fromUrl, toUrl);
181
+ if (!rewritten.changed) {
182
+ continue;
183
+ }
184
+ writeFileSync(source.file, `${JSON.stringify(rewritten.value, null, 2)}${before.endsWith('\n') ? '\n' : ''}`);
185
+ rewrittenFiles.push({ file: source.file, before, commit: source.kind !== 'draft' });
186
+ }
187
+ }
188
+ catch (error) {
189
+ // A write-phase failure: roll back immediately, inside prepare
190
+ // itself, and always classify as write-failed - commitPaths hasn't
191
+ // been called yet, so this can never be a GitOperationError.
192
+ const failures = undoMoveWrites();
193
+ if (failures.length > 0) {
194
+ throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
195
+ }
196
+ const detail = error instanceof Error ? error.message : String(error);
197
+ throw new MoveError('write-failed', `Move failed: ${detail}`, { cause: error });
198
+ }
199
+ const paths = [];
200
+ for (const page of affected) {
201
+ paths.push(sanitisePath(config.pagesRoot, page.oldRelativePath));
202
+ paths.push(sanitisePath(config.pagesRoot, page.newRelativePath));
203
+ }
204
+ if (redirectsChanged) {
205
+ paths.push(config.redirectsPath);
206
+ }
207
+ // Drafts are never committed; everything else rewritten is.
208
+ for (const rewritten of rewrittenFiles) {
209
+ if (rewritten.commit && !paths.includes(rewritten.file)) {
210
+ paths.push(rewritten.file);
211
+ }
212
+ }
213
+ return { paths, undo: undoMoveWrites };
214
+ }
215
+ async function movePageJob(config, fromUrl, toUrl, message, author, options = {}) {
216
+ const { paths, undo } = prepareMovePage(config, fromUrl, toUrl, options);
217
+ try {
218
+ commitPaths(config.siteRoot, paths, message, author);
219
+ }
220
+ catch (error) {
221
+ const failures = undo();
222
+ if (failures.length > 0) {
223
+ throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
224
+ }
225
+ // Always commit-failed here, structurally: prepare already
226
+ // succeeded, so anything caught in this try can only have come
227
+ // from commitPaths itself.
228
+ const detail = error instanceof Error ? error.message : String(error);
229
+ throw new MoveError('commit-failed', `Move failed: ${detail}`, { cause: error });
230
+ }
231
+ }
232
+ export function movePage(config, fromUrl, toUrl, message, author, options = {}) {
233
+ const result = enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
234
+ result.then(() => reindexInBackground(config), () => undefined);
235
+ return result;
236
+ }
@@ -0,0 +1,6 @@
1
+ export type PathSafetyReason = 'absolute-path' | 'traversal-segment' | 'malformed-encoding' | 'outside-root' | 'resolves-to-root' | 'symlink-escape' | 'invalid-path';
2
+ export declare class PathSafetyError extends Error {
3
+ readonly reason: PathSafetyReason;
4
+ constructor(reason: PathSafetyReason, message: string);
5
+ }
6
+ export declare function sanitisePath(root: string, requestedPath: string): string;
@@ -0,0 +1,112 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, resolve, sep } from 'node:path';
3
+ export class PathSafetyError extends Error {
4
+ reason;
5
+ constructor(reason, message) {
6
+ super(message);
7
+ this.name = 'PathSafetyError';
8
+ this.reason = reason;
9
+ }
10
+ }
11
+ function isWithin(root, candidate) {
12
+ return candidate === root || candidate.startsWith(root + sep);
13
+ }
14
+ // Only existing path segments can be symlinks, so for a target that
15
+ // doesn't exist yet (e.g. a new draft being written for the first time)
16
+ // it is sufficient to realpath the nearest existing ancestor and confirm
17
+ // that stays within the root; segments that don't exist can't smuggle a
18
+ // symlink escape.
19
+ function realpathOfNearestExisting(candidate) {
20
+ let current = candidate;
21
+ for (;;) {
22
+ try {
23
+ return realpathSync(current);
24
+ }
25
+ catch (error) {
26
+ const code = error.code;
27
+ // ENOENT/ENOTDIR mean only "this segment doesn't exist yet" -
28
+ // walk up to the nearest ancestor that does, per this function's
29
+ // own doc comment. Any other failure (ENAMETOOLONG for an
30
+ // over-length segment, ERR_INVALID_ARG_VALUE for an embedded null
31
+ // byte, etc. - verified empirically, not an exhaustive enum by
32
+ // design) means the path itself is malformed, not merely absent.
33
+ // Rejecting it here as a PathSafetyError is required, not
34
+ // optional: left as a raw rethrow, it would escape every route
35
+ // handler's `instanceof PathSafetyError` check and surface as an
36
+ // unsanitised 500, the exact failure mode this file exists to
37
+ // prevent (CLAUDE.md's #1 security concern).
38
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
39
+ throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
40
+ }
41
+ const parent = dirname(current);
42
+ if (parent === current) {
43
+ throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
44
+ }
45
+ current = parent;
46
+ }
47
+ }
48
+ }
49
+ // The single shared path-sanitisation helper. Every fs-touching code
50
+ // path that handles a request-supplied :path parameter must resolve it
51
+ // through this function before touching disk (enforced by a grep test,
52
+ // see test/static/static-analysis.test.ts).
53
+ export function sanitisePath(root, requestedPath) {
54
+ const normalisedRoot = resolve(root);
55
+ // Step 1: reject a raw absolute path before any decoding.
56
+ if (isAbsolute(requestedPath)) {
57
+ throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
58
+ }
59
+ // Step 2: percent-decode. Decoding before splitting into segments (not
60
+ // decoding each segment independently) is what's actually required to
61
+ // catch an encoded slash disguising a segment boundary, e.g.
62
+ // "a%2f..%2fetc" decodes to "a/../etc" and is then caught by the
63
+ // segment scan below; decoding per-segment would miss it. Malformed
64
+ // encoding is rejected outright rather than passed through: this will
65
+ // also reject a benign filename containing a literal unencoded "%",
66
+ // which is an acceptable reject-on-ambiguity trade-off, not a bug.
67
+ let decoded;
68
+ try {
69
+ decoded = decodeURIComponent(requestedPath);
70
+ }
71
+ catch {
72
+ throw new PathSafetyError('malformed-encoding', `Path contains malformed percent-encoding: "${requestedPath}"`);
73
+ }
74
+ // Step 3: an encoded absolute path (e.g. "%2fetc%2fpasswd") doesn't
75
+ // look absolute in raw form, so check again after decoding.
76
+ if (isAbsolute(decoded)) {
77
+ throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
78
+ }
79
+ // Step 4: reject any ".." segment, covering both plain and encoded
80
+ // traversal now that decoding has already happened.
81
+ if (decoded.split('/').includes('..')) {
82
+ throw new PathSafetyError('traversal-segment', `Path must not contain ".." segments: "${requestedPath}"`);
83
+ }
84
+ // Step 5: string-only containment check, still no fs call.
85
+ const resolved = resolve(normalisedRoot, decoded);
86
+ if (!isWithin(normalisedRoot, resolved)) {
87
+ throw new PathSafetyError('outside-root', `Path resolves outside the allowed root: "${requestedPath}"`);
88
+ }
89
+ // Step 6: deliberate hardening beyond the literal B3-B6 wording. A
90
+ // request that resolves to exactly the root (e.g. "", ".") must not be
91
+ // handed to a caller expecting a single file path.
92
+ if (resolved === normalisedRoot) {
93
+ throw new PathSafetyError('resolves-to-root', `Path must not resolve to the root itself: "${requestedPath}"`);
94
+ }
95
+ // Step 7: only now touch the filesystem, to catch a symlink inside the
96
+ // tree pointing outside the root.
97
+ const realRoot = realpathSync(normalisedRoot);
98
+ const realResolved = realpathOfNearestExisting(resolved);
99
+ if (!isWithin(realRoot, realResolved)) {
100
+ throw new PathSafetyError('symlink-escape', `Path escapes the allowed root via a symlink: "${requestedPath}"`);
101
+ }
102
+ // Accepted residual risk, not a structural gap: the symlink check
103
+ // above and the fs operation a caller performs with the returned
104
+ // path are not atomic, so a symlink swapped in between could in
105
+ // principle slip through (TOCTOU). Given the actual threat model -
106
+ // a single-operator site, all writes serialised through one queue,
107
+ // no concurrent untrusted filesystem access - this is low realistic
108
+ // risk. Flagged by plan-reviewer's Phase 1 audit; noted rather than
109
+ // solved, the same way other accepted trade-offs in this codebase
110
+ // are (see publish.ts's rollback() comment).
111
+ return resolved;
112
+ }
@@ -0,0 +1,13 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface PidRecord {
3
+ pid: number;
4
+ serverPid: number;
5
+ port: number;
6
+ startedAt: string;
7
+ }
8
+ export declare function pidFilePath(config: SiteConfig): string;
9
+ export declare function writePidFile(config: SiteConfig, port: number): void;
10
+ export declare function readPidFile(config: SiteConfig): PidRecord | null;
11
+ export declare function removeOwnPidFile(config: SiteConfig): void;
12
+ export declare function removePidFile(config: SiteConfig): void;
13
+ export declare function isProcessAlive(pid: number): boolean;
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function pidFilePath(config) {
4
+ return join(config.dataRoot, 'server.pid');
5
+ }
6
+ // Node sets WATCH_REPORT_DEPENDENCIES in the child it runs under
7
+ // --watch, whose parent is then the watcher itself.
8
+ function stopTarget() {
9
+ return process.env.WATCH_REPORT_DEPENDENCIES ? process.ppid : process.pid;
10
+ }
11
+ export function writePidFile(config, port) {
12
+ const record = { pid: stopTarget(), serverPid: process.pid, port, startedAt: new Date().toISOString() };
13
+ mkdirSync(config.dataRoot, { recursive: true });
14
+ writeFileSync(pidFilePath(config), `${JSON.stringify(record, null, 2)}\n`);
15
+ }
16
+ export function readPidFile(config) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(pidFilePath(config), 'utf-8'));
19
+ const { pid, serverPid, port, startedAt } = parsed;
20
+ if (typeof pid !== 'number' ||
21
+ typeof serverPid !== 'number' ||
22
+ typeof port !== 'number' ||
23
+ typeof startedAt !== 'string') {
24
+ return null;
25
+ }
26
+ return { pid, serverPid, port, startedAt };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ // Only removes the file if it is still this process's own record: under
33
+ // `npm run dev` the watcher starts a new process on a theme change, and
34
+ // the old one shutting down must not delete the record the new one has
35
+ // just written.
36
+ export function removeOwnPidFile(config) {
37
+ if (readPidFile(config)?.serverPid === process.pid) {
38
+ removePidFile(config);
39
+ }
40
+ }
41
+ export function removePidFile(config) {
42
+ rmSync(pidFilePath(config), { force: true });
43
+ }
44
+ // Whether a process with this id exists. EPERM means it does, but
45
+ // belongs to another user.
46
+ export function isProcessAlive(pid) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ return error instanceof Error && 'code' in error && error.code === 'EPERM';
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isBlogUrl(url: string): boolean;
2
+ export declare function urlToPostPath(url: string): string | null;
3
+ export declare function postPathToUrl(relativePostPath: string): string;