@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.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- 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
|
-
|
|
241
|
+
function assertSelfHosted(step: string): void {
|
|
167
242
|
if (siteResolutionMode === "host-based") {
|
|
168
243
|
throw new Error(
|
|
169
|
-
|
|
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(
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
202
|
-
db,
|
|
388
|
+
await recordSiteAnswers(
|
|
203
389
|
existingSiteId("completeSiteSetup"),
|
|
204
|
-
|
|
205
|
-
|
|
390
|
+
data,
|
|
391
|
+
opts,
|
|
392
|
+
deps,
|
|
206
393
|
);
|
|
394
|
+
},
|
|
207
395
|
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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>…</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 -}}
|