@jant/core 0.6.11 → 0.6.13

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 (139) hide show
  1. package/bin/commands/import-site.js +47 -30
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/app-CWJFPjuR.js +6 -0
  7. package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
  10. package/dist/client/_assets/client-DCrcHVJl.css +2 -0
  11. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
  12. package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
  13. package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
  14. package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +76 -2
  22. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  23. package/src/client/__tests__/compose-launch.test.ts +50 -1
  24. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  25. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  26. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  27. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  28. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  29. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  30. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  31. package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
  32. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  33. package/src/client/components/__tests__/jant-settings-general.test.ts +72 -0
  34. package/src/client/components/compose-title-from-heading.ts +66 -0
  35. package/src/client/components/compose-types.ts +8 -0
  36. package/src/client/components/inline-image-issues.ts +17 -0
  37. package/src/client/components/jant-command-palette.ts +67 -2
  38. package/src/client/components/jant-compose-dialog.ts +10 -2
  39. package/src/client/components/jant-compose-editor.ts +78 -1
  40. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  41. package/src/client/components/jant-nav-manager.ts +382 -152
  42. package/src/client/components/jant-settings-general.ts +56 -0
  43. package/src/client/components/nav-manager-types.ts +14 -10
  44. package/src/client/components/settings-types.ts +30 -0
  45. package/src/client/compose-launch.ts +30 -0
  46. package/src/client/compose-shortcuts.ts +46 -3
  47. package/src/client/site-header-nav.d.ts +1 -0
  48. package/src/client/site-header-nav.js +105 -47
  49. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  50. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  51. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  52. package/src/client/tiptap/create-editor.ts +7 -0
  53. package/src/client/tiptap/extensions.ts +7 -2
  54. package/src/client/tiptap/image-input-rules.ts +48 -0
  55. package/src/client/tiptap/image-node.ts +189 -7
  56. package/src/client/tiptap/rehost-images.ts +5 -1
  57. package/src/client-auth.ts +0 -1
  58. package/src/i18n/__tests__/fallback.test.ts +24 -0
  59. package/src/i18n/locales/public/en.po +20 -4
  60. package/src/i18n/locales/public/en.ts +1 -1
  61. package/src/i18n/locales/public/zh-Hans.po +22 -6
  62. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/public/zh-Hant.po +22 -6
  64. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  65. package/src/i18n/locales/settings/en.po +77 -20
  66. package/src/i18n/locales/settings/en.ts +1 -1
  67. package/src/i18n/locales/settings/zh-Hans.po +85 -28
  68. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  69. package/src/i18n/locales/settings/zh-Hant.po +79 -22
  70. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  71. package/src/lib/__tests__/feed.test.ts +96 -10
  72. package/src/lib/__tests__/navigation.test.ts +4 -37
  73. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  74. package/src/lib/__tests__/view.test.ts +6 -30
  75. package/src/lib/feed.ts +63 -3
  76. package/src/lib/github-sync-site-config.ts +0 -2
  77. package/src/lib/hugo-markdown.ts +1 -1
  78. package/src/lib/navigation.ts +5 -22
  79. package/src/lib/post-display.ts +14 -8
  80. package/src/lib/resolve-config.ts +9 -2
  81. package/src/lib/site-header-fragment.tsx +37 -0
  82. package/src/lib/view.ts +2 -7
  83. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  84. package/src/preset.css +60 -18
  85. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  86. package/src/routes/api/export.ts +0 -2
  87. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  88. package/src/routes/api/internal/sites.ts +5 -5
  89. package/src/routes/api/nav-items.ts +25 -4
  90. package/src/routes/dash/settings.tsx +48 -3
  91. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  92. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  93. package/src/routes/feed/feed.ts +1 -0
  94. package/src/routes/feed/sitemap.ts +2 -6
  95. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  96. package/src/routes/pages/archive.tsx +1 -0
  97. package/src/routes/pages/collection.tsx +1 -0
  98. package/src/routes/pages/featured.tsx +0 -4
  99. package/src/routes/pages/home.tsx +4 -51
  100. package/src/routes/pages/latest.tsx +2 -51
  101. package/src/routes/pages/page.tsx +44 -12
  102. package/src/services/__tests__/about-page.test.ts +116 -0
  103. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  104. package/src/services/__tests__/navigation.test.ts +231 -1
  105. package/src/services/__tests__/post-timeline.test.ts +175 -2
  106. package/src/services/__tests__/post.test.ts +33 -0
  107. package/src/services/__tests__/settings.test.ts +5 -20
  108. package/src/services/about-page.ts +143 -0
  109. package/src/services/export-theme/assets/client-site.js +10 -10
  110. package/src/services/export-theme/layouts/_default/rss.xml +7 -16
  111. package/src/services/export-theme/layouts/index.html +26 -39
  112. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  113. package/src/services/export-theme/styles/main.css +46 -14
  114. package/src/services/export.ts +5 -10
  115. package/src/services/github-app-installations.ts +1 -3
  116. package/src/services/index.ts +27 -17
  117. package/src/services/navigation.ts +186 -1
  118. package/src/services/post.ts +103 -63
  119. package/src/services/settings.ts +0 -10
  120. package/src/services/site-admin.ts +0 -2
  121. package/src/services/upload-session.ts +4 -18
  122. package/src/styles/components.css +20 -0
  123. package/src/styles/tokens.css +28 -10
  124. package/src/styles/ui.css +349 -13
  125. package/src/types/bindings.ts +0 -1
  126. package/src/types/config.ts +3 -7
  127. package/src/types/views.ts +13 -0
  128. package/src/ui/compose/ComposeDialog.tsx +28 -0
  129. package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
  130. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  131. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  132. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  133. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  134. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  135. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  136. package/src/ui/layouts/SiteLayout.tsx +249 -216
  137. package/dist/app-DqKkZenB.js +0 -6
  138. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
  139. package/src/client/nav-manager-bridge.ts +0 -54
@@ -10,7 +10,7 @@
10
10
  - featured (`.Type == "featured"`): featured_at != nil
11
11
  - archive (`.Type == "archive"`): all published (includes latest_hidden)
12
12
  - collection (`.Type == "collection"`): Params.collections.slug match,
13
- pinned first then per `sort_order`
13
+ ordered per `sort_order`; pinning is ignored for RSS
14
14
 
15
15
  Per-entry payload mirrors `buildSinglePostContent` + `buildFeedContent`:
16
16
  - Title: empty for quote posts, else .Title
@@ -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.
@@ -59,25 +60,15 @@
59
60
  {{- end -}}
60
61
  {{- end -}}
61
62
  {{- end -}}
62
- {{- $pinned := slice -}}
63
- {{- $unpinned := slice -}}
64
- {{- range $members -}}
65
- {{- if .entry.pinned_at -}}
66
- {{- $pinned = $pinned | append . -}}
67
- {{- else -}}
68
- {{- $unpinned = $unpinned | append . -}}
69
- {{- end -}}
70
- {{- end -}}
71
- {{- if $pinned -}}{{- $pinned = sort $pinned "entry.pinned_at" "desc" -}}{{- end -}}
72
63
  {{- $sortOrder := $page.Params.sort_order | default "position" -}}
73
- {{- if $unpinned -}}
64
+ {{- $ordered := $members -}}
65
+ {{- if $ordered -}}
74
66
  {{- if eq $sortOrder "collected_at_desc" -}}
75
- {{- $unpinned = sort $unpinned "entry.collected_at" "desc" -}}
67
+ {{- $ordered = sort $ordered "entry.collected_at" "desc" -}}
76
68
  {{- else -}}
77
- {{- $unpinned = sort $unpinned "entry.position" "asc" -}}
69
+ {{- $ordered = sort $ordered "entry.position" "asc" -}}
78
70
  {{- end -}}
79
71
  {{- end -}}
80
- {{- $ordered := $pinned | append $unpinned -}}
81
72
  {{- range $ordered -}}{{- $posts = $posts | append .post -}}{{- end -}}
82
73
  {{- end -}}
83
74
 
@@ -1,41 +1,28 @@
1
- {{ define "main" }}
2
- {{- $homeDefaultView := .Site.Params.home_default_view | default "latest" -}}
3
- {{- $pageSize := .Site.Params.page_size | default 10 -}}
4
- {{- /* Root posts are branch bundles (kind=section), so we iterate .Site.Pages, not .Site.RegularPages. */ -}}
5
- {{- $allPosts := where .Site.Pages "Type" "post" -}}
6
- {{- $pinned := where $allPosts "Params.pinned_at" "ne" nil -}}
7
- {{- $pinned = $pinned.ByDate.Reverse -}}
8
- {{- $main := slice -}}
9
- {{- if eq $homeDefaultView "featured" -}}
10
- {{- $featured := where $allPosts "Params.featured_at" "ne" nil -}}
11
- {{- $main = sort $featured "Params.featured_at" "desc" -}}
12
- {{- else -}}
13
- {{- $public := where $allPosts "Params.visibility" "public" -}}
14
- {{- $public = where $public "Params.pinned_at" nil -}}
15
- {{- $main = $public.ByDate.Reverse -}}
16
- {{- end -}}
17
- {{- $paginator := .Paginate $main $pageSize -}}
18
-
19
- <section class="home">
20
- {{- if and (eq $paginator.PageNumber 1) $pinned -}}
21
- <div class="post-list post-list-pinned" aria-label="Pinned posts">
22
- {{- range $i, $p := $pinned -}}
23
- {{- if gt $i 0 -}}<hr class="feed-divider" aria-hidden="true" />{{- end -}}
24
- {{ partial "thread-preview.html" $p }}
25
- {{- end -}}
26
- </div>
27
- {{- end -}}
1
+ {{ define "main" }} {{- $pageSize := .Site.Params.page_size | default 10 -}} {{-
2
+ /* Root posts are branch bundles (kind=section), so we iterate .Site.Pages, not
3
+ .Site.RegularPages. */ -}} {{- $allPosts := where .Site.Pages "Type" "post" -}}
4
+ {{- $pinned := where $allPosts "Params.pinned_at" "ne" nil -}} {{- $pinned =
5
+ $pinned.ByDate.Reverse -}} {{- $public := where $allPosts "Params.visibility"
6
+ "public" -}} {{- $public = where $public "Params.pinned_at" nil -}} {{- $main :=
7
+ $public.ByDate.Reverse -}} {{- $paginator := .Paginate $main $pageSize -}}
28
8
 
29
- {{- if $paginator.Pages -}}
30
- <div class="post-list post-list-main">
31
- {{- range $i, $p := $paginator.Pages -}}
32
- {{- if gt $i 0 -}}<hr class="feed-divider" aria-hidden="true" />{{- end -}}
33
- {{ partial "thread-preview.html" $p }}
34
- {{- end -}}
35
- </div>
36
- {{ partial "pagination.html" $paginator }}
37
- {{- else if not $pinned -}}
38
- <p class="empty-state">Nothing published yet. Write your first post to get started.</p>
39
- {{- end -}}
40
- </section>
9
+ <section class="home">
10
+ {{- if and (eq $paginator.PageNumber 1) $pinned -}}
11
+ <div class="post-list post-list-pinned" aria-label="Pinned posts">
12
+ {{- range $i, $p := $pinned -}} {{- if gt $i 0 -}}
13
+ <hr class="feed-divider" aria-hidden="true" />
14
+ {{- end -}} {{ partial "thread-preview.html" $p }} {{- end -}}
15
+ </div>
16
+ {{- end -}} {{- if $paginator.Pages -}}
17
+ <div class="post-list post-list-main">
18
+ {{- range $i, $p := $paginator.Pages -}} {{- if gt $i 0 -}}
19
+ <hr class="feed-divider" aria-hidden="true" />
20
+ {{- end -}} {{ partial "thread-preview.html" $p }} {{- end -}}
21
+ </div>
22
+ {{ partial "pagination.html" $paginator }} {{- else if not $pinned -}}
23
+ <p class="empty-state">
24
+ Nothing published yet. Write your first post to get started.
25
+ </p>
26
+ {{- end -}}
27
+ </section>
41
28
  {{ end }}
@@ -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;
@@ -102,7 +102,6 @@ export interface SiteConfig {
102
102
  siteDescription: string;
103
103
  siteLanguage: string;
104
104
  showJantBrandingOnHome: boolean;
105
- homeDefaultView: string;
106
105
  /** "latest" or "featured" — drives the default RSS nav link in the exported site. */
107
106
  mainRssFeed: string;
108
107
  siteFooter: string;
@@ -1374,9 +1373,8 @@ function resolveNavItemLabel(item: SiteConfig["navItems"][number]): string {
1374
1373
  * Resolve a nav item's final href for the Hugo export.
1375
1374
  *
1376
1375
  * Mirrors the runtime logic in `lib/view.ts:toNavItemView`. System URLs
1377
- * stored in the DB ("/latest", "/featured") are not real routes — they get
1378
- * rewritten to "/" when they match `homeDefaultView`, otherwise they
1379
- * resolve to the dedicated path.
1376
+ * stored in the DB are canonicalized during export so stale DB values do not
1377
+ * leak into the static site's navigation.
1380
1378
  *
1381
1379
  * The "rss" system nav item points at whichever Atom feed the site has
1382
1380
  * configured as its main feed: `mainRssFeed === "featured"` → the featured
@@ -1385,14 +1383,13 @@ function resolveNavItemLabel(item: SiteConfig["navItems"][number]): string {
1385
1383
  */
1386
1384
  function resolveNavItemUrl(
1387
1385
  item: SiteConfig["navItems"][number],
1388
- homeDefaultView: string,
1389
1386
  mainRssFeed: string,
1390
1387
  ): string {
1391
1388
  if (item.systemKey === "latest") {
1392
- return homeDefaultView === "latest" ? "/" : "/latest/";
1389
+ return "/";
1393
1390
  }
1394
1391
  if (item.systemKey === "featured") {
1395
- return homeDefaultView === "featured" ? "/" : "/featured/";
1392
+ return "/featured/";
1396
1393
  }
1397
1394
  if (item.systemKey === "collections") return "/collections/";
1398
1395
  if (item.systemKey === "archive") return "/archive/";
@@ -1474,7 +1471,6 @@ function buildHugoToml(config: SiteConfig): string {
1474
1471
  "",
1475
1472
  "[params]",
1476
1473
  ` description = "${escapeTomlString(config.siteDescription)}"`,
1477
- ` home_default_view = "${escapeTomlString(config.homeDefaultView)}"`,
1478
1474
  ` main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
1479
1475
  ` show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
1480
1476
  ` show_header_avatar = ${config.showHeaderAvatar}`,
@@ -1514,7 +1510,6 @@ function buildJantDataToml(
1514
1510
  `site_name = "${escapeTomlString(config.siteName)}"`,
1515
1511
  `site_description = "${escapeTomlString(config.siteDescription)}"`,
1516
1512
  `site_language = "${escapeTomlString(config.siteLanguage)}"`,
1517
- `home_default_view = "${escapeTomlString(config.homeDefaultView)}"`,
1518
1513
  `main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
1519
1514
  `show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
1520
1515
  `show_header_avatar = ${config.showHeaderAvatar}`,
@@ -1558,7 +1553,7 @@ function buildJantDataToml(
1558
1553
  parts.push(`type = "${escapeTomlString(item.type)}"`);
1559
1554
  parts.push(`label = "${escapeTomlString(resolveNavItemLabel(item))}"`);
1560
1555
  parts.push(
1561
- `url = "${escapeTomlString(resolveNavItemUrl(item, config.homeDefaultView, config.mainRssFeed))}"`,
1556
+ `url = "${escapeTomlString(resolveNavItemUrl(item, config.mainRssFeed))}"`,
1562
1557
  );
1563
1558
  parts.push(`system_key = "${escapeTomlString(item.systemKey ?? "")}"`);
1564
1559
  parts.push(`placement = "${escapeTomlString(item.placement ?? "header")}"`);
@@ -149,9 +149,7 @@ export function createGitHubAppInstallationsService(
149
149
  .select()
150
150
  .from(githubAppInstallation)
151
151
  .where(eq(githubAppInstallation.siteId, siteId));
152
- return rows
153
- .map(toStored)
154
- .sort((a, b) => b.addedAt - a.addedAt);
152
+ return rows.map(toStored).sort((a, b) => b.addedAt - a.addedAt);
155
153
  },
156
154
 
157
155
  async listSitesForInstallation(installationId) {
@@ -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 {
@@ -15,6 +15,12 @@ import {
15
15
  import { createEntityId } from "../lib/ids.js";
16
16
  import { ValidationError } from "../lib/errors.js";
17
17
  import { now } from "../lib/time.js";
18
+ import {
19
+ normalizePath,
20
+ normalizeSitePathPrefix,
21
+ stripSitePathPrefix,
22
+ toSameSitePath,
23
+ } from "../lib/url.js";
18
24
  import { COLLECTION_FRESHNESS_WINDOW_SECONDS } from "../types.js";
19
25
  import type {
20
26
  NavItem,
@@ -23,10 +29,20 @@ import type {
23
29
  CreateNavItem,
24
30
  UpdateNavItem,
25
31
  SystemNavKey,
32
+ SuggestedNavLink,
26
33
  } from "../types.js";
27
34
  import { SYSTEM_NAV_KEYS } from "../types.js";
28
35
 
29
36
  const POSITION_RETRY_ATTEMPTS = 5;
37
+ const SUGGESTED_NAV_LINK_CANDIDATES = [
38
+ { key: "about", path: "/about", label: "About" },
39
+ { key: "now", path: "/now", label: "Now" },
40
+ ] as const;
41
+
42
+ export interface ListSuggestedLinksOptions {
43
+ siteOrigin?: string;
44
+ sitePathPrefix?: string;
45
+ }
30
46
 
31
47
  // Re-export shared constraint detection — see db/dialect.ts
32
48
  import { isUniqueConstraintError } from "../db/dialect.js";
@@ -45,6 +61,9 @@ export interface NavItemService {
45
61
  afterId: string | null,
46
62
  beforeId: string | null,
47
63
  ): Promise<NavItem | null>;
64
+ listSuggestedLinks(
65
+ options?: ListSuggestedLinksOptions,
66
+ ): Promise<SuggestedNavLink[]>;
48
67
  getCollectionFreshness(collectionIds: string[]): Promise<Map<string, number>>;
49
68
  }
50
69
 
@@ -53,7 +72,8 @@ export function createNavItemService(
53
72
  siteId: string,
54
73
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
55
74
  ): NavItemService {
56
- const { navItems, postCollections, posts } = databaseSchema;
75
+ const { navItems, postCollections, posts, pathRegistry, collections } =
76
+ databaseSchema;
57
77
 
58
78
  const defaultSystemOrder = [
59
79
  "latest",
@@ -121,6 +141,58 @@ export function createNavItemService(
121
141
  };
122
142
  }
123
143
 
144
+ function withLeadingSlash(path: string): string {
145
+ const normalized = normalizePath(path);
146
+ return normalized ? `/${normalized}` : "/";
147
+ }
148
+
149
+ function getComparableInternalPath(
150
+ url: string,
151
+ options: ListSuggestedLinksOptions = {},
152
+ ): string | null {
153
+ const value = url.trim();
154
+ const sitePathPrefix = normalizeSitePathPrefix(
155
+ options.sitePathPrefix ?? "",
156
+ );
157
+ const sameSitePath = toSameSitePath(value, options.siteOrigin ?? "");
158
+ if (sameSitePath !== null) {
159
+ try {
160
+ const pathname = new URL(sameSitePath, "https://jant.invalid").pathname;
161
+ const internalPath = stripSitePathPrefix(pathname, sitePathPrefix);
162
+ return internalPath ? withLeadingSlash(internalPath) : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ if (
169
+ value.startsWith("http://") ||
170
+ value.startsWith("https://") ||
171
+ value.startsWith("//") ||
172
+ value.startsWith("mailto:") ||
173
+ value.startsWith("tel:") ||
174
+ value.startsWith("#")
175
+ ) {
176
+ return null;
177
+ }
178
+
179
+ try {
180
+ const pathname = new URL(value, "https://jant.invalid").pathname;
181
+ const internalPath = stripSitePathPrefix(pathname, sitePathPrefix);
182
+ return internalPath ? withLeadingSlash(internalPath) : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function normalizeSuggestedLabel(
189
+ label: string | null | undefined,
190
+ fallback: string,
191
+ ): string {
192
+ const trimmed = label?.trim();
193
+ return trimmed && trimmed.length > 0 ? trimmed : fallback;
194
+ }
195
+
124
196
  async function getLastPosition(): Promise<string | null> {
125
197
  const rows = await db
126
198
  .select({ position: navItems.position })
@@ -414,6 +486,119 @@ export function createNavItemService(
414
486
  throw new Error("Failed to assign a unique nav item position");
415
487
  },
416
488
 
489
+ async listSuggestedLinks(options: ListSuggestedLinksOptions = {}) {
490
+ const existingRows = await db
491
+ .select({
492
+ url: navItems.url,
493
+ collectionId: navItems.collectionId,
494
+ })
495
+ .from(navItems)
496
+ .where(eq(navItems.siteId, siteId));
497
+ const existingPaths = new Set(
498
+ existingRows.flatMap((item) => {
499
+ const path = getComparableInternalPath(item.url, options);
500
+ return path ? [path] : [];
501
+ }),
502
+ );
503
+ const existingCollectionIds = new Set(
504
+ existingRows.flatMap((item) =>
505
+ item.collectionId ? [item.collectionId] : [],
506
+ ),
507
+ );
508
+
509
+ const suggestions: SuggestedNavLink[] = [];
510
+
511
+ for (const candidate of SUGGESTED_NAV_LINK_CANDIDATES) {
512
+ const path = withLeadingSlash(candidate.path);
513
+ if (existingPaths.has(path)) continue;
514
+
515
+ const pathRows = await db
516
+ .select()
517
+ .from(pathRegistry)
518
+ .where(
519
+ and(
520
+ eq(pathRegistry.siteId, siteId),
521
+ eq(pathRegistry.path, normalizePath(candidate.path)),
522
+ ),
523
+ )
524
+ .limit(1);
525
+ const record = pathRows[0];
526
+ if (!record || record.kind === "redirect") continue;
527
+
528
+ if (record.postId) {
529
+ const postRows = await db
530
+ .select({
531
+ title: posts.title,
532
+ status: posts.status,
533
+ visibility: posts.visibility,
534
+ })
535
+ .from(posts)
536
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, record.postId)))
537
+ .limit(1);
538
+ const post = postRows[0];
539
+ if (
540
+ !post ||
541
+ post.status !== "published" ||
542
+ post.visibility === "private"
543
+ ) {
544
+ continue;
545
+ }
546
+
547
+ suggestions.push({
548
+ key: candidate.key,
549
+ label: normalizeSuggestedLabel(post.title, candidate.label),
550
+ url: path,
551
+ targetType: "page",
552
+ navItemType: "link",
553
+ });
554
+ continue;
555
+ }
556
+
557
+ if (record.collectionId) {
558
+ if (existingCollectionIds.has(record.collectionId)) continue;
559
+
560
+ const collectionRows = await db
561
+ .select({
562
+ title: collections.title,
563
+ })
564
+ .from(collections)
565
+ .where(
566
+ and(
567
+ eq(collections.siteId, siteId),
568
+ eq(collections.id, record.collectionId),
569
+ ),
570
+ )
571
+ .limit(1);
572
+ const collection = collectionRows[0];
573
+ if (!collection) continue;
574
+
575
+ suggestions.push({
576
+ key: candidate.key,
577
+ label: normalizeSuggestedLabel(collection.title, candidate.label),
578
+ url: path,
579
+ targetType: "collection",
580
+ navItemType: record.kind === "slug" ? "collection" : "link",
581
+ ...(record.kind === "slug" && {
582
+ collectionId: record.collectionId,
583
+ }),
584
+ });
585
+ continue;
586
+ }
587
+
588
+ if (record.kind === "archive") {
589
+ suggestions.push({
590
+ key: candidate.key,
591
+ label: candidate.label,
592
+ url: path,
593
+ targetType: "archive",
594
+ navItemType: "link",
595
+ });
596
+ }
597
+ }
598
+
599
+ return suggestions;
600
+ },
601
+
417
602
  async getCollectionFreshness(collectionIds) {
418
603
  if (collectionIds.length === 0) return new Map<string, number>();
419
604