@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,123 @@
1
+ import { join } from 'node:path';
2
+ import { getMenusMtimeMs, getPageMtimeMs, PageRenderError, renderPage } from "../renderer/render-page.js";
3
+ import { PathSafetyError } from "../services/path-safety.js";
4
+ import { resolveUrl } from "../services/resolve-url.js";
5
+ import { findStaticFile, sendStaticFile } from "../services/static-file.js";
6
+ import { resolveSiteSettings } from "../services/site-settings.js";
7
+ // resolveUrl's relativePath is relative to pagesRoot (e.g. "about.json"),
8
+ // but renderPage's relativePath is relative to contentRoot/draftsRoot
9
+ // directly (e.g. "pages/about.json") - two already-correct modules with
10
+ // two different conventions for the same string. This is the seam
11
+ // between them, not an arbitrary literal.
12
+ function toRenderPath(pagesRelativePath) {
13
+ return join('pages', pagesRelativePath);
14
+ }
15
+ // A themed 404: content/pages/404.json, if it exists and is published,
16
+ // is rendered through the ordinary public renderPage pipeline (layout +
17
+ // sections + blocks, same as any other page) - no special-casing in
18
+ // render-page.ts itself, no separate "is this the 404 page" concept.
19
+ // The HTTP status is always 404 regardless, even though the body is a
20
+ // full HTML page. Any failure at all while attempting this - no
21
+ // 404.json, it's unpublished, or its own theme is broken - falls back
22
+ // to today's plain JSON error rather than ever turning a 404 into a
23
+ // 500 for a visitor who was already looking at a missing page.
24
+ async function sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas) {
25
+ try {
26
+ const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath('404.json'), 'public', resolveSiteSettings(config, themeSchemas));
27
+ reply.code(404).type('text/html; charset=utf-8').send(html);
28
+ }
29
+ catch {
30
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
31
+ }
32
+ }
33
+ async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine, renderCache,
34
+ // Which theme rendered a cached page: one pushed since makes it stale.
35
+ themeGeneration, themeSchemas) {
36
+ // The public catch-all is registered without a /v1 prefix alongside
37
+ // v1Routes (which has its own exact/prefixed routes). Fastify's
38
+ // router already prefers exact matches over this wildcard regardless
39
+ // of registration order, but an *unmatched* /v1/* path would
40
+ // otherwise fall through to here and get treated as a page lookup
41
+ // instead of Fastify's real 404 - this guard is what actually keeps
42
+ // A4 true for any future typo'd or removed /v1/* route, not
43
+ // registration order.
44
+ if (request.url === '/v1' || request.url.startsWith('/v1/')) {
45
+ return reply.callNotFound();
46
+ }
47
+ // theme/root/ mirrors arbitrary bare paths (robots.txt, .well-known/*)
48
+ // at the site root, checked before page resolution - unlike /assets/
49
+ // or /media/, these aren't distinguishable by a fixed prefix, so
50
+ // Fastify's own "specific route beats /*" behaviour can't do this for
51
+ // us automatically. A miss (including the bare "/" itself - a
52
+ // directory, never a file) falls straight through to the existing
53
+ // page-lookup logic below, unchanged.
54
+ const rootMirrorMatch = findStaticFile(config.rootMirrorRoot, request.params['*']);
55
+ if (rootMirrorMatch) {
56
+ sendStaticFile(reply, rootMirrorMatch);
57
+ return;
58
+ }
59
+ const url = `/${request.params['*']}`;
60
+ try {
61
+ const resolved = resolveUrl(config, url);
62
+ if (resolved.kind === 'not-found') {
63
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
64
+ return;
65
+ }
66
+ if (resolved.kind === 'redirect') {
67
+ reply.code(301).header('location', resolved.to).send();
68
+ return;
69
+ }
70
+ const renderPath = toRenderPath(resolved.relativePath);
71
+ // Validated against real filesystem state, not invalidated by
72
+ // hooking every write path (publish/unpublish/delete/move/batch) -
73
+ // see render-cache.ts's own comment for why. pageMtimeMs is null
74
+ // only if the file vanished between resolveUrl confirming it
75
+ // exists and this check (vanishingly unlikely) - falls through to
76
+ // an ordinary uncached render rather than treating that as
77
+ // fatal.
78
+ const pageMtimeMs = getPageMtimeMs(config, renderPath);
79
+ if (pageMtimeMs !== null) {
80
+ const menusMtimeMs = getMenusMtimeMs(config);
81
+ const cached = renderCache.get(renderPath);
82
+ if (cached &&
83
+ cached.pageMtimeMs === pageMtimeMs &&
84
+ cached.menusMtimeMs === menusMtimeMs &&
85
+ cached.themeGeneration === themeGeneration) {
86
+ reply.type('text/html; charset=utf-8').send(cached.html);
87
+ return;
88
+ }
89
+ const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public', resolveSiteSettings(config, themeSchemas));
90
+ renderCache.set(renderPath, { html, pageMtimeMs, menusMtimeMs, themeGeneration });
91
+ reply.type('text/html; charset=utf-8').send(html);
92
+ return;
93
+ }
94
+ const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public', resolveSiteSettings(config, themeSchemas));
95
+ reply.type('text/html; charset=utf-8').send(html);
96
+ }
97
+ catch (error) {
98
+ // A traversal attempt and an ordinary miss get the identical 404 -
99
+ // deliberately not 400, so a public, unauthenticated route never
100
+ // gives an attacker a signal that distinguishes the two.
101
+ if (error instanceof PathSafetyError) {
102
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
103
+ return;
104
+ }
105
+ if (error instanceof PageRenderError && error.reason === 'page-not-found') {
106
+ await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
107
+ return;
108
+ }
109
+ // Any other PageRenderError reason (missing-section-type,
110
+ // missing-block-type, template-error) is a broken theme/content
111
+ // problem, not something to explain to a public visitor - rethrow
112
+ // and let the global error handler sanitise it as a 500 (A5).
113
+ throw error;
114
+ }
115
+ }
116
+ export const publicRoutes = async (fastify, opts) => {
117
+ fastify.get('/*', async (request, reply) => {
118
+ // Read once per request: a theme pushed mid-request applies to the
119
+ // next one, never half of this one.
120
+ const theme = opts.theme.current;
121
+ return handlePublicRequest(request, reply, opts.config, theme.themeTemplates, theme.layouts, theme.engine, opts.renderCache, theme.generation, theme.themeSchemas);
122
+ });
123
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeState } from '../theme-state.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface PublishRouteOptions {
6
+ config: SiteConfig;
7
+ theme: ThemeState;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const publishRoutes: FastifyPluginAsync<PublishRouteOptions>;
@@ -0,0 +1,139 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { PathSafetyError } from "../services/path-safety.js";
3
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
4
+ import { PublishError, publishDrafts, publishPage, unpublishPage } from "../services/publish.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function parsePublishBody(body) {
10
+ if (typeof body !== 'object' || body === null) {
11
+ return null;
12
+ }
13
+ const { paths, message, author } = body;
14
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
15
+ return null;
16
+ }
17
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
18
+ return null;
19
+ }
20
+ return { paths, message, author };
21
+ }
22
+ function parsePublishedFlagBody(body) {
23
+ if (typeof body !== 'object' || body === null) {
24
+ return null;
25
+ }
26
+ const { message, author } = body;
27
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
28
+ return null;
29
+ }
30
+ return { message, author };
31
+ }
32
+ // Shared by both handlers below: each only ever sees the subset of
33
+ // reasons its own service function can actually throw.
34
+ function replyForPublishError(reply, error) {
35
+ if (error.reason === 'validation-failed' || error.reason === 'duplicate-path') {
36
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
37
+ return;
38
+ }
39
+ if (error.reason === 'draft-not-found' || error.reason === 'page-not-found') {
40
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
41
+ return;
42
+ }
43
+ throw error;
44
+ }
45
+ export const publishRoutes = async (fastify, opts) => {
46
+ fastify.post('/publish', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
47
+ const parsed = parsePublishBody(request.body);
48
+ if (!parsed) {
49
+ reply.code(400).send({
50
+ statusCode: 400,
51
+ error: 'Bad Request',
52
+ message: 'Expected { paths: string[], message: string, author: { name, email } }',
53
+ });
54
+ return;
55
+ }
56
+ try {
57
+ await publishDrafts(opts.config, opts.theme.current.themeSchemas, parsed.paths, parsed.message, parsed.author);
58
+ reply.send({ ok: true });
59
+ }
60
+ catch (error) {
61
+ // PathSafetyError has no .statusCode - left uncaught it falls
62
+ // through to the global handler's sanitised 500, the same gap
63
+ // every other route touching a :path already guards against.
64
+ if (error instanceof PathSafetyError) {
65
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
66
+ return;
67
+ }
68
+ if (error instanceof PublishError) {
69
+ replyForPublishError(reply, error);
70
+ return;
71
+ }
72
+ throw error;
73
+ }
74
+ });
75
+ fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
76
+ const parsed = parsePublishedFlagBody(request.body);
77
+ if (!parsed) {
78
+ reply.code(400).send({
79
+ statusCode: 400,
80
+ error: 'Bad Request',
81
+ message: 'Expected { message: string, author: { name, email } }',
82
+ });
83
+ return;
84
+ }
85
+ const relativePath = request.params['*'];
86
+ try {
87
+ await unpublishPage(opts.config, relativePath, parsed.message, parsed.author);
88
+ reply.send({ ok: true });
89
+ }
90
+ catch (error) {
91
+ // PathSafetyError has no .statusCode - left uncaught it falls
92
+ // through to the global handler's sanitised 500, the same gap
93
+ // every other route touching a :path already guards against.
94
+ if (error instanceof PathSafetyError) {
95
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
96
+ return;
97
+ }
98
+ if (error instanceof PublishError) {
99
+ replyForPublishError(reply, error);
100
+ return;
101
+ }
102
+ throw error;
103
+ }
104
+ });
105
+ // The twin of /unpublish/* above: sets published:true on a live page
106
+ // in place and commits. Deliberately separate from /publish, which
107
+ // promotes drafts - a page that is live but unpublished has no draft
108
+ // to promote, so /publish cannot reach it at all (draft-not-found),
109
+ // and promoting a draft would publish every pending edit along with
110
+ // the flag. This only ever changes the one boolean.
111
+ fastify.post('/publish-page/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
112
+ const parsed = parsePublishedFlagBody(request.body);
113
+ if (!parsed) {
114
+ reply.code(400).send({
115
+ statusCode: 400,
116
+ error: 'Bad Request',
117
+ message: 'Expected { message: string, author: { name, email } }',
118
+ });
119
+ return;
120
+ }
121
+ const relativePath = request.params['*'];
122
+ try {
123
+ await publishPage(opts.config, relativePath, parsed.message, parsed.author);
124
+ reply.send({ ok: true });
125
+ }
126
+ catch (error) {
127
+ // Same PathSafetyError guard as every other :path route here.
128
+ if (error instanceof PathSafetyError) {
129
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
130
+ return;
131
+ }
132
+ if (error instanceof PublishError) {
133
+ replyForPublishError(reply, error);
134
+ return;
135
+ }
136
+ throw error;
137
+ }
138
+ });
139
+ };
@@ -0,0 +1,8 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ export interface RedirectsRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const redirectsRoutes: FastifyPluginAsync<RedirectsRouteOptions>;
@@ -0,0 +1,132 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { ManageRedirectError, createRedirect, deleteRedirect, updateRedirect } from "../services/manage-redirects.js";
3
+ import { loadRedirects } from "../services/redirects.js";
4
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function parseUpsertRedirectBody(body) {
10
+ if (typeof body !== 'object' || body === null) {
11
+ return null;
12
+ }
13
+ const { from, to, note, message, author } = body;
14
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
15
+ return null;
16
+ }
17
+ if (note !== undefined && typeof note !== 'string') {
18
+ return null;
19
+ }
20
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
21
+ return null;
22
+ }
23
+ return { from, to, note, message, author };
24
+ }
25
+ function parseDeleteRedirectBody(body) {
26
+ if (typeof body !== 'object' || body === null) {
27
+ return null;
28
+ }
29
+ const { from, message, author } = body;
30
+ if (!isNonEmptyString(from) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
31
+ return null;
32
+ }
33
+ return { from, message, author };
34
+ }
35
+ function sendManageRedirectError(reply, error) {
36
+ if (error.reason === 'invalid-from' || error.reason === 'invalid-target' || error.reason === 'redirect-cycle') {
37
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
38
+ return;
39
+ }
40
+ if (error.reason === 'not-found') {
41
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
42
+ return;
43
+ }
44
+ if (error.reason === 'already-exists' || error.reason === 'live-content-exists') {
45
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
46
+ return;
47
+ }
48
+ throw error;
49
+ }
50
+ async function handleListRedirects(_request, reply, config) {
51
+ reply.send(loadRedirects(config));
52
+ }
53
+ async function handleCreateRedirect(request, reply, config) {
54
+ const parsed = parseUpsertRedirectBody(request.body);
55
+ if (!parsed) {
56
+ reply.code(400).send({
57
+ statusCode: 400,
58
+ error: 'Bad Request',
59
+ message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
60
+ });
61
+ return;
62
+ }
63
+ try {
64
+ const result = await createRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
65
+ reply.send(result);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof ManageRedirectError) {
69
+ sendManageRedirectError(reply, error);
70
+ return;
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+ async function handleUpdateRedirect(request, reply, config) {
76
+ const parsed = parseUpsertRedirectBody(request.body);
77
+ if (!parsed) {
78
+ reply.code(400).send({
79
+ statusCode: 400,
80
+ error: 'Bad Request',
81
+ message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
82
+ });
83
+ return;
84
+ }
85
+ try {
86
+ const result = await updateRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
87
+ reply.send(result);
88
+ }
89
+ catch (error) {
90
+ if (error instanceof ManageRedirectError) {
91
+ sendManageRedirectError(reply, error);
92
+ return;
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+ async function handleDeleteRedirect(request, reply, config) {
98
+ const parsed = parseDeleteRedirectBody(request.body);
99
+ if (!parsed) {
100
+ reply.code(400).send({
101
+ statusCode: 400,
102
+ error: 'Bad Request',
103
+ message: 'Expected { from: string, message: string, author: { name, email } }',
104
+ });
105
+ return;
106
+ }
107
+ try {
108
+ await deleteRedirect(config, parsed.from, parsed.message, parsed.author);
109
+ reply.code(204).send();
110
+ }
111
+ catch (error) {
112
+ if (error instanceof ManageRedirectError) {
113
+ sendManageRedirectError(reply, error);
114
+ return;
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ // from/to are always taken from the JSON body, never a URL path
120
+ // segment: they are arbitrary public URLs that may contain slashes,
121
+ // and critically none of these operations address a filesystem path
122
+ // at all (this mutates one entry inside redirects.json, not a file at
123
+ // a client-supplied path) - sanitisePath/path-traversal simply doesn't
124
+ // apply here, matching POST /content/move's existing body-carries-the-
125
+ // identity precedent rather than forcing an arbitrary URL into a route
126
+ // segment.
127
+ export const redirectsRoutes = async (fastify, opts) => {
128
+ fastify.get('/redirects', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListRedirects(request, reply, opts.config));
129
+ fastify.post('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleCreateRedirect(request, reply, opts.config));
130
+ fastify.put('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUpdateRedirect(request, reply, opts.config));
131
+ fastify.delete('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteRedirect(request, reply, opts.config));
132
+ };
@@ -0,0 +1,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface SearchPublicRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare const searchPublicRoutes: FastifyPluginAsync<SearchPublicRouteOptions>;
@@ -0,0 +1,104 @@
1
+ import { queryContent } from "../search/query-content.js";
2
+ import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
3
+ const FIELD_OPS = ['eq', 'gt', 'gte', 'lt', 'lte'];
4
+ const DEFAULT_LIMIT = 20;
5
+ const MAX_LIMIT = 100;
6
+ function badRequest(reply, message) {
7
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message });
8
+ }
9
+ // field:value (op implied "eq") or field:op:value. Split on the FIRST
10
+ // colon, then check whether the next segment up to a second colon is
11
+ // one of the known op words - a value that itself contains a colon
12
+ // (unlikely for the fields this targets, but not impossible) still
13
+ // parses correctly either way, since only a genuine, recognised op
14
+ // token is ever treated as one.
15
+ function parseFilter(raw) {
16
+ const firstColon = raw.indexOf(':');
17
+ if (firstColon <= 0) {
18
+ return undefined;
19
+ }
20
+ const field = raw.slice(0, firstColon);
21
+ const rest = raw.slice(firstColon + 1);
22
+ const secondColon = rest.indexOf(':');
23
+ if (secondColon !== -1) {
24
+ const maybeOp = rest.slice(0, secondColon);
25
+ if (FIELD_OPS.includes(maybeOp)) {
26
+ const value = rest.slice(secondColon + 1);
27
+ return value === '' ? undefined : { field, op: maybeOp, value };
28
+ }
29
+ }
30
+ return rest === '' ? undefined : { field, op: 'eq', value: rest };
31
+ }
32
+ function parseSort(raw) {
33
+ return raw.startsWith('-') ? { field: raw.slice(1), direction: 'desc' } : { field: raw, direction: 'asc' };
34
+ }
35
+ function parseLimit(raw) {
36
+ if (raw === undefined) {
37
+ return DEFAULT_LIMIT;
38
+ }
39
+ const parsed = Number.parseInt(raw, 10);
40
+ if (!Number.isFinite(parsed) || parsed < 1) {
41
+ return DEFAULT_LIMIT;
42
+ }
43
+ return Math.min(parsed, MAX_LIMIT);
44
+ }
45
+ function parseOffset(raw) {
46
+ if (raw === undefined) {
47
+ return 0;
48
+ }
49
+ const parsed = Number.parseInt(raw, 10);
50
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
51
+ }
52
+ async function handleSearch(request, reply, config) {
53
+ const { q, pageType, sort } = request.query;
54
+ const rawFilters = request.query.filter;
55
+ const filterStrings = rawFilters === undefined ? [] : Array.isArray(rawFilters) ? rawFilters : [rawFilters];
56
+ const filters = [];
57
+ for (const raw of filterStrings) {
58
+ const parsed = parseFilter(raw);
59
+ if (!parsed) {
60
+ badRequest(reply, `invalid filter "${raw}" - expected field:value or field:op:value`);
61
+ return;
62
+ }
63
+ if (parsed.op !== 'eq' && !Number.isFinite(Number(parsed.value))) {
64
+ badRequest(reply, `value must be numeric for op "${parsed.op}" (filter "${raw}")`);
65
+ return;
66
+ }
67
+ filters.push(parsed);
68
+ }
69
+ const response = queryContent(config.searchIndexPath, {
70
+ q,
71
+ pageType,
72
+ filters,
73
+ sort: sort ? parseSort(sort) : undefined,
74
+ limit: parseLimit(request.query.limit),
75
+ offset: parseOffset(request.query.offset),
76
+ });
77
+ reply.send(response);
78
+ }
79
+ // The one public query surface - full-text (q), structured filters,
80
+ // sort, and pagination all in one endpoint (query-content.ts's own
81
+ // queryContent), rather than three narrow ones. Deliberately no
82
+ // requireScope, unlike every other route in this codebase: it's
83
+ // read-only and can only ever surface already-published data
84
+ // (rebuild-index.ts never indexes drafts or unpublished content), so
85
+ // there's nothing here a site visitor couldn't already see by
86
+ // browsing the live site directly - the whole point of this route is
87
+ // that a theme's own front-end JS can call it directly, which a
88
+ // token requirement would rule out entirely (a bearer token embedded
89
+ // in public client-side JS is not a secret - anyone's dev tools can
90
+ // read it straight back out, and this agent's tokens all carry real
91
+ // write scopes, not just search). NO_AUTH_ROUTE_RATE_LIMIT, the same
92
+ // defense-in-depth GET /v1/capabilities already has.
93
+ //
94
+ // GET /search.json, not GET /v1/search: this is a stable, public,
95
+ // front-end-facing contract, not part of the versioned admin/
96
+ // integration surface under /v1 - registered without a prefix in
97
+ // server.ts, alongside mediaPublicRoutes/assetsRoutes/sitemapRoutes.
98
+ // A single reserved path, not a whole prefix (unlike /media/* or
99
+ // /assets/*): a site's own content page can still live at the bare
100
+ // /search URL (see granite-starter/theme/sections/search-demo.liquid,
101
+ // which does exactly that) - only this one exact path is claimed.
102
+ export const searchPublicRoutes = async (fastify, opts) => {
103
+ fastify.get('/search.json', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => handleSearch(request, reply, opts.config));
104
+ };
@@ -0,0 +1,8 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { TokenEntry } from '../server-config.ts';
4
+ export interface SearchRouteOptions {
5
+ config: SiteConfig;
6
+ tokens: TokenEntry[];
7
+ }
8
+ export declare const searchRoutes: FastifyPluginAsync<SearchRouteOptions>;
@@ -0,0 +1,15 @@
1
+ import { rebuildIndex } from "../search/rebuild-index.js";
2
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
3
+ import { requireScope } from "../services/token-auth.js";
4
+ // The read side (GET /search.json) lives in routes/search-public.ts,
5
+ // registered separately, unprefixed, in server.ts - this file only
6
+ // ever holds the authenticated write side, matching media.ts (write,
7
+ // under /v1) vs media-public.ts (public read, unprefixed).
8
+ export const searchRoutes = async (fastify, opts) => {
9
+ fastify.post('/search/rebuild', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (_request, reply) => {
10
+ // rebuildIndex is already self-enqueue()d (search/rebuild-index.ts) -
11
+ // never wrap it in a second enqueue() here, nested calls deadlock.
12
+ await rebuildIndex(opts.config);
13
+ reply.send({ ok: true });
14
+ });
15
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeState } from '../theme-state.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface SettingsRouteOptions {
6
+ config: SiteConfig;
7
+ theme: ThemeState;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const settingsRoutes: FastifyPluginAsync<SettingsRouteOptions>;
@@ -0,0 +1,68 @@
1
+ import { isValidCommitAuthor } from "../services/git.js";
2
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
3
+ import { readSiteSettings, resolveSiteSettings, SaveSiteSettingsError, saveSiteSettings } from "../services/site-settings.js";
4
+ import { requireScope } from "../services/token-auth.js";
5
+ function parseSaveSettingsBody(body) {
6
+ if (typeof body !== 'object' || body === null) {
7
+ return null;
8
+ }
9
+ const { settings, message, author } = body;
10
+ if (settings === undefined || typeof message !== 'string' || message === '' || !isValidCommitAuthor(author)) {
11
+ return null;
12
+ }
13
+ return { settings, message, author };
14
+ }
15
+ // GET: everything the admin needs to build the Site Settings form - the
16
+ // theme's schema (null when it defines no settings), the values saved so
17
+ // far, what templates actually see (saved values, defaults filling the
18
+ // rest), and the ETag a save must present.
19
+ async function handleGetSettings(reply, opts) {
20
+ const { themeSchemas } = opts.theme.current;
21
+ const stored = readSiteSettings(opts.config);
22
+ if (stored.etag !== null) {
23
+ reply.header('etag', stored.etag);
24
+ }
25
+ reply.send({
26
+ schema: themeSchemas.settings ?? null,
27
+ settings: stored.settings,
28
+ resolved: resolveSiteSettings(opts.config, themeSchemas),
29
+ });
30
+ }
31
+ // PUT: saves and commits at once, like a menu - no draft state. If-Match
32
+ // is required; "*" (or anything) is accepted while no settings file
33
+ // exists yet.
34
+ async function handleSaveSettings(request, reply, opts) {
35
+ const ifMatch = request.headers['if-match'];
36
+ if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
37
+ reply.code(428).send({ statusCode: 428, error: 'Precondition Required', message: 'An If-Match header is required to save site settings' });
38
+ return;
39
+ }
40
+ const parsed = parseSaveSettingsBody(request.body);
41
+ if (!parsed) {
42
+ reply.code(400).send({
43
+ statusCode: 400,
44
+ error: 'Bad Request',
45
+ message: 'Expected { settings: object, message: string, author: { name, email } }',
46
+ });
47
+ return;
48
+ }
49
+ try {
50
+ const etag = await saveSiteSettings(opts.config, opts.theme.current.themeSchemas, parsed.settings, ifMatch, parsed.message, parsed.author);
51
+ reply.header('etag', etag).send({ ok: true });
52
+ }
53
+ catch (error) {
54
+ if (error instanceof SaveSiteSettingsError && error.reason === 'conflict') {
55
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
56
+ return;
57
+ }
58
+ if (error instanceof SaveSiteSettingsError && error.reason === 'validation-failed') {
59
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message, errors: error.errors });
60
+ return;
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+ export const settingsRoutes = async (fastify, opts) => {
66
+ fastify.get('/settings', { preHandler: requireScope(opts.tokens, 'content') }, async (_request, reply) => handleGetSettings(reply, opts));
67
+ fastify.put('/settings', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveSettings(request, reply, opts));
68
+ };
@@ -0,0 +1,7 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface SitemapRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare function buildSitemapUrls(config: SiteConfig): string[];
7
+ export declare const sitemapRoutes: FastifyPluginAsync<SitemapRouteOptions>;