@jant/core 0.6.16 → 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 +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- 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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- 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__/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 +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- 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 +23 -0
- 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__/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 +28 -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 +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- 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-types.ts +75 -1
- 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 +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- 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 +1086 -282
- 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 +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- 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/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/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-toolbar.ts +22 -9
- package/src/client/toast.ts +13 -0
- 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__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -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/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 +21 -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/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 +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- 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 +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- 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 +1526 -15
- 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__/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 +70 -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__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- 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 +926 -73
- 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 +16 -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/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-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 +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -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__/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 +159 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +23 -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 +5 -0
- 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/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 +370 -6
- 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__/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__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +471 -369
- 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 +178 -17
- 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 +108 -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.test.ts +46 -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 +331 -46
- 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 +278 -19
- 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 +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -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 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1087 -7219
- 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 +87 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- 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/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- 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 +282 -289
- 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 +65 -9
- 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/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 +20 -11
- 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/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.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-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- 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,10 +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";
|
|
14
22
|
import { getLinkPreviewProviderLabel } from "./link-preview.js";
|
|
15
23
|
import { extractDisplayDomain } from "./url.js";
|
|
16
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/";
|
|
17
35
|
|
|
18
36
|
/**
|
|
19
37
|
* Escape special XML characters.
|
|
@@ -143,6 +161,20 @@ interface SinglePostContentOptions {
|
|
|
143
161
|
* through Atom fields.
|
|
144
162
|
*/
|
|
145
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;
|
|
146
178
|
}
|
|
147
179
|
|
|
148
180
|
function renderLinkedText(text: string, href?: string): string {
|
|
@@ -150,7 +182,37 @@ function renderLinkedText(text: string, href?: string): string {
|
|
|
150
182
|
return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
|
|
151
183
|
}
|
|
152
184
|
|
|
153
|
-
|
|
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(
|
|
154
216
|
post: PostView,
|
|
155
217
|
permalinkUrl?: string,
|
|
156
218
|
): string[] {
|
|
@@ -179,6 +241,30 @@ function renderInlinePostHeader(
|
|
|
179
241
|
return parts;
|
|
180
242
|
}
|
|
181
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
|
+
|
|
182
268
|
/**
|
|
183
269
|
* Render a star rating as HTML for feed content.
|
|
184
270
|
*/
|
|
@@ -283,7 +369,6 @@ function renderMediaItem(
|
|
|
283
369
|
): string {
|
|
284
370
|
const category = getMediaCategory(item.mimeType);
|
|
285
371
|
const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
|
|
286
|
-
const name = item.originalName ?? "";
|
|
287
372
|
const altText = item.altText ?? "";
|
|
288
373
|
const caption = item.altText?.trim() || "";
|
|
289
374
|
const meta = getMediaMeta(item);
|
|
@@ -300,22 +385,38 @@ function renderMediaItem(
|
|
|
300
385
|
}
|
|
301
386
|
|
|
302
387
|
if (category === "video") {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
: "";
|
|
307
407
|
const dims =
|
|
308
408
|
item.width && item.height
|
|
309
409
|
? ` width="${item.width}" height="${item.height}"`
|
|
310
410
|
: "";
|
|
311
|
-
// Prefix the caption with a ▶ glyph as a video cue. A CSS overlay would
|
|
312
|
-
// be stripped by most feed-reader sanitizers, so a plain-text play
|
|
313
|
-
// character is the only marker that renders reliably everywhere. Link
|
|
314
|
-
// only the "Watch video" action label (so it's clickable like the
|
|
315
|
-
// thumbnail and reads cleanly to screen readers); metadata stays outside
|
|
316
|
-
// the link in parens, matching the audio/text/document attachment style.
|
|
317
411
|
const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
|
|
318
|
-
|
|
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
|
+
);
|
|
319
420
|
}
|
|
320
421
|
|
|
321
422
|
if (category === "audio") {
|
|
@@ -325,9 +426,9 @@ function renderMediaItem(
|
|
|
325
426
|
}
|
|
326
427
|
|
|
327
428
|
if (category === "text") {
|
|
328
|
-
const previewHref =
|
|
329
|
-
|
|
330
|
-
|
|
429
|
+
const previewHref = escapeXml(
|
|
430
|
+
getMediaPageUrl(item, siteUrl, postPermalinkUrl),
|
|
431
|
+
);
|
|
331
432
|
const linkText = buildAttachmentLinkText(item, "Attached text");
|
|
332
433
|
// Prefer character count over byte size — more meaningful for text.
|
|
333
434
|
const textMeta =
|
|
@@ -356,9 +457,64 @@ function renderMediaForFeed(
|
|
|
356
457
|
postPermalinkUrl?: string,
|
|
357
458
|
): string {
|
|
358
459
|
if (media.length === 0) return "";
|
|
359
|
-
|
|
460
|
+
const items = media
|
|
360
461
|
.map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
|
|
361
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
|
+
});
|
|
362
518
|
}
|
|
363
519
|
|
|
364
520
|
/**
|
|
@@ -377,51 +533,88 @@ function buildSinglePostContent(
|
|
|
377
533
|
const parts: string[] = [];
|
|
378
534
|
|
|
379
535
|
if (options.inline) {
|
|
380
|
-
|
|
536
|
+
const header = renderInlinePostHeader(post, permalinkUrl);
|
|
537
|
+
if (header) parts.push(header);
|
|
381
538
|
}
|
|
382
539
|
|
|
540
|
+
let quoteRendered = false;
|
|
383
541
|
if (post.format === "quote" && post.quoteText) {
|
|
384
542
|
const sourceName = post.title || "";
|
|
385
543
|
const sourceUrl = post.url || "";
|
|
386
544
|
const attribution = sourceName || sourceUrl;
|
|
387
545
|
const cite = sourceUrl ? ` cite="${escapeXml(sourceUrl)}"` : "";
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (attribution) {
|
|
392
|
-
const source = sourceUrl
|
|
546
|
+
const quoteHtml = renderPlainTextHtml(post.quoteText);
|
|
547
|
+
const source = attribution
|
|
548
|
+
? sourceUrl
|
|
393
549
|
? `<a href="${escapeXml(sourceUrl)}">${escapeXml(sourceName || extractDisplayDomain(sourceUrl) || sourceUrl)}</a>`
|
|
394
|
-
: 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) {
|
|
395
562
|
parts.push(`<p>— ${source}</p>`);
|
|
396
563
|
}
|
|
397
564
|
}
|
|
398
565
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
+
}
|
|
402
573
|
}
|
|
403
574
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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));
|
|
408
588
|
}
|
|
409
589
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
}
|
|
413
599
|
}
|
|
414
600
|
|
|
415
601
|
if (post.rating && post.rating > 0) {
|
|
416
602
|
parts.push(renderRatingHtml(post.rating));
|
|
417
603
|
}
|
|
418
604
|
|
|
419
|
-
|
|
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) {
|
|
420
611
|
parts.push(`<p>${escapeXml(getFeedSummaryText(post))}</p>`);
|
|
421
612
|
}
|
|
422
613
|
|
|
423
|
-
// For link posts, append a ★ permalink back to the blog post (Daring Fireball
|
|
424
|
-
|
|
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) {
|
|
425
618
|
parts.push(
|
|
426
619
|
`<p><a href="${escapeXml(permalinkUrl)}" title="Permalink"> ★ </a></p>`,
|
|
427
620
|
);
|
|
@@ -430,6 +623,158 @@ function buildSinglePostContent(
|
|
|
430
623
|
return parts.join("\n");
|
|
431
624
|
}
|
|
432
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
|
+
|
|
433
778
|
/**
|
|
434
779
|
* Build the full HTML content for a feed entry, including thread replies.
|
|
435
780
|
*
|
|
@@ -445,34 +790,352 @@ function buildFeedContent(
|
|
|
445
790
|
const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
|
|
446
791
|
const replies = post.threadReplies;
|
|
447
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.
|
|
448
795
|
if (!replies || replies.length === 0) {
|
|
449
796
|
return rootContent;
|
|
450
797
|
}
|
|
451
798
|
|
|
452
|
-
const
|
|
799
|
+
const rootPermalink =
|
|
800
|
+
permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
|
|
801
|
+
const parts = [rootContent, renderPostTailMeta(post, rootPermalink)];
|
|
453
802
|
|
|
454
803
|
for (const reply of replies) {
|
|
455
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.
|
|
456
807
|
parts.push("<hr/>");
|
|
457
|
-
parts.push(
|
|
458
|
-
`<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
|
|
459
|
-
);
|
|
460
808
|
parts.push(
|
|
461
809
|
buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
|
|
462
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),
|
|
894
|
+
);
|
|
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>`);
|
|
463
899
|
}
|
|
464
900
|
|
|
901
|
+
for (const reply of fold.trailingReplies) {
|
|
902
|
+
appendPost(reply, absolutePermalink(reply), true);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
appendPost(fold.latestReply, absolutePermalink(fold.latestReply), true);
|
|
906
|
+
|
|
465
907
|
return parts.join("\n");
|
|
466
908
|
}
|
|
467
909
|
|
|
468
|
-
|
|
469
|
-
|
|
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);
|
|
470
1105
|
for (const reply of post.threadReplies ?? []) {
|
|
471
|
-
media.push(...reply
|
|
1106
|
+
media.push(...collect(reply));
|
|
472
1107
|
}
|
|
473
1108
|
return media;
|
|
474
1109
|
}
|
|
475
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
|
+
|
|
476
1139
|
/**
|
|
477
1140
|
* Default Atom feed renderer.
|
|
478
1141
|
*
|
|
@@ -480,11 +1143,28 @@ function getEntryMedia(post: FeedPostView): MediaView[] {
|
|
|
480
1143
|
* @returns Atom XML string
|
|
481
1144
|
*/
|
|
482
1145
|
export function defaultFeedRenderer(data: FeedData): string {
|
|
483
|
-
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;
|
|
484
1161
|
const feedTitle = title ?? siteName;
|
|
485
1162
|
|
|
486
|
-
const
|
|
487
|
-
|
|
1163
|
+
const feedEntries = posts.map((post) => buildFeedEntry(post, siteUrl));
|
|
1164
|
+
|
|
1165
|
+
const entries = feedEntries
|
|
1166
|
+
.map((entry) => {
|
|
1167
|
+
const { post } = entry;
|
|
488
1168
|
const permalinkUrl = new URL(post.permalink, siteUrl).toString();
|
|
489
1169
|
const escapedPermalink = escapeXml(permalinkUrl);
|
|
490
1170
|
// Link-format posts point <link rel="alternate"> to the original URL
|
|
@@ -493,7 +1173,6 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
493
1173
|
? escapeXml(alternateUrl)
|
|
494
1174
|
: escapedPermalink;
|
|
495
1175
|
const title = getAtomTitle(post);
|
|
496
|
-
const summary = getFeedSummaryText(post);
|
|
497
1176
|
const publishedAt = post.feedPublishedAt ?? post.publishedAt;
|
|
498
1177
|
const updatedAt = post.feedUpdatedAt ?? post.updatedAt;
|
|
499
1178
|
|
|
@@ -502,10 +1181,20 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
502
1181
|
? `\n <link href="${escapedPermalink}" rel="related"/>`
|
|
503
1182
|
: "";
|
|
504
1183
|
|
|
505
|
-
// One <link rel="enclosure"> per attachment
|
|
506
|
-
// can fetch
|
|
507
|
-
// always known from the upload pipeline.
|
|
508
|
-
|
|
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")
|
|
509
1198
|
.map((m) => {
|
|
510
1199
|
const lengthAttr =
|
|
511
1200
|
m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
|
|
@@ -516,15 +1205,95 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
516
1205
|
})
|
|
517
1206
|
.join("");
|
|
518
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
|
+
|
|
519
1289
|
return `
|
|
520
1290
|
<entry>
|
|
521
1291
|
<title>${escapeXml(title)}</title>
|
|
522
1292
|
<link href="${alternateLink}" rel="alternate"/>${relatedLink}${enclosureLinks}
|
|
523
|
-
<id>${escapedPermalink}</id
|
|
1293
|
+
<id>${escapedPermalink}</id>${idElement}
|
|
524
1294
|
<published>${publishedAt}</published>
|
|
525
|
-
<updated>${updatedAt}</updated
|
|
526
|
-
<
|
|
527
|
-
<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>
|
|
528
1297
|
</entry>`;
|
|
529
1298
|
})
|
|
530
1299
|
.join("");
|
|
@@ -537,19 +1306,92 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
537
1306
|
const feedUpdated =
|
|
538
1307
|
posts
|
|
539
1308
|
.map((post) => post.feedUpdatedAt ?? post.updatedAt)
|
|
540
|
-
.reduce<
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
+
: "";
|
|
544
1386
|
|
|
545
1387
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
546
|
-
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
1388
|
+
<feed xmlns="http://www.w3.org/2005/Atom"${jantNs}${mediaNs}${langAttr}>
|
|
547
1389
|
<title>${escapeXml(feedTitle)}</title>
|
|
548
|
-
<subtitle>${escapeXml(siteDescription)}</subtitle
|
|
1390
|
+
<subtitle>${escapeXml(siteDescription)}</subtitle>${authorBlock}${iconBlock}
|
|
549
1391
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
550
|
-
<link href="${escapeXml(selfUrl)}" rel="self"
|
|
1392
|
+
<link href="${escapeXml(selfUrl)}" rel="self"/>${alternateFeedLinks}
|
|
551
1393
|
<id>${escapeXml(selfUrl)}</id>
|
|
552
|
-
<updated>${feedUpdated}</updated
|
|
1394
|
+
<updated>${feedUpdated}</updated>${discoverElement}
|
|
553
1395
|
${entries}
|
|
554
1396
|
</feed>`;
|
|
555
1397
|
}
|
|
@@ -567,15 +1409,15 @@ export interface SitemapUrlEntry {
|
|
|
567
1409
|
/** ISO date (YYYY-MM-DD) or full ISO datetime */
|
|
568
1410
|
lastmod?: string;
|
|
569
1411
|
changefreq?:
|
|
570
|
-
| "
|
|
571
|
-
| "hourly"
|
|
572
|
-
| "daily"
|
|
573
|
-
| "weekly"
|
|
574
|
-
| "monthly"
|
|
575
|
-
| "yearly"
|
|
576
|
-
| "never";
|
|
1412
|
+
"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
577
1413
|
/** "0.0" – "1.0" */
|
|
578
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[];
|
|
579
1421
|
}
|
|
580
1422
|
|
|
581
1423
|
/** One `<sitemap>` entry inside a `<sitemapindex>`. */
|
|
@@ -604,12 +1446,23 @@ export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
|
|
|
604
1446
|
if (entry.priority) {
|
|
605
1447
|
parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
|
|
606
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
|
+
}
|
|
607
1454
|
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
608
1455
|
})
|
|
609
1456
|
.join("\n");
|
|
610
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
|
+
|
|
611
1464
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
612
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
1465
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>
|
|
613
1466
|
${urls}
|
|
614
1467
|
</urlset>`;
|
|
615
1468
|
}
|