@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.
- package/LICENSE +21 -0
- package/README.md +870 -0
- package/admin/layouts/_flash.njk +5 -0
- package/admin/layouts/base.njk +27 -0
- package/admin/layouts/dashboard.njk +78 -0
- package/admin/layouts/document-conflict.njk +49 -0
- package/admin/layouts/document-editor.njk +167 -0
- package/admin/layouts/document-list.njk +92 -0
- package/admin/layouts/federation.njk +145 -0
- package/admin/layouts/login.njk +23 -0
- package/admin/layouts/placeholder.njk +10 -0
- package/admin/layouts/settings.njk +116 -0
- package/admin/layouts/setup.njk +27 -0
- package/admin/layouts/shell.njk +37 -0
- package/admin/layouts/users.njk +91 -0
- package/admin/static/admin.css +726 -0
- package/admin/static/editor.js +30 -0
- package/dist/admin/assets.d.ts +26 -0
- package/dist/admin/assets.d.ts.map +1 -0
- package/dist/admin/assets.js +38 -0
- package/dist/admin/assets.js.map +1 -0
- package/dist/admin/credentials.d.ts +28 -0
- package/dist/admin/credentials.d.ts.map +1 -0
- package/dist/admin/credentials.js +39 -0
- package/dist/admin/credentials.js.map +1 -0
- package/dist/admin/documents.d.ts +134 -0
- package/dist/admin/documents.d.ts.map +1 -0
- package/dist/admin/documents.js +613 -0
- package/dist/admin/documents.js.map +1 -0
- package/dist/admin/federation.d.ts +194 -0
- package/dist/admin/federation.d.ts.map +1 -0
- package/dist/admin/federation.js +299 -0
- package/dist/admin/federation.js.map +1 -0
- package/dist/admin/flash.d.ts +24 -0
- package/dist/admin/flash.d.ts.map +1 -0
- package/dist/admin/flash.js +31 -0
- package/dist/admin/flash.js.map +1 -0
- package/dist/admin/index.d.ts +23 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +15 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/passwords.d.ts +49 -0
- package/dist/admin/passwords.d.ts.map +1 -0
- package/dist/admin/passwords.js +122 -0
- package/dist/admin/passwords.js.map +1 -0
- package/dist/admin/preview.d.ts +18 -0
- package/dist/admin/preview.d.ts.map +1 -0
- package/dist/admin/preview.js +83 -0
- package/dist/admin/preview.js.map +1 -0
- package/dist/admin/routes.d.ts +54 -0
- package/dist/admin/routes.d.ts.map +1 -0
- package/dist/admin/routes.js +383 -0
- package/dist/admin/routes.js.map +1 -0
- package/dist/admin/session.d.ts +44 -0
- package/dist/admin/session.d.ts.map +1 -0
- package/dist/admin/session.js +69 -0
- package/dist/admin/session.js.map +1 -0
- package/dist/admin/settings.d.ts +194 -0
- package/dist/admin/settings.d.ts.map +1 -0
- package/dist/admin/settings.js +470 -0
- package/dist/admin/settings.js.map +1 -0
- package/dist/admin/store.d.ts +407 -0
- package/dist/admin/store.d.ts.map +1 -0
- package/dist/admin/store.js +704 -0
- package/dist/admin/store.js.map +1 -0
- package/dist/admin/templates.d.ts +36 -0
- package/dist/admin/templates.d.ts.map +1 -0
- package/dist/admin/templates.js +43 -0
- package/dist/admin/templates.js.map +1 -0
- package/dist/admin/uploads.d.ts +96 -0
- package/dist/admin/uploads.d.ts.map +1 -0
- package/dist/admin/uploads.js +179 -0
- package/dist/admin/uploads.js.map +1 -0
- package/dist/admin/users.d.ts +95 -0
- package/dist/admin/users.d.ts.map +1 -0
- package/dist/admin/users.js +233 -0
- package/dist/admin/users.js.map +1 -0
- package/dist/cli.d.ts +85 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +424 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +172 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +184 -0
- package/dist/config.js.map +1 -0
- package/dist/content/document.d.ts +66 -0
- package/dist/content/document.d.ts.map +1 -0
- package/dist/content/document.js +13 -0
- package/dist/content/document.js.map +1 -0
- package/dist/content/index.d.ts +17 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +10 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/markdown.d.ts +3 -0
- package/dist/content/markdown.d.ts.map +1 -0
- package/dist/content/markdown.js +39 -0
- package/dist/content/markdown.js.map +1 -0
- package/dist/content/media.d.ts +65 -0
- package/dist/content/media.d.ts.map +1 -0
- package/dist/content/media.js +143 -0
- package/dist/content/media.js.map +1 -0
- package/dist/content/parser.d.ts +24 -0
- package/dist/content/parser.d.ts.map +1 -0
- package/dist/content/parser.js +165 -0
- package/dist/content/parser.js.map +1 -0
- package/dist/content/save.d.ts +60 -0
- package/dist/content/save.d.ts.map +1 -0
- package/dist/content/save.js +103 -0
- package/dist/content/save.js.map +1 -0
- package/dist/content/slug.d.ts +25 -0
- package/dist/content/slug.d.ts.map +1 -0
- package/dist/content/slug.js +75 -0
- package/dist/content/slug.js.map +1 -0
- package/dist/content/store.d.ts +133 -0
- package/dist/content/store.d.ts.map +1 -0
- package/dist/content/store.js +411 -0
- package/dist/content/store.js.map +1 -0
- package/dist/content/sync.d.ts +146 -0
- package/dist/content/sync.d.ts.map +1 -0
- package/dist/content/sync.js +421 -0
- package/dist/content/sync.js.map +1 -0
- package/dist/content/writer.d.ts +37 -0
- package/dist/content/writer.d.ts.map +1 -0
- package/dist/content/writer.js +97 -0
- package/dist/content/writer.js.map +1 -0
- package/dist/env.d.ts +48 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +2 -0
- package/dist/env.js.map +1 -0
- package/dist/federation/actor.d.ts +55 -0
- package/dist/federation/actor.d.ts.map +1 -0
- package/dist/federation/actor.js +101 -0
- package/dist/federation/actor.js.map +1 -0
- package/dist/federation/article.d.ts +83 -0
- package/dist/federation/article.d.ts.map +1 -0
- package/dist/federation/article.js +169 -0
- package/dist/federation/article.js.map +1 -0
- package/dist/federation/delivery.d.ts +90 -0
- package/dist/federation/delivery.d.ts.map +1 -0
- package/dist/federation/delivery.js +289 -0
- package/dist/federation/delivery.js.map +1 -0
- package/dist/federation/federation.d.ts +75 -0
- package/dist/federation/federation.d.ts.map +1 -0
- package/dist/federation/federation.js +190 -0
- package/dist/federation/federation.js.map +1 -0
- package/dist/federation/followers.d.ts +43 -0
- package/dist/federation/followers.d.ts.map +1 -0
- package/dist/federation/followers.js +69 -0
- package/dist/federation/followers.js.map +1 -0
- package/dist/federation/inbox.d.ts +68 -0
- package/dist/federation/inbox.d.ts.map +1 -0
- package/dist/federation/inbox.js +171 -0
- package/dist/federation/inbox.js.map +1 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +10 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/federation/keys.d.ts +27 -0
- package/dist/federation/keys.d.ts.map +1 -0
- package/dist/federation/keys.js +60 -0
- package/dist/federation/keys.js.map +1 -0
- package/dist/federation/mount.d.ts +23 -0
- package/dist/federation/mount.d.ts.map +1 -0
- package/dist/federation/mount.js +71 -0
- package/dist/federation/mount.js.map +1 -0
- package/dist/federation/paths.d.ts +85 -0
- package/dist/federation/paths.d.ts.map +1 -0
- package/dist/federation/paths.js +97 -0
- package/dist/federation/paths.js.map +1 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +71 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +125 -0
- package/dist/init.js.map +1 -0
- package/dist/web/assets.d.ts +65 -0
- package/dist/web/assets.d.ts.map +1 -0
- package/dist/web/assets.js +152 -0
- package/dist/web/assets.js.map +1 -0
- package/dist/web/context.d.ts +121 -0
- package/dist/web/context.d.ts.map +1 -0
- package/dist/web/context.js +112 -0
- package/dist/web/context.js.map +1 -0
- package/dist/web/documents.d.ts +28 -0
- package/dist/web/documents.d.ts.map +1 -0
- package/dist/web/documents.js +39 -0
- package/dist/web/documents.js.map +1 -0
- package/dist/web/feeds.d.ts +122 -0
- package/dist/web/feeds.d.ts.map +1 -0
- package/dist/web/feeds.js +229 -0
- package/dist/web/feeds.js.map +1 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +10 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/negotiate.d.ts +219 -0
- package/dist/web/negotiate.d.ts.map +1 -0
- package/dist/web/negotiate.js +409 -0
- package/dist/web/negotiate.js.map +1 -0
- package/dist/web/pagination.d.ts +48 -0
- package/dist/web/pagination.d.ts.map +1 -0
- package/dist/web/pagination.js +30 -0
- package/dist/web/pagination.js.map +1 -0
- package/dist/web/render.d.ts +72 -0
- package/dist/web/render.d.ts.map +1 -0
- package/dist/web/render.js +73 -0
- package/dist/web/render.js.map +1 -0
- package/dist/web/routes.d.ts +31 -0
- package/dist/web/routes.d.ts.map +1 -0
- package/dist/web/routes.js +366 -0
- package/dist/web/routes.js.map +1 -0
- package/dist/web/templates.d.ts +34 -0
- package/dist/web/templates.d.ts.map +1 -0
- package/dist/web/templates.js +106 -0
- package/dist/web/templates.js.map +1 -0
- package/package.json +68 -0
- package/templates/site/content/_data/site.json +8 -0
- package/templates/site/content/pages/about.md +11 -0
- package/templates/site/content/pages/pages.json +3 -0
- package/templates/site/content/posts/2026-01-01-hello-world.md +34 -0
- package/templates/site/content/posts/posts.json +4 -0
- package/templates/site/geekity.config.ts +11 -0
- package/templates/site/gitignore +3 -0
- package/templates/site/server.ts +20 -0
- package/templates/site/tsconfig.json +18 -0
- package/themes/default/README.md +148 -0
- package/themes/default/layouts/404.njk +9 -0
- package/themes/default/layouts/base.njk +59 -0
- package/themes/default/layouts/home.njk +9 -0
- package/themes/default/layouts/page.njk +13 -0
- package/themes/default/layouts/post.njk +30 -0
- package/themes/default/layouts/tag.njk +19 -0
- package/themes/default/partials/pagination.njk +14 -0
- package/themes/default/partials/post-list.njk +24 -0
- package/themes/default/partials/tags.njk +12 -0
- 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
|