@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.
- package/bin/commands/import-site.js +45 -20
- package/dist/app-B5P1b4TF.js +6 -0
- package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BUxR-E8O.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
- package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
- package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +75 -0
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +8 -2
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-nav-manager.ts +142 -2
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -0
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +108 -0
- package/src/i18n/__tests__/fallback.test.ts +24 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +54 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +64 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +56 -2
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +63 -3
- package/src/lib/post-display.ts +14 -8
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +41 -2
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/__tests__/navigation.test.ts +190 -1
- package/src/services/__tests__/post-timeline.test.ts +119 -2
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/layouts/_default/rss.xml +2 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/index.ts +27 -17
- package/src/services/media.ts +111 -0
- package/src/services/navigation.ts +155 -1
- package/src/services/post.ts +24 -7
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +26 -8
- package/src/styles/ui.css +149 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- 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
|
|
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>
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
border
|
|
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.
|
|
144
|
-
background-color: var(--site-
|
|
145
|
-
border:
|
|
146
|
-
|
|
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.
|
|
227
|
+
padding-left: 1.4em;
|
|
221
228
|
margin: 1.25em 0;
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
li {
|
|
225
|
-
margin-top:
|
|
226
|
-
margin-bottom:
|
|
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-
|
|
1013
|
-
line-height: var(--type-
|
|
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;
|
package/src/services/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
package/src/services/media.ts
CHANGED
|
@@ -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(
|