@jant/core 0.6.14 → 0.6.16
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 +88 -13
- package/bin/commands/migrate.js +55 -0
- package/bin/commands/posts/rebuild-html.js +184 -0
- package/bin/commands/site/snapshot/import.js +14 -10
- package/bin/lib/d1-query.js +23 -5
- package/bin/lib/migration-runner.js +29 -7
- package/bin/lib/node-database.js +32 -0
- package/bin/lib/site-snapshot.js +347 -46
- package/bin/lib/sql-export.js +1 -1
- package/bin/lib/thread-collection-migration.js +326 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +7 -7
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +11 -10
- package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
- package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
- package/src/__tests__/export-hugo-build.test.ts +63 -4
- package/src/__tests__/export-import-roundtrip.test.ts +6 -4
- package/src/__tests__/export-service.test.ts +142 -9
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +2 -0
- package/src/__tests__/import-site-command.test.ts +65 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/vite-config.test.ts +45 -0
- package/src/app.tsx +16 -3
- package/src/client/__tests__/collection-created-notice.test.ts +36 -0
- package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
- package/src/client/__tests__/collection-page-actions.test.ts +71 -2
- package/src/client/__tests__/compose-bridge.test.ts +316 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/feed-video-player.test.ts +16 -1
- package/src/client/__tests__/footnote-rail.test.ts +472 -0
- package/src/client/__tests__/hydrate-partial.test.ts +3 -0
- package/src/client/__tests__/toast.test.ts +82 -1
- package/src/client/collection-created-notice.ts +66 -0
- package/src/client/collection-form-bridge.ts +17 -0
- package/src/client/collection-navigation.ts +44 -0
- package/src/client/collection-page-actions.ts +57 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
- package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
- package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
- package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
- package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-manager-types.ts +9 -3
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +55 -3
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +14 -7
- package/src/client/components/jant-compose-dialog.ts +1542 -694
- package/src/client/components/jant-compose-editor.ts +237 -99
- package/src/client/components/jant-compose-fullscreen.ts +27 -9
- package/src/client/components/jant-config-editor.ts +722 -0
- package/src/client/components/jant-media-lightbox.ts +100 -18
- package/src/client/components/jant-nav-manager.ts +1283 -54
- package/src/client/components/jant-post-form.ts +3 -3
- package/src/client/components/jant-post-menu.ts +177 -95
- package/src/client/components/nav-manager-types.ts +51 -2
- package/src/client/components/post-form-types.ts +1 -1
- package/src/client/compose-bridge.ts +147 -123
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/feed-video-player.ts +62 -2
- package/src/client/footnote-rail.ts +478 -0
- package/src/client/hydrate-partial.ts +2 -0
- package/src/client/media-lightbox-events.ts +63 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/site-header-fragment.ts +38 -0
- package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
- package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
- package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
- package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
- package/src/client/tiptap/bubble-menu.ts +88 -10
- package/src/client/tiptap/continuous-lists.ts +102 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +14 -5
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/markdown-clipboard.ts +608 -11
- package/src/client/tiptap/slash-commands.ts +8 -1
- package/src/client/tiptap/structural-keymap.ts +211 -0
- package/src/client/tiptap/tab-indent.ts +8 -22
- package/src/client/tiptap/table-control-labels.ts +30 -0
- package/src/client/tiptap/table-controls.ts +537 -0
- package/src/client/tiptap/table-size-picker.ts +254 -0
- package/src/client/toast.ts +133 -39
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/d1-query.test.ts +43 -1
- package/src/db/__tests__/migration-runner.test.ts +63 -0
- package/src/db/__tests__/migrations.test.ts +469 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0027_old_blue_blade.sql +39 -0
- package/src/db/migrations/0028_superb_post.sql +2 -0
- package/src/db/migrations/0029_boring_magma.sql +1 -0
- package/src/db/migrations/0030_typical_vivisector.sql +50 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0027_snapshot.json +2511 -0
- package/src/db/migrations/meta/0028_snapshot.json +2511 -0
- package/src/db/migrations/meta/0029_snapshot.json +2519 -0
- package/src/db/migrations/meta/0030_snapshot.json +2541 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +35 -0
- package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
- package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
- package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
- package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
- package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +35 -0
- package/src/db/pg/schema.ts +46 -20
- package/src/db/rehearsal-fixtures/demo-current.json +39 -4
- package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
- package/src/db/schema.ts +48 -20
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +222 -51
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +216 -45
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +216 -45
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +513 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +515 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +515 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/api-settings.test.ts +299 -0
- package/src/lib/__tests__/constants.test.ts +5 -0
- package/src/lib/__tests__/feed-policy.test.ts +25 -0
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
- package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
- package/src/lib/__tests__/markdown.test.ts +24 -6
- package/src/lib/__tests__/navigation.test.ts +59 -2
- package/src/lib/__tests__/post-body-html.test.ts +176 -0
- package/src/lib/__tests__/post-display.test.ts +64 -0
- package/src/lib/__tests__/resolve-config.test.ts +113 -1
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/summary.test.ts +82 -0
- package/src/lib/__tests__/timeline.test.ts +109 -70
- package/src/lib/__tests__/timezones.test.ts +31 -3
- package/src/lib/__tests__/tiptap-render.test.ts +268 -9
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/__tests__/view.test.ts +53 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
- package/src/lib/api-settings.ts +239 -12
- package/src/lib/collection-sort.ts +7 -5
- package/src/lib/constants.ts +2 -0
- package/src/lib/feed-path.ts +25 -0
- package/src/lib/feed-policy.ts +64 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +15 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +193 -5
- package/src/lib/markdown.ts +10 -3
- package/src/lib/navigation.ts +16 -3
- package/src/lib/post-body-html.ts +131 -0
- package/src/lib/post-display.ts +47 -8
- package/src/lib/render.tsx +9 -2
- package/src/lib/resolve-config.ts +100 -16
- package/src/lib/schemas.ts +133 -1
- package/src/lib/settings-paths.ts +5 -0
- package/src/lib/site-skill.ts +78 -0
- package/src/lib/summary.ts +16 -5
- package/src/lib/timeline.ts +137 -131
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +359 -83
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +58 -20
- package/src/lib/worker-response-cache.ts +11 -18
- package/src/middleware/__tests__/public-content-access.test.ts +91 -0
- package/src/middleware/config.ts +2 -2
- package/src/middleware/public-content-access.ts +87 -0
- package/src/node/__tests__/cli-export.test.ts +14 -0
- package/src/node/__tests__/cli-migrate.test.ts +6 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
- package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
- package/src/node/index.ts +2 -1
- package/src/preset.css +16 -2
- package/src/routes/__tests__/compose.test.ts +51 -0
- package/src/routes/__tests__/site-skill.test.ts +112 -0
- package/src/routes/api/__tests__/collections.test.ts +91 -19
- package/src/routes/api/__tests__/mcp.test.ts +65 -1
- package/src/routes/api/__tests__/nav-items.test.ts +91 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/public-access.test.ts +81 -0
- package/src/routes/api/__tests__/settings.test.ts +217 -0
- package/src/routes/api/collections.ts +22 -26
- package/src/routes/api/export.ts +2 -0
- package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
- package/src/routes/api/internal/post-body-html.ts +43 -0
- package/src/routes/api/internal/sites.ts +24 -0
- package/src/routes/api/nav-items.ts +24 -1
- package/src/routes/api/posts.ts +14 -10
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +246 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +48 -24
- package/src/routes/api/search.ts +3 -0
- package/src/routes/api/settings.ts +33 -0
- package/src/routes/auth/__tests__/setup.test.ts +7 -4
- package/src/routes/compose.tsx +8 -3
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
- package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
- package/src/routes/pages/__tests__/collections.test.ts +9 -9
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +176 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +90 -22
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +86 -11
- package/src/routes/site-skill.ts +18 -0
- package/src/services/__tests__/auth.test.ts +7 -5
- package/src/services/__tests__/collection.test.ts +190 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +249 -39
- package/src/services/__tests__/post.test.ts +844 -5
- package/src/services/__tests__/search.test.ts +48 -1
- package/src/services/__tests__/settings.test.ts +8 -0
- package/src/services/__tests__/site-admin.test.ts +121 -1
- package/src/services/auth.ts +2 -2
- package/src/services/bootstrap.ts +3 -2
- package/src/services/collection.ts +224 -167
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +46 -25
- package/src/services/export-theme/layouts/_default/list.html +18 -4
- package/src/services/export-theme/layouts/_default/rss.xml +8 -12
- package/src/services/export-theme/layouts/featured/list.html +5 -4
- package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
- package/src/services/export-theme/styles/main.css +30 -2
- package/src/services/export.ts +94 -85
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +1142 -342
- package/src/services/search.ts +17 -2
- package/src/services/settings.ts +2 -10
- package/src/services/site-admin.ts +63 -43
- package/src/style-cjk-jp.css +1 -1
- package/src/style-cjk-kr.css +1 -1
- package/src/style-cjk-tc.css +1 -1
- package/src/style-cjk.css +1 -1
- package/src/styles/components.css +540 -14
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +76 -10
- package/src/styles/ui.css +2227 -1347
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +243 -12
- package/src/types/constants.ts +22 -1
- package/src/types/entities.ts +14 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +20 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +22 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +109 -19
- package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
- package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
- package/src/ui/dash/settings/GeneralContent.tsx +15 -5
- package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
- package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
- package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
- package/src/ui/feed/LinkCard.tsx +2 -12
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +10 -20
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/feed/QuoteCard.tsx +2 -12
- package/src/ui/feed/ThreadPreview.tsx +26 -34
- package/src/ui/feed/TimelineFeed.tsx +2 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
- package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
- package/src/ui/feed/thread-preview-state.ts +5 -7
- package/src/ui/font-themes.ts +74 -26
- package/src/ui/layouts/BaseLayout.tsx +22 -8
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/CollectionPage.tsx +101 -22
- package/src/ui/pages/CollectionsPage.tsx +3 -1
- package/src/ui/pages/PostPage.tsx +39 -12
- package/src/ui/pages/SearchPage.tsx +6 -14
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
- package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -9
- package/src/ui/shared/CollectionsManager.tsx +17 -7
- package/src/ui/shared/DraftPreviewBar.tsx +49 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +38 -13
- package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
- package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
- package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
- package/src/ui/shared/collection-management-labels.ts +33 -2
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +52 -0
- package/dist/app-DZDlmh7C.js +0 -6
- package/dist/client/_assets/client-Cevfpm8H.css +0 -2
- package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
- package/src/client/tiptap/wrapping-input-rules.ts +0 -102
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Post } from "../types.js";
|
|
2
|
+
import { now, toISOString } from "./time.js";
|
|
3
|
+
|
|
4
|
+
/** Cache policy for dynamic Atom feed responses. */
|
|
5
|
+
export const RSS_FEED_CACHE_CONTROL = "public, max-age=60";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert an RSS publication delay into the exclusive upper bound expected by
|
|
9
|
+
* Post service queries.
|
|
10
|
+
*
|
|
11
|
+
* Posts published exactly `delaySeconds` ago are eligible. Because publication
|
|
12
|
+
* timestamps use whole seconds while `publishedBefore` is exclusive, the bound
|
|
13
|
+
* is one second after the latest eligible timestamp.
|
|
14
|
+
*
|
|
15
|
+
* @param delaySeconds - Non-negative publication delay in seconds
|
|
16
|
+
* @param currentTime - Current Unix timestamp, injectable for deterministic use
|
|
17
|
+
* @returns Exclusive `publishedBefore` timestamp for RSS queries
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* getRssPublishedBefore(300, 1_000); // 701, so publishedAt <= 700 is eligible
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function getRssPublishedBefore(
|
|
24
|
+
delaySeconds: number,
|
|
25
|
+
currentTime = now(),
|
|
26
|
+
): number {
|
|
27
|
+
return currentTime - delaySeconds + 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the Atom `updated` timestamp for a Thread entry from content that is
|
|
32
|
+
* actually present in the feed.
|
|
33
|
+
*
|
|
34
|
+
* @param root - Thread root used as a fallback when no Thread rows are loaded
|
|
35
|
+
* @param thread - Eligible Thread Posts included in the Atom entry
|
|
36
|
+
* @param additionalTimestamps - Other entry updates, such as Collection membership
|
|
37
|
+
* @returns Latest update timestamp as an ISO 8601 string
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* getFeedEntryUpdatedAt(root, [root, reply], [collectedAt]);
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function getFeedEntryUpdatedAt(
|
|
44
|
+
root: Pick<Post, "publishedAt" | "updatedAt">,
|
|
45
|
+
thread: readonly Pick<Post, "publishedAt" | "updatedAt">[] | undefined,
|
|
46
|
+
additionalTimestamps: readonly (number | null | undefined)[] = [],
|
|
47
|
+
): string {
|
|
48
|
+
let updatedAt = Math.max(root.updatedAt, root.publishedAt ?? root.updatedAt);
|
|
49
|
+
|
|
50
|
+
for (const post of thread ?? []) {
|
|
51
|
+
updatedAt = Math.max(
|
|
52
|
+
updatedAt,
|
|
53
|
+
post.updatedAt,
|
|
54
|
+
post.publishedAt ?? post.updatedAt,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
for (const timestamp of additionalTimestamps) {
|
|
58
|
+
if (timestamp !== null && timestamp !== undefined) {
|
|
59
|
+
updatedAt = Math.max(updatedAt, timestamp);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return toISOString(updatedAt);
|
|
64
|
+
}
|
package/src/lib/feed.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { FeedData, FeedPostView, MediaView, PostView } from "../types.js";
|
|
14
|
+
import { getLinkPreviewProviderLabel } from "./link-preview.js";
|
|
14
15
|
import { extractDisplayDomain } from "./url.js";
|
|
15
16
|
import { getMediaCategory } from "./upload.js";
|
|
16
17
|
|
|
@@ -40,6 +41,49 @@ function escapeCdata(str: string): string {
|
|
|
40
41
|
return str.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a URL for use outside the feed document's browser context.
|
|
46
|
+
*
|
|
47
|
+
* Feed readers do not consistently resolve root-relative URLs found inside
|
|
48
|
+
* Atom HTML content or enclosure attributes, so every non-fragment URL must
|
|
49
|
+
* carry its own origin.
|
|
50
|
+
*/
|
|
51
|
+
function toAbsoluteFeedUrl(url: string, siteUrl: string): string {
|
|
52
|
+
const normalizedUrl = url.trim();
|
|
53
|
+
if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
|
|
57
|
+
return new URL(normalizedUrl, baseUrl).toString();
|
|
58
|
+
} catch {
|
|
59
|
+
return normalizedUrl;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve navigational and media URL attributes inside trusted post HTML.
|
|
65
|
+
*
|
|
66
|
+
* Fragment-only links stay local to the rendered feed entry so footnotes and
|
|
67
|
+
* other in-entry references continue to work.
|
|
68
|
+
*/
|
|
69
|
+
function absolutizeFeedHtmlUrls(html: string, siteUrl: string): string {
|
|
70
|
+
return html.replaceAll(
|
|
71
|
+
/(\s)(href|poster|src)=(["'])([^"']*)\3/gi,
|
|
72
|
+
(
|
|
73
|
+
match,
|
|
74
|
+
whitespace: string,
|
|
75
|
+
attribute: string,
|
|
76
|
+
quote: string,
|
|
77
|
+
url: string,
|
|
78
|
+
) => {
|
|
79
|
+
const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
|
|
80
|
+
return absoluteUrl
|
|
81
|
+
? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}`
|
|
82
|
+
: match;
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
43
87
|
/**
|
|
44
88
|
* Strip embedded content that is unsafe or unsupported in feed readers.
|
|
45
89
|
*
|
|
@@ -144,6 +188,35 @@ function renderRatingHtml(rating: number): string {
|
|
|
144
188
|
return `<p>${filled}${empty} ${rating}/5</p>`;
|
|
145
189
|
}
|
|
146
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Render a Link post preview as feed-safe HTML.
|
|
193
|
+
*
|
|
194
|
+
* Feed readers commonly strip CSS overlays and embedded players, so video
|
|
195
|
+
* previews use a linked thumbnail plus a visible provider-aware action.
|
|
196
|
+
* Non-video Link previews keep the linked thumbnail without a video label.
|
|
197
|
+
*/
|
|
198
|
+
function renderLinkPreviewForFeed(post: PostView, siteUrl: string): string {
|
|
199
|
+
if (post.format !== "link") return "";
|
|
200
|
+
|
|
201
|
+
const imageUrl = post.previewImageUrl?.trim();
|
|
202
|
+
const linkUrl = post.url?.trim();
|
|
203
|
+
if (!imageUrl || !linkUrl) return "";
|
|
204
|
+
|
|
205
|
+
const isVideo = post.previewKind?.trim().toLowerCase() === "video";
|
|
206
|
+
const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
|
|
207
|
+
const fallbackAlt = isVideo
|
|
208
|
+
? providerLabel
|
|
209
|
+
? `${providerLabel} video`
|
|
210
|
+
: "Video preview"
|
|
211
|
+
: "Link preview";
|
|
212
|
+
const altText = post.title?.trim() || fallbackAlt;
|
|
213
|
+
const caption = isVideo
|
|
214
|
+
? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>`
|
|
215
|
+
: "";
|
|
216
|
+
|
|
217
|
+
return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
147
220
|
function formatFeedBytes(bytes: number): string {
|
|
148
221
|
if (bytes < 1024) return `${bytes} B`;
|
|
149
222
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
@@ -203,9 +276,13 @@ function buildAttachmentLinkText(
|
|
|
203
276
|
* and duration metadata when known. Text attachments link to the rendered
|
|
204
277
|
* preview page when a post permalink is available.
|
|
205
278
|
*/
|
|
206
|
-
function renderMediaItem(
|
|
279
|
+
function renderMediaItem(
|
|
280
|
+
item: MediaView,
|
|
281
|
+
siteUrl: string,
|
|
282
|
+
postPermalinkUrl?: string,
|
|
283
|
+
): string {
|
|
207
284
|
const category = getMediaCategory(item.mimeType);
|
|
208
|
-
const url = escapeXml(item.url);
|
|
285
|
+
const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
|
|
209
286
|
const name = item.originalName ?? "";
|
|
210
287
|
const altText = item.altText ?? "";
|
|
211
288
|
const caption = item.altText?.trim() || "";
|
|
@@ -223,7 +300,10 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
223
300
|
}
|
|
224
301
|
|
|
225
302
|
if (category === "video") {
|
|
226
|
-
const poster =
|
|
303
|
+
const poster = toAbsoluteFeedUrl(
|
|
304
|
+
item.posterUrl || item.thumbnailUrl,
|
|
305
|
+
siteUrl,
|
|
306
|
+
);
|
|
227
307
|
const dims =
|
|
228
308
|
item.width && item.height
|
|
229
309
|
? ` width="${item.width}" height="${item.height}"`
|
|
@@ -272,11 +352,12 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
272
352
|
*/
|
|
273
353
|
function renderMediaForFeed(
|
|
274
354
|
media: MediaView[],
|
|
355
|
+
siteUrl: string,
|
|
275
356
|
postPermalinkUrl?: string,
|
|
276
357
|
): string {
|
|
277
358
|
if (media.length === 0) return "";
|
|
278
359
|
return media
|
|
279
|
-
.map((item) => renderMediaItem(item, postPermalinkUrl))
|
|
360
|
+
.map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
|
|
280
361
|
.join("\n");
|
|
281
362
|
}
|
|
282
363
|
|
|
@@ -289,6 +370,7 @@ function renderMediaForFeed(
|
|
|
289
370
|
*/
|
|
290
371
|
function buildSinglePostContent(
|
|
291
372
|
post: PostView,
|
|
373
|
+
siteUrl: string,
|
|
292
374
|
permalinkUrl?: string,
|
|
293
375
|
options: SinglePostContentOptions = {},
|
|
294
376
|
): string {
|
|
@@ -314,11 +396,18 @@ function buildSinglePostContent(
|
|
|
314
396
|
}
|
|
315
397
|
}
|
|
316
398
|
|
|
399
|
+
const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
|
|
400
|
+
if (linkPreviewHtml) {
|
|
401
|
+
parts.push(linkPreviewHtml);
|
|
402
|
+
}
|
|
403
|
+
|
|
317
404
|
if (post.bodyHtml) {
|
|
318
|
-
parts.push(
|
|
405
|
+
parts.push(
|
|
406
|
+
absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(post.bodyHtml), siteUrl),
|
|
407
|
+
);
|
|
319
408
|
}
|
|
320
409
|
|
|
321
|
-
const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
|
|
410
|
+
const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
|
|
322
411
|
if (mediaHtml) {
|
|
323
412
|
parts.push(mediaHtml);
|
|
324
413
|
}
|
|
@@ -353,7 +442,7 @@ function buildFeedContent(
|
|
|
353
442
|
siteUrl: string,
|
|
354
443
|
permalinkUrl?: string,
|
|
355
444
|
): string {
|
|
356
|
-
const rootContent = buildSinglePostContent(post, permalinkUrl);
|
|
445
|
+
const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
|
|
357
446
|
const replies = post.threadReplies;
|
|
358
447
|
|
|
359
448
|
if (!replies || replies.length === 0) {
|
|
@@ -368,7 +457,9 @@ function buildFeedContent(
|
|
|
368
457
|
parts.push(
|
|
369
458
|
`<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
|
|
370
459
|
);
|
|
371
|
-
parts.push(
|
|
460
|
+
parts.push(
|
|
461
|
+
buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
|
|
462
|
+
);
|
|
372
463
|
}
|
|
373
464
|
|
|
374
465
|
return parts.join("\n");
|
|
@@ -421,7 +512,7 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
421
512
|
const titleAttr = m.originalName
|
|
422
513
|
? ` title="${escapeXml(m.originalName)}"`
|
|
423
514
|
: "";
|
|
424
|
-
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
|
|
515
|
+
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
|
|
425
516
|
})
|
|
426
517
|
.join("");
|
|
427
518
|
|
|
@@ -438,7 +529,18 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
438
529
|
})
|
|
439
530
|
.join("");
|
|
440
531
|
|
|
441
|
-
|
|
532
|
+
// The feed's own <updated> is the newest entry timestamp, not the current
|
|
533
|
+
// time — stamping "now" on every render tells every reader the feed changed
|
|
534
|
+
// on every poll, which is both untrue and useless for change detection.
|
|
535
|
+
// ISO 8601 from toISOString() is UTC and zero-padded, so lexical max is
|
|
536
|
+
// chronological max. Empty feeds fall back to now, since Atom requires it.
|
|
537
|
+
const feedUpdated =
|
|
538
|
+
posts
|
|
539
|
+
.map((post) => post.feedUpdatedAt ?? post.updatedAt)
|
|
540
|
+
.reduce<
|
|
541
|
+
string | null
|
|
542
|
+
>((latest, value) => (latest === null || value > latest ? value : latest), null) ??
|
|
543
|
+
new Date().toISOString();
|
|
442
544
|
|
|
443
545
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
444
546
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
@@ -447,7 +549,7 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
447
549
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
448
550
|
<link href="${escapeXml(selfUrl)}" rel="self"/>
|
|
449
551
|
<id>${escapeXml(selfUrl)}</id>
|
|
450
|
-
<updated>${
|
|
552
|
+
<updated>${feedUpdated}</updated>
|
|
451
553
|
${entries}
|
|
452
554
|
</feed>`;
|
|
453
555
|
}
|
package/src/lib/footnotes.ts
CHANGED
|
@@ -18,6 +18,16 @@ export interface FootnoteDefinitionTokenData {
|
|
|
18
18
|
contentMarkdown: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface LegacyFootnoteUpgradeResult {
|
|
22
|
+
doc: JSONContent;
|
|
23
|
+
upgraded: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LegacyFootnoteReference {
|
|
27
|
+
label: string;
|
|
28
|
+
node: JSONContent;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export function normalizeFootnoteLabel(label: unknown): string {
|
|
22
32
|
if (typeof label !== "string") return "";
|
|
23
33
|
return label.trim().replace(/\s+/g, " ");
|
|
@@ -82,6 +92,260 @@ export function normalizeFootnoteArtifacts(doc: JSONContent): JSONContent {
|
|
|
82
92
|
return content.length === doc.content.length ? doc : { ...doc, content };
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
function getLinkHref(node: JSONContent): string {
|
|
96
|
+
const linkMarks = (node.marks ?? []).filter((mark) => mark.type === "link");
|
|
97
|
+
if (linkMarks.length !== 1) return "";
|
|
98
|
+
|
|
99
|
+
const href = linkMarks[0]?.attrs?.href;
|
|
100
|
+
return typeof href === "string" ? href.trim() : "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decodeHashTarget(href: string): string {
|
|
104
|
+
if (!href.startsWith("#")) return "";
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return decodeURIComponent(href.slice(1));
|
|
108
|
+
} catch {
|
|
109
|
+
return href.slice(1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeLegacyTargetId(value: string): string {
|
|
114
|
+
return value.replace(/^user-content-/i, "").toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getLegacyReferenceLabel(node: JSONContent): string {
|
|
118
|
+
if (node.type !== "text" || typeof node.text !== "string") return "";
|
|
119
|
+
|
|
120
|
+
const text = node.text.trim();
|
|
121
|
+
const displayMatch = text.match(/^\[\^?([^\]\n]+)\]$/);
|
|
122
|
+
const label = normalizeFootnoteLabel(displayMatch?.[1] ?? text);
|
|
123
|
+
if (!label) return "";
|
|
124
|
+
|
|
125
|
+
const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
|
|
126
|
+
const expectedTargetId = `fn-${getFootnoteDomId(label)}`.toLowerCase();
|
|
127
|
+
return targetId === expectedTargetId ? label : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectLegacyReferences(
|
|
131
|
+
node: JSONContent,
|
|
132
|
+
references: LegacyFootnoteReference[],
|
|
133
|
+
): void {
|
|
134
|
+
const label = getLegacyReferenceLabel(node);
|
|
135
|
+
if (label) {
|
|
136
|
+
references.push({ label, node });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const child of node.content ?? []) {
|
|
141
|
+
collectLegacyReferences(child, references);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isBacklinkText(value: string | undefined): boolean {
|
|
146
|
+
return /^(?:↩(?:︎)?|↵)(?:\s*\d+)?$/.test(value?.trim() ?? "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getLegacyBacklinkLabel(
|
|
150
|
+
node: JSONContent,
|
|
151
|
+
labels: readonly string[],
|
|
152
|
+
): string {
|
|
153
|
+
if (node.type !== "text" || !isBacklinkText(node.text)) return "";
|
|
154
|
+
|
|
155
|
+
const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
|
|
156
|
+
if (!targetId.startsWith("fnref-")) return "";
|
|
157
|
+
|
|
158
|
+
for (const label of labels) {
|
|
159
|
+
const base = `fnref-${getFootnoteDomId(label)}`.toLowerCase();
|
|
160
|
+
const occurrence = targetId.startsWith(`${base}-`)
|
|
161
|
+
? targetId.slice(base.length + 1)
|
|
162
|
+
: "";
|
|
163
|
+
if (targetId === base || /^\d+$/.test(occurrence)) {
|
|
164
|
+
return label;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function collectLegacyBacklinkLabels(
|
|
172
|
+
node: JSONContent,
|
|
173
|
+
labels: readonly string[],
|
|
174
|
+
found: Set<string>,
|
|
175
|
+
): void {
|
|
176
|
+
const label = getLegacyBacklinkLabel(node, labels);
|
|
177
|
+
if (label) {
|
|
178
|
+
found.add(getFootnoteLabelKey(label));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const child of node.content ?? []) {
|
|
183
|
+
collectLegacyBacklinkLabels(child, labels, found);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function removeLegacyBacklinks(
|
|
188
|
+
node: JSONContent,
|
|
189
|
+
label: string,
|
|
190
|
+
): JSONContent | null {
|
|
191
|
+
if (
|
|
192
|
+
getFootnoteLabelKey(getLegacyBacklinkLabel(node, [label])) ===
|
|
193
|
+
getFootnoteLabelKey(label)
|
|
194
|
+
) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!node.content) return node;
|
|
199
|
+
|
|
200
|
+
const content = node.content.flatMap((child) => {
|
|
201
|
+
const normalized = removeLegacyBacklinks(child, label);
|
|
202
|
+
return normalized ? [normalized] : [];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (content.length === node.content.length) {
|
|
206
|
+
const unchanged = content.every(
|
|
207
|
+
(child, index) => child === node.content?.[index],
|
|
208
|
+
);
|
|
209
|
+
if (unchanged) return node;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return content.length > 0 ? { ...node, content } : { ...node, content: [] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function replaceLegacyReferences(
|
|
216
|
+
node: JSONContent,
|
|
217
|
+
labelsByNode: Map<JSONContent, string>,
|
|
218
|
+
): JSONContent {
|
|
219
|
+
const label = labelsByNode.get(node);
|
|
220
|
+
if (label) {
|
|
221
|
+
return { type: "footnoteReference", attrs: { label } };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!node.content) return node;
|
|
225
|
+
|
|
226
|
+
const content = node.content.map((child) =>
|
|
227
|
+
replaceLegacyReferences(child, labelsByNode),
|
|
228
|
+
);
|
|
229
|
+
const unchanged = content.every(
|
|
230
|
+
(child, index) => child === node.content?.[index],
|
|
231
|
+
);
|
|
232
|
+
return unchanged ? node : { ...node, content };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hasCanonicalFootnoteNode(node: JSONContent): boolean {
|
|
236
|
+
if (node.type === "footnoteReference" || node.type === "footnoteDefinition") {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (node.content ?? []).some(hasCanonicalFootnoteNode);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Upgrades the historical footnote shape that old rich-text pastes stored as
|
|
245
|
+
* ordinary fragment links plus a trailing ordered list.
|
|
246
|
+
*
|
|
247
|
+
* Conversion is deliberately all-or-nothing. The document must end in an
|
|
248
|
+
* `hr` and ordered list, every definition item must contain a matching
|
|
249
|
+
* `#fnref-*` backlink, and every `#fn-*` reference must have exactly one
|
|
250
|
+
* definition label. This keeps normal fragment links and numbered lists
|
|
251
|
+
* untouched.
|
|
252
|
+
*
|
|
253
|
+
* @param doc - Parsed TipTap document
|
|
254
|
+
* @returns The canonical document and whether a legacy footnote set changed
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* const result = upgradeLegacyFootnotes({
|
|
258
|
+
* type: "doc",
|
|
259
|
+
* content: [],
|
|
260
|
+
* });
|
|
261
|
+
* // { doc: { type: "doc", content: [] }, upgraded: false }
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
export function upgradeLegacyFootnotes(
|
|
265
|
+
doc: JSONContent,
|
|
266
|
+
): LegacyFootnoteUpgradeResult {
|
|
267
|
+
if (
|
|
268
|
+
doc.type !== "doc" ||
|
|
269
|
+
!Array.isArray(doc.content) ||
|
|
270
|
+
doc.content.length < 3 ||
|
|
271
|
+
hasCanonicalFootnoteNode(doc)
|
|
272
|
+
) {
|
|
273
|
+
return { doc, upgraded: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const definitionList = doc.content.at(-1);
|
|
277
|
+
const separator = doc.content.at(-2);
|
|
278
|
+
if (
|
|
279
|
+
definitionList?.type !== "orderedList" ||
|
|
280
|
+
separator?.type !== "horizontalRule" ||
|
|
281
|
+
!Array.isArray(definitionList.content) ||
|
|
282
|
+
definitionList.content.length === 0 ||
|
|
283
|
+
definitionList.content.some((item) => item.type !== "listItem")
|
|
284
|
+
) {
|
|
285
|
+
return { doc, upgraded: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const bodyNodes = doc.content.slice(0, -2);
|
|
289
|
+
const references: LegacyFootnoteReference[] = [];
|
|
290
|
+
for (const node of bodyNodes) collectLegacyReferences(node, references);
|
|
291
|
+
if (references.length === 0) return { doc, upgraded: false };
|
|
292
|
+
|
|
293
|
+
const labelsByKey = new Map<string, string>();
|
|
294
|
+
for (const reference of references) {
|
|
295
|
+
const key = getFootnoteLabelKey(reference.label);
|
|
296
|
+
if (!labelsByKey.has(key)) labelsByKey.set(key, reference.label);
|
|
297
|
+
}
|
|
298
|
+
const labels = [...labelsByKey.values()].sort(
|
|
299
|
+
(left, right) =>
|
|
300
|
+
getFootnoteDomId(right).length - getFootnoteDomId(left).length,
|
|
301
|
+
);
|
|
302
|
+
if (labels.length !== definitionList.content.length) {
|
|
303
|
+
return { doc, upgraded: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const definitions: JSONContent[] = [];
|
|
307
|
+
const definedLabels = new Set<string>();
|
|
308
|
+
for (const item of definitionList.content) {
|
|
309
|
+
const backlinkLabels = new Set<string>();
|
|
310
|
+
collectLegacyBacklinkLabels(item, labels, backlinkLabels);
|
|
311
|
+
if (backlinkLabels.size !== 1) return { doc, upgraded: false };
|
|
312
|
+
|
|
313
|
+
const key = [...backlinkLabels][0];
|
|
314
|
+
if (!key) return { doc, upgraded: false };
|
|
315
|
+
const label = labelsByKey.get(key);
|
|
316
|
+
if (!label || definedLabels.has(key)) return { doc, upgraded: false };
|
|
317
|
+
|
|
318
|
+
definedLabels.add(key);
|
|
319
|
+
const content = (item.content ?? []).flatMap((child) => {
|
|
320
|
+
const normalized = removeLegacyBacklinks(child, label);
|
|
321
|
+
return normalized ? [normalized] : [];
|
|
322
|
+
});
|
|
323
|
+
definitions.push({
|
|
324
|
+
type: "footnoteDefinition",
|
|
325
|
+
attrs: { label },
|
|
326
|
+
content: content.length > 0 ? content : [{ type: "paragraph" }],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (definedLabels.size !== labelsByKey.size) {
|
|
331
|
+
return { doc, upgraded: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const labelsByNode = new Map(
|
|
335
|
+
references.map((reference) => [reference.node, reference.label] as const),
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
doc: {
|
|
339
|
+
...doc,
|
|
340
|
+
content: [
|
|
341
|
+
...bodyNodes.map((node) => replaceLegacyReferences(node, labelsByNode)),
|
|
342
|
+
...definitions,
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
upgraded: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
85
349
|
/**
|
|
86
350
|
* Parses one Markdown footnote definition from the start of `src`.
|
|
87
351
|
*
|
|
@@ -30,6 +30,8 @@ export async function buildSyncSiteConfig(c: {
|
|
|
30
30
|
siteDescription: appConfig.siteDescription,
|
|
31
31
|
siteLanguage: appConfig.siteLanguage,
|
|
32
32
|
showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
|
|
33
|
+
publicApiEnabled: appConfig.publicApiEnabled,
|
|
34
|
+
rssFeedsEnabled: appConfig.rssFeedsEnabled,
|
|
33
35
|
mainRssFeed: appConfig.mainRssFeed,
|
|
34
36
|
siteFooter: appConfig.siteFooter,
|
|
35
37
|
showHeaderAvatar: appConfig.showHeaderAvatar,
|
package/src/lib/hugo-markdown.ts
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Per-collection membership metadata.
|
|
20
|
-
*
|
|
19
|
+
* Per-Thread collection membership metadata. Current exports write this
|
|
20
|
+
* only on the Thread root bundle. Import also accepts legacy reply-level
|
|
21
|
+
* entries and folds them into the root without losing metadata.
|
|
21
22
|
*/
|
|
22
23
|
export interface HugoCollectionRef {
|
|
23
24
|
slug: string;
|
|
@@ -110,11 +111,20 @@ export interface HugoFrontMatter {
|
|
|
110
111
|
rating?: number | null;
|
|
111
112
|
featured_at?: string | null;
|
|
112
113
|
pinned_at?: string | null;
|
|
114
|
+
/**
|
|
115
|
+
* Reply bundles only: the reply was published without announcing the Thread
|
|
116
|
+
* on Latest. Round-tripped so a restore rebuilds the same ordering.
|
|
117
|
+
*/
|
|
118
|
+
quiet_reply?: boolean;
|
|
119
|
+
|
|
120
|
+
// Derived Featured Thread projection (root bundles only)
|
|
121
|
+
featured_post_ids?: string[];
|
|
122
|
+
featured_sort_at?: string;
|
|
113
123
|
|
|
114
124
|
// Round-trip bookkeeping
|
|
115
125
|
root_aliases?: string[];
|
|
116
126
|
|
|
117
|
-
//
|
|
127
|
+
// Thread memberships (root bundles only) + attachments
|
|
118
128
|
collections?: HugoCollectionRef[];
|
|
119
129
|
media?: JantMedia[];
|
|
120
130
|
|
|
@@ -201,6 +211,8 @@ const FRONT_MATTER_KEY_ORDER: readonly string[] = [
|
|
|
201
211
|
"quote_text",
|
|
202
212
|
"rating",
|
|
203
213
|
"featured_at",
|
|
214
|
+
"featured_post_ids",
|
|
215
|
+
"featured_sort_at",
|
|
204
216
|
"pinned_at",
|
|
205
217
|
|
|
206
218
|
// Bookkeeping / attachments
|
package/src/lib/jant-branding.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { toPublicPath } from "./url.js";
|
|
|
23
23
|
|
|
24
24
|
export { JANT_APP_ICON_CORNER_RADIUS, JANT_LOGO_PATH_DATA, JANT_LOGO_VIEW_BOX };
|
|
25
25
|
|
|
26
|
+
export const JANT_HOME_URL = "https://jant.me";
|
|
26
27
|
export const JANT_REPO_URL = "https://github.com/jant-me/jant";
|
|
27
28
|
export const HOME_BRANDING_PREFIX = "Build with";
|
|
28
29
|
export const HOME_BRANDING_LINK_LABEL = "Jant";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const LINK_PREVIEW_PROVIDER_LABELS: Record<string, string> = {
|
|
2
|
+
youtube: "YouTube",
|
|
3
|
+
vimeo: "Vimeo",
|
|
4
|
+
bilibili: "Bilibili",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return the display label for a recognized Link preview provider.
|
|
9
|
+
*
|
|
10
|
+
* @param provider - Stored provider identifier, such as `youtube`
|
|
11
|
+
* @returns A stable display label, or undefined for unknown providers
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* getLinkPreviewProviderLabel("youtube"); // "YouTube"
|
|
15
|
+
* getLinkPreviewProviderLabel("unknown"); // undefined
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function getLinkPreviewProviderLabel(
|
|
19
|
+
provider?: string,
|
|
20
|
+
): string | undefined {
|
|
21
|
+
const normalizedProvider = provider?.trim().toLowerCase();
|
|
22
|
+
return normalizedProvider
|
|
23
|
+
? LINK_PREVIEW_PROVIDER_LABELS[normalizedProvider]
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|