@jant/core 0.6.10 → 0.6.12

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 (92) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
  8. package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
  9. package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/compose-types.ts +4 -0
  25. package/src/client/components/jant-command-palette.ts +67 -2
  26. package/src/client/components/jant-compose-dialog.ts +8 -2
  27. package/src/client/components/jant-compose-editor.ts +111 -0
  28. package/src/client/components/jant-nav-manager.ts +142 -2
  29. package/src/client/components/jant-settings-general.ts +56 -0
  30. package/src/client/components/nav-manager-types.ts +14 -0
  31. package/src/client/components/settings-types.ts +30 -0
  32. package/src/client/compose-bridge.ts +25 -8
  33. package/src/client/compose-launch.ts +30 -0
  34. package/src/client/compose-shortcuts.ts +46 -3
  35. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  36. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  37. package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
  38. package/src/client/tiptap/create-editor.ts +3 -0
  39. package/src/client/tiptap/extensions.ts +4 -0
  40. package/src/client/tiptap/inline-image-upload.ts +174 -50
  41. package/src/client/tiptap/rehost-images.ts +108 -0
  42. package/src/i18n/__tests__/fallback.test.ts +24 -0
  43. package/src/i18n/locales/public/en.po +10 -0
  44. package/src/i18n/locales/public/en.ts +1 -1
  45. package/src/i18n/locales/public/zh-Hans.po +10 -0
  46. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/public/zh-Hant.po +10 -0
  48. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  49. package/src/i18n/locales/settings/en.po +54 -0
  50. package/src/i18n/locales/settings/en.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  52. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  54. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  55. package/src/lib/__tests__/feed.test.ts +96 -10
  56. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  57. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  58. package/src/lib/feed.ts +63 -3
  59. package/src/lib/post-display.ts +14 -8
  60. package/src/lib/upload.ts +111 -0
  61. package/src/lib/url-fetch.ts +263 -0
  62. package/src/preset.css +60 -18
  63. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  64. package/src/routes/api/uploads.ts +52 -0
  65. package/src/routes/dash/settings.tsx +41 -2
  66. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  67. package/src/routes/pages/page.tsx +44 -12
  68. package/src/services/__tests__/about-page.test.ts +116 -0
  69. package/src/services/__tests__/media.test.ts +168 -1
  70. package/src/services/__tests__/navigation.test.ts +190 -1
  71. package/src/services/__tests__/post-timeline.test.ts +119 -2
  72. package/src/services/about-page.ts +143 -0
  73. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  74. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  75. package/src/services/export-theme/styles/main.css +46 -14
  76. package/src/services/index.ts +27 -17
  77. package/src/services/media.ts +111 -0
  78. package/src/services/navigation.ts +155 -1
  79. package/src/services/post.ts +24 -7
  80. package/src/styles/components.css +20 -0
  81. package/src/styles/tokens.css +26 -8
  82. package/src/styles/ui.css +149 -7
  83. package/src/types/views.ts +13 -0
  84. package/src/ui/compose/ComposeDialog.tsx +16 -0
  85. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  86. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  87. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  88. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  89. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  90. package/src/ui/layouts/BaseLayout.tsx +12 -0
  91. package/dist/app-D24n0DoH.js +0 -6
  92. package/dist/client/_assets/client-xWDl78yi.css +0 -2
@@ -464,8 +464,86 @@ describe("PostService - Timeline features", () => {
464
464
  });
465
465
  });
466
466
 
467
+ describe("listCollectionThreadRootIds", () => {
468
+ it("bumps a hidden collection thread when a non-quiet reply is published", async () => {
469
+ const collection = await collectionService.create({
470
+ slug: "thinking",
471
+ title: "Thinking",
472
+ });
473
+ const hiddenRoot = await postService.create({
474
+ format: "note",
475
+ bodyMarkdown: "Hidden root",
476
+ visibility: "latest_hidden",
477
+ publishedAt: 1000,
478
+ });
479
+ const newerRoot = await postService.create({
480
+ format: "note",
481
+ bodyMarkdown: "Newer root",
482
+ publishedAt: 2000,
483
+ });
484
+
485
+ await collectionService.addPost(collection.id, hiddenRoot.id);
486
+ await collectionService.addPost(collection.id, newerRoot.id);
487
+
488
+ await postService.create({
489
+ format: "note",
490
+ bodyMarkdown: "Normal reply",
491
+ replyToId: hiddenRoot.id,
492
+ publishedAt: 3000,
493
+ });
494
+
495
+ const rootIds = await postService.listCollectionThreadRootIds(
496
+ collection.id,
497
+ {
498
+ status: "published",
499
+ sortOrder: "newest",
500
+ },
501
+ );
502
+
503
+ expect(rootIds).toEqual([hiddenRoot.id, newerRoot.id]);
504
+ });
505
+
506
+ it("does not bump a collection thread when a reply is quiet", async () => {
507
+ const collection = await collectionService.create({
508
+ slug: "quiet",
509
+ title: "Quiet",
510
+ });
511
+ const olderRoot = await postService.create({
512
+ format: "note",
513
+ bodyMarkdown: "Older root",
514
+ publishedAt: 1000,
515
+ });
516
+ const newerRoot = await postService.create({
517
+ format: "note",
518
+ bodyMarkdown: "Newer root",
519
+ publishedAt: 2000,
520
+ });
521
+
522
+ await collectionService.addPost(collection.id, olderRoot.id);
523
+ await collectionService.addPost(collection.id, newerRoot.id);
524
+
525
+ await postService.create({
526
+ format: "note",
527
+ bodyMarkdown: "Quiet reply",
528
+ replyToId: olderRoot.id,
529
+ publishedAt: 3000,
530
+ quietReply: true,
531
+ });
532
+
533
+ const rootIds = await postService.listCollectionThreadRootIds(
534
+ collection.id,
535
+ {
536
+ status: "published",
537
+ sortOrder: "newest",
538
+ },
539
+ );
540
+
541
+ expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
542
+ });
543
+ });
544
+
467
545
  describe("listCollectionFeedEntries", () => {
468
- it("orders collection feeds by publishedAt and returns thread roots", async () => {
546
+ it("orders collection feeds by thread activity and returns thread roots", async () => {
469
547
  const collection = await collectionService.create({
470
548
  slug: "reading",
471
549
  title: "Reading",
@@ -565,11 +643,50 @@ describe("PostService - Timeline features", () => {
565
643
  );
566
644
 
567
645
  expect(entries).toHaveLength(2);
568
- // Sorted by publishedAt DESC: secondRoot created last has latest publishedAt
569
646
  expect(entries[0]?.post.id).toBe(secondRoot.id);
570
647
  expect(entries[0]?.collectedAt).toBe(200);
571
648
  expect(entries[1]?.post.id).toBe(sharedRoot.id);
572
649
  expect(entries[1]?.collectedAt).toBe(300);
573
650
  });
651
+
652
+ it("orders collection feeds by thread activity from non-quiet replies", async () => {
653
+ const collection = await collectionService.create({
654
+ slug: "activity",
655
+ title: "Activity",
656
+ });
657
+ const hiddenRoot = await postService.create({
658
+ format: "note",
659
+ bodyMarkdown: "Hidden root",
660
+ visibility: "latest_hidden",
661
+ publishedAt: 1000,
662
+ });
663
+ const newerRoot = await postService.create({
664
+ format: "note",
665
+ bodyMarkdown: "Newer root",
666
+ publishedAt: 2000,
667
+ });
668
+
669
+ await collectionService.addPost(collection.id, hiddenRoot.id);
670
+ await collectionService.addPost(collection.id, newerRoot.id);
671
+
672
+ await postService.create({
673
+ format: "note",
674
+ bodyMarkdown: "Normal reply",
675
+ replyToId: hiddenRoot.id,
676
+ publishedAt: 3000,
677
+ });
678
+
679
+ const entries = await postService.listCollectionFeedEntries(
680
+ collection.id,
681
+ {
682
+ status: "published",
683
+ },
684
+ );
685
+
686
+ expect(entries.map((entry) => entry.post.id)).toEqual([
687
+ hiddenRoot.id,
688
+ newerRoot.id,
689
+ ]);
690
+ });
574
691
  });
575
692
  });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * About Page Service
3
+ *
4
+ * Treats `/about` as the standard site About page. The page itself remains a
5
+ * normal post; this service reads that convention and can create the page when
6
+ * the author follows the settings prompt.
7
+ */
8
+
9
+ import { ValidationError } from "../lib/errors.js";
10
+ import type { Visibility } from "../types.js";
11
+ import type { CollectionService } from "./collection.js";
12
+ import type { PathService } from "./path.js";
13
+ import type { PostService } from "./post.js";
14
+
15
+ export const ABOUT_PAGE_PATH = "/about";
16
+ export const ABOUT_PAGE_SLUG = "about";
17
+
18
+ export type AboutPageStatus =
19
+ | {
20
+ state: "missing";
21
+ path: typeof ABOUT_PAGE_PATH;
22
+ }
23
+ | {
24
+ state: "ready";
25
+ path: typeof ABOUT_PAGE_PATH;
26
+ post: {
27
+ id: string;
28
+ title: string | null;
29
+ status: "draft" | "published";
30
+ visibility: Visibility;
31
+ };
32
+ }
33
+ | {
34
+ state: "conflict";
35
+ path: typeof ABOUT_PAGE_PATH;
36
+ conflict: {
37
+ targetType: "collection" | "redirect" | "archive" | "post";
38
+ id: string | null;
39
+ title: string | null;
40
+ };
41
+ };
42
+
43
+ export interface AboutPageService {
44
+ getStatus(): Promise<AboutPageStatus>;
45
+ ensurePage(): Promise<Extract<AboutPageStatus, { state: "ready" }>["post"]>;
46
+ }
47
+
48
+ export function createAboutPageService(deps: {
49
+ paths: PathService;
50
+ posts: PostService;
51
+ collections: CollectionService;
52
+ }): AboutPageService {
53
+ async function readStatus(): Promise<AboutPageStatus> {
54
+ const resolved = await deps.paths.resolve(ABOUT_PAGE_SLUG);
55
+ const base = {
56
+ path: ABOUT_PAGE_PATH,
57
+ } as const;
58
+
59
+ if (!resolved) {
60
+ return { ...base, state: "missing" };
61
+ }
62
+
63
+ if (resolved.targetType === "post" && resolved.postId) {
64
+ const post = await deps.posts.getById(resolved.postId);
65
+ if (!post) {
66
+ return {
67
+ ...base,
68
+ state: "conflict",
69
+ conflict: {
70
+ targetType: "post",
71
+ id: resolved.postId,
72
+ title: null,
73
+ },
74
+ };
75
+ }
76
+
77
+ return {
78
+ ...base,
79
+ state: "ready",
80
+ post: {
81
+ id: post.id,
82
+ title: post.title,
83
+ status: post.status,
84
+ visibility: post.visibility,
85
+ },
86
+ };
87
+ }
88
+
89
+ if (resolved.targetType === "collection" && resolved.collectionId) {
90
+ const collection = await deps.collections.getById(resolved.collectionId);
91
+ return {
92
+ ...base,
93
+ state: "conflict",
94
+ conflict: {
95
+ targetType: "collection",
96
+ id: resolved.collectionId,
97
+ title: collection?.title ?? null,
98
+ },
99
+ };
100
+ }
101
+
102
+ return {
103
+ ...base,
104
+ state: "conflict",
105
+ conflict: {
106
+ targetType: resolved.targetType,
107
+ id: null,
108
+ title: null,
109
+ },
110
+ };
111
+ }
112
+
113
+ return {
114
+ getStatus: readStatus,
115
+
116
+ async ensurePage() {
117
+ const status = await readStatus();
118
+ if (status.state === "ready") {
119
+ return status.post;
120
+ }
121
+
122
+ if (status.state === "conflict") {
123
+ throw new ValidationError(
124
+ "/about is already used. Rename that item before creating an About page.",
125
+ );
126
+ }
127
+
128
+ const post = await deps.posts.create({
129
+ format: "note",
130
+ title: "About",
131
+ slug: ABOUT_PAGE_SLUG,
132
+ status: "published",
133
+ visibility: "latest_hidden",
134
+ });
135
+ return {
136
+ id: post.id,
137
+ title: post.title,
138
+ status: post.status,
139
+ visibility: post.visibility,
140
+ };
141
+ },
142
+ };
143
+ }
@@ -23,7 +23,8 @@
23
23
  body → Hugo-rendered .Content (stripped of <script>/<style>)
24
24
  rating → <p>★★★★☆ 4/5</p>
25
25
  link → trailing <p><a href=permalink> ★ </a></p>
26
- replies → <hr/> + <p><small><time>{dt}</time></small></p> + content
26
+ replies → <hr/> + <p><small><time>{dt}</time></small></p>
27
+ + inline title/link metadata + content
27
28
 
28
29
  Hugo's default RSS media-type override (`[outputFormats.RSS]`) makes this
29
30
  template produce application/atom+xml at /{section}/index.xml.
@@ -8,6 +8,8 @@
8
8
  link-format "★" suffix)
9
9
  includeReplies — when true, appends each public reply separated by
10
10
  <hr/> + <small><time></time></small>
11
+ inline — when true, renders title/link metadata that top-level
12
+ Atom entries expose through their entry fields
11
13
 
12
14
  Output is raw HTML suitable for embedding inside Atom
13
15
  <content type="html"><![CDATA[...]]></content>. Since CDATA ends at the
@@ -18,9 +20,31 @@
18
20
  {{- $post := .post -}}
19
21
  {{- $permalink := .permalink -}}
20
22
  {{- $includeReplies := .includeReplies -}}
23
+ {{- $inline := false -}}
24
+ {{- with .inline -}}{{- $inline = . -}}{{- end -}}
21
25
  {{- $format := $post.Params.format | default "note" -}}
22
26
  {{- $parts := slice -}}
23
27
 
28
+ {{- /* --- Inline title/link metadata for thread replies --- */ -}}
29
+ {{- if and $inline (ne $format "quote") -}}
30
+ {{- $title := $post.Title | default "" -}}
31
+ {{- $linkUrl := string ($post.Params.link_url | default "") -}}
32
+ {{- if eq $format "link" -}}
33
+ {{- if ne $linkUrl "" -}}
34
+ {{- $domain := $linkUrl -}}
35
+ {{- with urls.Parse $linkUrl -}}{{- if .Host -}}{{- $domain = replaceRE `^(www|m|mobile)\.` "" .Host -}}{{- end -}}{{- end -}}
36
+ {{- $parts = $parts | append (printf `<p><a href=%q>%s</a></p>` $linkUrl (transform.XMLEscape $domain)) -}}
37
+ {{- end -}}
38
+ {{- if ne $title "" -}}
39
+ {{- $titleHref := $permalink -}}
40
+ {{- if ne $linkUrl "" -}}{{- $titleHref = $linkUrl -}}{{- end -}}
41
+ {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $titleHref (transform.XMLEscape $title)) -}}
42
+ {{- end -}}
43
+ {{- else if ne $title "" -}}
44
+ {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $permalink (transform.XMLEscape $title)) -}}
45
+ {{- end -}}
46
+ {{- end -}}
47
+
24
48
  {{- /* --- Quote block (format=quote) --- */ -}}
25
49
  {{- if eq $format "quote" -}}
26
50
  {{- $quoteText := string ($post.Params.quote_text | default "") -}}
@@ -100,7 +124,7 @@
100
124
  {{- $replyLabel := .Date.Format "Jan 2, 2006 15:04" -}}
101
125
  {{- $parts = $parts | append "<hr/>" -}}
102
126
  {{- $parts = $parts | append (printf `<p><small><time datetime=%q>%s</time></small></p>` $replyDatetime (transform.XMLEscape $replyLabel)) -}}
103
- {{- $parts = $parts | append (partial "feed-post-content.xml" (dict "post" . "permalink" $replyPermalink "includeReplies" false)) -}}
127
+ {{- $parts = $parts | append (partial "feed-post-content.xml" (dict "post" . "permalink" $replyPermalink "includeReplies" false "inline" true)) -}}
104
128
  {{- end -}}
105
129
  {{- end -}}
106
130
 
@@ -84,13 +84,11 @@ h1 {
84
84
 
85
85
  h2 {
86
86
  font-size: var(--type-content-title);
87
- font-style: italic;
88
87
  margin: 2.1rem 0 1.4rem;
89
88
  }
90
89
 
91
90
  h3 {
92
91
  font-size: var(--type-content-subtitle);
93
- font-style: italic;
94
92
  margin: 2rem 0 1.4rem;
95
93
  }
96
94
 
@@ -119,6 +117,12 @@ kbd,
119
117
  samp {
120
118
  font-family: var(--font-mono);
121
119
  font-size: var(--type-code);
120
+ line-height: 1.42;
121
+ }
122
+
123
+ code {
124
+ color: var(--site-code-text);
125
+ overflow-wrap: break-word;
122
126
  }
123
127
 
124
128
  pre {
@@ -127,23 +131,26 @@ pre {
127
131
  line-height: 1.5;
128
132
  padding: 1rem;
129
133
  overflow-x: auto;
130
- background-color: var(--site-feed-card-bg);
131
- border: 1px solid var(--site-feed-card-border);
132
- border-radius: 0.375rem;
134
+ color: var(--site-code-text);
135
+ background-color: var(--site-code-block-bg);
136
+ border: 1px solid var(--site-code-block-border);
137
+ border-radius: 6px;
133
138
  }
134
139
 
135
140
  pre code {
136
141
  padding: 0;
137
142
  background: transparent;
138
143
  border: 0;
144
+ color: inherit;
139
145
  font-size: inherit;
140
146
  }
141
147
 
142
148
  :not(pre) > code {
143
- padding: 0.1em 0.35em;
144
- background-color: var(--site-feed-card-bg);
145
- border: 1px solid var(--site-feed-card-border);
146
- border-radius: 0.25rem;
149
+ padding: 0.08em 0.28em;
150
+ background-color: var(--site-code-bg);
151
+ border-radius: 0.22em;
152
+ box-decoration-break: clone;
153
+ -webkit-box-decoration-break: clone;
147
154
  }
148
155
 
149
156
  /* Blockquotes — tinted background card with quote icon (matches main site). */
@@ -217,13 +224,13 @@ blockquote cite {
217
224
 
218
225
  ul,
219
226
  ol {
220
- padding-left: 1.625em;
227
+ padding-left: 1.4em;
221
228
  margin: 1.25em 0;
222
229
  }
223
230
 
224
231
  li {
225
- margin-top: 1.2em;
226
- margin-bottom: 1.2em;
232
+ margin-top: 0.65em;
233
+ margin-bottom: 0.65em;
227
234
  }
228
235
 
229
236
  /* Normalize li > p so list spacing is controlled by li alone,
@@ -850,6 +857,31 @@ hr.feed-divider {
850
857
  color: var(--site-reading-body);
851
858
  }
852
859
 
860
+ .post-card > .post-card-summary :is(h1, h2),
861
+ .post-card > .post-card-quote-commentary :is(h1, h2) {
862
+ font-size: calc(var(--type-content-body) * 1.12);
863
+ font-weight: var(--fw-medium);
864
+ margin-top: 1.45rem;
865
+ margin-bottom: 0.55rem;
866
+ }
867
+
868
+ .post-card > .post-card-summary :is(h3, h4),
869
+ .post-card > .post-card-quote-commentary :is(h3, h4) {
870
+ font-size: var(--type-content-body);
871
+ font-weight: var(--fw-medium);
872
+ margin-top: 1.2rem;
873
+ margin-bottom: 0.45rem;
874
+ }
875
+
876
+ .post-card > .post-card-summary :is(h5, h6),
877
+ .post-card > .post-card-quote-commentary :is(h5, h6) {
878
+ font-size: var(--type-secondary);
879
+ font-weight: var(--fw-medium);
880
+ color: var(--site-text-secondary);
881
+ margin-top: 1rem;
882
+ margin-bottom: 0.35rem;
883
+ }
884
+
853
885
  /* Link card domain row — shown ABOVE the title (matches main site's
854
886
  `.feed-link-domain` pattern). Inline flex with icon + host text. */
855
887
  .post-card-link-domain,
@@ -1009,8 +1041,8 @@ hr.feed-divider {
1009
1041
  .post-card-quote-content {
1010
1042
  font-family: var(--font-serif);
1011
1043
  color: var(--site-text-primary);
1012
- font-size: var(--type-content-subtitle);
1013
- line-height: var(--type-heading-leading);
1044
+ font-size: var(--type-content-quote);
1045
+ line-height: var(--type-content-quote-leading);
1014
1046
  white-space: pre-line;
1015
1047
  text-wrap: pretty;
1016
1048
  margin: 0;
@@ -35,6 +35,7 @@ import {
35
35
  createSiteProfileService,
36
36
  type SiteProfileService,
37
37
  } from "./site-profile.js";
38
+ import { createAboutPageService, type AboutPageService } from "./about-page.js";
38
39
  import {
39
40
  createUploadSessionService,
40
41
  type UploadSessionService,
@@ -64,6 +65,7 @@ export interface Services {
64
65
  siteAdmin: SiteAdminService;
65
66
  siteMembers: SiteMemberService;
66
67
  siteProfile: SiteProfileService;
68
+ aboutPage: AboutPageService;
67
69
  githubAppInstallations: GitHubAppInstallationsService;
68
70
  telegram: TelegramService;
69
71
  }
@@ -88,6 +90,23 @@ export function createServices(
88
90
  const settings = createSettingsService(db, siteId, databaseSchema, dialect);
89
91
  const paths = createPathService(db, siteId, databaseSchema);
90
92
  const navItems = createNavItemService(db, siteId, databaseSchema);
93
+ const posts = createPostService(
94
+ db,
95
+ {
96
+ slugIdLength: config?.slugIdLength ?? 5,
97
+ databaseDialect: dialect,
98
+ },
99
+ siteId,
100
+ paths,
101
+ databaseSchema,
102
+ );
103
+ const collections = createCollectionService(
104
+ db,
105
+ siteId,
106
+ paths,
107
+ databaseSchema,
108
+ dialect,
109
+ );
91
110
  const media = createMediaService(db, siteId, databaseSchema, dialect, {
92
111
  enforceHostedQuota: config?.enforceHostedMediaQuota ?? false,
93
112
  hostedControlPlane: config?.hostedControlPlane ?? null,
@@ -97,26 +116,11 @@ export function createServices(
97
116
  settings,
98
117
  site,
99
118
  paths,
100
- posts: createPostService(
101
- db,
102
- {
103
- slugIdLength: config?.slugIdLength ?? 5,
104
- databaseDialect: dialect,
105
- },
106
- siteId,
107
- paths,
108
- databaseSchema,
109
- ),
119
+ posts,
110
120
  customUrls: createCustomUrlService(db, siteId, paths, databaseSchema),
111
121
  media,
112
122
  uploads: createUploadSessionService(db, siteId, media, databaseSchema),
113
- collections: createCollectionService(
114
- db,
115
- siteId,
116
- paths,
117
- databaseSchema,
118
- dialect,
119
- ),
123
+ collections,
120
124
  search: createSearchService(rawQuery, siteId, dialect),
121
125
  navItems,
122
126
  auth: createAuthService(
@@ -148,6 +152,11 @@ export function createServices(
148
152
  );
149
153
  },
150
154
  }),
155
+ aboutPage: createAboutPageService({
156
+ paths,
157
+ posts,
158
+ collections,
159
+ }),
151
160
  githubAppInstallations: createGitHubAppInstallationsService(
152
161
  db,
153
162
  databaseSchema,
@@ -166,6 +175,7 @@ export type { UploadSessionService } from "./upload-session.js";
166
175
  export type { CollectionService } from "./collection.js";
167
176
  export type { SearchService, SearchResult, SearchOptions } from "./search.js";
168
177
  export type { NavItemService } from "./navigation.js";
178
+ export type { AboutPageService, AboutPageStatus } from "./about-page.js";
169
179
  export type { AuthService, DeleteAccountDeps } from "./auth.js";
170
180
  export type { ApiTokenService } from "./api-token.js";
171
181
  export type {
@@ -32,10 +32,18 @@ import { renderTiptapJson } from "../lib/tiptap-render.js";
32
32
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
33
33
  import {
34
34
  generateStorageKey,
35
+ imageExtensionForMimeType,
36
+ isAllowedSideloadImageType,
35
37
  SITE_ASSET_STORAGE_KEY_LIKE_PATTERN,
38
+ sniffImageMimeType,
36
39
  toMediaKind,
37
40
  validateUploadFileMetadata,
38
41
  } from "../lib/upload.js";
42
+ import {
43
+ IMAGE_DIMENSION_PEEK_BYTES,
44
+ parseImageDimensions,
45
+ } from "../lib/image-dimensions.js";
46
+ import { assertPublicHttpUrl, fetchImageBytes } from "../lib/url-fetch.js";
39
47
  import type {
40
48
  Media,
41
49
  MediaKind,
@@ -57,6 +65,26 @@ import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
57
65
 
58
66
  const DEFAULT_MEDIA_POSITION = "a0";
59
67
 
68
+ /**
69
+ * Derive a display filename from a remote image URL, falling back to
70
+ * `image.<ext>` when the path has no usable basename.
71
+ *
72
+ * @param url - The remote image URL
73
+ * @param ext - Extension to use when the URL path lacks one
74
+ * @returns A sanitized original filename
75
+ */
76
+ function remoteImageName(url: URL, ext: string): string {
77
+ let base: string;
78
+ try {
79
+ base = decodeURIComponent(url.pathname.split("/").pop() ?? "");
80
+ } catch {
81
+ base = url.pathname.split("/").pop() ?? "";
82
+ }
83
+ base = base.trim().replace(/[\r\n"\\]+/g, "");
84
+ if (!base) return `image.${ext}`;
85
+ return base.includes(".") ? base : `${base}.${ext}`;
86
+ }
87
+
60
88
  /**
61
89
  * Recycle-window length for deleted media storage objects. On delete we
62
90
  * hard-remove the DB row but defer deleting the underlying storage object
@@ -187,6 +215,12 @@ export interface TextAttachmentDeps {
187
215
  maxFileSizeMB: number;
188
216
  }
189
217
 
218
+ export interface IngestFromUrlDeps {
219
+ storage: StorageDriver;
220
+ storageDriver: string;
221
+ maxFileSizeMB: number;
222
+ }
223
+
190
224
  export interface MediaService {
191
225
  assertCanWriteBytes(additionalBytes: number): Promise<void>;
192
226
  getById(id: string): Promise<Media | null>;
@@ -195,6 +229,24 @@ export interface MediaService {
195
229
  getByPostIds(postIds: string[]): Promise<Map<string, Media[]>>;
196
230
  list(filters?: MediaFilters): Promise<Media[]>;
197
231
  create(data: CreateMediaData): Promise<Media>;
232
+ /**
233
+ * Fetch a remote image URL server-side and store it as the site's own media.
234
+ *
235
+ * Used to rehost images pasted from external articles: the server downloads
236
+ * the bytes (bypassing browser CORS), verifies they are a real image format,
237
+ * and stores them. The created row has `postId = null` like other compose
238
+ * uploads (reaped by the orphan sweep if the post is never published).
239
+ *
240
+ * @param input.url - The remote http(s) image URL
241
+ * @param input.alt - Optional alt text
242
+ * @param deps - Storage driver, provider name, and the max file size
243
+ * @returns The created media row
244
+ * @throws {ValidationError} For unsafe URLs, non-image content, or oversize files
245
+ */
246
+ ingestFromUrl(
247
+ input: { url: string; alt?: string },
248
+ deps: IngestFromUrlDeps,
249
+ ): Promise<Media>;
198
250
  /**
199
251
  * Validate media IDs: checks count limit and verifies all IDs exist in the database.
200
252
  * No-op when the array is empty.
@@ -788,6 +840,65 @@ export function createMediaService(
788
840
  return toMedia(result[0]!);
789
841
  },
790
842
 
843
+ async ingestFromUrl(input, deps) {
844
+ const url = assertPublicHttpUrl(input.url);
845
+ const maxBytes = deps.maxFileSizeMB * 1024 * 1024;
846
+
847
+ const { bytes } = await fetchImageBytes(url, {
848
+ maxBytes,
849
+ timeoutMs: 15000,
850
+ });
851
+
852
+ // Trust the bytes, not the server's content-type header: only store data
853
+ // we can positively identify as a supported image format. This blocks
854
+ // content-type spoofing (e.g. an HTML/script payload served as an image).
855
+ const sniffed = sniffImageMimeType(bytes);
856
+ const mimeType =
857
+ sniffed && isAllowedSideloadImageType(sniffed) ? sniffed : null;
858
+ if (!mimeType) {
859
+ throw new ValidationError(
860
+ "That URL didn't return a supported image. Try a different image.",
861
+ );
862
+ }
863
+
864
+ await assertCanWriteBytes(bytes.byteLength);
865
+
866
+ const dimensions = parseImageDimensions(
867
+ mimeType,
868
+ bytes.subarray(0, IMAGE_DIMENSION_PEEK_BYTES),
869
+ );
870
+ const ext = imageExtensionForMimeType(mimeType) ?? "bin";
871
+ const { id, filename, storageKey } = generateStorageKey(
872
+ siteId,
873
+ `image.${ext}`,
874
+ );
875
+ const originalName = remoteImageName(url, ext);
876
+
877
+ // SVG can carry scripts. Display still works via <img> (browsers disable
878
+ // scripting there), and attachment disposition makes direct navigation to
879
+ // the raw object download instead of render — neutralizing the XSS vector.
880
+ await deps.storage.put(storageKey, bytes, {
881
+ contentType: mimeType,
882
+ contentDisposition:
883
+ mimeType === "image/svg+xml" ? "attachment" : "inline",
884
+ cacheControl: "public, max-age=31536000, immutable",
885
+ });
886
+
887
+ return this.create({
888
+ id,
889
+ filename,
890
+ originalName,
891
+ mimeType,
892
+ size: bytes.byteLength,
893
+ storageKey,
894
+ provider: deps.storageDriver,
895
+ mediaKind: "image",
896
+ width: dimensions?.width,
897
+ height: dimensions?.height,
898
+ alt: input.alt?.trim() || undefined,
899
+ });
900
+ },
901
+
791
902
  async createTextAttachment(data, deps) {
792
903
  if (!deps.storage) {
793
904
  throw new ConfigurationError(