@jant/core 0.6.15 → 0.7.0
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/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/lib/feed.ts
CHANGED
|
@@ -10,9 +10,28 @@
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
FeedData,
|
|
15
|
+
FeedPostView,
|
|
16
|
+
LanguageAlternate,
|
|
17
|
+
MediaView,
|
|
18
|
+
PostView,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import { DISCOVER_NAMESPACE_URI } from "./discover.js";
|
|
21
|
+
import { extractTimelineSummary } from "./summary.js";
|
|
22
|
+
import { getLinkPreviewProviderLabel } from "./link-preview.js";
|
|
14
23
|
import { extractDisplayDomain } from "./url.js";
|
|
15
24
|
import { getMediaCategory } from "./upload.js";
|
|
25
|
+
import { foldThreadReplies } from "./thread-fold.js";
|
|
26
|
+
import type { ThreadFold } from "./thread-fold.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Media RSS namespace. Atom's own `<link rel="enclosure">` has no slots for
|
|
30
|
+
* pixel dimensions, duration, or a description, so the richer per-attachment
|
|
31
|
+
* metadata rides in this extension alongside it — enclosure stays because it
|
|
32
|
+
* is the only attachment mechanism a plain Atom parser understands.
|
|
33
|
+
*/
|
|
34
|
+
const MEDIA_RSS_NAMESPACE_URI = "http://search.yahoo.com/mrss/";
|
|
16
35
|
|
|
17
36
|
/**
|
|
18
37
|
* Escape special XML characters.
|
|
@@ -40,6 +59,49 @@ function escapeCdata(str: string): string {
|
|
|
40
59
|
return str.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
41
60
|
}
|
|
42
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a URL for use outside the feed document's browser context.
|
|
64
|
+
*
|
|
65
|
+
* Feed readers do not consistently resolve root-relative URLs found inside
|
|
66
|
+
* Atom HTML content or enclosure attributes, so every non-fragment URL must
|
|
67
|
+
* carry its own origin.
|
|
68
|
+
*/
|
|
69
|
+
function toAbsoluteFeedUrl(url: string, siteUrl: string): string {
|
|
70
|
+
const normalizedUrl = url.trim();
|
|
71
|
+
if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
|
|
75
|
+
return new URL(normalizedUrl, baseUrl).toString();
|
|
76
|
+
} catch {
|
|
77
|
+
return normalizedUrl;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve navigational and media URL attributes inside trusted post HTML.
|
|
83
|
+
*
|
|
84
|
+
* Fragment-only links stay local to the rendered feed entry so footnotes and
|
|
85
|
+
* other in-entry references continue to work.
|
|
86
|
+
*/
|
|
87
|
+
function absolutizeFeedHtmlUrls(html: string, siteUrl: string): string {
|
|
88
|
+
return html.replaceAll(
|
|
89
|
+
/(\s)(href|poster|src)=(["'])([^"']*)\3/gi,
|
|
90
|
+
(
|
|
91
|
+
match,
|
|
92
|
+
whitespace: string,
|
|
93
|
+
attribute: string,
|
|
94
|
+
quote: string,
|
|
95
|
+
url: string,
|
|
96
|
+
) => {
|
|
97
|
+
const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
|
|
98
|
+
return absoluteUrl
|
|
99
|
+
? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}`
|
|
100
|
+
: match;
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
43
105
|
/**
|
|
44
106
|
* Strip embedded content that is unsafe or unsupported in feed readers.
|
|
45
107
|
*
|
|
@@ -99,6 +161,20 @@ interface SinglePostContentOptions {
|
|
|
99
161
|
* through Atom fields.
|
|
100
162
|
*/
|
|
101
163
|
inline?: boolean;
|
|
164
|
+
/**
|
|
165
|
+
* Render only what a summary carries: the timeline's truncated body, the
|
|
166
|
+
* quoted text, the rating. Media, link previews and the ★ permalink are the
|
|
167
|
+
* post's content, and a consumer reads those from `<content>` or from the
|
|
168
|
+
* Media RSS elements.
|
|
169
|
+
*/
|
|
170
|
+
summary?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* The post's timeline summary, read only in summary mode. `buildFeedEntry`
|
|
173
|
+
* computes it once per post because three elements ask for it, so this is
|
|
174
|
+
* handed in rather than derived here. Null means the post has no TipTap
|
|
175
|
+
* document to truncate and keeps its full body.
|
|
176
|
+
*/
|
|
177
|
+
timelineSummary?: TimelineSummary | null;
|
|
102
178
|
}
|
|
103
179
|
|
|
104
180
|
function renderLinkedText(text: string, href?: string): string {
|
|
@@ -106,7 +182,37 @@ function renderLinkedText(text: string, href?: string): string {
|
|
|
106
182
|
return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
|
|
107
183
|
}
|
|
108
184
|
|
|
109
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The title and source line a reply needs, since only the entry's own root has
|
|
187
|
+
* `<title>` and `link[@rel="alternate"]` to carry them.
|
|
188
|
+
*
|
|
189
|
+
* Wrapped in `<header>`, which is the whole point of the element here: a
|
|
190
|
+
* consumer drawing its own card takes this post's title, target and preview
|
|
191
|
+
* from its `<jant:post>` row, and has to drop what the text already says or
|
|
192
|
+
* print it twice. Removing one named element beats matching "a leading `<p>`
|
|
193
|
+
* holding a link, then an `<h2>`" — a body's own first paragraph can be
|
|
194
|
+
* exactly that. The rule ends up uniform: drop any `<header>`, draw the chrome
|
|
195
|
+
* from the row. A root block has none to drop and needs no special case.
|
|
196
|
+
*
|
|
197
|
+
* A reader that knows nothing about any of this still sees the heading, which
|
|
198
|
+
* is why the markup stays in `<summary>` rather than moving to the row alone.
|
|
199
|
+
*
|
|
200
|
+
* @param post - The reply being rendered inline
|
|
201
|
+
* @param permalinkUrl - Absolute permalink, for a titled note's heading link
|
|
202
|
+
* @returns A `<header>` block, or "" when the post has no chrome to show
|
|
203
|
+
* @example
|
|
204
|
+
* renderInlinePostHeader(linkReply, url);
|
|
205
|
+
* // '<header><p><a …>example.com</a></p><h2><a …>Title</a></h2></header>'
|
|
206
|
+
*/
|
|
207
|
+
function renderInlinePostHeader(post: PostView, permalinkUrl?: string): string {
|
|
208
|
+
return wrapInlineHeader(collectInlineHeaderParts(post, permalinkUrl));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function wrapInlineHeader(parts: string[]): string {
|
|
212
|
+
return parts.length > 0 ? `<header>${parts.join("")}</header>` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function collectInlineHeaderParts(
|
|
110
216
|
post: PostView,
|
|
111
217
|
permalinkUrl?: string,
|
|
112
218
|
): string[] {
|
|
@@ -135,6 +241,30 @@ function renderInlinePostHeader(
|
|
|
135
241
|
return parts;
|
|
136
242
|
}
|
|
137
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Render author-authored plain text as feed-safe HTML, preserving its breaks.
|
|
246
|
+
*
|
|
247
|
+
* The site keeps quote line breaks with `white-space: pre-line`, but feed
|
|
248
|
+
* readers strip CSS, so the breaks have to be structural: a blank line starts
|
|
249
|
+
* a new `<p>`, a single newline becomes a `<br/>`.
|
|
250
|
+
*
|
|
251
|
+
* @param text - Raw plain text as the author typed it
|
|
252
|
+
* @returns One or more `<p>` blocks, or an empty string for blank input
|
|
253
|
+
* @example
|
|
254
|
+
* renderPlainTextHtml("one\ntwo\n\nthree")
|
|
255
|
+
* // => "<p>one<br/>two</p>\n<p>three</p>"
|
|
256
|
+
*/
|
|
257
|
+
function renderPlainTextHtml(text: string): string {
|
|
258
|
+
return text
|
|
259
|
+
.replaceAll("\r\n", "\n")
|
|
260
|
+
.replaceAll("\r", "\n")
|
|
261
|
+
.split(/\n{2,}/)
|
|
262
|
+
.map((block) => block.trim())
|
|
263
|
+
.filter((block) => block.length > 0)
|
|
264
|
+
.map((block) => `<p>${escapeXml(block).replaceAll("\n", "<br/>")}</p>`)
|
|
265
|
+
.join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
138
268
|
/**
|
|
139
269
|
* Render a star rating as HTML for feed content.
|
|
140
270
|
*/
|
|
@@ -144,6 +274,35 @@ function renderRatingHtml(rating: number): string {
|
|
|
144
274
|
return `<p>${filled}${empty} ${rating}/5</p>`;
|
|
145
275
|
}
|
|
146
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Render a Link post preview as feed-safe HTML.
|
|
279
|
+
*
|
|
280
|
+
* Feed readers commonly strip CSS overlays and embedded players, so video
|
|
281
|
+
* previews use a linked thumbnail plus a visible provider-aware action.
|
|
282
|
+
* Non-video Link previews keep the linked thumbnail without a video label.
|
|
283
|
+
*/
|
|
284
|
+
function renderLinkPreviewForFeed(post: PostView, siteUrl: string): string {
|
|
285
|
+
if (post.format !== "link") return "";
|
|
286
|
+
|
|
287
|
+
const imageUrl = post.previewImageUrl?.trim();
|
|
288
|
+
const linkUrl = post.url?.trim();
|
|
289
|
+
if (!imageUrl || !linkUrl) return "";
|
|
290
|
+
|
|
291
|
+
const isVideo = post.previewKind?.trim().toLowerCase() === "video";
|
|
292
|
+
const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
|
|
293
|
+
const fallbackAlt = isVideo
|
|
294
|
+
? providerLabel
|
|
295
|
+
? `${providerLabel} video`
|
|
296
|
+
: "Video preview"
|
|
297
|
+
: "Link preview";
|
|
298
|
+
const altText = post.title?.trim() || fallbackAlt;
|
|
299
|
+
const caption = isVideo
|
|
300
|
+
? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>`
|
|
301
|
+
: "";
|
|
302
|
+
|
|
303
|
+
return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
147
306
|
function formatFeedBytes(bytes: number): string {
|
|
148
307
|
if (bytes < 1024) return `${bytes} B`;
|
|
149
308
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
@@ -203,10 +362,13 @@ function buildAttachmentLinkText(
|
|
|
203
362
|
* and duration metadata when known. Text attachments link to the rendered
|
|
204
363
|
* preview page when a post permalink is available.
|
|
205
364
|
*/
|
|
206
|
-
function renderMediaItem(
|
|
365
|
+
function renderMediaItem(
|
|
366
|
+
item: MediaView,
|
|
367
|
+
siteUrl: string,
|
|
368
|
+
postPermalinkUrl?: string,
|
|
369
|
+
): string {
|
|
207
370
|
const category = getMediaCategory(item.mimeType);
|
|
208
|
-
const url = escapeXml(item.url);
|
|
209
|
-
const name = item.originalName ?? "";
|
|
371
|
+
const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
|
|
210
372
|
const altText = item.altText ?? "";
|
|
211
373
|
const caption = item.altText?.trim() || "";
|
|
212
374
|
const meta = getMediaMeta(item);
|
|
@@ -223,19 +385,38 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
223
385
|
}
|
|
224
386
|
|
|
225
387
|
if (category === "video") {
|
|
226
|
-
|
|
388
|
+
// One rendering whether or not the clip has a poster frame — the still is
|
|
389
|
+
// an attribute here, not a branch. `preload="none"` is load-bearing: a
|
|
390
|
+
// reader painting a timeline must not start pulling a 28 MB file.
|
|
391
|
+
//
|
|
392
|
+
// `thumbnailUrl` is a real still only for images; the media pipeline
|
|
393
|
+
// leaves it pointing at the file itself for everything else, so a clip
|
|
394
|
+
// with no poster key would otherwise poster itself with its own MP4.
|
|
395
|
+
//
|
|
396
|
+
// The link sits in the `<figcaption>`, outside the `<video>`, rather than
|
|
397
|
+
// as its fallback child: a sanitizer that drops a disallowed element takes
|
|
398
|
+
// its children with it, and out here the clip stays reachable whichever
|
|
399
|
+
// way a reader's allowlist goes. That makes inlining the player a pure
|
|
400
|
+
// upgrade over the old poster-thumbnail rendering — worst case it degrades
|
|
401
|
+
// to exactly the link that rendering already offered.
|
|
402
|
+
const posterSource = (item.posterUrl || item.thumbnailUrl)?.trim();
|
|
403
|
+
const posterAttr =
|
|
404
|
+
posterSource && posterSource !== item.url
|
|
405
|
+
? ` poster="${escapeXml(toAbsoluteFeedUrl(posterSource, siteUrl))}"`
|
|
406
|
+
: "";
|
|
227
407
|
const dims =
|
|
228
408
|
item.width && item.height
|
|
229
409
|
? ` width="${item.width}" height="${item.height}"`
|
|
230
410
|
: "";
|
|
231
|
-
// Prefix the caption with a ▶ glyph as a video cue. A CSS overlay would
|
|
232
|
-
// be stripped by most feed-reader sanitizers, so a plain-text play
|
|
233
|
-
// character is the only marker that renders reliably everywhere. Link
|
|
234
|
-
// only the "Watch video" action label (so it's clickable like the
|
|
235
|
-
// thumbnail and reads cleanly to screen readers); metadata stays outside
|
|
236
|
-
// the link in parens, matching the audio/text/document attachment style.
|
|
237
411
|
const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
|
|
238
|
-
|
|
412
|
+
// `<video>` has no `alt`, so the description a poster image used to carry
|
|
413
|
+
// moves into the caption rather than being dropped.
|
|
414
|
+
const altSuffix = caption ? `: ${escapeXml(caption)}` : "";
|
|
415
|
+
return (
|
|
416
|
+
`<figure><video controls preload="none"${posterAttr}${dims}>` +
|
|
417
|
+
`<source src="${url}" type="${escapeXml(cleanMimeType(item.mimeType))}"/>` +
|
|
418
|
+
`</video><figcaption><a href="${url}">▶ Watch video</a>${metaSuffix}${altSuffix}</figcaption></figure>`
|
|
419
|
+
);
|
|
239
420
|
}
|
|
240
421
|
|
|
241
422
|
if (category === "audio") {
|
|
@@ -245,9 +426,9 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
245
426
|
}
|
|
246
427
|
|
|
247
428
|
if (category === "text") {
|
|
248
|
-
const previewHref =
|
|
249
|
-
|
|
250
|
-
|
|
429
|
+
const previewHref = escapeXml(
|
|
430
|
+
getMediaPageUrl(item, siteUrl, postPermalinkUrl),
|
|
431
|
+
);
|
|
251
432
|
const linkText = buildAttachmentLinkText(item, "Attached text");
|
|
252
433
|
// Prefer character count over byte size — more meaningful for text.
|
|
253
434
|
const textMeta =
|
|
@@ -272,12 +453,68 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
272
453
|
*/
|
|
273
454
|
function renderMediaForFeed(
|
|
274
455
|
media: MediaView[],
|
|
456
|
+
siteUrl: string,
|
|
275
457
|
postPermalinkUrl?: string,
|
|
276
458
|
): string {
|
|
277
459
|
if (media.length === 0) return "";
|
|
278
|
-
|
|
279
|
-
.map((item) => renderMediaItem(item, postPermalinkUrl))
|
|
460
|
+
const items = media
|
|
461
|
+
.map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
|
|
280
462
|
.join("\n");
|
|
463
|
+
// The site lays a post's attachments out as one horizontally scrolling strip
|
|
464
|
+
// and marks that container `data-post-media`, which is part of the markup
|
|
465
|
+
// contract themes and external scripts already read. Carrying the same
|
|
466
|
+
// container into the feed lets a consumer style the strip instead of
|
|
467
|
+
// reassembling it from the Media RSS elements — and in a thread it puts each
|
|
468
|
+
// post's attachments next to that post's own text, which a flat list cannot.
|
|
469
|
+
// A reader that ignores the attribute stacks the figures exactly as before.
|
|
470
|
+
return `<div data-post-media>\n${items}\n</div>`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The timeline's truncated rendering of a post, and whether it was cut. */
|
|
474
|
+
interface TimelineSummary {
|
|
475
|
+
html: string;
|
|
476
|
+
hasMore: boolean;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The truncated body the site's timeline renders for this post, at the same
|
|
481
|
+
* boundary the page uses.
|
|
482
|
+
*
|
|
483
|
+
* Derived here rather than read off `PostView.summaryHtml`, which exists only
|
|
484
|
+
* for titled posts: an untitled note expands in place on the page, so the card
|
|
485
|
+
* renders the full body with a break marker and never needs a second HTML
|
|
486
|
+
* string. `NoteCard` reads `summaryHtml ?? bodyHtml`, so populating it for
|
|
487
|
+
* notes to serve the feed would silently switch the site off expand-in-place.
|
|
488
|
+
* `PostView.body` carries the source document, so the feed derives its own.
|
|
489
|
+
*
|
|
490
|
+
* A Quote is not cut at all. `QuoteCard` passes `bodyHtml` straight through and
|
|
491
|
+
* nothing clamps `.feed-quote-commentary`, so the site shows a quote's
|
|
492
|
+
* commentary whole however long it runs — and `<summary>` is the timeline's
|
|
493
|
+
* rendering, not a shorter one of the feed's own. The quoted text is not cut
|
|
494
|
+
* either, so a Quote arrives entire on both halves.
|
|
495
|
+
*
|
|
496
|
+
* This is the one exception. An untitled note's body *is* cut here even though
|
|
497
|
+
* the site renders it whole, because the site hides the tail with CSS the
|
|
498
|
+
* reader strips.
|
|
499
|
+
*
|
|
500
|
+
* Each call parses and renders the TipTap document, and three elements of an
|
|
501
|
+
* entry want the answer — `<summary>`, the `<jant:post>` row, and
|
|
502
|
+
* `<jant:truncated/>`. `buildFeedEntry` therefore calls this once per post
|
|
503
|
+
* and the elements read `FeedEntry.summaryPosts`; nothing else should call it
|
|
504
|
+
* inside the entry render.
|
|
505
|
+
*
|
|
506
|
+
* @param post - Post view data, carrying the TipTap document in `body`
|
|
507
|
+
* @returns Truncated HTML and whether content continues, null when the post has
|
|
508
|
+
* no TipTap document to truncate or is a Quote
|
|
509
|
+
* @example
|
|
510
|
+
* getTimelineSummary(post) // { html: "<p>Intro</p>", hasMore: true }
|
|
511
|
+
*/
|
|
512
|
+
function getTimelineSummary(post: PostView): TimelineSummary | null {
|
|
513
|
+
if (post.format === "quote") return null;
|
|
514
|
+
|
|
515
|
+
return extractTimelineSummary(post.body, !!post.title, {
|
|
516
|
+
namespace: post.id,
|
|
517
|
+
});
|
|
281
518
|
}
|
|
282
519
|
|
|
283
520
|
/**
|
|
@@ -289,50 +526,95 @@ function renderMediaForFeed(
|
|
|
289
526
|
*/
|
|
290
527
|
function buildSinglePostContent(
|
|
291
528
|
post: PostView,
|
|
529
|
+
siteUrl: string,
|
|
292
530
|
permalinkUrl?: string,
|
|
293
531
|
options: SinglePostContentOptions = {},
|
|
294
532
|
): string {
|
|
295
533
|
const parts: string[] = [];
|
|
296
534
|
|
|
297
535
|
if (options.inline) {
|
|
298
|
-
|
|
536
|
+
const header = renderInlinePostHeader(post, permalinkUrl);
|
|
537
|
+
if (header) parts.push(header);
|
|
299
538
|
}
|
|
300
539
|
|
|
540
|
+
let quoteRendered = false;
|
|
301
541
|
if (post.format === "quote" && post.quoteText) {
|
|
302
542
|
const sourceName = post.title || "";
|
|
303
543
|
const sourceUrl = post.url || "";
|
|
304
544
|
const attribution = sourceName || sourceUrl;
|
|
305
545
|
const cite = sourceUrl ? ` cite="${escapeXml(sourceUrl)}"` : "";
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (attribution) {
|
|
310
|
-
const source = sourceUrl
|
|
546
|
+
const quoteHtml = renderPlainTextHtml(post.quoteText);
|
|
547
|
+
const source = attribution
|
|
548
|
+
? sourceUrl
|
|
311
549
|
? `<a href="${escapeXml(sourceUrl)}">${escapeXml(sourceName || extractDisplayDomain(sourceUrl) || sourceUrl)}</a>`
|
|
312
|
-
: escapeXml(attribution)
|
|
550
|
+
: escapeXml(attribution)
|
|
551
|
+
: "";
|
|
552
|
+
if (quoteHtml) {
|
|
553
|
+
// `<figure>`/`<figcaption>` is how the site card groups the quote with
|
|
554
|
+
// its source (`h-cite`), and how this renderer already pairs media with
|
|
555
|
+
// a caption. A loose `<p>— source</p>` sibling said neither.
|
|
556
|
+
const caption = source ? `<figcaption>— ${source}</figcaption>` : "";
|
|
557
|
+
parts.push(
|
|
558
|
+
`<figure><blockquote${cite}>${quoteHtml}</blockquote>${caption}</figure>`,
|
|
559
|
+
);
|
|
560
|
+
quoteRendered = true;
|
|
561
|
+
} else if (source) {
|
|
313
562
|
parts.push(`<p>— ${source}</p>`);
|
|
314
563
|
}
|
|
315
564
|
}
|
|
316
565
|
|
|
317
|
-
|
|
318
|
-
|
|
566
|
+
// The preview image is media: `<media:thumbnail>` carries it for a consumer
|
|
567
|
+
// laying out its own card, so the summary has no reason to repeat the markup.
|
|
568
|
+
if (!options.summary) {
|
|
569
|
+
const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
|
|
570
|
+
if (linkPreviewHtml) {
|
|
571
|
+
parts.push(linkPreviewHtml);
|
|
572
|
+
}
|
|
319
573
|
}
|
|
320
574
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
575
|
+
// In summary mode the body is the timeline's truncated rendering; a post
|
|
576
|
+
// without a TipTap document (legacy plain-text rows) has nothing to truncate
|
|
577
|
+
// and keeps its full body.
|
|
578
|
+
const bodyHtml = options.summary
|
|
579
|
+
? (options.timelineSummary?.html ?? post.bodyHtml)
|
|
580
|
+
: post.bodyHtml;
|
|
581
|
+
|
|
582
|
+
if (bodyHtml) {
|
|
583
|
+
// The site draws a hairline between a quote and the author's commentary
|
|
584
|
+
// (`.feed-quote-commentary::before` in ui.css). Feed readers strip CSS, so
|
|
585
|
+
// that separator only survives as an element.
|
|
586
|
+
if (quoteRendered) parts.push("<hr/>");
|
|
587
|
+
parts.push(absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(bodyHtml), siteUrl));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Media belongs to `<content>` and to `<media:content>`, not here. A feed
|
|
591
|
+
// cannot express the timeline's justified row anyway — the consumer computes
|
|
592
|
+
// it from the dimensions on `<media:content>` — so a consumer that lays out
|
|
593
|
+
// its own card wants this field to be the text and nothing else.
|
|
594
|
+
if (!options.summary) {
|
|
595
|
+
const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
|
|
596
|
+
if (mediaHtml) {
|
|
597
|
+
parts.push(mediaHtml);
|
|
598
|
+
}
|
|
324
599
|
}
|
|
325
600
|
|
|
326
601
|
if (post.rating && post.rating > 0) {
|
|
327
602
|
parts.push(renderRatingHtml(post.rating));
|
|
328
603
|
}
|
|
329
604
|
|
|
330
|
-
|
|
605
|
+
// An entry's content cannot be empty, so a post with nothing but a title or
|
|
606
|
+
// a URL falls back to its plain-text projection. A summary can be empty —
|
|
607
|
+
// that is what "there is no shorter rendering" looks like — and must be: a
|
|
608
|
+
// post carrying only attachments has no text to summarise, and the fallback
|
|
609
|
+
// would put a bare `Post #<id>` where a teaser belongs.
|
|
610
|
+
if (parts.length === 0 && !options.summary) {
|
|
331
611
|
parts.push(`<p>${escapeXml(getFeedSummaryText(post))}</p>`);
|
|
332
612
|
}
|
|
333
613
|
|
|
334
|
-
// For link posts, append a ★ permalink back to the blog post (Daring Fireball
|
|
335
|
-
|
|
614
|
+
// For link posts, append a ★ permalink back to the blog post (Daring Fireball
|
|
615
|
+
// style). A feed convention, not something the site's card shows, so it stays
|
|
616
|
+
// out of the summary.
|
|
617
|
+
if (post.format === "link" && permalinkUrl && !options.summary) {
|
|
336
618
|
parts.push(
|
|
337
619
|
`<p><a href="${escapeXml(permalinkUrl)}" title="Permalink"> ★ </a></p>`,
|
|
338
620
|
);
|
|
@@ -341,6 +623,158 @@ function buildSinglePostContent(
|
|
|
341
623
|
return parts.join("\n");
|
|
342
624
|
}
|
|
343
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Declare that an entry is a thread, and give its shape.
|
|
628
|
+
*
|
|
629
|
+
* The text constructs answer this only to something willing to parse HTML: the
|
|
630
|
+
* tail meta marks the joints, and the gap link states the hidden count in a
|
|
631
|
+
* sentence. A consumer that reads the entry's elements and draws its own card
|
|
632
|
+
* from `<summary>` plus `<media:content>` should not have to. Absence is the
|
|
633
|
+
* whole of its rule: no element, not a thread.
|
|
634
|
+
*
|
|
635
|
+
* `hidden` is stated rather than derived. How many posts fold away is the
|
|
636
|
+
* site's decision, and `posts - 2` only holds while that decision is "keep the
|
|
637
|
+
* root and the newest reply" — a consumer that derived it would silently
|
|
638
|
+
* disagree with the summary the day the rule changed. It also carries the count
|
|
639
|
+
* as a number, which the gap link only has as hardcoded English.
|
|
640
|
+
*
|
|
641
|
+
* @param entry - The entry, with its fold and the posts its summary renders
|
|
642
|
+
* @param siteUrl - Site base URL for absolute permalinks
|
|
643
|
+
* @returns The element, newline-prefixed, or "" when the entry is a lone post
|
|
644
|
+
* @example
|
|
645
|
+
* renderThreadElement(entry, "https://example.com");
|
|
646
|
+
* // '\n <jant:thread posts="4" hidden="2" gap="…/r1" latest="…/r3"/>'
|
|
647
|
+
*/
|
|
648
|
+
function renderThreadElement(entry: FeedEntry, siteUrl: string): string {
|
|
649
|
+
const { post, fold, summaryPosts } = entry;
|
|
650
|
+
if (!fold) return "";
|
|
651
|
+
const replies = post.threadReplies ?? [];
|
|
652
|
+
|
|
653
|
+
const attrs = [
|
|
654
|
+
`posts="${replies.length + 1}"`,
|
|
655
|
+
`hidden="${fold.hiddenCount}"`,
|
|
656
|
+
];
|
|
657
|
+
if (fold.hiddenCount > 0 && fold.firstHiddenReply) {
|
|
658
|
+
attrs.push(
|
|
659
|
+
`gap="${escapeXml(toAbsoluteFeedUrl(fold.firstHiddenReply.permalink, siteUrl))}"`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
attrs.push(
|
|
663
|
+
`latest="${escapeXml(toAbsoluteFeedUrl(fold.latestReply.permalink, siteUrl))}"`,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
// One row per post, in thread order. `<jant:format>` above describes the
|
|
667
|
+
// entry, which is the root — a reply that is a Quote or a Link says so
|
|
668
|
+
// nowhere else, and a consumer laying the thread out itself rather than
|
|
669
|
+
// injecting the HTML cannot see the `<blockquote>` that would have told it.
|
|
670
|
+
//
|
|
671
|
+
// This is where `gap` and `latest` point, and where `media:content`'s
|
|
672
|
+
// `jant:post` resolves: the attribute references, this declares.
|
|
673
|
+
//
|
|
674
|
+
// A row carries what Atom itself would put on this post's entry — its links,
|
|
675
|
+
// its title, its date — plus what Jant adds at entry level: `format` and
|
|
676
|
+
// `truncated`. Never the body, a summary, or an excerpt: `<content>` remains
|
|
677
|
+
// the only place the posts' words appear, and this stays a table of contents.
|
|
678
|
+
//
|
|
679
|
+
// The root gets the same row as every reply, repeating what the entry
|
|
680
|
+
// already says about it. A consumer walking the rows should not have to know
|
|
681
|
+
// that one of them is described somewhere else instead.
|
|
682
|
+
const rows = [post, ...replies]
|
|
683
|
+
.map((member) => {
|
|
684
|
+
const rowAttrs = [
|
|
685
|
+
`href="${escapeXml(toAbsoluteFeedUrl(member.permalink, siteUrl))}"`,
|
|
686
|
+
`format="${escapeXml(member.format)}"`,
|
|
687
|
+
`published="${escapeXml(member.publishedAt)}"`,
|
|
688
|
+
];
|
|
689
|
+
|
|
690
|
+
// The entry's own rule for `<title>`, applied per post: a quote's
|
|
691
|
+
// attribution is not its title, and `getAtomTitle` is where that is
|
|
692
|
+
// decided, so a quote row carries none.
|
|
693
|
+
const rowTitle = getAtomTitle(member);
|
|
694
|
+
if (rowTitle) rowAttrs.push(`title="${escapeXml(rowTitle)}"`);
|
|
695
|
+
|
|
696
|
+
// Where a Link post points — the row's answer to the entry's
|
|
697
|
+
// `link[rel="alternate"]`, which only ever describes the root.
|
|
698
|
+
if (member.format === "link" && member.url) {
|
|
699
|
+
rowAttrs.push(`url="${escapeXml(member.url)}"`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// A Link post's preview image, the row's `media:thumbnail`. It is a
|
|
703
|
+
// scrape of someone else's page rather than a published file, which is
|
|
704
|
+
// why it is an attribute here and not a `media:content` of its own.
|
|
705
|
+
const rowThumbnail = member.previewImageUrl?.trim();
|
|
706
|
+
if (rowThumbnail) {
|
|
707
|
+
rowAttrs.push(
|
|
708
|
+
`thumbnail="${escapeXml(toAbsoluteFeedUrl(rowThumbnail, siteUrl))}"`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Whether the fold hid this post. Every other decision on this row is
|
|
713
|
+
// declared rather than inferred, and this was the last one a consumer
|
|
714
|
+
// had to reverse-engineer from the summary's shape — which does not
|
|
715
|
+
// work: a photo with no caption renders on the site and contributes no
|
|
716
|
+
// text, so it leaves the summary looking exactly like a folded post.
|
|
717
|
+
// Written only when true, the same as `truncated`, so a thread that
|
|
718
|
+
// hides nothing pays nothing. The count of these equals `@hidden`.
|
|
719
|
+
if (!summaryPosts.has(member)) {
|
|
720
|
+
rowAttrs.push(`folded="true"`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Truncation happens only in `<summary>`, and only to the posts the fold
|
|
724
|
+
// renders. A post it hid has no block to cut — and no timeline summary
|
|
725
|
+
// in the entry — so its row never carries this: absence means "not cut
|
|
726
|
+
// here", never "shown whole".
|
|
727
|
+
if (summaryPosts.get(member)?.hasMore === true) {
|
|
728
|
+
rowAttrs.push(`truncated="true"`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return `\n <jant:post ${rowAttrs.join(" ")}/>`;
|
|
732
|
+
})
|
|
733
|
+
.join("");
|
|
734
|
+
|
|
735
|
+
return `\n <jant:thread ${attrs.join(" ")}>${rows}\n </jant:thread>`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Close one post's block inside a thread with its own dated permalink.
|
|
740
|
+
*
|
|
741
|
+
* A thread arrives as one entry, so both text constructs run several posts
|
|
742
|
+
* together and a consumer has to find the joints. `<hr/>` cannot say where
|
|
743
|
+
* they are — it also separates a quote from its commentary, and the author can
|
|
744
|
+
* type one — so the joint has to be something only this renderer emits.
|
|
745
|
+
*
|
|
746
|
+
* The site already has the shape: `PostFooter`'s `PostPublishedLink` puts the
|
|
747
|
+
* timestamp *after* the post and links it, carrying microformats2 `u-url` and
|
|
748
|
+
* `dt-published` inside its `h-entry`. Emitting the same thing here means a
|
|
749
|
+
* consumer with an mf2 parser gets the segmentation for free, and one without
|
|
750
|
+
* still has an unambiguous marker: a `<time datetime>` wrapped in an `<a>` is
|
|
751
|
+
* a shape no post body produces, and it survives a reader that strips `class`.
|
|
752
|
+
*
|
|
753
|
+
* Trailing, not leading, for a reason beyond matching the site: with markers
|
|
754
|
+
* after each block the rule is "every marker ends the block before it", which
|
|
755
|
+
* holds for the root as well. A leading marker leaves the root's own block
|
|
756
|
+
* unnamed. The root therefore carries one too, and the redundancy with the
|
|
757
|
+
* entry's `<published>` is confined to threads, where the date has stopped
|
|
758
|
+
* being the entry's only one.
|
|
759
|
+
*
|
|
760
|
+
* `title` is left off: the site's is a translated sentence, and the feed's
|
|
761
|
+
* strings are hardcoded English already.
|
|
762
|
+
*
|
|
763
|
+
* @param post - The post whose block is ending
|
|
764
|
+
* @param permalinkUrl - Absolute permalink of that post
|
|
765
|
+
* @returns A paragraph carrying the post's date, linked to the post
|
|
766
|
+
* @example
|
|
767
|
+
* renderPostTailMeta(reply, "https://example.com/reply-1");
|
|
768
|
+
* // '<p><small><a href="…" class="u-url"><time class="dt-published" …>…</time></a></small></p>'
|
|
769
|
+
*/
|
|
770
|
+
function renderPostTailMeta(post: PostView, permalinkUrl: string): string {
|
|
771
|
+
return (
|
|
772
|
+
`<p><small><a href="${escapeXml(permalinkUrl)}" class="u-url">` +
|
|
773
|
+
`<time class="dt-published" datetime="${escapeXml(post.publishedAt)}">` +
|
|
774
|
+
`${escapeXml(post.publishedAtFormatted)}</time></a></small></p>`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
344
778
|
/**
|
|
345
779
|
* Build the full HTML content for a feed entry, including thread replies.
|
|
346
780
|
*
|
|
@@ -353,35 +787,355 @@ function buildFeedContent(
|
|
|
353
787
|
siteUrl: string,
|
|
354
788
|
permalinkUrl?: string,
|
|
355
789
|
): string {
|
|
356
|
-
const rootContent = buildSinglePostContent(post, permalinkUrl);
|
|
790
|
+
const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
|
|
357
791
|
const replies = post.threadReplies;
|
|
358
792
|
|
|
793
|
+
// A standalone post needs no marker: the entry's own `<published>` dates it,
|
|
794
|
+
// and there is no second block to tell it apart from.
|
|
359
795
|
if (!replies || replies.length === 0) {
|
|
360
796
|
return rootContent;
|
|
361
797
|
}
|
|
362
798
|
|
|
363
|
-
const
|
|
799
|
+
const rootPermalink =
|
|
800
|
+
permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
|
|
801
|
+
const parts = [rootContent, renderPostTailMeta(post, rootPermalink)];
|
|
364
802
|
|
|
365
803
|
for (const reply of replies) {
|
|
366
804
|
const replyPermalink = new URL(reply.permalink, siteUrl).toString();
|
|
805
|
+
// Kept as the visual joint the site draws between cards. It is no longer
|
|
806
|
+
// the structural one — the tail meta above is.
|
|
367
807
|
parts.push("<hr/>");
|
|
368
808
|
parts.push(
|
|
369
|
-
|
|
809
|
+
buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
|
|
810
|
+
);
|
|
811
|
+
parts.push(renderPostTailMeta(reply, replyPermalink));
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return parts.join("\n");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Build the HTML for a feed entry's `<summary>` — the entry's text, as the
|
|
819
|
+
* timeline shows it: truncated at the same boundary, quoted text and rating
|
|
820
|
+
* included, a thread folded to its root, a gap link and its newest reply.
|
|
821
|
+
*
|
|
822
|
+
* In a thread each rendered post's block closes with `renderPostTailMeta`, so
|
|
823
|
+
* a consumer can tell the root's words from the reply's and attribute either
|
|
824
|
+
* to a permalink — which is also how it lines the text up with the entry's
|
|
825
|
+
* `<media:content>`, each of which names its own post.
|
|
826
|
+
*
|
|
827
|
+
* Text only. Media is in `<content>` and, structured, in the Media RSS
|
|
828
|
+
* elements — and it has to be read from there anyway, because a feed cannot
|
|
829
|
+
* express the timeline's justified row and a consumer has to compute it from
|
|
830
|
+
* the dimensions. So the field a consumer reaches for to draw its own card
|
|
831
|
+
* carries the words, and nothing it would have to strip back out.
|
|
832
|
+
*
|
|
833
|
+
* Comes back empty for a post with no text at all — a photo with no caption —
|
|
834
|
+
* and the caller drops the element there. That is the only reason it is ever
|
|
835
|
+
* missing, which makes `summary ?? ""` the whole of a consumer's rule; Atom
|
|
836
|
+
* only mandates a summary for `src`/base64 content (RFC 4287 §4.1.2).
|
|
837
|
+
*
|
|
838
|
+
* @param entry - The entry, with its fold and the posts its summary renders
|
|
839
|
+
* @param siteUrl - Site base URL for absolute permalinks
|
|
840
|
+
* @param permalinkUrl - Absolute permalink URL for the root post
|
|
841
|
+
* @returns The entry's text as the timeline renders it, or "" when it has none
|
|
842
|
+
* @example
|
|
843
|
+
* buildFeedSummary(entry, "https://example.com", "https://example.com/hello")
|
|
844
|
+
* // "<p>Intro</p>\n<p><small><a …><time …>Mar 19, 2026</time></a></small></p>
|
|
845
|
+
* // \n<hr/>\n<p><small><a …>2 more posts</a></small></p>…"
|
|
846
|
+
*/
|
|
847
|
+
function buildFeedSummary(
|
|
848
|
+
entry: FeedEntry,
|
|
849
|
+
siteUrl: string,
|
|
850
|
+
permalinkUrl?: string,
|
|
851
|
+
): string {
|
|
852
|
+
const { post, fold, summaryPosts } = entry;
|
|
853
|
+
const rootPermalink =
|
|
854
|
+
permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
|
|
855
|
+
|
|
856
|
+
const parts: string[] = [];
|
|
857
|
+
|
|
858
|
+
const appendPost = (
|
|
859
|
+
member: PostView,
|
|
860
|
+
memberPermalink: string,
|
|
861
|
+
inline: boolean,
|
|
862
|
+
) => {
|
|
863
|
+
const markup = buildSinglePostContent(member, siteUrl, memberPermalink, {
|
|
864
|
+
inline,
|
|
865
|
+
summary: true,
|
|
866
|
+
timelineSummary: summaryPosts.get(member),
|
|
867
|
+
});
|
|
868
|
+
if (!markup) return;
|
|
869
|
+
if (parts.length > 0) parts.push("<hr/>");
|
|
870
|
+
parts.push(markup);
|
|
871
|
+
// A thread runs several posts through one field, so each block closes with
|
|
872
|
+
// its own dated permalink. A lone post needs no marker: `<published>`
|
|
873
|
+
// dates it and there is no second block to tell it apart from.
|
|
874
|
+
if (fold) parts.push(renderPostTailMeta(member, memberPermalink));
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
appendPost(post, rootPermalink, false);
|
|
878
|
+
|
|
879
|
+
if (!fold) return parts.join("\n");
|
|
880
|
+
|
|
881
|
+
const absolutePermalink = (member: PostView) =>
|
|
882
|
+
new URL(member.permalink, siteUrl).toString();
|
|
883
|
+
|
|
884
|
+
for (const reply of fold.leadingReplies) {
|
|
885
|
+
appendPost(reply, absolutePermalink(reply), true);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (fold.hiddenCount > 0 && fold.firstHiddenReply) {
|
|
889
|
+
// The same gap the site draws: a link to the first post it hides, not just
|
|
890
|
+
// a count. It matters more here — a feed has no toggle to expand context,
|
|
891
|
+
// so the link is the only way through.
|
|
892
|
+
const gapHref = escapeXml(
|
|
893
|
+
toAbsoluteFeedUrl(fold.firstHiddenReply.permalink, siteUrl),
|
|
370
894
|
);
|
|
371
|
-
|
|
895
|
+
const label =
|
|
896
|
+
fold.hiddenCount === 1 ? "1 more post" : `${fold.hiddenCount} more posts`;
|
|
897
|
+
if (parts.length > 0) parts.push("<hr/>");
|
|
898
|
+
parts.push(`<p><small><a href="${gapHref}">${label}</a></small></p>`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
for (const reply of fold.trailingReplies) {
|
|
902
|
+
appendPost(reply, absolutePermalink(reply), true);
|
|
372
903
|
}
|
|
373
904
|
|
|
905
|
+
appendPost(fold.latestReply, absolutePermalink(fold.latestReply), true);
|
|
906
|
+
|
|
374
907
|
return parts.join("\n");
|
|
375
908
|
}
|
|
376
909
|
|
|
377
|
-
|
|
378
|
-
|
|
910
|
+
/**
|
|
911
|
+
* Whether the summary's text is shorter than the post's own.
|
|
912
|
+
*
|
|
913
|
+
* `<summary>` is sent whenever an entry has text, so its presence says nothing
|
|
914
|
+
* about truncation — but a consumer drawing the timeline needs to know whether
|
|
915
|
+
* to offer the "Read more" the site offers. Covers the newest reply too, since
|
|
916
|
+
* that is the post a thread's summary shows in full.
|
|
917
|
+
*
|
|
918
|
+
* @param entry - The entry, with the posts its summary renders
|
|
919
|
+
* @returns true when some text in the summary was cut
|
|
920
|
+
* @example
|
|
921
|
+
* isEntryTruncated(longArticleEntry); // true
|
|
922
|
+
*/
|
|
923
|
+
function isEntryTruncated(entry: FeedEntry): boolean {
|
|
924
|
+
for (const summary of entry.summaryPosts.values()) {
|
|
925
|
+
if (summary?.hasMore === true) return true;
|
|
926
|
+
}
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* What one entry's elements share, derived once.
|
|
932
|
+
*
|
|
933
|
+
* `<summary>`, the `<jant:post>` rows and `<jant:truncated/>` all ask which
|
|
934
|
+
* posts the fold shows and where each is cut; `<jant:thread>` and the gap
|
|
935
|
+
* link ask for the fold; the enclosures, the Media RSS elements and the
|
|
936
|
+
* namespace declaration all ask for the attachments. Each answer used to be
|
|
937
|
+
* re-derived at every asking, and the timeline summary is the expensive one —
|
|
938
|
+
* it parses and renders the post's TipTap document — so a page of six-post
|
|
939
|
+
* threads paid three renders per post for one rendering. The entry derives
|
|
940
|
+
* them here and every element reads the result. Per request, not cached
|
|
941
|
+
* across them: the feed route's own cache is the only one.
|
|
942
|
+
*/
|
|
943
|
+
interface FeedEntry {
|
|
944
|
+
post: FeedPostView;
|
|
945
|
+
/** The thread's fold, or null when the entry is a lone post. */
|
|
946
|
+
fold: ThreadFold<PostView> | null;
|
|
947
|
+
/**
|
|
948
|
+
* The posts `<summary>` renders, root first, each with its timeline summary
|
|
949
|
+
* — null when the post has no TipTap document to truncate. The fold decides
|
|
950
|
+
* which replies survive; this is the one place that turns it into
|
|
951
|
+
* "everything with a block", which is what `folded` and `truncated` are
|
|
952
|
+
* asked about, per row and at the entry.
|
|
953
|
+
*/
|
|
954
|
+
summaryPosts: Map<PostView, TimelineSummary | null>;
|
|
955
|
+
/** Every attachment in the thread, root first, each naming its post. */
|
|
956
|
+
media: EntryMedia[];
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Derive what an entry's elements share, once.
|
|
961
|
+
*
|
|
962
|
+
* The fold has already dropped a reply that falls in two windows, so each
|
|
963
|
+
* post below appears once and its summary is rendered once.
|
|
964
|
+
*
|
|
965
|
+
* @param post - Root post view data
|
|
966
|
+
* @param siteUrl - Site base URL, for the attachments' post permalinks
|
|
967
|
+
* @returns The entry, ready for every element that describes it
|
|
968
|
+
* @example
|
|
969
|
+
* buildFeedEntry(root, "https://example.com").summaryPosts.has(reply);
|
|
970
|
+
* // whether the summary shows it
|
|
971
|
+
*/
|
|
972
|
+
function buildFeedEntry(post: FeedPostView, siteUrl: string): FeedEntry {
|
|
973
|
+
const fold = foldThreadReplies(post.threadReplies ?? []);
|
|
974
|
+
const shown: PostView[] = fold
|
|
975
|
+
? [post, ...fold.leadingReplies, ...fold.trailingReplies, fold.latestReply]
|
|
976
|
+
: [post];
|
|
977
|
+
return {
|
|
978
|
+
post,
|
|
979
|
+
fold,
|
|
980
|
+
summaryPosts: new Map(
|
|
981
|
+
shown.map((member) => [member, getTimelineSummary(member)] as const),
|
|
982
|
+
),
|
|
983
|
+
media: getEntryMedia(post, siteUrl),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Media RSS `medium` for an attachment. The vocabulary is fixed
|
|
989
|
+
* (image/audio/video/document/executable), so everything that is not a
|
|
990
|
+
* playable or visual file is a document.
|
|
991
|
+
*/
|
|
992
|
+
function getMediaRssMedium(mimeType: string): string {
|
|
993
|
+
const category = getMediaCategory(mimeType);
|
|
994
|
+
if (category === "image") return "image";
|
|
995
|
+
if (category === "video") return "video";
|
|
996
|
+
if (category === "audio") return "audio";
|
|
997
|
+
return "document";
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Render one attachment as `<media:content>`, carrying the dimensions,
|
|
1002
|
+
* duration, size, and description that Atom's `<link rel="enclosure">` has
|
|
1003
|
+
* nowhere to put.
|
|
1004
|
+
*
|
|
1005
|
+
* @param item - Attachment view data
|
|
1006
|
+
* @param siteUrl - Site base URL, for absolutizing stored paths
|
|
1007
|
+
* @param inThread - Whether the entry carries a whole thread, in which case
|
|
1008
|
+
* every attachment names its post
|
|
1009
|
+
* @returns A `<media:content>` element, newline-prefixed for entry indentation
|
|
1010
|
+
* @example
|
|
1011
|
+
* renderMediaRssContent(photo, "https://example.com", false)
|
|
1012
|
+
* // '\n <media:content url="…" type="image/jpeg" medium="image" …/>'
|
|
1013
|
+
*/
|
|
1014
|
+
function renderMediaRssContent(
|
|
1015
|
+
{ item, postPermalinkUrl }: EntryMedia,
|
|
1016
|
+
siteUrl: string,
|
|
1017
|
+
inThread: boolean,
|
|
1018
|
+
): string {
|
|
1019
|
+
const fileUrl = toAbsoluteFeedUrl(item.url, siteUrl);
|
|
1020
|
+
const attrs = [
|
|
1021
|
+
`url="${escapeXml(fileUrl)}"`,
|
|
1022
|
+
`type="${escapeXml(item.mimeType)}"`,
|
|
1023
|
+
`medium="${getMediaRssMedium(item.mimeType)}"`,
|
|
1024
|
+
];
|
|
1025
|
+
|
|
1026
|
+
if (item.size != null && item.size > 0) attrs.push(`fileSize="${item.size}"`);
|
|
1027
|
+
if (item.width != null && item.width > 0) attrs.push(`width="${item.width}"`);
|
|
1028
|
+
if (item.height != null && item.height > 0) {
|
|
1029
|
+
attrs.push(`height="${item.height}"`);
|
|
1030
|
+
}
|
|
1031
|
+
if (item.durationSeconds != null && item.durationSeconds > 0) {
|
|
1032
|
+
attrs.push(`duration="${Math.round(item.durationSeconds)}"`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Where to send someone who clicks the attachment, after the Media RSS
|
|
1036
|
+
// attributes so the foreign one stays out of their way. Only carried when it
|
|
1037
|
+
// is not the file itself — today that means text attachments — so a consumer
|
|
1038
|
+
// reads `jant:page ?? url` and needs no rule per attachment kind.
|
|
1039
|
+
const pageUrl = getMediaPageUrl(item, siteUrl, postPermalinkUrl);
|
|
1040
|
+
if (pageUrl !== fileUrl) {
|
|
1041
|
+
attrs.push(`jant:page="${escapeXml(pageUrl)}"`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Which post in the thread carries this file. An entry's media is drawn from
|
|
1045
|
+
// the whole thread, so without this the list is flat and a consumer laying
|
|
1046
|
+
// out the folded card would hang a hidden reply's photo under the root.
|
|
1047
|
+
//
|
|
1048
|
+
// In a thread every attachment carries it, the root's included. `jant:page`
|
|
1049
|
+
// earns its omission because guessing wrong there only lands you on the file
|
|
1050
|
+
// instead of its page; guessing wrong here hangs a photo under the wrong
|
|
1051
|
+
// post. A bare `<media:content>` should answer "whose is this" by itself
|
|
1052
|
+
// rather than through a rule about what a missing attribute means.
|
|
1053
|
+
//
|
|
1054
|
+
// A lone post's entry has exactly one post, so nothing there is ambiguous
|
|
1055
|
+
// and the attribute would only repeat `<id>` on every file.
|
|
1056
|
+
if (inThread) {
|
|
1057
|
+
attrs.push(`jant:post="${escapeXml(postPermalinkUrl)}"`);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const children: string[] = [];
|
|
1061
|
+
const title = item.originalName?.trim();
|
|
1062
|
+
if (title) {
|
|
1063
|
+
children.push(
|
|
1064
|
+
`<media:title type="plain">${escapeXml(title)}</media:title>`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
// Alt text describes a picture; a text attachment has none but carries an
|
|
1068
|
+
// excerpt, which is what the site prints on its card. One slot, because to a
|
|
1069
|
+
// consumer both answer "what is this file".
|
|
1070
|
+
const description = item.altText?.trim() || item.summary?.trim();
|
|
1071
|
+
if (description) {
|
|
1072
|
+
children.push(
|
|
1073
|
+
`<media:description type="plain">${escapeXml(description)}</media:description>`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const thumbnail = (item.posterUrl || item.thumbnailUrl)?.trim();
|
|
1077
|
+
if (thumbnail && thumbnail !== item.url) {
|
|
1078
|
+
children.push(
|
|
1079
|
+
`<media:thumbnail url="${escapeXml(toAbsoluteFeedUrl(thumbnail, siteUrl))}"/>`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return children.length > 0
|
|
1084
|
+
? `\n <media:content ${attrs.join(" ")}>${children.join("")}</media:content>`
|
|
1085
|
+
: `\n <media:content ${attrs.join(" ")}/>`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* One attachment plus the post it hangs off, since an entry's media is drawn
|
|
1090
|
+
* from a whole thread and a text attachment's page lives under its own post.
|
|
1091
|
+
*/
|
|
1092
|
+
interface EntryMedia {
|
|
1093
|
+
item: MediaView;
|
|
1094
|
+
/** Absolute permalink of the post carrying this attachment. */
|
|
1095
|
+
postPermalinkUrl: string;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function getEntryMedia(post: FeedPostView, siteUrl: string): EntryMedia[] {
|
|
1099
|
+
const collect = (from: PostView): EntryMedia[] => {
|
|
1100
|
+
const postPermalinkUrl = new URL(from.permalink, siteUrl).toString();
|
|
1101
|
+
return from.media.map((item) => ({ item, postPermalinkUrl }));
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
const media = collect(post);
|
|
379
1105
|
for (const reply of post.threadReplies ?? []) {
|
|
380
|
-
media.push(...reply
|
|
1106
|
+
media.push(...collect(reply));
|
|
381
1107
|
}
|
|
382
1108
|
return media;
|
|
383
1109
|
}
|
|
384
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* The URL a browser can usefully open for an attachment.
|
|
1113
|
+
*
|
|
1114
|
+
* For a picture, a clip or a PDF that is the file itself. A text attachment's
|
|
1115
|
+
* file is markdown or plain text, which a browser downloads or dumps unstyled,
|
|
1116
|
+
* so it points at the page that renders it instead. Consumers laying out their
|
|
1117
|
+
* own card follow this rather than `url`, which stays the file for fetching
|
|
1118
|
+
* and for the enclosure.
|
|
1119
|
+
*
|
|
1120
|
+
* @param item - Attachment view data
|
|
1121
|
+
* @param siteUrl - Site base URL, for absolutizing a stored path
|
|
1122
|
+
* @param postPermalinkUrl - Absolute permalink of the post carrying it
|
|
1123
|
+
* @returns An absolute URL worth opening in a browser
|
|
1124
|
+
* @example
|
|
1125
|
+
* getMediaPageUrl(note, "https://example.com", "https://example.com/hn2v7")
|
|
1126
|
+
* // "https://example.com/hn2v7/text/med_01m13xhg"
|
|
1127
|
+
*/
|
|
1128
|
+
function getMediaPageUrl(
|
|
1129
|
+
item: MediaView,
|
|
1130
|
+
siteUrl: string,
|
|
1131
|
+
postPermalinkUrl?: string,
|
|
1132
|
+
): string {
|
|
1133
|
+
if (getMediaCategory(item.mimeType) === "text" && postPermalinkUrl) {
|
|
1134
|
+
return `${postPermalinkUrl}/text/${item.id}`;
|
|
1135
|
+
}
|
|
1136
|
+
return toAbsoluteFeedUrl(item.url, siteUrl);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
385
1139
|
/**
|
|
386
1140
|
* Default Atom feed renderer.
|
|
387
1141
|
*
|
|
@@ -389,11 +1143,28 @@ function getEntryMedia(post: FeedPostView): MediaView[] {
|
|
|
389
1143
|
* @returns Atom XML string
|
|
390
1144
|
*/
|
|
391
1145
|
export function defaultFeedRenderer(data: FeedData): string {
|
|
392
|
-
const {
|
|
1146
|
+
const {
|
|
1147
|
+
siteName,
|
|
1148
|
+
siteDescription,
|
|
1149
|
+
siteUrl,
|
|
1150
|
+
siteLanguage,
|
|
1151
|
+
title,
|
|
1152
|
+
selfUrl,
|
|
1153
|
+
posts,
|
|
1154
|
+
siteIconUrl,
|
|
1155
|
+
discover,
|
|
1156
|
+
discoverFeedUrl,
|
|
1157
|
+
discoverFeaturedFeedUrl,
|
|
1158
|
+
discoverStatusUrl,
|
|
1159
|
+
languageAlternates,
|
|
1160
|
+
} = data;
|
|
393
1161
|
const feedTitle = title ?? siteName;
|
|
394
1162
|
|
|
395
|
-
const
|
|
396
|
-
|
|
1163
|
+
const feedEntries = posts.map((post) => buildFeedEntry(post, siteUrl));
|
|
1164
|
+
|
|
1165
|
+
const entries = feedEntries
|
|
1166
|
+
.map((entry) => {
|
|
1167
|
+
const { post } = entry;
|
|
397
1168
|
const permalinkUrl = new URL(post.permalink, siteUrl).toString();
|
|
398
1169
|
const escapedPermalink = escapeXml(permalinkUrl);
|
|
399
1170
|
// Link-format posts point <link rel="alternate"> to the original URL
|
|
@@ -402,7 +1173,6 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
402
1173
|
? escapeXml(alternateUrl)
|
|
403
1174
|
: escapedPermalink;
|
|
404
1175
|
const title = getAtomTitle(post);
|
|
405
|
-
const summary = getFeedSummaryText(post);
|
|
406
1176
|
const publishedAt = post.feedPublishedAt ?? post.publishedAt;
|
|
407
1177
|
const updatedAt = post.feedUpdatedAt ?? post.updatedAt;
|
|
408
1178
|
|
|
@@ -411,43 +1181,217 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
411
1181
|
? `\n <link href="${escapedPermalink}" rel="related"/>`
|
|
412
1182
|
: "";
|
|
413
1183
|
|
|
414
|
-
// One <link rel="enclosure"> per attachment
|
|
415
|
-
// can fetch
|
|
416
|
-
// always known from the upload pipeline.
|
|
417
|
-
|
|
1184
|
+
// One <link rel="enclosure"> per attachment the content cannot already
|
|
1185
|
+
// show, so podcast/offline readers can fetch it. Atom omits length when
|
|
1186
|
+
// size is unknown; mimeType is always known from the upload pipeline.
|
|
1187
|
+
//
|
|
1188
|
+
// Images are excluded: the content already renders them full size inside
|
|
1189
|
+
// a link to the original, so an enclosure adds nothing a plain Atom
|
|
1190
|
+
// parser could not already see — it only asks readers with an attachment
|
|
1191
|
+
// shelf to list the picture a second time under the post. Every podcast
|
|
1192
|
+
// feed works this way, enclosing the audio it cannot inline while
|
|
1193
|
+
// leaving its inline show-note images alone.
|
|
1194
|
+
const isThreadEntry = (post.threadReplies?.length ?? 0) > 0;
|
|
1195
|
+
const enclosureLinks = entry.media
|
|
1196
|
+
.map(({ item }) => item)
|
|
1197
|
+
.filter((m) => getMediaCategory(m.mimeType) !== "image")
|
|
418
1198
|
.map((m) => {
|
|
419
1199
|
const lengthAttr =
|
|
420
1200
|
m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
|
|
421
1201
|
const titleAttr = m.originalName
|
|
422
1202
|
? ` title="${escapeXml(m.originalName)}"`
|
|
423
1203
|
: "";
|
|
424
|
-
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
|
|
1204
|
+
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
|
|
425
1205
|
})
|
|
426
1206
|
.join("");
|
|
427
1207
|
|
|
1208
|
+
// The same attachments again, with the metadata Atom's `<link>` cannot
|
|
1209
|
+
// carry — pixel dimensions, duration, alt text, poster. Enclosure stays
|
|
1210
|
+
// above because it is what a plain Atom parser reads; this is the layer
|
|
1211
|
+
// a reader that knows Media RSS can lay out without fetching the file.
|
|
1212
|
+
const mediaContentElements = entry.media
|
|
1213
|
+
.map((m) => renderMediaRssContent(m, siteUrl, isThreadEntry))
|
|
1214
|
+
.join("");
|
|
1215
|
+
|
|
1216
|
+
// The entry's representative image, for readers that lay out cards or a
|
|
1217
|
+
// grid. A link post's preview is a thumbnail of someone else's page, not
|
|
1218
|
+
// a file the author published, so it gets no `rel="enclosure"` — that
|
|
1219
|
+
// would tell podcast and download clients to fetch it as content. Left
|
|
1220
|
+
// out, a reader has to scrape the first `<img>` out of the content HTML.
|
|
1221
|
+
// Dimensions are unknown: the URL is a scale-down transform of a stored
|
|
1222
|
+
// key, and Media RSS makes width/height optional.
|
|
1223
|
+
const previewImageUrl = post.previewImageUrl?.trim();
|
|
1224
|
+
const thumbnailElement = previewImageUrl
|
|
1225
|
+
? `\n <media:thumbnail url="${escapeXml(toAbsoluteFeedUrl(previewImageUrl, siteUrl))}"/>`
|
|
1226
|
+
: "";
|
|
1227
|
+
|
|
1228
|
+
// What kind of post this is, in Jant's own namespace. Atom has no field
|
|
1229
|
+
// for it, and `<category>` is the wrong place: a reader would show
|
|
1230
|
+
// "quote" as a tag the author never wrote. A namespaced element is
|
|
1231
|
+
// invisible to readers that do not know it.
|
|
1232
|
+
//
|
|
1233
|
+
// A link post is already tellable from the `rel="related"` above, but a
|
|
1234
|
+
// quote and an untitled note are identical from the feed alone — both
|
|
1235
|
+
// carry an empty `<title>` and a body — so anything reading feeds had to
|
|
1236
|
+
// fetch the post's page to tell them apart. It rides on every entry
|
|
1237
|
+
// rather than only the ambiguous ones, because a consumer should be able
|
|
1238
|
+
// to read one field instead of inferring three cases.
|
|
1239
|
+
//
|
|
1240
|
+
// Core's own three formats, not a consumer's rendering distinctions: a
|
|
1241
|
+
// titled note is still a note here, and whether that is drawn as an
|
|
1242
|
+
// article is the reader's call, made from this and `<title>`.
|
|
1243
|
+
const formatElement = `\n <jant:format>${escapeXml(post.format)}</jant:format>`;
|
|
1244
|
+
|
|
1245
|
+
// The post's own ID, which never changes. `<id>` is the permalink, and
|
|
1246
|
+
// stays so — changing it would make every subscriber's reader show the
|
|
1247
|
+
// feed again — but a permalink moves when a slug is renamed or the site
|
|
1248
|
+
// changes domain. A consumer that has to recognise the same post across
|
|
1249
|
+
// either keys on this, and it is what the Discover status endpoint takes.
|
|
1250
|
+
const idElement = `\n <jant:id>${escapeXml(post.id)}</jant:id>`;
|
|
1251
|
+
|
|
1252
|
+
// The site's own tags for this post, which Atom has a field for — unlike
|
|
1253
|
+
// `<jant:format>` above, a collection is a label the author chose, so
|
|
1254
|
+
// showing it as one is right. Replies inherit their root's, and the site
|
|
1255
|
+
// prints them on the root alone, so they ride on the entry once.
|
|
1256
|
+
// `jant:page` because a single collection lives in the root URL
|
|
1257
|
+
// namespace and a site path prefix makes it unguessable from the term.
|
|
1258
|
+
const categoryElements = post.collections
|
|
1259
|
+
.map(
|
|
1260
|
+
(collection) =>
|
|
1261
|
+
`\n <category term="${escapeXml(collection.slug)}" label="${escapeXml(collection.title)}" jant:page="${escapeXml(toAbsoluteFeedUrl(collection.url, siteUrl))}"/>`,
|
|
1262
|
+
)
|
|
1263
|
+
.join("");
|
|
1264
|
+
|
|
1265
|
+
// Whether the summary's text was cut. `<summary>` is present whenever
|
|
1266
|
+
// there is text, so only this says whether the site would offer a
|
|
1267
|
+
// "Read more" — a consumer drawing its own timeline cannot tell without
|
|
1268
|
+
// fetching and comparing the content.
|
|
1269
|
+
const truncatedElement = isEntryTruncated(entry)
|
|
1270
|
+
? "\n <jant:truncated/>"
|
|
1271
|
+
: "";
|
|
1272
|
+
|
|
1273
|
+
// Whether this entry is a whole thread, and how much of it the summary
|
|
1274
|
+
// folded away. The text says so only in prose a consumer would have to
|
|
1275
|
+
// parse.
|
|
1276
|
+
const threadElement = renderThreadElement(entry, siteUrl);
|
|
1277
|
+
|
|
1278
|
+
// `<summary>` is the entry's text, `<content>` the post's full page. It
|
|
1279
|
+
// is present whenever there is text — a bare note repeats itself here,
|
|
1280
|
+
// which costs the words and nothing else, and buys a field that means one
|
|
1281
|
+
// thing on its own rather than one defined by what content happens to
|
|
1282
|
+
// hold. Missing therefore says exactly one thing: this post has no text.
|
|
1283
|
+
const contentMarkup = buildFeedContent(post, siteUrl, permalinkUrl);
|
|
1284
|
+
const summaryMarkup = buildFeedSummary(entry, siteUrl, permalinkUrl);
|
|
1285
|
+
const summaryElement = summaryMarkup
|
|
1286
|
+
? `\n <summary type="html"><![CDATA[${escapeCdata(summaryMarkup)}]]></summary>`
|
|
1287
|
+
: "";
|
|
1288
|
+
|
|
428
1289
|
return `
|
|
429
1290
|
<entry>
|
|
430
1291
|
<title>${escapeXml(title)}</title>
|
|
431
1292
|
<link href="${alternateLink}" rel="alternate"/>${relatedLink}${enclosureLinks}
|
|
432
|
-
<id>${escapedPermalink}</id
|
|
1293
|
+
<id>${escapedPermalink}</id>${idElement}
|
|
433
1294
|
<published>${publishedAt}</published>
|
|
434
|
-
<updated>${updatedAt}</updated
|
|
435
|
-
<
|
|
436
|
-
<content type="html"><![CDATA[${escapeCdata(buildFeedContent(post, siteUrl, permalinkUrl))}]]></content>
|
|
1295
|
+
<updated>${updatedAt}</updated>${formatElement}${threadElement}${truncatedElement}${categoryElements}${thumbnailElement}${mediaContentElements}${summaryElement}
|
|
1296
|
+
<content type="html"><![CDATA[${escapeCdata(contentMarkup)}]]></content>
|
|
437
1297
|
</entry>`;
|
|
438
1298
|
})
|
|
439
1299
|
.join("");
|
|
440
1300
|
|
|
441
|
-
|
|
1301
|
+
// The feed's own <updated> is the newest entry timestamp, not the current
|
|
1302
|
+
// time — stamping "now" on every render tells every reader the feed changed
|
|
1303
|
+
// on every poll, which is both untrue and useless for change detection.
|
|
1304
|
+
// ISO 8601 from toISOString() is UTC and zero-padded, so lexical max is
|
|
1305
|
+
// chronological max. Empty feeds fall back to now, since Atom requires it.
|
|
1306
|
+
const feedUpdated =
|
|
1307
|
+
posts
|
|
1308
|
+
.map((post) => post.feedUpdatedAt ?? post.updatedAt)
|
|
1309
|
+
.reduce<string | null>(
|
|
1310
|
+
(latest, value) => (latest === null || value > latest ? value : latest),
|
|
1311
|
+
null,
|
|
1312
|
+
) ?? new Date().toISOString();
|
|
1313
|
+
|
|
1314
|
+
// A feed states its own language: at the root that is the site's, and in a
|
|
1315
|
+
// language view it is that view's, so a reader subscribing to /en/feed gets
|
|
1316
|
+
// a feed their reader can label and their screen reader can pronounce.
|
|
1317
|
+
const langAttr = siteLanguage ? ` xml:lang="${escapeXml(siteLanguage)}"` : "";
|
|
1318
|
+
|
|
1319
|
+
// The jant namespace is only declared when something in it is emitted, the
|
|
1320
|
+
// same way the sitemap declares xhtml only for alternates. What lives in it:
|
|
1321
|
+
// the Discover declaration below, and every entry's format and ID — so only
|
|
1322
|
+
// a feed with neither, which means an empty feed on a site that has never
|
|
1323
|
+
// answered Discover, leaves it out.
|
|
1324
|
+
const jantNs =
|
|
1325
|
+
discover || posts.length > 0
|
|
1326
|
+
? ` xmlns:jant="${escapeXml(DISCOVER_NAMESPACE_URI)}"`
|
|
1327
|
+
: "";
|
|
1328
|
+
|
|
1329
|
+
// Media RSS rides along only when an entry actually carries an attachment or
|
|
1330
|
+
// a representative image, on the same rule as the jant namespace above.
|
|
1331
|
+
const mediaNs = feedEntries.some(
|
|
1332
|
+
(entry) =>
|
|
1333
|
+
entry.media.length > 0 || Boolean(entry.post.previewImageUrl?.trim()),
|
|
1334
|
+
)
|
|
1335
|
+
? ` xmlns:media="${escapeXml(MEDIA_RSS_NAMESPACE_URI)}"`
|
|
1336
|
+
: "";
|
|
1337
|
+
|
|
1338
|
+
// Sibling-language feeds. `type` is carried because Atom forbids two
|
|
1339
|
+
// `rel="alternate"` links sharing a type/hreflang pair, and the site's own
|
|
1340
|
+
// HTML alternate above has neither.
|
|
1341
|
+
const alternateFeedLinks = (languageAlternates ?? [])
|
|
1342
|
+
.map(
|
|
1343
|
+
(alternate) =>
|
|
1344
|
+
`\n <link href="${escapeXml(alternate.href)}" rel="alternate" type="application/atom+xml" hreflang="${escapeXml(alternate.hreflang)}"/>`,
|
|
1345
|
+
)
|
|
1346
|
+
.join("");
|
|
1347
|
+
|
|
1348
|
+
// The Discover declaration. It rides in every feed the site emits, so a
|
|
1349
|
+
// crawler holding any one of them learns the site's answer and which feeds
|
|
1350
|
+
// to poll for it. `feed` is omitted when the site is not listed — there is
|
|
1351
|
+
// nothing to point at. `featured` names the featured feed beside it under
|
|
1352
|
+
// `latest`, so a directory can keep featured posts and everything else on
|
|
1353
|
+
// separate lists without guessing the address from the site root. `status`
|
|
1354
|
+
// is where the directory asks whether posts it already holds are still in
|
|
1355
|
+
// those feeds, which the feeds themselves cannot say about a post that has
|
|
1356
|
+
// merely been pushed past their length.
|
|
1357
|
+
const discoverFeedAttr = discoverFeedUrl
|
|
1358
|
+
? ` feed="${escapeXml(discoverFeedUrl)}"`
|
|
1359
|
+
: "";
|
|
1360
|
+
const discoverFeaturedAttr = discoverFeaturedFeedUrl
|
|
1361
|
+
? ` featured="${escapeXml(discoverFeaturedFeedUrl)}"`
|
|
1362
|
+
: "";
|
|
1363
|
+
const discoverStatusAttr = discoverStatusUrl
|
|
1364
|
+
? ` status="${escapeXml(discoverStatusUrl)}"`
|
|
1365
|
+
: "";
|
|
1366
|
+
const discoverElement = discover
|
|
1367
|
+
? `\n <jant:discover${discoverFeedAttr}${discoverFeaturedAttr}${discoverStatusAttr}>${escapeXml(discover)}</jant:discover>`
|
|
1368
|
+
: "";
|
|
1369
|
+
|
|
1370
|
+
// The feed's title is composed — "<site> - Latest posts" — because a reader's
|
|
1371
|
+
// sidebar sorts by it and one site's feeds should stay together there. That
|
|
1372
|
+
// leaves nothing carrying the site's own name, which is what a directory
|
|
1373
|
+
// needs to label a blog. `atom:author` is where a single-author blog's name
|
|
1374
|
+
// belongs anyway, and every feed reader already knows what to do with it.
|
|
1375
|
+
const authorBlock = siteName
|
|
1376
|
+
? `\n <author><name>${escapeXml(siteName)}</name></author>`
|
|
1377
|
+
: "";
|
|
1378
|
+
|
|
1379
|
+
// `atom:icon` is the site's avatar. A reader puts it in its sidebar, and a
|
|
1380
|
+
// directory listing this blog has nowhere else to read one from — the feed
|
|
1381
|
+
// is the only machine-readable surface it is guaranteed to have fetched.
|
|
1382
|
+
// Atom requires an IRI, so a stored path is resolved against the site URL.
|
|
1383
|
+
const iconBlock = siteIconUrl
|
|
1384
|
+
? `\n <icon>${escapeXml(toAbsoluteFeedUrl(siteIconUrl, siteUrl))}</icon>`
|
|
1385
|
+
: "";
|
|
442
1386
|
|
|
443
1387
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
444
|
-
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
1388
|
+
<feed xmlns="http://www.w3.org/2005/Atom"${jantNs}${mediaNs}${langAttr}>
|
|
445
1389
|
<title>${escapeXml(feedTitle)}</title>
|
|
446
|
-
<subtitle>${escapeXml(siteDescription)}</subtitle
|
|
1390
|
+
<subtitle>${escapeXml(siteDescription)}</subtitle>${authorBlock}${iconBlock}
|
|
447
1391
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
448
|
-
<link href="${escapeXml(selfUrl)}" rel="self"
|
|
1392
|
+
<link href="${escapeXml(selfUrl)}" rel="self"/>${alternateFeedLinks}
|
|
449
1393
|
<id>${escapeXml(selfUrl)}</id>
|
|
450
|
-
<updated>${
|
|
1394
|
+
<updated>${feedUpdated}</updated>${discoverElement}
|
|
451
1395
|
${entries}
|
|
452
1396
|
</feed>`;
|
|
453
1397
|
}
|
|
@@ -465,15 +1409,15 @@ export interface SitemapUrlEntry {
|
|
|
465
1409
|
/** ISO date (YYYY-MM-DD) or full ISO datetime */
|
|
466
1410
|
lastmod?: string;
|
|
467
1411
|
changefreq?:
|
|
468
|
-
| "
|
|
469
|
-
| "hourly"
|
|
470
|
-
| "daily"
|
|
471
|
-
| "weekly"
|
|
472
|
-
| "monthly"
|
|
473
|
-
| "yearly"
|
|
474
|
-
| "never";
|
|
1412
|
+
"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
475
1413
|
/** "0.0" – "1.0" */
|
|
476
1414
|
priority?: string;
|
|
1415
|
+
/**
|
|
1416
|
+
* Other-language versions of this URL. Sitemap `hreflang` groups must be
|
|
1417
|
+
* reciprocal and self-inclusive, so this lists every member of the group,
|
|
1418
|
+
* this URL included.
|
|
1419
|
+
*/
|
|
1420
|
+
alternates?: LanguageAlternate[];
|
|
477
1421
|
}
|
|
478
1422
|
|
|
479
1423
|
/** One `<sitemap>` entry inside a `<sitemapindex>`. */
|
|
@@ -502,12 +1446,23 @@ export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
|
|
|
502
1446
|
if (entry.priority) {
|
|
503
1447
|
parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
|
|
504
1448
|
}
|
|
1449
|
+
for (const alternate of entry.alternates ?? []) {
|
|
1450
|
+
parts.push(
|
|
1451
|
+
` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${escapeXml(alternate.href)}"/>`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
505
1454
|
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
506
1455
|
})
|
|
507
1456
|
.join("\n");
|
|
508
1457
|
|
|
1458
|
+
// The xhtml namespace is only meaningful for `<xhtml:link>` alternates, so
|
|
1459
|
+
// it is declared only when some URL carries them.
|
|
1460
|
+
const xhtmlNs = entries.some((entry) => entry.alternates?.length)
|
|
1461
|
+
? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"'
|
|
1462
|
+
: "";
|
|
1463
|
+
|
|
509
1464
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
510
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
1465
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>
|
|
511
1466
|
${urls}
|
|
512
1467
|
</urlset>`;
|
|
513
1468
|
}
|