@jant/core 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
@@ -486,6 +486,133 @@ describe("PostService - Timeline features", () => {
486
486
  });
487
487
  });
488
488
 
489
+ // A reply can be older than its root: a post moved into a Thread keeps its
490
+ // own creation time, and an export without `created` dates every post by
491
+ // publication. Thread order still opens on the root.
492
+ describe("a reply created before its root", () => {
493
+ async function createThreadWithOlderReplies() {
494
+ const root = await postService.create({
495
+ format: "note",
496
+ bodyMarkdown: "Root",
497
+ featured: true,
498
+ publishedAt: 3000,
499
+ });
500
+ const first = await postService.create({
501
+ format: "note",
502
+ bodyMarkdown: "First reply",
503
+ replyToId: root.id,
504
+ publishedAt: 1000,
505
+ });
506
+ const second = await postService.create({
507
+ format: "note",
508
+ bodyMarkdown: "Second reply",
509
+ replyToId: first.id,
510
+ publishedAt: 2000,
511
+ });
512
+ // Backdated after the fact, the way a restore lands them, so each test
513
+ // reads the shape without going through the reply guard.
514
+ for (const [id, createdAt] of [
515
+ [root.id, 3000],
516
+ [first.id, 1000],
517
+ [second.id, 2000],
518
+ ] as const) {
519
+ await db.update(posts).set({ createdAt }).where(eq(posts.id, id));
520
+ }
521
+ return { root, first, second };
522
+ }
523
+
524
+ it("opens getThread on the root", async () => {
525
+ const { root, first, second } = await createThreadWithOlderReplies();
526
+
527
+ const thread = await postService.getThread(root.id);
528
+
529
+ expect(thread.map((post) => post.id)).toEqual([
530
+ root.id,
531
+ first.id,
532
+ second.id,
533
+ ]);
534
+ });
535
+
536
+ it("opens getPublishedThreads on the root", async () => {
537
+ const { root, first, second } = await createThreadWithOlderReplies();
538
+
539
+ const threads = await postService.getPublishedThreads([root.id]);
540
+
541
+ expect(threads.get(root.id)?.map((post) => post.id)).toEqual([
542
+ root.id,
543
+ first.id,
544
+ second.id,
545
+ ]);
546
+ });
547
+
548
+ it("puts the root at position 0 of the Featured projection", async () => {
549
+ const { root, second } = await createThreadWithOlderReplies();
550
+
551
+ const result = await postService.getFeaturedThreadTimelineData([root.id]);
552
+
553
+ expect(
554
+ result
555
+ .get(root.id)
556
+ ?.posts.map(({ post, position }) => [post.id, position]),
557
+ ).toEqual([
558
+ [root.id, 0],
559
+ [second.id, 2],
560
+ ]);
561
+ expect(result.get(root.id)?.featuredPostIds).toEqual([root.id]);
562
+ });
563
+
564
+ it("ends the Thread on the newest reply, not the root", async () => {
565
+ const { root, second } = await createThreadWithOlderReplies();
566
+
567
+ const published = await postService.getThreadTailIds([root.id]);
568
+ const withDrafts = await postService.getThreadTailIds([root.id], {
569
+ includeDrafts: true,
570
+ });
571
+
572
+ expect(published.get(root.id)).toBe(second.id);
573
+ expect(withDrafts.get(root.id)).toBe(second.id);
574
+ });
575
+
576
+ it("refuses a reply to the root once a reply exists", async () => {
577
+ const { root, second } = await createThreadWithOlderReplies();
578
+
579
+ await expect(
580
+ postService.create({
581
+ format: "note",
582
+ bodyMarkdown: "Fork",
583
+ replyToId: root.id,
584
+ }),
585
+ ).rejects.toThrow("This post is no longer the end of the thread.");
586
+
587
+ const next = await postService.create({
588
+ format: "note",
589
+ bodyMarkdown: "Third reply",
590
+ replyToId: second.id,
591
+ });
592
+ expect(next.threadId).toBe(root.id);
593
+ });
594
+
595
+ it("folds the replies for the timeline in Thread order", async () => {
596
+ const { root, first, second } = await createThreadWithOlderReplies();
597
+
598
+ const context = (
599
+ await postService.getThreadTimelineContext([root.id])
600
+ ).get(root.id);
601
+ const previews = await postService.getThreadPreviews([root.id]);
602
+
603
+ expect(context?.leadingReplies.map((post) => post.id)).toEqual([
604
+ first.id,
605
+ second.id,
606
+ ]);
607
+ expect(context?.latestReply.id).toBe(second.id);
608
+ expect(context?.totalReplyCount).toBe(2);
609
+ expect(previews.get(root.id)?.map((post) => post.id)).toEqual([
610
+ first.id,
611
+ second.id,
612
+ ]);
613
+ });
614
+ });
615
+
489
616
  describe("timeline assembly", () => {
490
617
  it("fetches published non-reply posts for the timeline", async () => {
491
618
  const root = await postService.create({
@@ -13,6 +13,7 @@ import type { Database } from "../../db/index.js";
13
13
  import { createPathService } from "../path.js";
14
14
  import type { MediaService } from "../media.js";
15
15
  import { POST_BODY_HTML_VERSION } from "../../lib/post-body-html.js";
16
+ import { ValidationError } from "../../lib/errors.js";
16
17
  import type BetterSqlite3 from "better-sqlite3";
17
18
 
18
19
  function createMockStorage() {
@@ -90,6 +91,79 @@ describe("PostService", () => {
90
91
  expect(post.threadId).toBe(post.id);
91
92
  });
92
93
 
94
+ it("stores an empty list item with the paragraph the schema requires", async () => {
95
+ const post = await postService.create({
96
+ format: "note",
97
+ body: JSON.stringify({
98
+ type: "doc",
99
+ content: [
100
+ {
101
+ type: "orderedList",
102
+ content: [
103
+ { type: "listItem", content: [] },
104
+ {
105
+ type: "listItem",
106
+ content: [
107
+ {
108
+ type: "paragraph",
109
+ content: [{ type: "text", text: "Install" }],
110
+ },
111
+ ],
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ }),
117
+ });
118
+
119
+ const stored = JSON.parse(post.body ?? "");
120
+ expect(stored.content[0].content[0]).toEqual({
121
+ type: "listItem",
122
+ content: [{ type: "paragraph" }],
123
+ });
124
+ });
125
+
126
+ it("restores the creation and edit times an import passes", async () => {
127
+ const published = await postService.create({
128
+ format: "note",
129
+ bodyMarkdown: "Moved from another site",
130
+ publishedAt: 1700000500,
131
+ createdAt: 1700000000,
132
+ updatedAt: 1700009000,
133
+ });
134
+ expect(published).toMatchObject({
135
+ createdAt: 1700000000,
136
+ updatedAt: 1700009000,
137
+ lastActivityAt: 1700000500,
138
+ });
139
+
140
+ const draft = await postService.create({
141
+ format: "note",
142
+ status: "draft",
143
+ bodyMarkdown: "Unfinished",
144
+ createdAt: 1700000000,
145
+ updatedAt: 1700004000,
146
+ });
147
+ expect(draft).toMatchObject({
148
+ createdAt: 1700000000,
149
+ updatedAt: 1700004000,
150
+ lastActivityAt: 1700004000,
151
+ });
152
+
153
+ const backdated = await postService.create({
154
+ format: "note",
155
+ bodyMarkdown: "Only a creation time",
156
+ createdAt: 1700000000,
157
+ });
158
+ expect(backdated.updatedAt).toBe(1700000000);
159
+ });
160
+
161
+ it("rejects a body that isn't TipTap JSON", async () => {
162
+ await expect(
163
+ postService.create({ format: "note", body: "not json" }),
164
+ ).rejects.toThrow(ValidationError);
165
+ });
166
+
93
167
  it("creates a link post with commentary", async () => {
94
168
  const body = JSON.stringify({
95
169
  type: "doc",
@@ -13,14 +13,22 @@
13
13
  * the second act runs, against the site the request resolved — the same site
14
14
  * the route's membership check reads. On a hosted install the database holds
15
15
  * every tenant, so "the only site" is not a question with an answer there.
16
+ *
17
+ * A self-hosted install that is deployed rather than clicked through runs both
18
+ * acts back to back without a browser — see `setUpInstance`.
16
19
  */
17
20
 
21
+ import { and, eq } from "drizzle-orm";
18
22
  import type { DatabaseDialect } from "../db/dialect.js";
19
23
  import type { Database } from "../db/index.js";
20
24
  import {
21
25
  sqliteSchemaBundle,
22
26
  type DatabaseSchema,
23
27
  } from "../db/schema-bundle.js";
28
+ import { AUTH_ID_PREFIX, createTypeId } from "../lib/ids.js";
29
+ import { hashPassword, verifyPassword } from "../lib/password.js";
30
+ import { deriveAccountName } from "../lib/schemas.js";
31
+ import { now } from "../lib/time.js";
24
32
  import { createNavItemService } from "./navigation.js";
25
33
  import { createSettingsService } from "./settings.js";
26
34
  import { createSiteMemberService } from "./site-member.js";
@@ -73,6 +81,38 @@ export interface CompleteSiteSetupDeps {
73
81
  updateCurrentUserName?: ((displayName: string) => Promise<void>) | null;
74
82
  }
75
83
 
84
+ export interface SetUpInstanceData {
85
+ /** The owner's sign-in address, normalized the way sign-in normalizes it. */
86
+ email: string;
87
+ /** The owner's password in plain text. Only its hash is stored. */
88
+ password: string;
89
+ /** The id to create the site with. See `EnsureSingleSiteOptions.id`. */
90
+ siteId?: string;
91
+ /** The site's name. Omitted leaves the built-in fallback name in place. */
92
+ siteName?: string;
93
+ /**
94
+ * Language the site publishes in. Omitted means the base locale, the same
95
+ * as an empty answer on the screen.
96
+ */
97
+ siteLanguage?: string;
98
+ /** The site's time zone. Omitted means UTC, as on the screen. */
99
+ timeZone?: string;
100
+ }
101
+
102
+ /**
103
+ * What `setUpInstance` found:
104
+ * - `created`: nothing existed, and now the account, the site and its answers do
105
+ * - `resumed`: an earlier run stopped after creating the account; this one
106
+ * finished the rest
107
+ * - `already-set-up`: setup had finished before, and nothing was changed
108
+ */
109
+ export type SetUpInstanceOutcome = "created" | "resumed" | "already-set-up";
110
+
111
+ export interface SetUpInstanceResult {
112
+ outcome: SetUpInstanceOutcome;
113
+ siteId: string;
114
+ }
115
+
76
116
  export interface BootstrapService {
77
117
  /**
78
118
  * Stand the site up around a newly created owner account.
@@ -110,6 +150,41 @@ export interface BootstrapService {
110
150
  opts: { oldLanguage: string },
111
151
  deps?: CompleteSiteSetupDeps,
112
152
  ): Promise<void>;
153
+
154
+ /**
155
+ * Set a self-hosted install up without a browser: the owner account, the
156
+ * site around it, and the answers the second screen would have collected,
157
+ * ending with onboarding complete.
158
+ *
159
+ * The site half is the screens' own code. The account half differs in one
160
+ * way: the first screen opens the account through better-auth so the
161
+ * browser leaves with a session, and there is no browser here to hand one
162
+ * to. The rows are written directly instead, with the same password hash
163
+ * better-auth is configured with, and named the way the first screen names
164
+ * them until the site's name replaces it.
165
+ *
166
+ * Safe to run on every start. An install that finished setup is left as it
167
+ * is, whatever this call was given — the owner may have changed their
168
+ * address or password since, and a restart must not undo that. A run that
169
+ * stopped after creating the account resumes, but only with that account's
170
+ * address and password, which is what the first screen asks for too.
171
+ *
172
+ * @param data - The owner's credentials and the site's answers, already
173
+ * validated
174
+ * @returns What was found, and the site
175
+ * @throws {Error} On a host-based install; when the site exists under an id
176
+ * other than `siteId`; when an account for another address exists; when
177
+ * the password does not match the account an earlier run left
178
+ * @example
179
+ * ```ts
180
+ * await bootstrap.setUpInstance({
181
+ * email: "owner@example.com",
182
+ * password: "correct horse battery",
183
+ * siteName: "Field Notes",
184
+ * });
185
+ * ```
186
+ */
187
+ setUpInstance(data: SetUpInstanceData): Promise<SetUpInstanceResult>;
113
188
  }
114
189
 
115
190
  /**
@@ -151,11 +226,11 @@ export function createBootstrapService(
151
226
  return siteId;
152
227
  }
153
228
 
229
+ function settingsFor(targetSiteId: string) {
230
+ return createSettingsService(db, targetSiteId, databaseSchema, dialect);
231
+ }
232
+
154
233
  /**
155
- * The site the account step stands up. A self-hosted install has no row
156
- * until now, so this is the step that creates it — along with its domain,
157
- * when one is configured.
158
- *
159
234
  * A host-based install refuses before anything is written. The control
160
235
  * plane creates each hosted site already provisioned, and its owner arrives
161
236
  * through the hosted handoff, the only way anyone becomes a member of one.
@@ -163,71 +238,215 @@ export function createBootstrapService(
163
238
  * any account in a database every tenant shares take a site that had lost
164
239
  * its onboarding status.
165
240
  */
166
- async function siteToProvision(): Promise<string> {
241
+ function assertSelfHosted(step: string): void {
167
242
  if (siteResolutionMode === "host-based") {
168
243
  throw new Error(
169
- "provisionOwnerAccount runs on self-hosted installs only. A hosted site's owner arrives through the control plane's handoff.",
244
+ `${step} runs on self-hosted installs only. A hosted site's owner arrives through the control plane's handoff.`,
170
245
  );
171
246
  }
247
+ }
248
+
249
+ /**
250
+ * Stand the site up around its owner. A self-hosted install has no row until
251
+ * now, so this is the step that creates it — along with its domain, when one
252
+ * is configured.
253
+ *
254
+ * @returns The site's id
255
+ */
256
+ async function provisionSite(
257
+ step: string,
258
+ ownerUserId: string,
259
+ siteOptions: EnsureSingleSiteOptions | undefined,
260
+ ): Promise<string> {
261
+ assertSelfHosted(step);
172
262
  const { site } = await createSiteService(
173
263
  db,
174
264
  databaseSchema,
175
- ).ensureSingleSite(options?.bootstrapSite);
265
+ ).ensureSingleSite(siteOptions);
266
+ const navItems = createNavItemService(db, site.id, databaseSchema);
267
+ const siteMembers = createSiteMemberService(db, databaseSchema);
268
+
269
+ await siteMembers.ensure(site.id, ownerUserId, "owner");
270
+ await navItems.materializeDefaultNavigation();
271
+ await settingsFor(site.id).markSiteProvisioned();
176
272
  return site.id;
177
273
  }
178
274
 
275
+ async function recordSiteAnswers(
276
+ targetSiteId: string,
277
+ data: CompleteSiteSetupData,
278
+ opts: { oldLanguage: string },
279
+ deps: CompleteSiteSetupDeps | undefined,
280
+ ): Promise<void> {
281
+ const settings = settingsFor(targetSiteId);
282
+
283
+ const siteName = data.siteName?.trim();
284
+ if (siteName) {
285
+ await settings.set("SITE_NAME", siteName);
286
+ // Before onboarding closes, deliberately: if the rename fails the site
287
+ // is still provisioned, and the author retries the screen they were
288
+ // already on rather than landing in a finished site under a stale name.
289
+ await deps?.updateCurrentUserName?.(siteName);
290
+ }
291
+
292
+ // `undefined` means the shell already has one. `null` is a browser that
293
+ // reported nothing, which is the same UTC default the site would fall
294
+ // back to anyway — written so a later reader sees a chosen value.
295
+ if (data.timeZone !== undefined) {
296
+ await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
297
+ }
298
+
299
+ await settings.confirmFirstRunLanguage(
300
+ {
301
+ siteLanguage: data.siteLanguage ?? "",
302
+ browserLanguage: data.browserLanguage,
303
+ },
304
+ opts,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * The account a browserless setup stands the site up around: a new one when
310
+ * the install has none, or the one an interrupted run left, claimed with its
311
+ * own password — the way the first screen resumes by signing in.
312
+ */
313
+ async function claimOwnerAccount(
314
+ email: string,
315
+ password: string,
316
+ ): Promise<{ userId: string; created: boolean }> {
317
+ const { account, user } = databaseSchema;
318
+ const [existing] = await db
319
+ .select({ id: user.id, passwordHash: account.password })
320
+ .from(user)
321
+ .leftJoin(
322
+ account,
323
+ and(eq(account.userId, user.id), eq(account.providerId, "credential")),
324
+ )
325
+ .where(eq(user.email, email))
326
+ .limit(1);
327
+
328
+ if (existing) {
329
+ const matches =
330
+ !!existing.passwordHash &&
331
+ (await verifyPassword({ hash: existing.passwordHash, password }));
332
+ if (!matches) {
333
+ throw new Error(
334
+ `An earlier setup created the account for ${email}, and the password given does not match it.`,
335
+ );
336
+ }
337
+ return { userId: existing.id, created: false };
338
+ }
339
+
340
+ const [other] = await db.select({ id: user.id }).from(user).limit(1);
341
+ if (other) {
342
+ throw new Error(
343
+ `This install already has an account under another address. Pass that account's address and password to finish setup.`,
344
+ );
345
+ }
346
+
347
+ const userId = createTypeId(AUTH_ID_PREFIX.user);
348
+ const timestamp = new Date(now() * 1000);
349
+ await db.insert(user).values({
350
+ id: userId,
351
+ email,
352
+ emailVerified: false,
353
+ image: null,
354
+ // The first screen's stand-in, replaced when the site is named.
355
+ name: deriveAccountName(email),
356
+ role: "admin",
357
+ createdAt: timestamp,
358
+ updatedAt: timestamp,
359
+ });
360
+ await db.insert(account).values({
361
+ id: createTypeId(AUTH_ID_PREFIX.account),
362
+ accountId: userId,
363
+ userId,
364
+ providerId: "credential",
365
+ password: await hashPassword(password),
366
+ accessToken: null,
367
+ accessTokenExpiresAt: null,
368
+ idToken: null,
369
+ refreshToken: null,
370
+ refreshTokenExpiresAt: null,
371
+ scope: null,
372
+ createdAt: timestamp,
373
+ updatedAt: timestamp,
374
+ });
375
+ return { userId, created: true };
376
+ }
377
+
179
378
  return {
180
379
  async provisionOwnerAccount(data) {
181
- const provisionedSiteId = await siteToProvision();
182
- const settings = createSettingsService(
183
- db,
184
- provisionedSiteId,
185
- databaseSchema,
186
- dialect,
380
+ await provisionSite(
381
+ "provisionOwnerAccount",
382
+ data.ownerUserId,
383
+ options?.bootstrapSite,
187
384
  );
188
- const navItems = createNavItemService(
189
- db,
190
- provisionedSiteId,
191
- databaseSchema,
192
- );
193
- const siteMembers = createSiteMemberService(db, databaseSchema);
194
-
195
- await siteMembers.ensure(provisionedSiteId, data.ownerUserId, "owner");
196
- await navItems.materializeDefaultNavigation();
197
- await settings.markSiteProvisioned();
198
385
  },
199
386
 
200
387
  async completeSiteSetup(data, opts, deps) {
201
- const settings = createSettingsService(
202
- db,
388
+ await recordSiteAnswers(
203
389
  existingSiteId("completeSiteSetup"),
204
- databaseSchema,
205
- dialect,
390
+ data,
391
+ opts,
392
+ deps,
206
393
  );
394
+ },
207
395
 
208
- const siteName = data.siteName?.trim();
209
- if (siteName) {
210
- await settings.set("SITE_NAME", siteName);
211
- // Before onboarding closes, deliberately: if the rename fails the site
212
- // is still provisioned, and the author retries the screen they were
213
- // already on rather than landing in a finished site under a stale name.
214
- await deps?.updateCurrentUserName?.(siteName);
215
- }
396
+ async setUpInstance(data) {
397
+ assertSelfHosted("setUpInstance");
216
398
 
217
- // `undefined` means the shell already has one. `null` is a browser that
218
- // reported nothing, which is the same UTC default the site would fall
219
- // back to anyway — written so a later reader sees a chosen value.
220
- if (data.timeZone !== undefined) {
221
- await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
399
+ const existingSite = await createSiteService(
400
+ db,
401
+ databaseSchema,
402
+ ).getOnlySite();
403
+ if (
404
+ existingSite &&
405
+ data.siteId !== undefined &&
406
+ existingSite.id !== data.siteId
407
+ ) {
408
+ throw new Error(
409
+ `This install's site is ${existingSite.id}, not ${data.siteId}. Check that the database is the one you meant.`,
410
+ );
411
+ }
412
+ if (
413
+ existingSite &&
414
+ (await settingsFor(existingSite.id).isOnboardingComplete())
415
+ ) {
416
+ return { outcome: "already-set-up", siteId: existingSite.id };
222
417
  }
223
418
 
224
- await settings.confirmFirstRunLanguage(
419
+ const owner = await claimOwnerAccount(data.email, data.password);
420
+ const setUpSiteId = await provisionSite("setUpInstance", owner.userId, {
421
+ ...options?.bootstrapSite,
422
+ id: data.siteId,
423
+ });
424
+ const { user } = databaseSchema;
425
+ await recordSiteAnswers(
426
+ setUpSiteId,
225
427
  {
226
- siteLanguage: data.siteLanguage ?? "",
227
- browserLanguage: data.browserLanguage,
428
+ siteName: data.siteName,
429
+ siteLanguage: data.siteLanguage,
430
+ timeZone: data.timeZone ?? null,
431
+ },
432
+ {
433
+ oldLanguage:
434
+ (await settingsFor(setUpSiteId).get("SITE_LANGUAGE")) ?? "",
435
+ },
436
+ {
437
+ async updateCurrentUserName(displayName) {
438
+ await db
439
+ .update(user)
440
+ .set({ name: displayName, updatedAt: new Date(now() * 1000) })
441
+ .where(eq(user.id, owner.userId));
442
+ },
228
443
  },
229
- opts,
230
444
  );
445
+
446
+ return {
447
+ outcome: owner.created ? "created" : "resumed",
448
+ siteId: setUpSiteId,
449
+ };
231
450
  },
232
451
  };
233
452
  }
@@ -108,7 +108,7 @@ export function createCustomUrlService(
108
108
  : eq(pathRegistry.collectionId, targetId),
109
109
  ),
110
110
  )
111
- .orderBy(desc(pathRegistry.createdAt))
111
+ .orderBy(desc(pathRegistry.createdAt), desc(pathRegistry.id))
112
112
  .limit(1);
113
113
  return result[0] ? toCustomUrl(result[0]) : null;
114
114
  },
@@ -228,7 +228,7 @@ export function createCustomUrlService(
228
228
  .where(
229
229
  and(eq(pathRegistry.siteId, siteId), ne(pathRegistry.kind, "slug")),
230
230
  )
231
- .orderBy(desc(pathRegistry.createdAt))
231
+ .orderBy(desc(pathRegistry.createdAt), desc(pathRegistry.id))
232
232
  .$dynamic();
233
233
  if (opts?.limit !== undefined) q = q.limit(opts.limit);
234
234
  if (opts?.offset !== undefined) q = q.offset(opts.offset);
@@ -1,3 +1,13 @@
1
+ {{- /*
2
+ Alias page. Hugo renders one of these for every entry in a post's
3
+ `aliases:` front matter.
4
+
5
+ `.Permalink` is the alias target. It is empty whenever the page the alias
6
+ points at was not built — an empty `content=0; url=` is read by browsers as
7
+ "reload this page", which turns the alias into an infinite refresh loop. So
8
+ the redirect markup is only emitted when there is somewhere to redirect to.
9
+ */ -}}
10
+ {{- if .Permalink -}}
1
11
  <!doctype html>
2
12
  <html lang="{{ .Site.LanguageCode | default "en" }}">
3
13
  <head>
@@ -9,7 +19,10 @@
9
19
  <script>
10
20
  (function () {
11
21
  var target = {{ .Permalink }};
12
- var rootAliases = {{ with .Params.root_aliases }}{{ . | jsonify }}{{ else }}[]{{ end }};
22
+ // `safeJS` keeps this an array literal. Without it Hugo's contextual
23
+ // escaping emits a quoted JSON string, and the loop below compares
24
+ // single characters instead of paths.
25
+ var rootAliases = {{ with .Params.root_aliases }}{{ . | jsonify | safeJS }}{{ else }}[]{{ end }};
13
26
  try {
14
27
  var path = window.location.pathname || "";
15
28
  var normalized = "/" + path.replace(/^\/+|\/+$/g, "") + "/";
@@ -47,3 +60,16 @@
47
60
  <p>Redirecting to <a href="{{ .Permalink }}">{{ .Permalink }}</a>&hellip;</p>
48
61
  </body>
49
62
  </html>
63
+ {{- else -}}
64
+ <!doctype html>
65
+ <html lang="{{ .Site.LanguageCode | default "en" }}">
66
+ <head>
67
+ <meta charset="utf-8">
68
+ <title>{{ .Site.Title }}</title>
69
+ <meta name="robots" content="noindex,nofollow">
70
+ </head>
71
+ <body>
72
+ <p>This page is not available.</p>
73
+ </body>
74
+ </html>
75
+ {{- end -}}