@geekity/cms 0.3.0

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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +870 -0
  3. package/admin/layouts/_flash.njk +5 -0
  4. package/admin/layouts/base.njk +27 -0
  5. package/admin/layouts/dashboard.njk +78 -0
  6. package/admin/layouts/document-conflict.njk +49 -0
  7. package/admin/layouts/document-editor.njk +167 -0
  8. package/admin/layouts/document-list.njk +92 -0
  9. package/admin/layouts/federation.njk +145 -0
  10. package/admin/layouts/login.njk +23 -0
  11. package/admin/layouts/placeholder.njk +10 -0
  12. package/admin/layouts/settings.njk +116 -0
  13. package/admin/layouts/setup.njk +27 -0
  14. package/admin/layouts/shell.njk +37 -0
  15. package/admin/layouts/users.njk +91 -0
  16. package/admin/static/admin.css +726 -0
  17. package/admin/static/editor.js +30 -0
  18. package/dist/admin/assets.d.ts +26 -0
  19. package/dist/admin/assets.d.ts.map +1 -0
  20. package/dist/admin/assets.js +38 -0
  21. package/dist/admin/assets.js.map +1 -0
  22. package/dist/admin/credentials.d.ts +28 -0
  23. package/dist/admin/credentials.d.ts.map +1 -0
  24. package/dist/admin/credentials.js +39 -0
  25. package/dist/admin/credentials.js.map +1 -0
  26. package/dist/admin/documents.d.ts +134 -0
  27. package/dist/admin/documents.d.ts.map +1 -0
  28. package/dist/admin/documents.js +613 -0
  29. package/dist/admin/documents.js.map +1 -0
  30. package/dist/admin/federation.d.ts +194 -0
  31. package/dist/admin/federation.d.ts.map +1 -0
  32. package/dist/admin/federation.js +299 -0
  33. package/dist/admin/federation.js.map +1 -0
  34. package/dist/admin/flash.d.ts +24 -0
  35. package/dist/admin/flash.d.ts.map +1 -0
  36. package/dist/admin/flash.js +31 -0
  37. package/dist/admin/flash.js.map +1 -0
  38. package/dist/admin/index.d.ts +23 -0
  39. package/dist/admin/index.d.ts.map +1 -0
  40. package/dist/admin/index.js +15 -0
  41. package/dist/admin/index.js.map +1 -0
  42. package/dist/admin/passwords.d.ts +49 -0
  43. package/dist/admin/passwords.d.ts.map +1 -0
  44. package/dist/admin/passwords.js +122 -0
  45. package/dist/admin/passwords.js.map +1 -0
  46. package/dist/admin/preview.d.ts +18 -0
  47. package/dist/admin/preview.d.ts.map +1 -0
  48. package/dist/admin/preview.js +83 -0
  49. package/dist/admin/preview.js.map +1 -0
  50. package/dist/admin/routes.d.ts +54 -0
  51. package/dist/admin/routes.d.ts.map +1 -0
  52. package/dist/admin/routes.js +383 -0
  53. package/dist/admin/routes.js.map +1 -0
  54. package/dist/admin/session.d.ts +44 -0
  55. package/dist/admin/session.d.ts.map +1 -0
  56. package/dist/admin/session.js +69 -0
  57. package/dist/admin/session.js.map +1 -0
  58. package/dist/admin/settings.d.ts +194 -0
  59. package/dist/admin/settings.d.ts.map +1 -0
  60. package/dist/admin/settings.js +470 -0
  61. package/dist/admin/settings.js.map +1 -0
  62. package/dist/admin/store.d.ts +407 -0
  63. package/dist/admin/store.d.ts.map +1 -0
  64. package/dist/admin/store.js +704 -0
  65. package/dist/admin/store.js.map +1 -0
  66. package/dist/admin/templates.d.ts +36 -0
  67. package/dist/admin/templates.d.ts.map +1 -0
  68. package/dist/admin/templates.js +43 -0
  69. package/dist/admin/templates.js.map +1 -0
  70. package/dist/admin/uploads.d.ts +96 -0
  71. package/dist/admin/uploads.d.ts.map +1 -0
  72. package/dist/admin/uploads.js +179 -0
  73. package/dist/admin/uploads.js.map +1 -0
  74. package/dist/admin/users.d.ts +95 -0
  75. package/dist/admin/users.d.ts.map +1 -0
  76. package/dist/admin/users.js +233 -0
  77. package/dist/admin/users.js.map +1 -0
  78. package/dist/cli.d.ts +85 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +424 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/config.d.ts +172 -0
  83. package/dist/config.d.ts.map +1 -0
  84. package/dist/config.js +184 -0
  85. package/dist/config.js.map +1 -0
  86. package/dist/content/document.d.ts +66 -0
  87. package/dist/content/document.d.ts.map +1 -0
  88. package/dist/content/document.js +13 -0
  89. package/dist/content/document.js.map +1 -0
  90. package/dist/content/index.d.ts +17 -0
  91. package/dist/content/index.d.ts.map +1 -0
  92. package/dist/content/index.js +10 -0
  93. package/dist/content/index.js.map +1 -0
  94. package/dist/content/markdown.d.ts +3 -0
  95. package/dist/content/markdown.d.ts.map +1 -0
  96. package/dist/content/markdown.js +39 -0
  97. package/dist/content/markdown.js.map +1 -0
  98. package/dist/content/media.d.ts +65 -0
  99. package/dist/content/media.d.ts.map +1 -0
  100. package/dist/content/media.js +143 -0
  101. package/dist/content/media.js.map +1 -0
  102. package/dist/content/parser.d.ts +24 -0
  103. package/dist/content/parser.d.ts.map +1 -0
  104. package/dist/content/parser.js +165 -0
  105. package/dist/content/parser.js.map +1 -0
  106. package/dist/content/save.d.ts +60 -0
  107. package/dist/content/save.d.ts.map +1 -0
  108. package/dist/content/save.js +103 -0
  109. package/dist/content/save.js.map +1 -0
  110. package/dist/content/slug.d.ts +25 -0
  111. package/dist/content/slug.d.ts.map +1 -0
  112. package/dist/content/slug.js +75 -0
  113. package/dist/content/slug.js.map +1 -0
  114. package/dist/content/store.d.ts +133 -0
  115. package/dist/content/store.d.ts.map +1 -0
  116. package/dist/content/store.js +411 -0
  117. package/dist/content/store.js.map +1 -0
  118. package/dist/content/sync.d.ts +146 -0
  119. package/dist/content/sync.d.ts.map +1 -0
  120. package/dist/content/sync.js +421 -0
  121. package/dist/content/sync.js.map +1 -0
  122. package/dist/content/writer.d.ts +37 -0
  123. package/dist/content/writer.d.ts.map +1 -0
  124. package/dist/content/writer.js +97 -0
  125. package/dist/content/writer.js.map +1 -0
  126. package/dist/env.d.ts +48 -0
  127. package/dist/env.d.ts.map +1 -0
  128. package/dist/env.js +2 -0
  129. package/dist/env.js.map +1 -0
  130. package/dist/federation/actor.d.ts +55 -0
  131. package/dist/federation/actor.d.ts.map +1 -0
  132. package/dist/federation/actor.js +101 -0
  133. package/dist/federation/actor.js.map +1 -0
  134. package/dist/federation/article.d.ts +83 -0
  135. package/dist/federation/article.d.ts.map +1 -0
  136. package/dist/federation/article.js +169 -0
  137. package/dist/federation/article.js.map +1 -0
  138. package/dist/federation/delivery.d.ts +90 -0
  139. package/dist/federation/delivery.d.ts.map +1 -0
  140. package/dist/federation/delivery.js +289 -0
  141. package/dist/federation/delivery.js.map +1 -0
  142. package/dist/federation/federation.d.ts +75 -0
  143. package/dist/federation/federation.d.ts.map +1 -0
  144. package/dist/federation/federation.js +190 -0
  145. package/dist/federation/federation.js.map +1 -0
  146. package/dist/federation/followers.d.ts +43 -0
  147. package/dist/federation/followers.d.ts.map +1 -0
  148. package/dist/federation/followers.js +69 -0
  149. package/dist/federation/followers.js.map +1 -0
  150. package/dist/federation/inbox.d.ts +68 -0
  151. package/dist/federation/inbox.d.ts.map +1 -0
  152. package/dist/federation/inbox.js +171 -0
  153. package/dist/federation/inbox.js.map +1 -0
  154. package/dist/federation/index.d.ts +14 -0
  155. package/dist/federation/index.d.ts.map +1 -0
  156. package/dist/federation/index.js +10 -0
  157. package/dist/federation/index.js.map +1 -0
  158. package/dist/federation/keys.d.ts +27 -0
  159. package/dist/federation/keys.d.ts.map +1 -0
  160. package/dist/federation/keys.js +60 -0
  161. package/dist/federation/keys.js.map +1 -0
  162. package/dist/federation/mount.d.ts +23 -0
  163. package/dist/federation/mount.d.ts.map +1 -0
  164. package/dist/federation/mount.js +71 -0
  165. package/dist/federation/mount.js.map +1 -0
  166. package/dist/federation/paths.d.ts +85 -0
  167. package/dist/federation/paths.d.ts.map +1 -0
  168. package/dist/federation/paths.js +97 -0
  169. package/dist/federation/paths.js.map +1 -0
  170. package/dist/index.d.ts +106 -0
  171. package/dist/index.d.ts.map +1 -0
  172. package/dist/index.js +148 -0
  173. package/dist/index.js.map +1 -0
  174. package/dist/init.d.ts +71 -0
  175. package/dist/init.d.ts.map +1 -0
  176. package/dist/init.js +125 -0
  177. package/dist/init.js.map +1 -0
  178. package/dist/web/assets.d.ts +65 -0
  179. package/dist/web/assets.d.ts.map +1 -0
  180. package/dist/web/assets.js +152 -0
  181. package/dist/web/assets.js.map +1 -0
  182. package/dist/web/context.d.ts +121 -0
  183. package/dist/web/context.d.ts.map +1 -0
  184. package/dist/web/context.js +112 -0
  185. package/dist/web/context.js.map +1 -0
  186. package/dist/web/documents.d.ts +28 -0
  187. package/dist/web/documents.d.ts.map +1 -0
  188. package/dist/web/documents.js +39 -0
  189. package/dist/web/documents.js.map +1 -0
  190. package/dist/web/feeds.d.ts +122 -0
  191. package/dist/web/feeds.d.ts.map +1 -0
  192. package/dist/web/feeds.js +229 -0
  193. package/dist/web/feeds.js.map +1 -0
  194. package/dist/web/index.d.ts +17 -0
  195. package/dist/web/index.d.ts.map +1 -0
  196. package/dist/web/index.js +10 -0
  197. package/dist/web/index.js.map +1 -0
  198. package/dist/web/negotiate.d.ts +219 -0
  199. package/dist/web/negotiate.d.ts.map +1 -0
  200. package/dist/web/negotiate.js +409 -0
  201. package/dist/web/negotiate.js.map +1 -0
  202. package/dist/web/pagination.d.ts +48 -0
  203. package/dist/web/pagination.d.ts.map +1 -0
  204. package/dist/web/pagination.js +30 -0
  205. package/dist/web/pagination.js.map +1 -0
  206. package/dist/web/render.d.ts +72 -0
  207. package/dist/web/render.d.ts.map +1 -0
  208. package/dist/web/render.js +73 -0
  209. package/dist/web/render.js.map +1 -0
  210. package/dist/web/routes.d.ts +31 -0
  211. package/dist/web/routes.d.ts.map +1 -0
  212. package/dist/web/routes.js +366 -0
  213. package/dist/web/routes.js.map +1 -0
  214. package/dist/web/templates.d.ts +34 -0
  215. package/dist/web/templates.d.ts.map +1 -0
  216. package/dist/web/templates.js +106 -0
  217. package/dist/web/templates.js.map +1 -0
  218. package/package.json +68 -0
  219. package/templates/site/content/_data/site.json +8 -0
  220. package/templates/site/content/pages/about.md +11 -0
  221. package/templates/site/content/pages/pages.json +3 -0
  222. package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
  223. package/templates/site/content/posts/posts.json +4 -0
  224. package/templates/site/geekity.config.ts +11 -0
  225. package/templates/site/gitignore +3 -0
  226. package/templates/site/server.ts +20 -0
  227. package/templates/site/tsconfig.json +18 -0
  228. package/themes/default/README.md +148 -0
  229. package/themes/default/layouts/404.njk +9 -0
  230. package/themes/default/layouts/base.njk +59 -0
  231. package/themes/default/layouts/home.njk +9 -0
  232. package/themes/default/layouts/page.njk +13 -0
  233. package/themes/default/layouts/post.njk +30 -0
  234. package/themes/default/layouts/tag.njk +19 -0
  235. package/themes/default/partials/pagination.njk +14 -0
  236. package/themes/default/partials/post-list.njk +24 -0
  237. package/themes/default/partials/tags.njk +12 -0
  238. package/themes/default/static/style.css +327 -0
@@ -0,0 +1,194 @@
1
+ import type { Hono } from 'hono';
2
+ import type { ResolvedConfig } from '../config.ts';
3
+ import type { GeekityEnv } from '../env.ts';
4
+ import type { SiteData } from '../web/context.ts';
5
+ import type { AdminRender } from './documents.ts';
6
+ import type { AdminStore } from './store.ts';
7
+ /** Where the settings screen lives. */
8
+ export declare const SETTINGS_PATH = "/admin/settings";
9
+ /** Where the avatar's upload form and its Remove button post. */
10
+ export declare const AVATAR_PATH = "/admin/settings/avatar";
11
+ /** The fields those two forms submit. */
12
+ export declare const AVATAR_FIELDS: {
13
+ readonly file: 'avatar';
14
+ readonly action: 'action';
15
+ };
16
+ /** The {@link AVATAR_FIELDS.action} that takes the avatar down again. */
17
+ export declare const AVATAR_REMOVE = "remove";
18
+ /**
19
+ * The ActivityPub actor types doc-4 allows a site to be.
20
+ *
21
+ * `Person` is first because it is the default: some clients hide `Service`
22
+ * actors from timelines, which is the wrong thing for a personal blog.
23
+ */
24
+ export declare const ACTOR_TYPES: readonly string[];
25
+ /**
26
+ * What an actor handle may be made of. Tighter than a username: the handle
27
+ * becomes the local part of `@handle@host` in WebFinger and in every mention
28
+ * somebody types, so it holds no punctuation that would need escaping there.
29
+ */
30
+ export declare const ACTOR_HANDLE_PATTERN: RegExp;
31
+ /**
32
+ * The settings the admin owns, doc-1's "data that lives only in SQLite".
33
+ *
34
+ * They are the source of truth once a site has booted once. The public subset
35
+ * is mirrored to `content/_data/site.json` on every save so an Eleventy build
36
+ * of the same content directory renders with the same values.
37
+ */
38
+ export interface SiteSettings {
39
+ /** Site title, shown in the header, the `<title>` and the feeds. */
40
+ title: string;
41
+ /** One-line description under the title. May be empty. */
42
+ tagline: string;
43
+ /**
44
+ * Public origin. Whether it is the one in effect depends on
45
+ * {@link ResolvedConfig.baseUrlSource}; see {@link effectiveBaseUrl}.
46
+ */
47
+ baseUrl: string;
48
+ /** An IANA zone name, e.g. `Europe/London`. */
49
+ timezone: string;
50
+ /** How many posts a listing page holds. A positive integer. */
51
+ postsPerPage: number;
52
+ /** Site author, used as the feed author. May be empty. */
53
+ author: string;
54
+ /** The local part of the ActivityPub handle, `@{handle}@{host}` (doc-4). */
55
+ actorHandle: string;
56
+ /** Which ActivityPub actor type the site is, one of {@link ACTOR_TYPES}. */
57
+ actorType: string;
58
+ /**
59
+ * The site's avatar, as the public path the upload endpoint handed back —
60
+ * `/uploads/2026/09/me.png` — or an absolute URL for one hosted elsewhere.
61
+ * Empty when the site has none.
62
+ *
63
+ * It is not a field of the settings form: an image is uploaded and removed
64
+ * through {@link AVATAR_PATH}, because a file cannot travel in a urlencoded
65
+ * body and because a save of the other fields must not silently drop it.
66
+ */
67
+ avatar: string;
68
+ }
69
+ /**
70
+ * The settings the form on the settings screen carries.
71
+ *
72
+ * Every setting but the avatar, which is a file rather than a field; see
73
+ * {@link SiteSettings.avatar}.
74
+ */
75
+ export type SettingsField = Exclude<keyof SiteSettings, 'avatar'>;
76
+ /**
77
+ * What a site is worth before anybody has said otherwise.
78
+ *
79
+ * `baseUrl` is empty rather than a guess: only the resolved config knows what
80
+ * port the site is on, so the fallback is applied where the config is in hand.
81
+ */
82
+ export declare const DEFAULT_SITE_SETTINGS: SiteSettings;
83
+ /** The form field each setting is submitted under. */
84
+ export declare const SETTINGS_FIELDS: {
85
+ readonly title: 'title';
86
+ readonly tagline: 'tagline';
87
+ readonly baseUrl: 'base_url';
88
+ readonly timezone: 'timezone';
89
+ readonly postsPerPage: 'posts_per_page';
90
+ readonly author: 'author';
91
+ readonly actorHandle: 'actor_handle';
92
+ readonly actorType: 'actor_type';
93
+ };
94
+ /** A submitted settings form, before it is known to be valid. */
95
+ export type SettingsForm = Record<SettingsField, string>;
96
+ /** One message per field that is wrong. An empty object is a valid form. */
97
+ export type SettingsProblems = Partial<Record<SettingsField, string>>;
98
+ /** The stored settings, with every default filled in. */
99
+ export declare function readSiteSettings(store: AdminStore): SiteSettings;
100
+ /** Write a whole settings object back to the store. */
101
+ export declare function writeSiteSettings(store: AdminStore, settings: SiteSettings): void;
102
+ /**
103
+ * The settings as the `site` global, for the theme and for the JSON mirror.
104
+ *
105
+ * Empty strings are left out rather than written as empty values, so a key a
106
+ * site keeps in `site.json` by hand and leaves blank in the form is overlaid
107
+ * rather than blanked.
108
+ */
109
+ export declare function settingsSiteData(settings: SiteSettings): Partial<SiteData>;
110
+ /**
111
+ * `content/_data/site.json` as it should read for these settings.
112
+ *
113
+ * Every key the form manages is written whether or not it has a value, so the
114
+ * file's shape is stable and an Eleventy template may reference `site.author`
115
+ * without guarding it. Every other key the file already had is kept: a site
116
+ * may put anything in there and reach it from its templates, and the settings
117
+ * form is not going to be the thing that throws it away.
118
+ */
119
+ export declare function siteJsonFor(settings: SiteSettings, existing?: Record<string, unknown>): Record<string, unknown>;
120
+ /** The absolute path of one site's `content/_data/site.json`. */
121
+ export declare function siteDataPath(contentDir: string): string;
122
+ /**
123
+ * Rewrite `content/_data/site.json` from the settings.
124
+ *
125
+ * Written to a temporary file in the same directory and renamed over the old
126
+ * one, so a reader — an Eleventy build, or the CMS's own site data source —
127
+ * sees either the whole old file or the whole new one and never a half-written
128
+ * one.
129
+ */
130
+ export declare function writeSiteJson(options: {
131
+ contentDir: string;
132
+ settings: SiteSettings;
133
+ }): Promise<void>;
134
+ /**
135
+ * Fill an empty settings table from `content/_data/site.json`, so a site that
136
+ * existed before this screen did — or one `geekity init` just wrote — keeps
137
+ * the values it already had.
138
+ *
139
+ * Runs once, on the boot that finds the table empty. After that SQLite is the
140
+ * source and the file is the mirror, so a later hand edit of the file no
141
+ * longer wins.
142
+ */
143
+ export declare function seedSiteSettings(options: {
144
+ store: AdminStore;
145
+ config: Pick<ResolvedConfig, 'contentDir' | 'baseUrl' | 'baseUrlSource'>;
146
+ }): SiteSettings;
147
+ /**
148
+ * The base URL actually in effect, and why.
149
+ *
150
+ * A base URL is a deployment fact before it is a preference: it decides the
151
+ * absolute URLs in the feeds, the ActivityPub ids, and whether the session
152
+ * cookie is `Secure`. So `GEEKITY_BASE_URL` and a `baseUrl` in the config file
153
+ * both win over the stored setting, and the settings form says so rather than
154
+ * offering a field that would quietly do nothing.
155
+ */
156
+ export declare function effectiveBaseUrl(config: Pick<ResolvedConfig, 'baseUrl' | 'baseUrlSource'>, settings: SiteSettings): string;
157
+ /** What is wrong with a submitted settings form, one message per field. */
158
+ export declare function settingsProblems(form: SettingsForm): SettingsProblems;
159
+ /**
160
+ * A validated form as settings. Only call it on a form
161
+ * {@link settingsProblems} found nothing wrong with.
162
+ *
163
+ * The avatar is carried in rather than read off the form, because it is not on
164
+ * it: the image is uploaded and removed through {@link AVATAR_PATH}, and a
165
+ * save of the other fields keeps whatever is stored.
166
+ */
167
+ export declare function settingsFromForm(form: SettingsForm, avatar?: string): SiteSettings;
168
+ /** The settings as the form shows them. */
169
+ export declare function formFromSettings(settings: SiteSettings): SettingsForm;
170
+ /** What {@link mountSettings} needs from the admin around it. */
171
+ export interface MountSettingsOptions {
172
+ /** The admin's renderer, which injects the chrome, the CSRF token and the flash. */
173
+ render: AdminRender;
174
+ }
175
+ /**
176
+ * Register the settings screen.
177
+ *
178
+ * A save writes SQLite and `content/_data/site.json` in that order, so the
179
+ * store — which is what the theme reads — is never behind the file. A form the
180
+ * validator has anything to say about is a 400 that writes neither.
181
+ */
182
+ export declare function mountSettings(app: Hono<GeekityEnv>, options: MountSettingsOptions): void;
183
+ /**
184
+ * Whether a save moved something the ActivityPub actor carries, which is what
185
+ * decides whether the followers are told (doc-4: the profile fields come from
186
+ * the settings). The rest — the time zone, the page size — is the site's own
187
+ * business and nobody else's.
188
+ *
189
+ * The base URL is not here either, though the profile is built on it: it is
190
+ * settled at boot ({@link effectiveBaseUrl}), so an actor built the moment it
191
+ * is saved would carry the old one and say nothing new.
192
+ */
193
+ export declare function profileChanged(before: SiteSettings, after: SiteSettings): boolean;
194
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.d.ts","sourceRoot":"","sources":["../../src/admin/settings.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,470 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { SITE_DATA_FILE } from '../web/context.js';
5
+ import { flash } from './flash.js';
6
+ import { ADMIN_PREFIX } from './session.js';
7
+ import { ADMIN_TEMPLATES } from './templates.js';
8
+ import { refusedUpload, storeUpload } from './uploads.js';
9
+ /** Where the settings screen lives. */
10
+ export const SETTINGS_PATH = `${ADMIN_PREFIX}/settings`;
11
+ /** Where the avatar's upload form and its Remove button post. */
12
+ export const AVATAR_PATH = `${SETTINGS_PATH}/avatar`;
13
+ /** The fields those two forms submit. */
14
+ export const AVATAR_FIELDS = { file: 'avatar', action: 'action' };
15
+ /** The {@link AVATAR_FIELDS.action} that takes the avatar down again. */
16
+ export const AVATAR_REMOVE = 'remove';
17
+ /**
18
+ * The ActivityPub actor types doc-4 allows a site to be.
19
+ *
20
+ * `Person` is first because it is the default: some clients hide `Service`
21
+ * actors from timelines, which is the wrong thing for a personal blog.
22
+ */
23
+ export const ACTOR_TYPES = [
24
+ 'Person',
25
+ 'Organization',
26
+ 'Service',
27
+ 'Group',
28
+ 'Application',
29
+ ];
30
+ /**
31
+ * What an actor handle may be made of. Tighter than a username: the handle
32
+ * becomes the local part of `@handle@host` in WebFinger and in every mention
33
+ * somebody types, so it holds no punctuation that would need escaping there.
34
+ */
35
+ export const ACTOR_HANDLE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
36
+ /**
37
+ * What a site is worth before anybody has said otherwise.
38
+ *
39
+ * `baseUrl` is empty rather than a guess: only the resolved config knows what
40
+ * port the site is on, so the fallback is applied where the config is in hand.
41
+ */
42
+ export const DEFAULT_SITE_SETTINGS = {
43
+ title: 'Geekity',
44
+ tagline: '',
45
+ baseUrl: '',
46
+ timezone: 'UTC',
47
+ postsPerPage: 10,
48
+ author: '',
49
+ actorHandle: 'blog',
50
+ actorType: 'Person',
51
+ avatar: '',
52
+ };
53
+ /** The form field each setting is submitted under. */
54
+ export const SETTINGS_FIELDS = {
55
+ title: 'title',
56
+ tagline: 'tagline',
57
+ baseUrl: 'base_url',
58
+ timezone: 'timezone',
59
+ postsPerPage: 'posts_per_page',
60
+ author: 'author',
61
+ actorHandle: 'actor_handle',
62
+ actorType: 'actor_type',
63
+ };
64
+ /** The stored settings, with every default filled in. */
65
+ export function readSiteSettings(store) {
66
+ const stored = store.allSettings();
67
+ const postsPerPage = Number(stored['postsPerPage']);
68
+ return {
69
+ title: stored['title'] ?? DEFAULT_SITE_SETTINGS.title,
70
+ tagline: stored['tagline'] ?? DEFAULT_SITE_SETTINGS.tagline,
71
+ baseUrl: stored['baseUrl'] ?? DEFAULT_SITE_SETTINGS.baseUrl,
72
+ timezone: stored['timezone'] ?? DEFAULT_SITE_SETTINGS.timezone,
73
+ postsPerPage: Number.isInteger(postsPerPage) && postsPerPage > 0
74
+ ? postsPerPage
75
+ : DEFAULT_SITE_SETTINGS.postsPerPage,
76
+ author: stored['author'] ?? DEFAULT_SITE_SETTINGS.author,
77
+ actorHandle: stored['actorHandle'] ?? DEFAULT_SITE_SETTINGS.actorHandle,
78
+ actorType: ACTOR_TYPES.includes(stored['actorType'] ?? '')
79
+ ? stored['actorType']
80
+ : DEFAULT_SITE_SETTINGS.actorType,
81
+ avatar: stored['avatar'] ?? DEFAULT_SITE_SETTINGS.avatar,
82
+ };
83
+ }
84
+ /** Write a whole settings object back to the store. */
85
+ export function writeSiteSettings(store, settings) {
86
+ store.setSettings({
87
+ title: settings.title,
88
+ tagline: settings.tagline,
89
+ baseUrl: settings.baseUrl,
90
+ timezone: settings.timezone,
91
+ postsPerPage: String(settings.postsPerPage),
92
+ author: settings.author,
93
+ actorHandle: settings.actorHandle,
94
+ actorType: settings.actorType,
95
+ avatar: settings.avatar,
96
+ });
97
+ }
98
+ /**
99
+ * The settings as the `site` global, for the theme and for the JSON mirror.
100
+ *
101
+ * Empty strings are left out rather than written as empty values, so a key a
102
+ * site keeps in `site.json` by hand and leaves blank in the form is overlaid
103
+ * rather than blanked.
104
+ */
105
+ export function settingsSiteData(settings) {
106
+ return {
107
+ ...(settings.title === '' ? {} : { title: settings.title }),
108
+ ...(settings.tagline === '' ? {} : { tagline: settings.tagline }),
109
+ ...(settings.baseUrl === '' ? {} : { url: settings.baseUrl }),
110
+ ...(settings.author === '' ? {} : { author: settings.author }),
111
+ ...(settings.timezone === '' ? {} : { timezone: settings.timezone }),
112
+ ...(settings.avatar === '' ? {} : { avatar: settings.avatar }),
113
+ postsPerPage: settings.postsPerPage,
114
+ };
115
+ }
116
+ /**
117
+ * `content/_data/site.json` as it should read for these settings.
118
+ *
119
+ * Every key the form manages is written whether or not it has a value, so the
120
+ * file's shape is stable and an Eleventy template may reference `site.author`
121
+ * without guarding it. Every other key the file already had is kept: a site
122
+ * may put anything in there and reach it from its templates, and the settings
123
+ * form is not going to be the thing that throws it away.
124
+ */
125
+ export function siteJsonFor(settings, existing = {}) {
126
+ return {
127
+ ...existing,
128
+ title: settings.title,
129
+ tagline: settings.tagline,
130
+ url: settings.baseUrl,
131
+ author: settings.author,
132
+ postsPerPage: settings.postsPerPage,
133
+ timezone: settings.timezone,
134
+ avatar: settings.avatar,
135
+ };
136
+ }
137
+ /** The absolute path of one site's `content/_data/site.json`. */
138
+ export function siteDataPath(contentDir) {
139
+ return path.join(contentDir, ...SITE_DATA_FILE.split('/'));
140
+ }
141
+ /**
142
+ * Rewrite `content/_data/site.json` from the settings.
143
+ *
144
+ * Written to a temporary file in the same directory and renamed over the old
145
+ * one, so a reader — an Eleventy build, or the CMS's own site data source —
146
+ * sees either the whole old file or the whole new one and never a half-written
147
+ * one.
148
+ */
149
+ export async function writeSiteJson(options) {
150
+ const file = siteDataPath(options.contentDir);
151
+ await mkdir(path.dirname(file), { recursive: true });
152
+ let existing = {};
153
+ try {
154
+ existing = parseSiteJson(await readFile(file, 'utf8'));
155
+ }
156
+ catch {
157
+ // No file yet, or one that will not parse. Either way the settings are
158
+ // what the file is about to say.
159
+ }
160
+ const temporary = `${file}.${process.pid.toString(36)}.tmp`;
161
+ await writeFile(temporary, `${JSON.stringify(siteJsonFor(options.settings, existing), null, 2)}\n`, 'utf8');
162
+ await rename(temporary, file);
163
+ }
164
+ /**
165
+ * Fill an empty settings table from `content/_data/site.json`, so a site that
166
+ * existed before this screen did — or one `geekity init` just wrote — keeps
167
+ * the values it already had.
168
+ *
169
+ * Runs once, on the boot that finds the table empty. After that SQLite is the
170
+ * source and the file is the mirror, so a later hand edit of the file no
171
+ * longer wins.
172
+ */
173
+ export function seedSiteSettings(options) {
174
+ const { store, config } = options;
175
+ if (store.countSettings() > 0)
176
+ return readSiteSettings(store);
177
+ const file = readSiteJsonSync(siteDataPath(config.contentDir));
178
+ const postsPerPage = Number(file['postsPerPage']);
179
+ const seeded = {
180
+ ...DEFAULT_SITE_SETTINGS,
181
+ ...(typeof file['title'] === 'string' && file['title'] !== '' ? { title: file['title'] } : {}),
182
+ ...(typeof file['tagline'] === 'string' ? { tagline: file['tagline'] } : {}),
183
+ ...(typeof file['author'] === 'string' ? { author: file['author'] } : {}),
184
+ ...(typeof file['timezone'] === 'string' && file['timezone'] !== ''
185
+ ? { timezone: file['timezone'] }
186
+ : {}),
187
+ ...(typeof file['avatar'] === 'string' ? { avatar: file['avatar'] } : {}),
188
+ ...(Number.isInteger(postsPerPage) && postsPerPage > 0 ? { postsPerPage } : {}),
189
+ // The file's `url` only becomes the setting when the deployment has not
190
+ // named one; otherwise the setting records what is actually in effect.
191
+ baseUrl: config.baseUrlSource === 'default' && typeof file['url'] === 'string' && file['url'] !== ''
192
+ ? file['url']
193
+ : config.baseUrl,
194
+ };
195
+ writeSiteSettings(store, seeded);
196
+ return seeded;
197
+ }
198
+ /**
199
+ * The base URL actually in effect, and why.
200
+ *
201
+ * A base URL is a deployment fact before it is a preference: it decides the
202
+ * absolute URLs in the feeds, the ActivityPub ids, and whether the session
203
+ * cookie is `Secure`. So `GEEKITY_BASE_URL` and a `baseUrl` in the config file
204
+ * both win over the stored setting, and the settings form says so rather than
205
+ * offering a field that would quietly do nothing.
206
+ */
207
+ export function effectiveBaseUrl(config, settings) {
208
+ if (config.baseUrlSource !== 'default')
209
+ return config.baseUrl;
210
+ return settings.baseUrl === '' ? config.baseUrl : settings.baseUrl;
211
+ }
212
+ /** What is wrong with a submitted settings form, one message per field. */
213
+ export function settingsProblems(form) {
214
+ const problems = {};
215
+ if (form.title.trim() === '')
216
+ problems.title = 'The site needs a title.';
217
+ const url = normalizeBaseUrl(form.baseUrl);
218
+ if (url === undefined) {
219
+ problems.baseUrl = 'The base URL has to be an absolute http:// or https:// URL.';
220
+ }
221
+ const perPage = Number(form.postsPerPage);
222
+ if (!Number.isInteger(perPage) || perPage < 1) {
223
+ problems.postsPerPage = 'Posts per page has to be a whole number of one or more.';
224
+ }
225
+ if (!isValidTimezone(form.timezone)) {
226
+ problems.timezone = 'That is not an IANA time zone name, such as Europe/London.';
227
+ }
228
+ if (!ACTOR_HANDLE_PATTERN.test(form.actorHandle)) {
229
+ problems.actorHandle =
230
+ 'An actor handle is 1 to 64 letters, digits, dashes or underscores, with no @ and no dots.';
231
+ }
232
+ if (!ACTOR_TYPES.includes(form.actorType)) {
233
+ problems.actorType = `An actor type is one of ${ACTOR_TYPES.join(', ')}.`;
234
+ }
235
+ return problems;
236
+ }
237
+ /**
238
+ * A validated form as settings. Only call it on a form
239
+ * {@link settingsProblems} found nothing wrong with.
240
+ *
241
+ * The avatar is carried in rather than read off the form, because it is not on
242
+ * it: the image is uploaded and removed through {@link AVATAR_PATH}, and a
243
+ * save of the other fields keeps whatever is stored.
244
+ */
245
+ export function settingsFromForm(form, avatar = DEFAULT_SITE_SETTINGS.avatar) {
246
+ return {
247
+ avatar,
248
+ title: form.title.trim(),
249
+ tagline: form.tagline.trim(),
250
+ baseUrl: normalizeBaseUrl(form.baseUrl) ?? '',
251
+ timezone: form.timezone.trim(),
252
+ postsPerPage: Number(form.postsPerPage),
253
+ author: form.author.trim(),
254
+ actorHandle: form.actorHandle.trim(),
255
+ actorType: form.actorType,
256
+ };
257
+ }
258
+ /** The settings as the form shows them. */
259
+ export function formFromSettings(settings) {
260
+ return {
261
+ title: settings.title,
262
+ tagline: settings.tagline,
263
+ baseUrl: settings.baseUrl,
264
+ timezone: settings.timezone,
265
+ postsPerPage: String(settings.postsPerPage),
266
+ author: settings.author,
267
+ actorHandle: settings.actorHandle,
268
+ actorType: settings.actorType,
269
+ };
270
+ }
271
+ /**
272
+ * Register the settings screen.
273
+ *
274
+ * A save writes SQLite and `content/_data/site.json` in that order, so the
275
+ * store — which is what the theme reads — is never behind the file. A form the
276
+ * validator has anything to say about is a 400 that writes neither.
277
+ */
278
+ export function mountSettings(app, options) {
279
+ const { render } = options;
280
+ app.get(SETTINGS_PATH, (c) => render(c, ADMIN_TEMPLATES.settings, screen(c.var.config, readSiteSettings(c.var.admin))));
281
+ app.post(SETTINGS_PATH, async (c) => {
282
+ const body = await c.req.parseBody();
283
+ const stored = readSiteSettings(c.var.admin);
284
+ const submitted = {
285
+ title: field(body[SETTINGS_FIELDS.title]),
286
+ tagline: field(body[SETTINGS_FIELDS.tagline]),
287
+ // A field the form rendered read-only is not submitted, so an overridden
288
+ // base URL keeps the value it had rather than being cleared by a save.
289
+ baseUrl: c.var.config.baseUrlSource === 'default'
290
+ ? field(body[SETTINGS_FIELDS.baseUrl])
291
+ : stored.baseUrl === ''
292
+ ? c.var.config.baseUrl
293
+ : stored.baseUrl,
294
+ timezone: field(body[SETTINGS_FIELDS.timezone]),
295
+ postsPerPage: field(body[SETTINGS_FIELDS.postsPerPage]),
296
+ author: field(body[SETTINGS_FIELDS.author]),
297
+ actorHandle: field(body[SETTINGS_FIELDS.actorHandle]),
298
+ actorType: field(body[SETTINGS_FIELDS.actorType]),
299
+ };
300
+ const problems = settingsProblems(submitted);
301
+ if (Object.keys(problems).length > 0) {
302
+ c.status(400);
303
+ return render(c, ADMIN_TEMPLATES.settings, {
304
+ ...screen(c.var.config, stored),
305
+ form: submitted,
306
+ problems,
307
+ });
308
+ }
309
+ const settings = settingsFromForm(submitted, stored.avatar);
310
+ await store(c, settings);
311
+ // The name, the summary and the handle are the actor's profile as much as
312
+ // the avatar is, and a follower's copy of it is only as fresh as the last
313
+ // thing it was told.
314
+ const report = profileChanged(stored, settings)
315
+ ? await c.var.delivery.updateActor()
316
+ : undefined;
317
+ flash(c, 'notice', `Settings saved.${toldFollowers(report)}`);
318
+ return c.redirect(SETTINGS_PATH, 303);
319
+ });
320
+ /**
321
+ * The avatar's own endpoint: one multipart form uploads an image, and a
322
+ * second, plain one takes it down again.
323
+ *
324
+ * It is separate from the settings form because a file cannot travel in a
325
+ * urlencoded body, and because the two should not share a fate: a rejected
326
+ * image must not lose an edit to the title, and a rejected title must not
327
+ * lose the avatar. A refusal is a flash and a redirect, so what is stored is
328
+ * exactly what it was and the screen says why.
329
+ */
330
+ app.post(AVATAR_PATH, async (c) => {
331
+ const body = await c.req.parseBody();
332
+ const stored = readSiteSettings(c.var.admin);
333
+ if (field(body[AVATAR_FIELDS.action]) === AVATAR_REMOVE) {
334
+ if (stored.avatar === '') {
335
+ flash(c, 'notice', 'The site has no avatar.');
336
+ return c.redirect(SETTINGS_PATH, 303);
337
+ }
338
+ await store(c, { ...stored, avatar: '' });
339
+ const removal = await c.var.delivery.updateActor();
340
+ flash(c, 'notice', `Avatar removed.${toldFollowers(removal)}`);
341
+ return c.redirect(SETTINGS_PATH, 303);
342
+ }
343
+ const outcome = await storeUpload(body[AVATAR_FIELDS.file], c.var.config, {
344
+ imagesOnly: true,
345
+ });
346
+ if (refusedUpload(outcome)) {
347
+ flash(c, 'error', `${outcome.error} The avatar is unchanged.`);
348
+ return c.redirect(SETTINGS_PATH, 303);
349
+ }
350
+ await store(c, { ...stored, avatar: outcome.url });
351
+ const report = await c.var.delivery.updateActor();
352
+ flash(c, 'notice', `Avatar saved.${toldFollowers(report)}`);
353
+ return c.redirect(SETTINGS_PATH, 303);
354
+ });
355
+ /**
356
+ * Write settings to SQLite and then to `content/_data/site.json`, in that
357
+ * order, so the store — which is what the theme reads — is never behind the
358
+ * file.
359
+ */
360
+ async function store(c, settings) {
361
+ writeSiteSettings(c.var.admin, settings);
362
+ await writeSiteJson({ contentDir: c.var.config.contentDir, settings });
363
+ }
364
+ }
365
+ /**
366
+ * Whether a save moved something the ActivityPub actor carries, which is what
367
+ * decides whether the followers are told (doc-4: the profile fields come from
368
+ * the settings). The rest — the time zone, the page size — is the site's own
369
+ * business and nobody else's.
370
+ *
371
+ * The base URL is not here either, though the profile is built on it: it is
372
+ * settled at boot ({@link effectiveBaseUrl}), so an actor built the moment it
373
+ * is saved would carry the old one and say nothing new.
374
+ */
375
+ export function profileChanged(before, after) {
376
+ return (before.title !== after.title ||
377
+ before.tagline !== after.tagline ||
378
+ before.actorHandle !== after.actorHandle ||
379
+ before.actorType !== after.actorType ||
380
+ before.avatar !== after.avatar);
381
+ }
382
+ /**
383
+ * The sentence a flash adds about the followers, when there were any: a save
384
+ * of the profile is also an announcement, and it should say so rather than
385
+ * leave the admin wondering.
386
+ */
387
+ function toldFollowers(report) {
388
+ const total = report?.deliveries.length ?? 0;
389
+ if (total === 0)
390
+ return '';
391
+ return total === 1
392
+ ? ' One follower has been told.'
393
+ : ` ${String(total)} followers have been told.`;
394
+ }
395
+ /** Everything the settings template renders, for a given set of settings. */
396
+ function screen(config, settings) {
397
+ const overridden = config.baseUrlSource !== 'default';
398
+ return {
399
+ section: 'settings',
400
+ settingsUrl: SETTINGS_PATH,
401
+ fields: SETTINGS_FIELDS,
402
+ actorTypes: ACTOR_TYPES,
403
+ avatar: settings.avatar,
404
+ avatarUrl: AVATAR_PATH,
405
+ avatarFields: AVATAR_FIELDS,
406
+ avatarRemove: AVATAR_REMOVE,
407
+ form: formFromSettings(settings),
408
+ problems: {},
409
+ baseUrlInEffect: effectiveBaseUrl(config, settings),
410
+ baseUrlOverridden: overridden,
411
+ baseUrlSource: config.baseUrlSource,
412
+ baseUrlNote: overridden
413
+ ? config.baseUrlSource === 'environment'
414
+ ? 'GEEKITY_BASE_URL is set, so it is the base URL in effect and this field is not editable here.'
415
+ : 'The config file sets baseUrl, so it is the base URL in effect and this field is not editable here.'
416
+ : 'Absolute URLs, the feeds and the session cookie pick this up when the site next starts.',
417
+ };
418
+ }
419
+ /** A form field as a string. A file upload, or a missing field, is the empty one. */
420
+ function field(value) {
421
+ return typeof value === 'string' ? value : '';
422
+ }
423
+ /**
424
+ * A base URL, normalised the way `resolveConfig` normalises the configured
425
+ * one, or `undefined` when it is not an absolute http(s) URL.
426
+ */
427
+ function normalizeBaseUrl(value) {
428
+ const trimmed = value.trim();
429
+ if (trimmed === '')
430
+ return undefined;
431
+ let parsed;
432
+ try {
433
+ parsed = new URL(trimmed);
434
+ }
435
+ catch {
436
+ return undefined;
437
+ }
438
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
439
+ return undefined;
440
+ const pathname = parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
441
+ return parsed.origin + pathname;
442
+ }
443
+ /** Whether `Intl` knows the zone. An empty name is not a zone. */
444
+ function isValidTimezone(value) {
445
+ const zone = value.trim();
446
+ if (zone === '')
447
+ return false;
448
+ try {
449
+ new Intl.DateTimeFormat(undefined, { timeZone: zone });
450
+ return true;
451
+ }
452
+ catch {
453
+ return false;
454
+ }
455
+ }
456
+ function readSiteJsonSync(file) {
457
+ try {
458
+ return parseSiteJson(readFileSync(file, 'utf8'));
459
+ }
460
+ catch {
461
+ return {};
462
+ }
463
+ }
464
+ function parseSiteJson(source) {
465
+ const parsed = JSON.parse(source);
466
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
467
+ ? parsed
468
+ : {};
469
+ }
470
+ //# sourceMappingURL=settings.js.map