@minamocms/framework 0.0.0-stage → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +234 -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 +81 -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,27 @@
1
+ // Pure, filesystem-free mapping between a /blog/<slug> URL and a
2
+ // post's path relative to postsRoot. Unlike pages' arbitrary nested
3
+ // paths (about.json beside a sibling about/ directory), posts are
4
+ // flat only - a URL with more than one segment after /blog/ is never
5
+ // a valid post URL, enforced here rather than left to sanitisePath.
6
+ const BLOG_PREFIX = '/blog/';
7
+ // /blog is a permanently reserved namespace: both "/blog" itself (no
8
+ // slug) and every "/blog/..." URL are recognised here, so a caller can
9
+ // route the whole namespace to post resolution before ever checking
10
+ // for a page, matching the confirmed reserved-namespace decision.
11
+ export function isBlogUrl(url) {
12
+ return url === '/blog' || url.startsWith(BLOG_PREFIX);
13
+ }
14
+ export function urlToPostPath(url) {
15
+ if (!url.startsWith(BLOG_PREFIX)) {
16
+ return null;
17
+ }
18
+ const slug = url.slice(BLOG_PREFIX.length);
19
+ if (slug === '' || slug.includes('/')) {
20
+ return null;
21
+ }
22
+ return `${slug}.json`;
23
+ }
24
+ export function postPathToUrl(relativePostPath) {
25
+ const withoutExtension = relativePostPath.replace(/\.json$/, '');
26
+ return `${BLOG_PREFIX}${withoutExtension}`;
27
+ }
@@ -0,0 +1,4 @@
1
+ export interface PreparedOperation {
2
+ paths: string[];
3
+ undo: () => unknown[];
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { PreparedOperation } from './prepared-operation.ts';
4
+ import type { ThemeSchemas } from './validation.ts';
5
+ export type PublishReason = 'validation-failed' | 'draft-not-found' | 'page-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
6
+ export declare class PublishError extends Error {
7
+ readonly reason: PublishReason;
8
+ constructor(reason: PublishReason, message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ export declare function preparePublishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[]): PreparedOperation;
13
+ export declare function publishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
14
+ export declare function unpublishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
15
+ export declare function publishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
@@ -0,0 +1,313 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { GitOperationError, commitPaths } from "./git.js";
4
+ import { sanitisePath } from "./path-safety.js";
5
+ import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
6
+ import { pagePathToUrl } from "./urls.js";
7
+ import { validateContent } from "./validation.js";
8
+ import { enqueue } from "./write-queue.js";
9
+ import { reindexInBackground } from "./reindex-on-write.js";
10
+ const PAGES_PREFIX = 'pages/';
11
+ // Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
12
+ // helper, not a shared abstraction. Returns null for anything else
13
+ // (menus have no public URL, so redirect semantics don't apply).
14
+ function urlForPublishedEntry(relativePath) {
15
+ if (relativePath.startsWith(PAGES_PREFIX)) {
16
+ return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
17
+ }
18
+ return null;
19
+ }
20
+ export class PublishError extends Error {
21
+ reason;
22
+ constructor(reason, message, options) {
23
+ super(message, options);
24
+ this.name = 'PublishError';
25
+ this.reason = reason;
26
+ }
27
+ }
28
+ // Hand-rolled fs snapshot/restore, not a database transaction
29
+ // (deliberate, given the minimal-dependency policy). Attempts every
30
+ // restore even if one fails, rather than aborting on the first error,
31
+ // and reports every failure to the caller: a filesystem cannot give a
32
+ // true atomicity guarantee (disk full, permissions changing mid-flight
33
+ // are real residual risks), so the honest contract is "restores
34
+ // everything it can, and is loud if it can't" rather than a silent
35
+ // best-effort.
36
+ function rollback(snapshots, redirects) {
37
+ const failures = [];
38
+ for (const snapshot of snapshots) {
39
+ try {
40
+ if (snapshot.liveExisted) {
41
+ writeFileSync(snapshot.livePath, snapshot.liveOriginal);
42
+ }
43
+ else if (existsSync(snapshot.livePath)) {
44
+ unlinkSync(snapshot.livePath);
45
+ }
46
+ }
47
+ catch (error) {
48
+ failures.push(error);
49
+ }
50
+ try {
51
+ mkdirSync(dirname(snapshot.draftPath), { recursive: true });
52
+ writeFileSync(snapshot.draftPath, snapshot.draftOriginal);
53
+ }
54
+ catch (error) {
55
+ failures.push(error);
56
+ }
57
+ }
58
+ if (redirects) {
59
+ try {
60
+ if (redirects.existed) {
61
+ writeFileSync(redirects.path, redirects.original);
62
+ }
63
+ else if (existsSync(redirects.path)) {
64
+ unlinkSync(redirects.path);
65
+ }
66
+ }
67
+ catch (error) {
68
+ failures.push(error);
69
+ }
70
+ }
71
+ return failures;
72
+ }
73
+ function rollbackOrRethrow(snapshots, cause, redirects) {
74
+ const failures = rollback(snapshots, redirects);
75
+ if (failures.length > 0) {
76
+ throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause });
77
+ }
78
+ const reason = cause instanceof GitOperationError ? 'commit-failed' : 'write-failed';
79
+ const message = cause instanceof Error ? cause.message : String(cause);
80
+ throw new PublishError(reason, `Publish failed: ${message}`, { cause });
81
+ }
82
+ // Validates and writes everything a publish needs, stopping short of
83
+ // the commit itself - used both by the standalone publishDraftsJob
84
+ // (which commits immediately after) and by batch.ts (which accumulates
85
+ // this alongside other operations and commits once for the whole
86
+ // batch). A write-phase failure is caught and rolled back here,
87
+ // inside prepare itself - never left for the caller to discover -
88
+ // so prepare either fully succeeds or fully undoes itself before
89
+ // throwing.
90
+ export function preparePublishDrafts(config, themeSchemas, relativePaths) {
91
+ // Reject duplicates up front: without this, a repeated path's live-
92
+ // file snapshot would capture already-overwritten state instead of
93
+ // the true original, corrupting any later rollback.
94
+ const seen = new Set();
95
+ for (const relativePath of relativePaths) {
96
+ if (seen.has(relativePath)) {
97
+ throw new PublishError('duplicate-path', `Duplicate path in publish request: "${relativePath}"`);
98
+ }
99
+ seen.add(relativePath);
100
+ }
101
+ // Validate every path before writing anything. Nothing is written
102
+ // during this pass, so "no files change and no commit is created" on
103
+ // any failure (C6) falls out of ordering, not cleanup logic. Safe
104
+ // because the write queue serialises everything: no other job can
105
+ // interleave and change a draft between this pass and the write pass.
106
+ const entries = [];
107
+ for (const relativePath of relativePaths) {
108
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
109
+ const livePath = sanitisePath(config.contentRoot, relativePath);
110
+ let draftContent;
111
+ try {
112
+ draftContent = readFileSync(draftPath);
113
+ }
114
+ catch {
115
+ throw new PublishError('draft-not-found', `No draft found at "${relativePath}"`);
116
+ }
117
+ const parsed = JSON.parse(draftContent.toString('utf-8'));
118
+ const result = validateContent(relativePath, parsed, themeSchemas);
119
+ if (!result.valid) {
120
+ throw new PublishError('validation-failed', `Draft at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`);
121
+ }
122
+ entries.push({ relativePath, draftPath, livePath, draftContent });
123
+ }
124
+ const snapshots = [];
125
+ let redirectsSnapshot;
126
+ try {
127
+ for (const entry of entries) {
128
+ let liveExisted = false;
129
+ let liveOriginal;
130
+ try {
131
+ liveOriginal = readFileSync(entry.livePath);
132
+ liveExisted = true;
133
+ }
134
+ catch {
135
+ liveExisted = false;
136
+ }
137
+ snapshots.push({
138
+ livePath: entry.livePath,
139
+ liveExisted,
140
+ liveOriginal,
141
+ draftPath: entry.draftPath,
142
+ draftOriginal: entry.draftContent,
143
+ });
144
+ mkdirSync(dirname(entry.livePath), { recursive: true });
145
+ writeFileSync(entry.livePath, entry.draftContent);
146
+ unlinkSync(entry.draftPath);
147
+ }
148
+ // E5 (publish half): a brand-new live page may supersede a stale
149
+ // redirect recorded at its own URL (checklist wording: "creating a
150
+ // page at a path that has a redirect entry"). Only applies to page
151
+ // content - redirect semantics aren't defined for menus (no public
152
+ // URL), so anything else is skipped silently via
153
+ // urlForPublishedEntry's null return.
154
+ const qualifyingUrls = entries
155
+ .map((entry) => urlForPublishedEntry(entry.relativePath))
156
+ .filter((url) => url !== null);
157
+ if (qualifyingUrls.length > 0) {
158
+ const existed = existsSync(config.redirectsPath);
159
+ const original = existed ? readFileSync(config.redirectsPath, 'utf-8') : '';
160
+ let entriesAfterRemoval = loadRedirects(config).entries;
161
+ for (const url of qualifyingUrls) {
162
+ entriesAfterRemoval = removeRedirectForPath(entriesAfterRemoval, url);
163
+ }
164
+ const updated = serialiseRedirects(entriesAfterRemoval);
165
+ if (updated !== (existed ? original : '')) {
166
+ writeFileSync(config.redirectsPath, updated);
167
+ redirectsSnapshot = { path: config.redirectsPath, existed, original };
168
+ }
169
+ }
170
+ }
171
+ catch (error) {
172
+ // A write-phase failure: roll back immediately, inside prepare
173
+ // itself. cause here can never be a GitOperationError (commitPaths
174
+ // hasn't been called yet), so rollbackOrRethrow's own
175
+ // instanceof-based classification correctly resolves to
176
+ // 'write-failed', never 'commit-failed'.
177
+ rollbackOrRethrow(snapshots, error, redirectsSnapshot);
178
+ }
179
+ // Only the live paths (and redirects.json, if it changed) are ever
180
+ // staged: drafts are never git-tracked in the first place (saves
181
+ // don't commit, per C2), so a draft's deletion has no git-visible
182
+ // effect at all. Including it in the commit would fail outright,
183
+ // since `git add` on a path that is both absent from disk and was
184
+ // never tracked is a hard error ("pathspec did not match any
185
+ // files"), not a silent no-op.
186
+ const paths = entries.map((entry) => entry.livePath);
187
+ if (redirectsSnapshot) {
188
+ paths.push(redirectsSnapshot.path);
189
+ }
190
+ return { paths, undo: () => rollback(snapshots, redirectsSnapshot) };
191
+ }
192
+ async function publishDraftsJob(config, themeSchemas, relativePaths, message, author) {
193
+ const { paths, undo } = preparePublishDrafts(config, themeSchemas, relativePaths);
194
+ try {
195
+ // commitPaths gives its own all-or-nothing contract for git staging,
196
+ // so a failure here only ever leaves filesystem state for this
197
+ // catch block to worry about, never git index state.
198
+ commitPaths(config.siteRoot, paths, message, author);
199
+ }
200
+ catch (error) {
201
+ const failures = undo();
202
+ if (failures.length > 0) {
203
+ throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
204
+ }
205
+ // Always commit-failed here, structurally: prepare already
206
+ // succeeded (no write-phase error), so anything caught in this try
207
+ // can only have come from commitPaths itself.
208
+ const errorMessage = error instanceof Error ? error.message : String(error);
209
+ throw new PublishError('commit-failed', `Publish failed: ${errorMessage}`, { cause: error });
210
+ }
211
+ }
212
+ async function unpublishPageJob(config, relativePath, message, author) {
213
+ const livePath = sanitisePath(config.contentRoot, relativePath);
214
+ let original;
215
+ try {
216
+ original = readFileSync(livePath);
217
+ }
218
+ catch {
219
+ throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
220
+ }
221
+ const parsed = JSON.parse(original.toString('utf-8'));
222
+ parsed.published = false;
223
+ const updated = Buffer.from(JSON.stringify(parsed, null, 2));
224
+ // Unpublish only ever touches one file and never a draft, so it gets
225
+ // its own minimal inline restore rather than routing through the
226
+ // two-file publish rollback() above, which would need an unused
227
+ // draft slot to fit the same shape for no benefit.
228
+ try {
229
+ writeFileSync(livePath, updated);
230
+ commitPaths(config.siteRoot, [livePath], message, author);
231
+ }
232
+ catch (error) {
233
+ try {
234
+ writeFileSync(livePath, original);
235
+ }
236
+ catch {
237
+ throw new PublishError('rollback-failed', 'Unpublish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
238
+ }
239
+ const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
240
+ const errorMessage = error instanceof Error ? error.message : String(error);
241
+ throw new PublishError(reason, `Unpublish failed: ${errorMessage}`, { cause: error });
242
+ }
243
+ }
244
+ // The exact twin of unpublishPageJob above, flipping the same flag the
245
+ // other way: reads the live file, sets published true, commits. Kept as
246
+ // its own job rather than a parameterised shared one - the two read
247
+ // identically but say opposite things, and a boolean argument at every
248
+ // call site ("publishPage(config, path, true)") reads far worse than
249
+ // two named functions.
250
+ //
251
+ // Deliberately does NOT validate against the theme schemas, matching
252
+ // unpublish rather than publishDrafts: this only ever touches content
253
+ // that is already live, and was therefore already validated when it was
254
+ // published in the first place. publishDrafts validates because it
255
+ // promotes a draft, which may never have been checked before.
256
+ //
257
+ // Like unpublish, never touches a draft. A page with unpublished edits
258
+ // pending keeps them, and this only changes whether what is already
259
+ // live is publicly visible.
260
+ async function publishPageJob(config, relativePath, message, author) {
261
+ const livePath = sanitisePath(config.contentRoot, relativePath);
262
+ let original;
263
+ try {
264
+ original = readFileSync(livePath);
265
+ }
266
+ catch {
267
+ throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
268
+ }
269
+ const parsed = JSON.parse(original.toString('utf-8'));
270
+ parsed.published = true;
271
+ const updated = Buffer.from(JSON.stringify(parsed, null, 2));
272
+ // Same minimal inline restore unpublishPageJob uses, and for the same
273
+ // reason - one file, no draft, so the two-file publish rollback()
274
+ // above would need an unused draft slot for no benefit.
275
+ try {
276
+ writeFileSync(livePath, updated);
277
+ commitPaths(config.siteRoot, [livePath], message, author);
278
+ }
279
+ catch (error) {
280
+ try {
281
+ writeFileSync(livePath, original);
282
+ }
283
+ catch {
284
+ throw new PublishError('rollback-failed', 'Publishing this page failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
285
+ }
286
+ const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
287
+ const errorMessage = error instanceof Error ? error.message : String(error);
288
+ throw new PublishError(reason, `Publishing this page failed: ${errorMessage}`, { cause: error });
289
+ }
290
+ }
291
+ export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
292
+ const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
293
+ // Only on success - a failed publish changed nothing, so there's
294
+ // nothing to reindex. Chained onto `result` rather than awaited here:
295
+ // by the time this callback runs, the write-queue's own tail has
296
+ // already advanced past this job, so reindexInBackground's own
297
+ // enqueue()d rebuild queues cleanly behind it (see that function's
298
+ // own comment on why calling it any earlier would deadlock).
299
+ result.then(() => reindexInBackground(config), () => undefined);
300
+ return result;
301
+ }
302
+ export function unpublishPage(config, relativePath, message, author) {
303
+ const result = enqueue(() => unpublishPageJob(config, relativePath, message, author));
304
+ result.then(() => reindexInBackground(config), () => undefined);
305
+ return result;
306
+ }
307
+ // Twin of unpublishPage - same queue, same background reindex (a page
308
+ // becoming visible changes the index exactly as much as one leaving it).
309
+ export function publishPage(config, relativePath, message, author) {
310
+ const result = enqueue(() => publishPageJob(config, relativePath, message, author));
311
+ result.then(() => reindexInBackground(config), () => undefined);
312
+ return result;
313
+ }
@@ -0,0 +1,9 @@
1
+ export declare const WRITE_ROUTE_RATE_LIMIT: {
2
+ rateLimit: {};
3
+ };
4
+ export declare const NO_AUTH_ROUTE_RATE_LIMIT: {
5
+ rateLimit: {
6
+ max: number;
7
+ timeWindow: number;
8
+ };
9
+ };
@@ -0,0 +1,22 @@
1
+ // Shared rate-limit config markers for route options objects
2
+ // (Fastify's config: { rateLimit: {...} } opt-in shape, consumed by
3
+ // @fastify/rate-limit registered with global: false in server.ts).
4
+ // One constant per tier, not ad-hoc literals at each of the ~10 call
5
+ // sites, both for DRYness and so the structural test
6
+ // (test/static/static-analysis.test.ts) has something unambiguous to
7
+ // grep for.
8
+ // An empty per-route override: @fastify/rate-limit (registered with
9
+ // global: false in server.ts) still requires each route to opt in via
10
+ // this marker, but an empty {} inherits the plugin's own registration-
11
+ // level max/timeWindow - which server.ts sets from serverConfig.rateLimit
12
+ // (site.config.json-configurable, defaulting to 60/min - see
13
+ // server-config.ts). Verified empirically before relying on it: a
14
+ // route's own config.rateLimit only needs *something* present to opt
15
+ // in, not a duplicated copy of the numbers.
16
+ export const WRITE_ROUTE_RATE_LIMIT = { rateLimit: {} };
17
+ // Generous defense-in-depth against basic scanning/abuse of the
18
+ // endpoints reachable with zero credentials (GET /v1/capabilities,
19
+ // GET /v1/search), not a meaningful throttle on legitimate use - a
20
+ // real front-end calling search on every keystroke of a live search
21
+ // box should never realistically hit this.
22
+ export const NO_AUTH_ROUTE_RATE_LIMIT = { rateLimit: { max: 300, timeWindow: 60000 } };
@@ -0,0 +1,27 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type RedirectReason = 'redirect-cycle' | 'malformed-file' | 'invalid-target';
3
+ export declare class RedirectError extends Error {
4
+ readonly reason: RedirectReason;
5
+ constructor(reason: RedirectReason, message: string);
6
+ }
7
+ export interface RedirectEntry {
8
+ from: string;
9
+ to: string;
10
+ note?: string;
11
+ }
12
+ export interface Redirects {
13
+ schemaVersion: number;
14
+ entries: RedirectEntry[];
15
+ }
16
+ export declare const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
17
+ export declare function loadRedirects(config: SiteConfig): Redirects;
18
+ export declare function loadRedirectsStrict(config: SiteConfig): Redirects;
19
+ export declare function serialiseRedirects(entries: RedirectEntry[]): string;
20
+ export declare function buildRedirectLookup(entries: RedirectEntry[]): Map<string, string>;
21
+ export interface AddRedirectResult {
22
+ entries: RedirectEntry[];
23
+ retargeted: RedirectEntry[];
24
+ }
25
+ export declare function addRedirect(entries: RedirectEntry[], from: string, to: string, note?: string): AddRedirectResult;
26
+ export declare function removeRedirectForPath(entries: RedirectEntry[], url: string): RedirectEntry[];
27
+ export declare function isValidRedirectTarget(value: string): boolean;
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { validateRedirects } from "./validation.js";
3
+ export class RedirectError extends Error {
4
+ reason;
5
+ constructor(reason, message) {
6
+ super(message);
7
+ this.name = 'RedirectError';
8
+ this.reason = reason;
9
+ }
10
+ }
11
+ export const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
12
+ const EMPTY_REDIRECTS = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries: [] };
13
+ function isPlainObject(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ // The old shape (pre-Group M) was a flat { from: to } map, with no
17
+ // schemaVersion or entries key at all. Identified purely by the
18
+ // ABSENCE of both new-shape keys - a file that has an `entries` key is
19
+ // always treated as new-shape from here on, even if malformed, so a
20
+ // corrupt new-shape file is never silently reinterpreted as an empty
21
+ // old-shape map (which would look like "no redirects" and risk a
22
+ // write-time caller clobbering real data - see loadRedirectsStrict).
23
+ function isOldFlatShape(value) {
24
+ return !('schemaVersion' in value) && !('entries' in value);
25
+ }
26
+ function upgradeOldFlatShape(value) {
27
+ const entries = Object.entries(value)
28
+ .filter((pair) => typeof pair[1] === 'string')
29
+ .map(([from, to]) => ({ from, to }));
30
+ return { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
31
+ }
32
+ // Read-time loader, used by public request handling (resolve-url.ts,
33
+ // resolve-blog-url.ts) as well as by write paths before they call
34
+ // loadRedirectsStrict for the extra guarantee. Graceful-empty on any
35
+ // failure - including a malformed new-shape file - matching
36
+ // theme-schemas.ts's established pattern: a corrupt redirects.json
37
+ // must never crash public page serving, it should just mean redirects
38
+ // don't resolve until the file is fixed.
39
+ export function loadRedirects(config) {
40
+ let raw;
41
+ try {
42
+ raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
43
+ }
44
+ catch {
45
+ return EMPTY_REDIRECTS;
46
+ }
47
+ if (!isPlainObject(raw)) {
48
+ return EMPTY_REDIRECTS;
49
+ }
50
+ if (isOldFlatShape(raw)) {
51
+ return upgradeOldFlatShape(raw);
52
+ }
53
+ const result = validateRedirects(raw);
54
+ if (!result.valid) {
55
+ return EMPTY_REDIRECTS;
56
+ }
57
+ return raw;
58
+ }
59
+ // Stricter sibling of loadRedirects, for write paths only (manage-
60
+ // redirects.ts, and any other future direct redirect writer). Unlike
61
+ // the graceful read-time loader, this throws on a malformed new-shape
62
+ // file instead of silently treating it as empty - a write that follows
63
+ // an incorrectly-"empty" load would overwrite and destroy whatever
64
+ // real entries the file actually held, which is a much worse outcome
65
+ // for a write than for a page render.
66
+ export function loadRedirectsStrict(config) {
67
+ if (!existsSync(config.redirectsPath)) {
68
+ return EMPTY_REDIRECTS;
69
+ }
70
+ let raw;
71
+ try {
72
+ raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
73
+ }
74
+ catch {
75
+ throw new RedirectError('malformed-file', 'redirects.json exists but is not valid JSON; refusing to write until it is fixed');
76
+ }
77
+ if (!isPlainObject(raw)) {
78
+ throw new RedirectError('malformed-file', 'redirects.json exists but is not a JSON object');
79
+ }
80
+ if (isOldFlatShape(raw)) {
81
+ return upgradeOldFlatShape(raw);
82
+ }
83
+ const result = validateRedirects(raw);
84
+ if (!result.valid) {
85
+ throw new RedirectError('malformed-file', `redirects.json exists but does not match the expected shape: ${result.errors.map((e) => e.message).join(', ')}`);
86
+ }
87
+ return raw;
88
+ }
89
+ export function serialiseRedirects(entries) {
90
+ const redirects = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
91
+ return JSON.stringify(redirects, null, 2);
92
+ }
93
+ export function buildRedirectLookup(entries) {
94
+ return new Map(entries.map((entry) => [entry.from, entry.to]));
95
+ }
96
+ // Collapses chains at write time. Walks `to` forward through any
97
+ // existing chain to its final destination (not just one hop), then
98
+ // rewrites every existing entry that pointed at the just-added `from`
99
+ // to point at that final destination too, so old entries stay
100
+ // collapsed alongside the new one. Also handles updating an existing
101
+ // entry in place: if `from` already has an entry, it is replaced.
102
+ export function addRedirect(entries, from, to, note) {
103
+ const byFrom = new Map(entries.map((entry) => [entry.from, entry]));
104
+ let target = to;
105
+ const visited = new Set();
106
+ for (;;) {
107
+ const next = byFrom.get(target)?.to;
108
+ if (next === undefined) {
109
+ break;
110
+ }
111
+ if (visited.has(target)) {
112
+ throw new RedirectError('redirect-cycle', `redirects.json already contains a cycle reachable from "${to}"`);
113
+ }
114
+ visited.add(target);
115
+ target = next;
116
+ }
117
+ // The walk above only detects a cycle among *pre-existing* entries.
118
+ // A walk that terminates normally can still resolve back to `from`
119
+ // itself (e.g. adding "/b" -> "/a" when "/a" -> "/b" already exists:
120
+ // the walk hits "/b", which has no further mapping, and stops without
121
+ // revisiting anything) - that's a self-referential redirect, checked
122
+ // separately here.
123
+ if (target === from) {
124
+ throw new RedirectError('redirect-cycle', `Adding redirect "${from}" -> "${to}" would create a cycle (resolves back to "${from}")`);
125
+ }
126
+ const retargeted = [];
127
+ const updated = [];
128
+ for (const entry of entries) {
129
+ if (entry.from === from) {
130
+ continue;
131
+ }
132
+ if (entry.to === from) {
133
+ const rewritten = { ...entry, to: target };
134
+ retargeted.push(rewritten);
135
+ updated.push(rewritten);
136
+ continue;
137
+ }
138
+ updated.push(entry);
139
+ }
140
+ const newEntry = note !== undefined ? { from, to: target, note } : { from, to: target };
141
+ updated.push(newEntry);
142
+ return { entries: updated, retargeted };
143
+ }
144
+ export function removeRedirectForPath(entries, url) {
145
+ if (!entries.some((entry) => entry.from === url)) {
146
+ return entries;
147
+ }
148
+ return entries.filter((entry) => entry.from !== url);
149
+ }
150
+ // Checked by char code rather than a regex control-character class, to
151
+ // avoid embedding literal control characters in source.
152
+ function hasControlCharacter(value) {
153
+ for (let i = 0; i < value.length; i += 1) {
154
+ const code = value.charCodeAt(i);
155
+ if (code <= 0x1f || code === 0x7f) {
156
+ return true;
157
+ }
158
+ }
159
+ return false;
160
+ }
161
+ // Shared by manage-redirects.ts (POST/PUT /v1/redirects) and the
162
+ // retroactive fix to delete-content.ts's client-supplied redirectTo -
163
+ // both must enforce the same rule on a redirect target, or the two
164
+ // write paths would silently disagree on what's allowed into the same
165
+ // file. Internal-only: must start with a single "/", never a scheme,
166
+ // host, or protocol-relative "//" - closes an open-redirect vector.
167
+ // Control characters are rejected regardless of the internal/external
168
+ // question, since public.ts writes this value directly into the
169
+ // Location response header.
170
+ export function isValidRedirectTarget(value) {
171
+ if (value.length === 0 || hasControlCharacter(value)) {
172
+ return false;
173
+ }
174
+ return value.startsWith('/') && !value.startsWith('//');
175
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function reindexInBackground(config: SiteConfig): void;
3
+ export declare function reindexOnBootIfMissing(config: SiteConfig): void;
@@ -0,0 +1,30 @@
1
+ import { rebuildIndex, rebuildIndexIfMissing } from "../search/rebuild-index.js";
2
+ // Search is a derived, disposable index (constraint 3) - previously
3
+ // nothing ever rebuilt it except a caller manually hitting
4
+ // POST /v1/search/rebuild, so GET /search.json stayed empty forever on
5
+ // a fresh site and stale forever after a real edit. This is the one
6
+ // place that closes that gap: called after a content-affecting write's
7
+ // own enqueue()d promise has already resolved.
8
+ //
9
+ // Deliberately fire-and-forget, never awaited by the caller: rebuildIndex
10
+ // is itself enqueue()d internally (search/rebuild-index.ts) - awaiting it
11
+ // from inside the very job whose own completion is what let this run
12
+ // would deadlock (routes/search.ts's own comment describes the same
13
+ // hazard for a second, nested enqueue() call). A failed reindex must
14
+ // also never surface as a failure of the write that triggered it - the
15
+ // write already succeeded, and the index is rebuildable from content at
16
+ // any time, so a logged-and-swallowed failure here is the correct
17
+ // severity, not a thrown error.
18
+ export function reindexInBackground(config) {
19
+ void rebuildIndex(config).catch((error) => {
20
+ console.error('Background search reindex failed:', error);
21
+ });
22
+ }
23
+ // startServer's own boot-time call - see rebuildIndexIfMissing's own
24
+ // comment for why this is conditional (existsSync), unlike the
25
+ // unconditional reindexInBackground above.
26
+ export function reindexOnBootIfMissing(config) {
27
+ void rebuildIndexIfMissing(config).catch((error) => {
28
+ console.error('Background search reindex failed:', error);
29
+ });
30
+ }
@@ -0,0 +1,11 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ResolvedBlogUrl = {
3
+ kind: 'post';
4
+ relativePath: string;
5
+ } | {
6
+ kind: 'redirect';
7
+ to: string;
8
+ } | {
9
+ kind: 'not-found';
10
+ };
11
+ export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;