@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
|
@@ -81,6 +81,23 @@ describe("feed renderers", () => {
|
|
|
81
81
|
expect(feedUpdated).toBe("2026-05-09T00:00:00.000Z");
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
// A directory listing this blog has no other machine-readable place to read
|
|
85
|
+
// an avatar from, so the feed is where it has to be.
|
|
86
|
+
it("emits the site avatar as an absolute atom:icon", () => {
|
|
87
|
+
const xml = defaultFeedRenderer({
|
|
88
|
+
...makeFeedData(makePostView()),
|
|
89
|
+
siteIconUrl: "/media/avatar.png",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(xml).toContain("<icon>https://example.com/media/avatar.png</icon>");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("leaves atom:icon out when the site has no avatar", () => {
|
|
96
|
+
const xml = defaultFeedRenderer(makeFeedData(makePostView()));
|
|
97
|
+
|
|
98
|
+
expect(xml).not.toContain("<icon>");
|
|
99
|
+
});
|
|
100
|
+
|
|
84
101
|
it("falls back to the render time for an empty feed", () => {
|
|
85
102
|
const xml = defaultFeedRenderer({
|
|
86
103
|
...makeFeedData(makePostView()),
|
|
@@ -107,7 +124,10 @@ describe("feed renderers", () => {
|
|
|
107
124
|
);
|
|
108
125
|
|
|
109
126
|
expect(xml).toContain("<title></title>");
|
|
110
|
-
|
|
127
|
+
// The summary carries the entry's text whether or not it was truncated.
|
|
128
|
+
expect(xml).toContain(
|
|
129
|
+
'<summary type="html"><![CDATA[<p>哈哈哈😍</p>]]></summary>',
|
|
130
|
+
);
|
|
111
131
|
expect(xml).toContain("<![CDATA[<p>哈哈哈😍</p>]]>");
|
|
112
132
|
expect(xml).not.toContain("data-jant-meta");
|
|
113
133
|
expect(xml).not.toContain('{"kind":"text"}');
|
|
@@ -186,10 +206,89 @@ describe("feed renderers", () => {
|
|
|
186
206
|
|
|
187
207
|
expect(xml).toContain("<title></title>");
|
|
188
208
|
expect(xml).toContain(
|
|
189
|
-
'<
|
|
209
|
+
'<blockquote cite="https://example.com/meditations">' +
|
|
210
|
+
"<p>What stands in the way becomes the way.</p></blockquote>",
|
|
190
211
|
);
|
|
191
212
|
expect(xml).toContain("Marcus Aurelius");
|
|
192
213
|
expect(xml).toContain("https://example.com/meditations");
|
|
214
|
+
// The quoted text is the entry's text, so it rides in the summary too.
|
|
215
|
+
expect(xml).toContain(
|
|
216
|
+
'<summary type="html"><![CDATA[<figure><blockquote cite=',
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The site keeps quote line breaks with `white-space: pre-line`; feed
|
|
221
|
+
// readers strip CSS, so the breaks have to be markup or the quote collapses
|
|
222
|
+
// into one run-on paragraph.
|
|
223
|
+
it("keeps quote line breaks as markup", () => {
|
|
224
|
+
const xml = defaultFeedRenderer(
|
|
225
|
+
makeFeedData(
|
|
226
|
+
makePostView({
|
|
227
|
+
format: "quote",
|
|
228
|
+
quoteText: "Roses are red\nViolets are blue\n\nSo it goes.",
|
|
229
|
+
summary: undefined,
|
|
230
|
+
excerpt: undefined,
|
|
231
|
+
}),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(xml).toContain(
|
|
236
|
+
"<blockquote><p>Roses are red<br/>Violets are blue</p>\n<p>So it goes.</p></blockquote>",
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("normalizes CRLF line breaks in quotes", () => {
|
|
241
|
+
const xml = defaultFeedRenderer(
|
|
242
|
+
makeFeedData(
|
|
243
|
+
makePostView({
|
|
244
|
+
format: "quote",
|
|
245
|
+
quoteText: "First line\r\nSecond line",
|
|
246
|
+
summary: undefined,
|
|
247
|
+
excerpt: undefined,
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const content =
|
|
253
|
+
/<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
254
|
+
xml,
|
|
255
|
+
)?.[1];
|
|
256
|
+
expect(content).toBe(
|
|
257
|
+
"<figure><blockquote><p>First line<br/>Second line</p></blockquote></figure>",
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("escapes quote text before inserting line-break markup", () => {
|
|
262
|
+
const xml = defaultFeedRenderer(
|
|
263
|
+
makeFeedData(
|
|
264
|
+
makePostView({
|
|
265
|
+
format: "quote",
|
|
266
|
+
quoteText: "<script>alert(1)</script>\nsecond & last",
|
|
267
|
+
summary: undefined,
|
|
268
|
+
excerpt: undefined,
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(xml).toContain(
|
|
274
|
+
"<blockquote><p><script>alert(1)</script><br/>second & last</p></blockquote>",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("falls back to the summary when a quote holds only whitespace", () => {
|
|
279
|
+
const xml = defaultFeedRenderer(
|
|
280
|
+
makeFeedData(
|
|
281
|
+
makePostView({
|
|
282
|
+
format: "quote",
|
|
283
|
+
quoteText: " \n ",
|
|
284
|
+
summary: "A quote worth keeping",
|
|
285
|
+
excerpt: undefined,
|
|
286
|
+
}),
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
expect(xml).not.toContain("<blockquote");
|
|
291
|
+
expect(xml).toContain("<p>A quote worth keeping</p>");
|
|
193
292
|
});
|
|
194
293
|
|
|
195
294
|
it("link posts point <link> to original URL with ★ permalink back to blog", () => {
|
|
@@ -233,8 +332,15 @@ describe("feed renderers", () => {
|
|
|
233
332
|
expect(xml).toContain(
|
|
234
333
|
'<figure><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"><img src="https://example.com/media/previews/youtube.jpg" alt="A useful video"/></a><figcaption><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">▶ Watch on YouTube</a></figcaption></figure>',
|
|
235
334
|
);
|
|
236
|
-
|
|
237
|
-
|
|
335
|
+
// The reference comes before the commentary. Scoped to the content: the
|
|
336
|
+
// summary carries the commentary without the preview, so a whole-document
|
|
337
|
+
// search would find the note there first.
|
|
338
|
+
const content =
|
|
339
|
+
/<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
340
|
+
xml,
|
|
341
|
+
)?.[1] ?? "";
|
|
342
|
+
expect(content.indexOf("<figure>")).toBeLessThan(
|
|
343
|
+
content.indexOf("My notes on the video."),
|
|
238
344
|
);
|
|
239
345
|
expect(xml).not.toContain("<iframe");
|
|
240
346
|
expect(xml).not.toContain('rel="enclosure"');
|
|
@@ -296,7 +402,7 @@ describe("feed renderers", () => {
|
|
|
296
402
|
expect(xml).toContain("<updated>2026-03-20T09:45:00.000Z</updated>");
|
|
297
403
|
});
|
|
298
404
|
|
|
299
|
-
it("renders thread replies with hr separator and
|
|
405
|
+
it("renders thread replies with hr separator and a dated permalink", () => {
|
|
300
406
|
const reply = makePostView({
|
|
301
407
|
id: "reply-1",
|
|
302
408
|
permalink: "/reply-1",
|
|
@@ -321,7 +427,9 @@ describe("feed renderers", () => {
|
|
|
321
427
|
|
|
322
428
|
expect(xml).toContain("<p>Root content</p>");
|
|
323
429
|
expect(xml).toContain("<hr/>");
|
|
324
|
-
expect(xml).toContain(
|
|
430
|
+
expect(xml).toContain(
|
|
431
|
+
'<time class="dt-published" datetime="2026-03-19T12:00:00.000Z">',
|
|
432
|
+
);
|
|
325
433
|
expect(xml).toContain("<p>This is a reply</p>");
|
|
326
434
|
});
|
|
327
435
|
|
|
@@ -474,12 +582,12 @@ describe("feed renderers", () => {
|
|
|
474
582
|
'<img src="https://example.com/media/photo.jpg" alt="A red bicycle" width="1200" height="800"/>',
|
|
475
583
|
);
|
|
476
584
|
expect(xml).toContain("<figcaption>A red bicycle</figcaption>");
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
);
|
|
585
|
+
// The content already shows the picture inside a link to the original, so
|
|
586
|
+
// an enclosure would only ask an attachment shelf to list it again.
|
|
587
|
+
expect(xml).not.toContain('rel="enclosure"');
|
|
480
588
|
});
|
|
481
589
|
|
|
482
|
-
it("
|
|
590
|
+
it("inlines a video player with the poster as an attribute", () => {
|
|
483
591
|
const post = makePostView({
|
|
484
592
|
media: [
|
|
485
593
|
makeMediaView({
|
|
@@ -497,12 +605,19 @@ describe("feed renderers", () => {
|
|
|
497
605
|
});
|
|
498
606
|
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
499
607
|
|
|
500
|
-
expect(xml).not.toContain("<video");
|
|
501
608
|
expect(xml).toContain(
|
|
502
|
-
'<
|
|
609
|
+
'<video controls preload="none" ' +
|
|
610
|
+
'poster="https://example.com/media/clip-poster.jpg" ' +
|
|
611
|
+
'width="1920" height="1080">' +
|
|
612
|
+
'<source src="https://example.com/media/clip.mp4" type="video/mp4"/>' +
|
|
613
|
+
"</video>",
|
|
503
614
|
);
|
|
504
|
-
// The
|
|
505
|
-
//
|
|
615
|
+
// The poster is an attribute now, not an <img> the reader might show
|
|
616
|
+
// beside the player.
|
|
617
|
+
expect(xml).not.toContain("<img");
|
|
618
|
+
// The action label stays a link outside the <video>, so it survives a
|
|
619
|
+
// sanitizer that drops the player; metadata sits outside the link in
|
|
620
|
+
// parens, matching the audio/text attachment style.
|
|
506
621
|
expect(xml).toContain(
|
|
507
622
|
'<figcaption><a href="https://example.com/media/clip.mp4">▶ Watch video</a> (0:42 · 1.1 MB)</figcaption>',
|
|
508
623
|
);
|
|
@@ -511,6 +626,74 @@ describe("feed renderers", () => {
|
|
|
511
626
|
);
|
|
512
627
|
});
|
|
513
628
|
|
|
629
|
+
// The media pipeline only transforms image MIME types, so a video's
|
|
630
|
+
// `thumbnailUrl` is the file itself. Feeding that to `<img src>` produced a
|
|
631
|
+
// broken image pointing at an MP4.
|
|
632
|
+
it("omits the poster for a video with no poster frame", () => {
|
|
633
|
+
const xml = defaultFeedRenderer(
|
|
634
|
+
makeFeedData(
|
|
635
|
+
makePostView({
|
|
636
|
+
media: [
|
|
637
|
+
makeMediaView({
|
|
638
|
+
id: "med_clip",
|
|
639
|
+
url: "https://example.com/media/clip.mp4",
|
|
640
|
+
thumbnailUrl: "https://example.com/media/clip.mp4",
|
|
641
|
+
mimeType: "video/mp4",
|
|
642
|
+
size: 28_000_000,
|
|
643
|
+
width: 960,
|
|
644
|
+
height: 448,
|
|
645
|
+
originalName: "clip.mp4",
|
|
646
|
+
}),
|
|
647
|
+
],
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// No <img>, and crucially no poster pointing at the video file itself.
|
|
653
|
+
expect(xml).not.toContain("<img");
|
|
654
|
+
expect(xml).not.toContain("poster=");
|
|
655
|
+
expect(xml).toContain(
|
|
656
|
+
'<video controls preload="none" width="960" height="448">' +
|
|
657
|
+
'<source src="https://example.com/media/clip.mp4" type="video/mp4"/>' +
|
|
658
|
+
"</video>",
|
|
659
|
+
);
|
|
660
|
+
// The play cue, the link, and the size all survive without the still.
|
|
661
|
+
expect(xml).toContain(
|
|
662
|
+
'<figcaption><a href="https://example.com/media/clip.mp4">▶ Watch video</a> (26.7 MB)</figcaption>',
|
|
663
|
+
);
|
|
664
|
+
// Media RSS must not invent a thumbnail out of the video either.
|
|
665
|
+
expect(xml).not.toContain("<media:thumbnail");
|
|
666
|
+
// The enclosure is what lets a reader offer its own player.
|
|
667
|
+
expect(xml).toContain(
|
|
668
|
+
'<link rel="enclosure" type="video/mp4" href="https://example.com/media/clip.mp4"',
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// `<video>` has no `alt` attribute, so a described clip would lose its
|
|
673
|
+
// description entirely if the caption did not carry it.
|
|
674
|
+
it("moves a video's alt text into its caption", () => {
|
|
675
|
+
const xml = defaultFeedRenderer(
|
|
676
|
+
makeFeedData(
|
|
677
|
+
makePostView({
|
|
678
|
+
media: [
|
|
679
|
+
makeMediaView({
|
|
680
|
+
id: "med_described",
|
|
681
|
+
url: "https://example.com/media/described.mp4",
|
|
682
|
+
thumbnailUrl: "https://example.com/media/described.mp4",
|
|
683
|
+
mimeType: "video/mp4",
|
|
684
|
+
altText: "A heron takes off",
|
|
685
|
+
}),
|
|
686
|
+
],
|
|
687
|
+
}),
|
|
688
|
+
),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
expect(xml).toContain(
|
|
692
|
+
'<figcaption><a href="https://example.com/media/described.mp4">' +
|
|
693
|
+
"▶ Watch video</a>: A heron takes off</figcaption>",
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
|
|
514
697
|
it("resolves relative attachment, poster, and enclosure URLs", () => {
|
|
515
698
|
const post = makePostView({
|
|
516
699
|
media: [
|
|
@@ -526,7 +709,10 @@ describe("feed renderers", () => {
|
|
|
526
709
|
const xml = defaultFeedRenderer(makeFeedData(post));
|
|
527
710
|
|
|
528
711
|
expect(xml).toContain(
|
|
529
|
-
'
|
|
712
|
+
'poster="https://example.com/media/local-clip-poster.jpg"',
|
|
713
|
+
);
|
|
714
|
+
expect(xml).toContain(
|
|
715
|
+
'<source src="https://example.com/media/local-clip.mp4" type="video/mp4"/>',
|
|
530
716
|
);
|
|
531
717
|
expect(xml).toContain(
|
|
532
718
|
'<a href="https://example.com/media/local-clip.mp4">▶ Watch video</a>',
|
|
@@ -706,3 +892,1328 @@ describe("feed renderers", () => {
|
|
|
706
892
|
expect(xml).not.toContain("<figure>");
|
|
707
893
|
});
|
|
708
894
|
});
|
|
895
|
+
|
|
896
|
+
describe("feed Discover declaration", () => {
|
|
897
|
+
it("declares the mode and the feeds to poll", () => {
|
|
898
|
+
const xml = defaultFeedRenderer({
|
|
899
|
+
...makeFeedData(makePostView()),
|
|
900
|
+
discover: "latest",
|
|
901
|
+
discoverFeedUrl: "https://example.com/latest/feed",
|
|
902
|
+
discoverFeaturedFeedUrl: "https://example.com/featured/feed",
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(xml).toContain('xmlns:jant="https://jant.me/ns"');
|
|
906
|
+
expect(xml).toContain(
|
|
907
|
+
'<jant:discover feed="https://example.com/latest/feed" featured="https://example.com/featured/feed">latest</jant:discover>',
|
|
908
|
+
);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("names no featured feed when none was given", () => {
|
|
912
|
+
const xml = defaultFeedRenderer({
|
|
913
|
+
...makeFeedData(makePostView()),
|
|
914
|
+
discover: "latest",
|
|
915
|
+
discoverFeedUrl: "https://example.com/latest/feed",
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
expect(xml).toContain(
|
|
919
|
+
'<jant:discover feed="https://example.com/latest/feed">latest</jant:discover>',
|
|
920
|
+
);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("points featured sites at the featured feed", () => {
|
|
924
|
+
const xml = defaultFeedRenderer({
|
|
925
|
+
...makeFeedData(makePostView()),
|
|
926
|
+
discover: "featured",
|
|
927
|
+
discoverFeedUrl: "https://example.com/featured/feed",
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
expect(xml).toContain(
|
|
931
|
+
'<jant:discover feed="https://example.com/featured/feed">featured</jant:discover>',
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// `none` is an answer, so it is still declared — a crawler that already
|
|
936
|
+
// knows the site has to be told to stop, and silence would read as "this
|
|
937
|
+
// site predates Discover" instead.
|
|
938
|
+
it("declares none without a feed attribute", () => {
|
|
939
|
+
const xml = defaultFeedRenderer({
|
|
940
|
+
...makeFeedData(makePostView()),
|
|
941
|
+
discover: "none",
|
|
942
|
+
discoverFeedUrl: null,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(xml).toContain("<jant:discover>none</jant:discover>");
|
|
946
|
+
expect(xml).not.toContain("feed=");
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("declares nothing when the field is absent", () => {
|
|
950
|
+
const xml = defaultFeedRenderer(makeFeedData(makePostView()));
|
|
951
|
+
|
|
952
|
+
expect(xml).not.toContain("jant:discover");
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// The namespace is declared for whatever is emitted in it, and an entry's
|
|
956
|
+
// format is emitted whatever the site answered about Discover. So the only
|
|
957
|
+
// feed that leaves the declaration out is one with nothing in it at all.
|
|
958
|
+
it("declares no namespace when nothing in it is emitted", () => {
|
|
959
|
+
const xml = defaultFeedRenderer({
|
|
960
|
+
...makeFeedData(makePostView()),
|
|
961
|
+
posts: [],
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
expect(xml).not.toContain("xmlns:jant");
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("keeps a sitePathPrefix in the polled feed URL", () => {
|
|
968
|
+
const xml = defaultFeedRenderer({
|
|
969
|
+
...makeFeedData(makePostView()),
|
|
970
|
+
siteUrl: "https://example.com/blog",
|
|
971
|
+
discover: "latest",
|
|
972
|
+
discoverFeedUrl: "https://example.com/blog/latest/feed",
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
expect(xml).toContain('feed="https://example.com/blog/latest/feed"');
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe("feed language alternates", () => {
|
|
980
|
+
it("links each language's copy of the same feed", () => {
|
|
981
|
+
const xml = defaultFeedRenderer({
|
|
982
|
+
...makeFeedData(makePostView()),
|
|
983
|
+
languageAlternates: [
|
|
984
|
+
{ hreflang: "zh-Hans", href: "https://example.com/latest/feed" },
|
|
985
|
+
{ hreflang: "en", href: "https://example.com/en/latest/feed" },
|
|
986
|
+
],
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
expect(xml).toContain(
|
|
990
|
+
'<link href="https://example.com/latest/feed" rel="alternate" type="application/atom+xml" hreflang="zh-Hans"/>',
|
|
991
|
+
);
|
|
992
|
+
expect(xml).toContain(
|
|
993
|
+
'<link href="https://example.com/en/latest/feed" rel="alternate" type="application/atom+xml" hreflang="en"/>',
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("emits nothing extra for a single-language site", () => {
|
|
998
|
+
const xml = defaultFeedRenderer({
|
|
999
|
+
...makeFeedData(makePostView()),
|
|
1000
|
+
languageAlternates: [],
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
expect(xml).not.toContain("hreflang");
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
describe("feed author", () => {
|
|
1008
|
+
// The feed title is composed, so it is not a name. A consumer that wants to
|
|
1009
|
+
// label the blog needs the site's own name somewhere, and this is the place
|
|
1010
|
+
// Atom already has for it.
|
|
1011
|
+
it("names the blog, separately from the composed feed title", () => {
|
|
1012
|
+
const xml = defaultFeedRenderer({
|
|
1013
|
+
...makeFeedData(makePostView()),
|
|
1014
|
+
siteName: "A blog",
|
|
1015
|
+
title: "A blog - Latest posts",
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
expect(xml).toContain("<author><name>A blog</name></author>");
|
|
1019
|
+
expect(xml).toContain("<title>A blog - Latest posts</title>");
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("escapes a name that contains markup characters", () => {
|
|
1023
|
+
const xml = defaultFeedRenderer({
|
|
1024
|
+
...makeFeedData(makePostView()),
|
|
1025
|
+
siteName: "Q&A <notes>",
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
expect(xml).toContain(
|
|
1029
|
+
"<author><name>Q&A <notes></name></author>",
|
|
1030
|
+
);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("emits nothing when the site has no name", () => {
|
|
1034
|
+
const xml = defaultFeedRenderer({
|
|
1035
|
+
...makeFeedData(makePostView()),
|
|
1036
|
+
siteName: "",
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
expect(xml).not.toContain("<author>");
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
describe("feed entry format", () => {
|
|
1044
|
+
// A quote and an untitled note are identical from the feed alone — both have
|
|
1045
|
+
// an empty <title> and a body — so this element is the only way to tell them
|
|
1046
|
+
// apart without fetching the post's page.
|
|
1047
|
+
it.each([
|
|
1048
|
+
["note", "note"],
|
|
1049
|
+
["link", "link"],
|
|
1050
|
+
["quote", "quote"],
|
|
1051
|
+
] as const)("declares a %s post as %s", (format, declared) => {
|
|
1052
|
+
const xml = defaultFeedRenderer(
|
|
1053
|
+
makeFeedData(
|
|
1054
|
+
makePostView({
|
|
1055
|
+
format,
|
|
1056
|
+
url: format === "link" ? "https://external.com/article" : undefined,
|
|
1057
|
+
quoteText: format === "quote" ? "A quote worth keeping" : undefined,
|
|
1058
|
+
}),
|
|
1059
|
+
),
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
expect(xml).toContain(`<jant:format>${declared}</jant:format>`);
|
|
1063
|
+
expect(xml).toContain('xmlns:jant="https://jant.me/ns"');
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Core's own three formats. A titled note stays a note; drawing it as an
|
|
1067
|
+
// article is a decision the consumer makes from this and <title>.
|
|
1068
|
+
it("calls a titled note a note", () => {
|
|
1069
|
+
const xml = defaultFeedRenderer(
|
|
1070
|
+
makeFeedData(makePostView({ format: "note", title: "A titled note" })),
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
expect(xml).toContain("<jant:format>note</jant:format>");
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// Threads arrive as one entry keyed to the root, so the format is the root's.
|
|
1077
|
+
it("declares a thread by its root post", () => {
|
|
1078
|
+
const xml = defaultFeedRenderer(
|
|
1079
|
+
makeFeedData(
|
|
1080
|
+
makePostView({
|
|
1081
|
+
format: "quote",
|
|
1082
|
+
quoteText: "A quote worth keeping",
|
|
1083
|
+
threadReplies: [
|
|
1084
|
+
makePostView({
|
|
1085
|
+
id: "post-2",
|
|
1086
|
+
permalink: "/post-2",
|
|
1087
|
+
format: "note",
|
|
1088
|
+
}),
|
|
1089
|
+
],
|
|
1090
|
+
}),
|
|
1091
|
+
),
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
expect(xml).toContain("<jant:format>quote</jant:format>");
|
|
1095
|
+
expect(xml).not.toContain("<jant:format>note</jant:format>");
|
|
1096
|
+
});
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* `<summary>` is the entry's text as the timeline renders it; `<content>` is
|
|
1101
|
+
* the post's full page. Media lives in the content and in the Media RSS
|
|
1102
|
+
* elements, never here.
|
|
1103
|
+
*/
|
|
1104
|
+
describe("feed entry summary", () => {
|
|
1105
|
+
function makeBody(paragraphs: string[]): string {
|
|
1106
|
+
return JSON.stringify({
|
|
1107
|
+
type: "doc",
|
|
1108
|
+
content: paragraphs.map((text) => ({
|
|
1109
|
+
type: "paragraph",
|
|
1110
|
+
content: [{ type: "text", text }],
|
|
1111
|
+
})),
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const longBody = makeBody([
|
|
1116
|
+
"Alpha ".repeat(40).trim(),
|
|
1117
|
+
"Bravo ".repeat(40).trim(),
|
|
1118
|
+
"Charlie ".repeat(40).trim(),
|
|
1119
|
+
"Delta ".repeat(40).trim(),
|
|
1120
|
+
]);
|
|
1121
|
+
|
|
1122
|
+
function getSummary(xml: string): string | undefined {
|
|
1123
|
+
return /<summary type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/summary>/.exec(
|
|
1124
|
+
xml,
|
|
1125
|
+
)?.[1];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function getContent(xml: string): string | undefined {
|
|
1129
|
+
return /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
1130
|
+
xml,
|
|
1131
|
+
)?.[1];
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
it("sends a truncated summary alongside the full content", () => {
|
|
1135
|
+
const xml = defaultFeedRenderer(
|
|
1136
|
+
makeFeedData(
|
|
1137
|
+
makePostView({
|
|
1138
|
+
title: "A long read",
|
|
1139
|
+
body: longBody,
|
|
1140
|
+
bodyHtml: "<p>Alpha</p><p>Bravo</p><p>Charlie</p><p>Delta</p>",
|
|
1141
|
+
}),
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
|
|
1145
|
+
const summary = getSummary(xml);
|
|
1146
|
+
expect(summary).toBeDefined();
|
|
1147
|
+
expect(summary).toContain("Alpha");
|
|
1148
|
+
expect(summary).not.toContain("Delta");
|
|
1149
|
+
expect(getContent(xml)).toContain("Delta");
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// The field means one thing on its own — "this entry's text" — rather than
|
|
1153
|
+
// one defined by what the content happens to hold, so a short note repeats
|
|
1154
|
+
// itself here. That costs the words and buys `summary ?? ""` as the whole of
|
|
1155
|
+
// a consumer's rule.
|
|
1156
|
+
it("sends the summary even when it repeats the content", () => {
|
|
1157
|
+
const xml = defaultFeedRenderer(
|
|
1158
|
+
makeFeedData(
|
|
1159
|
+
makePostView({
|
|
1160
|
+
body: makeBody(["Just the one line."]),
|
|
1161
|
+
bodyHtml: "<p>Just the one line.</p>",
|
|
1162
|
+
}),
|
|
1163
|
+
),
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
expect(getSummary(xml)).toBe("<p>Just the one line.</p>");
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
// A feed cannot express the timeline's justified row, so a consumer computes
|
|
1170
|
+
// it from the dimensions on `<media:content>`. Repeating the markup here
|
|
1171
|
+
// would only give it something to strip back out.
|
|
1172
|
+
it("leaves media out of the summary and keeps the rating", () => {
|
|
1173
|
+
const xml = defaultFeedRenderer(
|
|
1174
|
+
makeFeedData(
|
|
1175
|
+
makePostView({
|
|
1176
|
+
body: makeBody(["嘿嘿嘿"]),
|
|
1177
|
+
bodyHtml: "<p>嘿嘿嘿</p>",
|
|
1178
|
+
rating: 4,
|
|
1179
|
+
media: [
|
|
1180
|
+
makeMediaView({
|
|
1181
|
+
url: "https://example.com/media/one.webp",
|
|
1182
|
+
thumbnailUrl: "https://example.com/media/one.webp",
|
|
1183
|
+
mimeType: "image/webp",
|
|
1184
|
+
}),
|
|
1185
|
+
],
|
|
1186
|
+
}),
|
|
1187
|
+
),
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
expect(getSummary(xml)).toBe("<p>嘿嘿嘿</p>\n<p>★★★★☆ 4/5</p>");
|
|
1191
|
+
expect(getContent(xml)).toContain("one.webp");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("leaves a link post's preview and ★ permalink out of the summary", () => {
|
|
1195
|
+
const xml = defaultFeedRenderer(
|
|
1196
|
+
makeFeedData(
|
|
1197
|
+
makePostView({
|
|
1198
|
+
format: "link",
|
|
1199
|
+
title: "A useful video",
|
|
1200
|
+
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1201
|
+
body: makeBody(["My notes."]),
|
|
1202
|
+
bodyHtml: "<p>My notes.</p>",
|
|
1203
|
+
previewImageUrl: "/media/previews/youtube.jpg",
|
|
1204
|
+
}),
|
|
1205
|
+
),
|
|
1206
|
+
);
|
|
1207
|
+
|
|
1208
|
+
expect(getSummary(xml)).toBe("<p>My notes.</p>");
|
|
1209
|
+
const content = getContent(xml);
|
|
1210
|
+
expect(content).toContain("youtube.jpg");
|
|
1211
|
+
expect(content).toContain("★");
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
// Missing says exactly one thing: this post has no text.
|
|
1215
|
+
it("omits the summary for a post with no text at all", () => {
|
|
1216
|
+
const xml = defaultFeedRenderer(
|
|
1217
|
+
makeFeedData(
|
|
1218
|
+
makePostView({
|
|
1219
|
+
media: [
|
|
1220
|
+
makeMediaView({
|
|
1221
|
+
url: "https://example.com/media/1031.pdf",
|
|
1222
|
+
thumbnailUrl: "https://example.com/media/1031.pdf",
|
|
1223
|
+
mimeType: "application/pdf",
|
|
1224
|
+
size: 915872,
|
|
1225
|
+
originalName: "1031.pdf",
|
|
1226
|
+
}),
|
|
1227
|
+
],
|
|
1228
|
+
}),
|
|
1229
|
+
),
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
expect(xml).not.toContain("<summary");
|
|
1233
|
+
expect(getContent(xml)).toContain("1031.pdf");
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// An entry's content may not be empty, so a post with no body, no media and
|
|
1237
|
+
// no title falls back to a bare `Post #<id>`. A summary may be empty, and
|
|
1238
|
+
// must be — that placeholder is not text the author wrote.
|
|
1239
|
+
it("omits the summary rather than falling back to a placeholder", () => {
|
|
1240
|
+
const xml = defaultFeedRenderer(makeFeedData(makePostView()));
|
|
1241
|
+
|
|
1242
|
+
expect(xml).not.toContain("<summary");
|
|
1243
|
+
expect(getContent(xml)).toContain("Post #post-1");
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// The site draws this separator with `.feed-quote-commentary::before`, which
|
|
1247
|
+
// feed readers strip along with the rest of the CSS.
|
|
1248
|
+
it("separates a quote from its commentary with a rule", () => {
|
|
1249
|
+
const content = getContent(
|
|
1250
|
+
defaultFeedRenderer(
|
|
1251
|
+
makeFeedData(
|
|
1252
|
+
makePostView({
|
|
1253
|
+
format: "quote",
|
|
1254
|
+
title: "Marcus Aurelius",
|
|
1255
|
+
url: "https://example.com/meditations",
|
|
1256
|
+
quoteText: "What stands in the way becomes the way.",
|
|
1257
|
+
bodyHtml: "<p>Still true.</p>",
|
|
1258
|
+
}),
|
|
1259
|
+
),
|
|
1260
|
+
),
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
expect(content).toContain(
|
|
1264
|
+
"<figcaption>— " +
|
|
1265
|
+
'<a href="https://example.com/meditations">Marcus Aurelius</a>' +
|
|
1266
|
+
"</figcaption></figure>\n<hr/>\n<p>Still true.</p>",
|
|
1267
|
+
);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
// The same fold the site's timeline applies: the first two replies as
|
|
1271
|
+
// context, the last three with the newest as the hero, the run between them
|
|
1272
|
+
// collapsed into a gap link.
|
|
1273
|
+
it("folds a long thread the way the timeline does", () => {
|
|
1274
|
+
const reply = (n: number) =>
|
|
1275
|
+
makePostView({
|
|
1276
|
+
id: `reply-${n}`,
|
|
1277
|
+
permalink: `/reply-${n}`,
|
|
1278
|
+
body: makeBody([`Reply ${n} body.`]),
|
|
1279
|
+
bodyHtml: `<p>Reply ${n} body.</p>`,
|
|
1280
|
+
});
|
|
1281
|
+
const xml = defaultFeedRenderer(
|
|
1282
|
+
makeFeedData(
|
|
1283
|
+
makePostView({
|
|
1284
|
+
title: "Thread root",
|
|
1285
|
+
body: longBody,
|
|
1286
|
+
bodyHtml: "<p>Alpha</p><p>Delta</p>",
|
|
1287
|
+
threadReplies: [1, 2, 3, 4, 5, 6, 7].map(reply),
|
|
1288
|
+
}),
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
const summary = getSummary(xml);
|
|
1293
|
+
// Leading context.
|
|
1294
|
+
expect(summary).toContain("Reply 1 body.");
|
|
1295
|
+
expect(summary).toContain("Reply 2 body.");
|
|
1296
|
+
// The gap stands for replies 3 and 4, and opens the first of them.
|
|
1297
|
+
expect(summary).toContain(
|
|
1298
|
+
'<p><small><a href="https://example.com/reply-3">2 more posts</a></small></p>',
|
|
1299
|
+
);
|
|
1300
|
+
expect(summary).not.toContain("Reply 3 body.");
|
|
1301
|
+
expect(summary).not.toContain("Reply 4 body.");
|
|
1302
|
+
// Trailing context, then the hero.
|
|
1303
|
+
expect(summary).toContain("Reply 5 body.");
|
|
1304
|
+
expect(summary).toContain("Reply 6 body.");
|
|
1305
|
+
expect(summary).toContain("Reply 7 body.");
|
|
1306
|
+
|
|
1307
|
+
// The content still carries every reply.
|
|
1308
|
+
const content = getContent(xml);
|
|
1309
|
+
for (const n of [1, 2, 3, 4, 5, 6, 7]) {
|
|
1310
|
+
expect(content).toContain(`Reply ${n} body.`);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
// Up to six posts fit the fold, so a shorter thread hides nothing at all.
|
|
1315
|
+
it("hides nothing in a thread the fold fits whole", () => {
|
|
1316
|
+
const reply = (n: number) =>
|
|
1317
|
+
makePostView({
|
|
1318
|
+
id: `reply-${n}`,
|
|
1319
|
+
permalink: `/reply-${n}`,
|
|
1320
|
+
body: makeBody([`Reply ${n} body.`]),
|
|
1321
|
+
bodyHtml: `<p>Reply ${n} body.</p>`,
|
|
1322
|
+
});
|
|
1323
|
+
const summary = getSummary(
|
|
1324
|
+
defaultFeedRenderer(
|
|
1325
|
+
makeFeedData(
|
|
1326
|
+
makePostView({
|
|
1327
|
+
body: makeBody(["Root."]),
|
|
1328
|
+
bodyHtml: "<p>Root.</p>",
|
|
1329
|
+
threadReplies: [1, 2, 3, 4, 5].map(reply),
|
|
1330
|
+
}),
|
|
1331
|
+
),
|
|
1332
|
+
),
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
for (const n of [1, 2, 3, 4, 5]) {
|
|
1336
|
+
expect(summary).toContain(`Reply ${n} body.`);
|
|
1337
|
+
}
|
|
1338
|
+
expect(summary).not.toContain("more post");
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("writes the gap count in the singular for a single hidden post", () => {
|
|
1342
|
+
const reply = (n: number) =>
|
|
1343
|
+
makePostView({
|
|
1344
|
+
id: `reply-${n}`,
|
|
1345
|
+
permalink: `/reply-${n}`,
|
|
1346
|
+
body: makeBody([`Reply ${n}.`]),
|
|
1347
|
+
bodyHtml: `<p>Reply ${n}.</p>`,
|
|
1348
|
+
});
|
|
1349
|
+
// Six replies: two lead, three trail, one falls in the gap.
|
|
1350
|
+
const summary = getSummary(
|
|
1351
|
+
defaultFeedRenderer(
|
|
1352
|
+
makeFeedData(
|
|
1353
|
+
makePostView({
|
|
1354
|
+
body: makeBody(["Root."]),
|
|
1355
|
+
bodyHtml: "<p>Root.</p>",
|
|
1356
|
+
threadReplies: [1, 2, 3, 4, 5, 6].map(reply),
|
|
1357
|
+
}),
|
|
1358
|
+
),
|
|
1359
|
+
),
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
expect(summary).toContain(">1 more post</a>");
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it("keeps a thread's root and newest reply without a gap when none is hidden", () => {
|
|
1366
|
+
const xml = defaultFeedRenderer(
|
|
1367
|
+
makeFeedData(
|
|
1368
|
+
makePostView({
|
|
1369
|
+
body: makeBody(["Root."]),
|
|
1370
|
+
bodyHtml: "<p>Root.</p>",
|
|
1371
|
+
threadReplies: [
|
|
1372
|
+
makePostView({
|
|
1373
|
+
id: "reply-1",
|
|
1374
|
+
permalink: "/reply-1",
|
|
1375
|
+
body: makeBody(["Only reply."]),
|
|
1376
|
+
bodyHtml: "<p>Only reply.</p>",
|
|
1377
|
+
}),
|
|
1378
|
+
],
|
|
1379
|
+
}),
|
|
1380
|
+
),
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
const summary = getSummary(xml);
|
|
1384
|
+
expect(summary).toContain("<p>Root.</p>");
|
|
1385
|
+
expect(summary).toContain("<p>Only reply.</p>");
|
|
1386
|
+
expect(summary).not.toContain("more post");
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
// The site renders a quote's commentary whole — `QuoteCard` passes `bodyHtml`
|
|
1390
|
+
// through and nothing clamps `.feed-quote-commentary` — so a summary that cut
|
|
1391
|
+
// it would not be the timeline's rendering.
|
|
1392
|
+
it("keeps a quote's commentary whole, however long it runs", () => {
|
|
1393
|
+
const xml = defaultFeedRenderer(
|
|
1394
|
+
makeFeedData(
|
|
1395
|
+
makePostView({
|
|
1396
|
+
format: "quote",
|
|
1397
|
+
title: "Marcus Aurelius",
|
|
1398
|
+
url: "https://example.com/meditations",
|
|
1399
|
+
quoteText: "What stands in the way becomes the way.",
|
|
1400
|
+
body: longBody,
|
|
1401
|
+
bodyHtml: "<p>Alpha</p><p>Bravo</p><p>Charlie</p><p>Delta</p>",
|
|
1402
|
+
}),
|
|
1403
|
+
),
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
const summary = getSummary(xml);
|
|
1407
|
+
expect(summary).toContain("<p>Delta</p>");
|
|
1408
|
+
expect(summary).toBe(getContent(xml));
|
|
1409
|
+
expect(xml).not.toContain("<jant:truncated/>");
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* What an entry says about itself beyond its text: whether the timeline cuts
|
|
1415
|
+
* it, and which collections the author filed it under.
|
|
1416
|
+
*/
|
|
1417
|
+
describe("feed entry metadata", () => {
|
|
1418
|
+
function makeBody(paragraphs: string[]): string {
|
|
1419
|
+
return JSON.stringify({
|
|
1420
|
+
type: "doc",
|
|
1421
|
+
content: paragraphs.map((text) => ({
|
|
1422
|
+
type: "paragraph",
|
|
1423
|
+
content: [{ type: "text", text }],
|
|
1424
|
+
})),
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// `<summary>` is sent whenever there is text, so only this tells a consumer
|
|
1429
|
+
// whether the site would offer a "Read more".
|
|
1430
|
+
it("marks an entry whose summary text was cut", () => {
|
|
1431
|
+
const xml = defaultFeedRenderer(
|
|
1432
|
+
makeFeedData(
|
|
1433
|
+
makePostView({
|
|
1434
|
+
title: "A long read",
|
|
1435
|
+
body: makeBody([
|
|
1436
|
+
"Alpha ".repeat(90).trim(),
|
|
1437
|
+
"Omega ".repeat(90).trim(),
|
|
1438
|
+
]),
|
|
1439
|
+
bodyHtml: "<p>Alpha</p><p>Omega</p>",
|
|
1440
|
+
}),
|
|
1441
|
+
),
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
expect(xml).toContain("<jant:truncated/>");
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
it("leaves the mark off an entry the timeline shows in full", () => {
|
|
1448
|
+
const xml = defaultFeedRenderer(
|
|
1449
|
+
makeFeedData(
|
|
1450
|
+
makePostView({
|
|
1451
|
+
body: makeBody(["Short enough."]),
|
|
1452
|
+
bodyHtml: "<p>Short enough.</p>",
|
|
1453
|
+
}),
|
|
1454
|
+
),
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
expect(xml).not.toContain("<jant:truncated/>");
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("marks an entry whose newest reply was cut", () => {
|
|
1461
|
+
const xml = defaultFeedRenderer(
|
|
1462
|
+
makeFeedData(
|
|
1463
|
+
makePostView({
|
|
1464
|
+
body: makeBody(["Root."]),
|
|
1465
|
+
bodyHtml: "<p>Root.</p>",
|
|
1466
|
+
threadReplies: [
|
|
1467
|
+
makePostView({
|
|
1468
|
+
id: "reply-1",
|
|
1469
|
+
permalink: "/reply-1",
|
|
1470
|
+
title: "A long reply",
|
|
1471
|
+
body: makeBody([
|
|
1472
|
+
"Alpha ".repeat(90).trim(),
|
|
1473
|
+
"Omega ".repeat(90).trim(),
|
|
1474
|
+
]),
|
|
1475
|
+
bodyHtml: "<p>Alpha</p><p>Omega</p>",
|
|
1476
|
+
}),
|
|
1477
|
+
],
|
|
1478
|
+
}),
|
|
1479
|
+
),
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
expect(xml).toContain("<jant:truncated/>");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// A collection is a label the author chose, which is what `<category>` is
|
|
1486
|
+
// for — unlike the format, which no author typed.
|
|
1487
|
+
it("files an entry under its collections", () => {
|
|
1488
|
+
const xml = defaultFeedRenderer(
|
|
1489
|
+
makeFeedData(
|
|
1490
|
+
makePostView({
|
|
1491
|
+
collections: [
|
|
1492
|
+
{ slug: "reading", title: "Reading", url: "/reading" },
|
|
1493
|
+
{ slug: "notes", title: "Field Notes", url: "/notes" },
|
|
1494
|
+
],
|
|
1495
|
+
}),
|
|
1496
|
+
),
|
|
1497
|
+
);
|
|
1498
|
+
|
|
1499
|
+
// A single collection lives in the root URL namespace and a site path
|
|
1500
|
+
// prefix makes it unguessable from the term, so the URL rides along.
|
|
1501
|
+
expect(xml).toContain(
|
|
1502
|
+
'<category term="reading" label="Reading" jant:page="https://example.com/reading"/>',
|
|
1503
|
+
);
|
|
1504
|
+
expect(xml).toContain(
|
|
1505
|
+
'<category term="notes" label="Field Notes" jant:page="https://example.com/notes"/>',
|
|
1506
|
+
);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it("emits no categories for a post in no collection", () => {
|
|
1510
|
+
expect(defaultFeedRenderer(makeFeedData(makePostView()))).not.toContain(
|
|
1511
|
+
"<category",
|
|
1512
|
+
);
|
|
1513
|
+
});
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
describe("feed attachment metadata", () => {
|
|
1517
|
+
it("describes an image with Media RSS instead of an enclosure", () => {
|
|
1518
|
+
const xml = defaultFeedRenderer(
|
|
1519
|
+
makeFeedData(
|
|
1520
|
+
makePostView({
|
|
1521
|
+
media: [
|
|
1522
|
+
makeMediaView({
|
|
1523
|
+
url: "https://example.com/media/photo.jpg",
|
|
1524
|
+
thumbnailUrl: "https://example.com/media/photo-thumb.jpg",
|
|
1525
|
+
mimeType: "image/jpeg",
|
|
1526
|
+
size: 245000,
|
|
1527
|
+
width: 1600,
|
|
1528
|
+
height: 1200,
|
|
1529
|
+
altText: "A quiet street",
|
|
1530
|
+
originalName: "photo.jpg",
|
|
1531
|
+
}),
|
|
1532
|
+
],
|
|
1533
|
+
}),
|
|
1534
|
+
),
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
|
|
1538
|
+
expect(xml).toContain(
|
|
1539
|
+
'<media:content url="https://example.com/media/photo.jpg" ' +
|
|
1540
|
+
'type="image/jpeg" medium="image" fileSize="245000" ' +
|
|
1541
|
+
'width="1600" height="1200">',
|
|
1542
|
+
);
|
|
1543
|
+
expect(xml).toContain(
|
|
1544
|
+
'<media:description type="plain">A quiet street</media:description>',
|
|
1545
|
+
);
|
|
1546
|
+
expect(xml).toContain(
|
|
1547
|
+
'<media:thumbnail url="https://example.com/media/photo-thumb.jpg"/>',
|
|
1548
|
+
);
|
|
1549
|
+
// No enclosure for a picture the content already renders in full.
|
|
1550
|
+
expect(xml).not.toContain('rel="enclosure"');
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// An enclosure is for a file the content cannot inline. Audio is exactly
|
|
1554
|
+
// that; a picture the reader is already looking at is not.
|
|
1555
|
+
it("encloses audio but not the image beside it, and describes both", () => {
|
|
1556
|
+
const xml = defaultFeedRenderer(
|
|
1557
|
+
makeFeedData(
|
|
1558
|
+
makePostView({
|
|
1559
|
+
media: [
|
|
1560
|
+
makeMediaView({
|
|
1561
|
+
id: "med_img",
|
|
1562
|
+
url: "https://example.com/media/photo.jpg",
|
|
1563
|
+
thumbnailUrl: "https://example.com/media/photo.jpg",
|
|
1564
|
+
mimeType: "image/jpeg",
|
|
1565
|
+
size: 245000,
|
|
1566
|
+
}),
|
|
1567
|
+
makeMediaView({
|
|
1568
|
+
id: "med_audio",
|
|
1569
|
+
url: "https://example.com/media/song.mp3",
|
|
1570
|
+
thumbnailUrl: "https://example.com/media/song.mp3",
|
|
1571
|
+
mimeType: "audio/mpeg",
|
|
1572
|
+
size: 5242880,
|
|
1573
|
+
durationSeconds: 201,
|
|
1574
|
+
originalName: "song.mp3",
|
|
1575
|
+
}),
|
|
1576
|
+
],
|
|
1577
|
+
}),
|
|
1578
|
+
),
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
expect(xml).toContain(
|
|
1582
|
+
'<link rel="enclosure" type="audio/mpeg" href="https://example.com/media/song.mp3"',
|
|
1583
|
+
);
|
|
1584
|
+
expect(xml).not.toContain('<link rel="enclosure" type="image/jpeg"');
|
|
1585
|
+
|
|
1586
|
+
// Media RSS still describes both — it is a metadata layer, not a shelf.
|
|
1587
|
+
expect(xml).toContain('medium="image"');
|
|
1588
|
+
expect(xml).toContain('medium="audio"');
|
|
1589
|
+
expect(xml).toContain('duration="201"');
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it("carries duration for timed media and resolves relative URLs", () => {
|
|
1593
|
+
const xml = defaultFeedRenderer(
|
|
1594
|
+
makeFeedData(
|
|
1595
|
+
makePostView({
|
|
1596
|
+
media: [
|
|
1597
|
+
makeMediaView({
|
|
1598
|
+
url: "/media/clip.mp4",
|
|
1599
|
+
thumbnailUrl: "/media/clip-poster.jpg",
|
|
1600
|
+
mimeType: "video/mp4",
|
|
1601
|
+
durationSeconds: 42.4,
|
|
1602
|
+
width: 1920,
|
|
1603
|
+
height: 1080,
|
|
1604
|
+
}),
|
|
1605
|
+
],
|
|
1606
|
+
}),
|
|
1607
|
+
),
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
expect(xml).toContain(
|
|
1611
|
+
'<media:content url="https://example.com/media/clip.mp4" ' +
|
|
1612
|
+
'type="video/mp4" medium="video" width="1920" height="1080" ' +
|
|
1613
|
+
'duration="42">',
|
|
1614
|
+
);
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it("calls non-visual attachments documents", () => {
|
|
1618
|
+
const xml = defaultFeedRenderer(
|
|
1619
|
+
makeFeedData(
|
|
1620
|
+
makePostView({
|
|
1621
|
+
media: [
|
|
1622
|
+
makeMediaView({
|
|
1623
|
+
url: "https://example.com/media/spec.pdf",
|
|
1624
|
+
thumbnailUrl: "https://example.com/media/spec.pdf",
|
|
1625
|
+
mimeType: "application/pdf",
|
|
1626
|
+
originalName: "spec.pdf",
|
|
1627
|
+
}),
|
|
1628
|
+
],
|
|
1629
|
+
}),
|
|
1630
|
+
),
|
|
1631
|
+
);
|
|
1632
|
+
|
|
1633
|
+
expect(xml).toContain('medium="document"');
|
|
1634
|
+
expect(xml).toContain('<media:title type="plain">spec.pdf</media:title>');
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
// Card and grid views need a representative image. Without one declared, a
|
|
1638
|
+
// reader has to scrape the first `<img>` out of the content HTML.
|
|
1639
|
+
it("declares a link preview as the entry's representative image", () => {
|
|
1640
|
+
const xml = defaultFeedRenderer(
|
|
1641
|
+
makeFeedData(
|
|
1642
|
+
makePostView({
|
|
1643
|
+
format: "link",
|
|
1644
|
+
url: "https://external.com/article",
|
|
1645
|
+
title: "An article",
|
|
1646
|
+
previewImageUrl: "/previews/article.jpg",
|
|
1647
|
+
}),
|
|
1648
|
+
),
|
|
1649
|
+
);
|
|
1650
|
+
|
|
1651
|
+
expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
|
|
1652
|
+
expect(xml).toContain(
|
|
1653
|
+
'<media:thumbnail url="https://example.com/previews/article.jpg"/>',
|
|
1654
|
+
);
|
|
1655
|
+
// A scraped thumbnail of someone else's page is not a published file, so
|
|
1656
|
+
// it must not tell podcast and download clients to fetch it.
|
|
1657
|
+
expect(xml).not.toContain('rel="enclosure"');
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
// The site lays a post's attachments out as one strip and marks the container
|
|
1661
|
+
// `data-post-media`. Carrying it into the feed lets a consumer style the
|
|
1662
|
+
// strip instead of reassembling it from the Media RSS elements.
|
|
1663
|
+
it("groups a post's attachments in one container", () => {
|
|
1664
|
+
const content =
|
|
1665
|
+
/<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
1666
|
+
defaultFeedRenderer(
|
|
1667
|
+
makeFeedData(
|
|
1668
|
+
makePostView({
|
|
1669
|
+
bodyHtml: "<p>Look.</p>",
|
|
1670
|
+
media: [
|
|
1671
|
+
makeMediaView({
|
|
1672
|
+
id: "med_a",
|
|
1673
|
+
url: "https://example.com/media/a.webp",
|
|
1674
|
+
thumbnailUrl: "https://example.com/media/a.webp",
|
|
1675
|
+
mimeType: "image/webp",
|
|
1676
|
+
}),
|
|
1677
|
+
makeMediaView({
|
|
1678
|
+
id: "med_b",
|
|
1679
|
+
url: "https://example.com/media/b.webp",
|
|
1680
|
+
thumbnailUrl: "https://example.com/media/b.webp",
|
|
1681
|
+
mimeType: "image/webp",
|
|
1682
|
+
}),
|
|
1683
|
+
],
|
|
1684
|
+
}),
|
|
1685
|
+
),
|
|
1686
|
+
),
|
|
1687
|
+
)?.[1];
|
|
1688
|
+
|
|
1689
|
+
expect(content).toContain("<div data-post-media>");
|
|
1690
|
+
expect(content?.match(/<div data-post-media>/g)).toHaveLength(1);
|
|
1691
|
+
expect(content).toContain("a.webp");
|
|
1692
|
+
expect(content).toContain("b.webp");
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
// A flat list cannot say which post in a thread an attachment belongs to.
|
|
1696
|
+
// The content can, because each post's strip sits beside its own text.
|
|
1697
|
+
it("gives each post in a thread its own container", () => {
|
|
1698
|
+
const content =
|
|
1699
|
+
/<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
1700
|
+
defaultFeedRenderer(
|
|
1701
|
+
makeFeedData(
|
|
1702
|
+
makePostView({
|
|
1703
|
+
bodyHtml: "<p>Root.</p>",
|
|
1704
|
+
media: [
|
|
1705
|
+
makeMediaView({
|
|
1706
|
+
id: "med_root",
|
|
1707
|
+
url: "https://example.com/media/root.webp",
|
|
1708
|
+
thumbnailUrl: "https://example.com/media/root.webp",
|
|
1709
|
+
mimeType: "image/webp",
|
|
1710
|
+
}),
|
|
1711
|
+
],
|
|
1712
|
+
threadReplies: [
|
|
1713
|
+
makePostView({
|
|
1714
|
+
id: "reply-1",
|
|
1715
|
+
permalink: "/reply-1",
|
|
1716
|
+
bodyHtml: "<p>Reply.</p>",
|
|
1717
|
+
media: [
|
|
1718
|
+
makeMediaView({
|
|
1719
|
+
id: "med_reply",
|
|
1720
|
+
url: "https://example.com/media/reply.webp",
|
|
1721
|
+
thumbnailUrl: "https://example.com/media/reply.webp",
|
|
1722
|
+
mimeType: "image/webp",
|
|
1723
|
+
}),
|
|
1724
|
+
],
|
|
1725
|
+
}),
|
|
1726
|
+
],
|
|
1727
|
+
}),
|
|
1728
|
+
),
|
|
1729
|
+
),
|
|
1730
|
+
)?.[1];
|
|
1731
|
+
|
|
1732
|
+
expect(content?.match(/<div data-post-media>/g)).toHaveLength(2);
|
|
1733
|
+
// Each strip sits after its own post's text.
|
|
1734
|
+
expect(content?.indexOf("root.webp")).toBeLessThan(
|
|
1735
|
+
content?.indexOf("Reply.") ?? -1,
|
|
1736
|
+
);
|
|
1737
|
+
expect(content?.indexOf("Reply.")).toBeLessThan(
|
|
1738
|
+
content?.indexOf("reply.webp") ?? -1,
|
|
1739
|
+
);
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
// A markdown file is not worth opening raw — the browser downloads it or
|
|
1743
|
+
// dumps it unstyled — so a text attachment points at the page that renders
|
|
1744
|
+
// it while `url` stays the file.
|
|
1745
|
+
it("points a text attachment at the page that renders it", () => {
|
|
1746
|
+
const xml = defaultFeedRenderer(
|
|
1747
|
+
makeFeedData(
|
|
1748
|
+
makePostView({
|
|
1749
|
+
permalink: "/hn2v7",
|
|
1750
|
+
media: [
|
|
1751
|
+
makeMediaView({
|
|
1752
|
+
id: "med_txt",
|
|
1753
|
+
url: "https://cdn.example.com/files/med_txt.md",
|
|
1754
|
+
thumbnailUrl: "https://cdn.example.com/files/med_txt.md",
|
|
1755
|
+
mimeType: "text/markdown; charset=utf-8",
|
|
1756
|
+
size: 21,
|
|
1757
|
+
summary: "松松哈哈哈",
|
|
1758
|
+
originalName: "attached-text.md",
|
|
1759
|
+
}),
|
|
1760
|
+
],
|
|
1761
|
+
}),
|
|
1762
|
+
),
|
|
1763
|
+
);
|
|
1764
|
+
|
|
1765
|
+
expect(xml).toContain('jant:page="https://example.com/hn2v7/text/med_txt"');
|
|
1766
|
+
// `url` stays the file, for fetching and for the enclosure.
|
|
1767
|
+
expect(xml).toContain('url="https://cdn.example.com/files/med_txt.md"');
|
|
1768
|
+
// A text attachment has no alt text; its excerpt is what the card prints.
|
|
1769
|
+
expect(xml).toContain(
|
|
1770
|
+
'<media:description type="plain">松松哈哈哈</media:description>',
|
|
1771
|
+
);
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
it("gives an attachment no page of its own when the file is the page", () => {
|
|
1775
|
+
const xml = defaultFeedRenderer(
|
|
1776
|
+
makeFeedData(
|
|
1777
|
+
makePostView({
|
|
1778
|
+
media: [
|
|
1779
|
+
makeMediaView({
|
|
1780
|
+
url: "https://example.com/media/photo.jpg",
|
|
1781
|
+
thumbnailUrl: "https://example.com/media/photo.jpg",
|
|
1782
|
+
mimeType: "image/jpeg",
|
|
1783
|
+
}),
|
|
1784
|
+
],
|
|
1785
|
+
}),
|
|
1786
|
+
),
|
|
1787
|
+
);
|
|
1788
|
+
|
|
1789
|
+
expect(xml).not.toContain("jant:page");
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
it("leaves the Media RSS namespace out of a feed with no attachments", () => {
|
|
1793
|
+
const xml = defaultFeedRenderer(makeFeedData(makePostView()));
|
|
1794
|
+
expect(xml).not.toContain("xmlns:media");
|
|
1795
|
+
expect(xml).not.toContain("<media:content");
|
|
1796
|
+
expect(xml).not.toContain("<media:thumbnail");
|
|
1797
|
+
});
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
/**
|
|
1801
|
+
* A thread arrives as one entry, so both text constructs run several posts
|
|
1802
|
+
* together. These are the three things that let a consumer take it apart:
|
|
1803
|
+
* which entries are threads, where one post's words end, and which post owns
|
|
1804
|
+
* each file.
|
|
1805
|
+
*/
|
|
1806
|
+
describe("feed entry thread segmentation", () => {
|
|
1807
|
+
function makeBody(paragraphs: string[]): string {
|
|
1808
|
+
return JSON.stringify({
|
|
1809
|
+
type: "doc",
|
|
1810
|
+
content: paragraphs.map((text) => ({
|
|
1811
|
+
type: "paragraph",
|
|
1812
|
+
content: [{ type: "text", text }],
|
|
1813
|
+
})),
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function getSummary(xml: string): string | undefined {
|
|
1818
|
+
return /<summary type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/summary>/.exec(
|
|
1819
|
+
xml,
|
|
1820
|
+
)?.[1];
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function getContent(xml: string): string | undefined {
|
|
1824
|
+
return /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
|
|
1825
|
+
xml,
|
|
1826
|
+
)?.[1];
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function makeReply(n: number, overrides: Partial<FeedPostView> = {}) {
|
|
1830
|
+
return makePostView({
|
|
1831
|
+
id: `reply-${n}`,
|
|
1832
|
+
permalink: `/reply-${n}`,
|
|
1833
|
+
slug: `reply-${n}`,
|
|
1834
|
+
body: makeBody([`Reply ${n} body.`]),
|
|
1835
|
+
bodyHtml: `<p>Reply ${n} body.</p>`,
|
|
1836
|
+
publishedAt: `2026-03-2${n}T00:00:00.000Z`,
|
|
1837
|
+
publishedAtFormatted: `Mar 2${n}, 2026`,
|
|
1838
|
+
...overrides,
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function makeRoot(overrides: Partial<FeedPostView> = {}) {
|
|
1843
|
+
return makePostView({
|
|
1844
|
+
body: makeBody(["Root body."]),
|
|
1845
|
+
bodyHtml: "<p>Root body.</p>",
|
|
1846
|
+
...overrides,
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// The root's own marker is what makes the rule "every marker ends the block
|
|
1851
|
+
// before it" hold. Without it the root and the first reply share a segment.
|
|
1852
|
+
it("closes the root's own block in a thread, not just the replies'", () => {
|
|
1853
|
+
const summary = getSummary(
|
|
1854
|
+
defaultFeedRenderer(
|
|
1855
|
+
makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
|
|
1856
|
+
),
|
|
1857
|
+
);
|
|
1858
|
+
|
|
1859
|
+
expect(summary).toContain(
|
|
1860
|
+
'<p>Root body.</p>\n<p><small><a href="https://example.com/post-1" class="u-url"><time class="dt-published" datetime="2026-03-19T00:00:00.000Z">Mar 19, 2026</time></a></small></p>',
|
|
1861
|
+
);
|
|
1862
|
+
expect(summary).toContain(
|
|
1863
|
+
'<p>Reply 1 body.</p>\n<p><small><a href="https://example.com/reply-1" class="u-url"><time class="dt-published" datetime="2026-03-21T00:00:00.000Z">Mar 21, 2026</time></a></small></p>',
|
|
1864
|
+
);
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
// A reader prints <published> itself, so a lone post that also ended with
|
|
1868
|
+
// its own date would show it twice.
|
|
1869
|
+
it("leaves a standalone entry unmarked", () => {
|
|
1870
|
+
const xml = defaultFeedRenderer(makeFeedData(makeRoot()));
|
|
1871
|
+
|
|
1872
|
+
expect(getSummary(xml)).not.toContain("dt-published");
|
|
1873
|
+
expect(getContent(xml)).not.toContain("dt-published");
|
|
1874
|
+
expect(xml).not.toContain("<jant:thread");
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
// The marker ends a block, so a post that contributed no block to the
|
|
1878
|
+
// summary — a photo with no caption — must not leave one behind.
|
|
1879
|
+
it("marks no block for a thread root with no text", () => {
|
|
1880
|
+
const summary = getSummary(
|
|
1881
|
+
defaultFeedRenderer(
|
|
1882
|
+
makeFeedData(
|
|
1883
|
+
makePostView({
|
|
1884
|
+
media: [
|
|
1885
|
+
makeMediaView({
|
|
1886
|
+
url: "https://example.com/media/photo.jpg",
|
|
1887
|
+
mimeType: "image/jpeg",
|
|
1888
|
+
}),
|
|
1889
|
+
],
|
|
1890
|
+
threadReplies: [makeReply(1)],
|
|
1891
|
+
}),
|
|
1892
|
+
),
|
|
1893
|
+
),
|
|
1894
|
+
);
|
|
1895
|
+
|
|
1896
|
+
expect(summary).not.toContain("https://example.com/post-1");
|
|
1897
|
+
expect(summary).toContain('href="https://example.com/reply-1"');
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
it("closes every reply's block in the content, in order", () => {
|
|
1901
|
+
const content = getContent(
|
|
1902
|
+
defaultFeedRenderer(
|
|
1903
|
+
makeFeedData(makeRoot({ threadReplies: [makeReply(1), makeReply(2)] })),
|
|
1904
|
+
),
|
|
1905
|
+
);
|
|
1906
|
+
|
|
1907
|
+
const markers = [...(content ?? "").matchAll(/class="u-url"/g)];
|
|
1908
|
+
expect(markers).toHaveLength(3);
|
|
1909
|
+
expect(content?.indexOf("Reply 1 body.")).toBeLessThan(
|
|
1910
|
+
content?.indexOf("Reply 2 body.") ?? -1,
|
|
1911
|
+
);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it("declares a thread's shape so a consumer need not read the HTML", () => {
|
|
1915
|
+
const xml = defaultFeedRenderer(
|
|
1916
|
+
makeFeedData(
|
|
1917
|
+
makeRoot({
|
|
1918
|
+
threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) => makeReply(n)),
|
|
1919
|
+
}),
|
|
1920
|
+
),
|
|
1921
|
+
);
|
|
1922
|
+
|
|
1923
|
+
// Seven replies: 1 and 2 lead, 5-7 trail, 3 and 4 fall in the gap.
|
|
1924
|
+
expect(xml).toContain(
|
|
1925
|
+
'<jant:thread posts="8" hidden="2" gap="https://example.com/reply-3" latest="https://example.com/reply-7">',
|
|
1926
|
+
);
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
// One reply is the hero, so nothing folds away and there is nowhere to send
|
|
1930
|
+
// a reader who wants the middle.
|
|
1931
|
+
it("declares no gap when a thread hides nothing", () => {
|
|
1932
|
+
const xml = defaultFeedRenderer(
|
|
1933
|
+
makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1936
|
+
expect(xml).toContain(
|
|
1937
|
+
'<jant:thread posts="2" hidden="0" latest="https://example.com/reply-1">',
|
|
1938
|
+
);
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
// `<jant:format>` describes the entry, which is the root. A reply that is a
|
|
1942
|
+
// Quote says so nowhere else — the `<blockquote>` in the content is not a
|
|
1943
|
+
// declaration a consumer laying the thread out itself can read.
|
|
1944
|
+
it("lists every post in the thread with its own format and date", () => {
|
|
1945
|
+
const xml = defaultFeedRenderer(
|
|
1946
|
+
makeFeedData(
|
|
1947
|
+
makeRoot({
|
|
1948
|
+
threadReplies: [
|
|
1949
|
+
makeReply(1, {
|
|
1950
|
+
format: "quote",
|
|
1951
|
+
title: "Marcus Aurelius",
|
|
1952
|
+
quoteText: "What stands in the way becomes the way.",
|
|
1953
|
+
}),
|
|
1954
|
+
],
|
|
1955
|
+
}),
|
|
1956
|
+
),
|
|
1957
|
+
);
|
|
1958
|
+
|
|
1959
|
+
expect(xml).toContain(
|
|
1960
|
+
'<jant:post href="https://example.com/post-1" format="note" published="2026-03-19T00:00:00.000Z"/>',
|
|
1961
|
+
);
|
|
1962
|
+
expect(xml).toContain(
|
|
1963
|
+
'<jant:post href="https://example.com/reply-1" format="quote" published="2026-03-21T00:00:00.000Z"/>',
|
|
1964
|
+
);
|
|
1965
|
+
// The entry still declares the root's format, unchanged.
|
|
1966
|
+
expect(xml).toContain("<jant:format>note</jant:format>");
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
// A row carries what Atom would put on this post's entry. `<title>` and
|
|
1970
|
+
// `link[rel="alternate"]` only ever describe the root, so a titled reply and
|
|
1971
|
+
// a Link reply had nowhere to say either.
|
|
1972
|
+
it("gives a row the title, target and preview its own entry would carry", () => {
|
|
1973
|
+
const xml = defaultFeedRenderer(
|
|
1974
|
+
makeFeedData(
|
|
1975
|
+
makeRoot({
|
|
1976
|
+
threadReplies: [
|
|
1977
|
+
makeReply(1, {
|
|
1978
|
+
format: "link",
|
|
1979
|
+
title: "The AeroPress guide",
|
|
1980
|
+
url: "https://other.example/aeropress",
|
|
1981
|
+
previewImageUrl: "/media/preview.jpg",
|
|
1982
|
+
}),
|
|
1983
|
+
makeReply(2, { title: "A titled reply" }),
|
|
1984
|
+
],
|
|
1985
|
+
}),
|
|
1986
|
+
),
|
|
1987
|
+
);
|
|
1988
|
+
|
|
1989
|
+
expect(xml).toContain(
|
|
1990
|
+
'<jant:post href="https://example.com/reply-1" format="link" published="2026-03-21T00:00:00.000Z"' +
|
|
1991
|
+
' title="The AeroPress guide" url="https://other.example/aeropress"' +
|
|
1992
|
+
' thumbnail="https://example.com/media/preview.jpg"/>',
|
|
1993
|
+
);
|
|
1994
|
+
expect(xml).toContain(
|
|
1995
|
+
'<jant:post href="https://example.com/reply-2" format="note" published="2026-03-22T00:00:00.000Z" title="A titled reply"/>',
|
|
1996
|
+
);
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
// The entry's own `<title>` is empty for a quote because the attribution is
|
|
2000
|
+
// not a title. A row follows the same rule.
|
|
2001
|
+
it("keeps a quote reply's attribution out of its row title", () => {
|
|
2002
|
+
const xml = defaultFeedRenderer(
|
|
2003
|
+
makeFeedData(
|
|
2004
|
+
makeRoot({
|
|
2005
|
+
threadReplies: [
|
|
2006
|
+
makeReply(1, {
|
|
2007
|
+
format: "quote",
|
|
2008
|
+
title: "Marcus Aurelius",
|
|
2009
|
+
quoteText: "What stands in the way becomes the way.",
|
|
2010
|
+
}),
|
|
2011
|
+
],
|
|
2012
|
+
}),
|
|
2013
|
+
),
|
|
2014
|
+
);
|
|
2015
|
+
|
|
2016
|
+
expect(xml).toContain(
|
|
2017
|
+
'<jant:post href="https://example.com/reply-1" format="quote" published="2026-03-21T00:00:00.000Z"/>',
|
|
2018
|
+
);
|
|
2019
|
+
expect(xml).not.toContain('title="Marcus Aurelius"');
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
// Truncation lives in `<summary>`, which renders the root and the newest
|
|
2023
|
+
// reply. A folded post has no block to cut, so its row must stay silent
|
|
2024
|
+
// rather than claim it arrived whole.
|
|
2025
|
+
it("marks every cut post's row, and no folded post's", () => {
|
|
2026
|
+
const longBody = makeBody([
|
|
2027
|
+
"Alpha ".repeat(80).trim(),
|
|
2028
|
+
"Bravo ".repeat(80).trim(),
|
|
2029
|
+
"Charlie ".repeat(80).trim(),
|
|
2030
|
+
"Delta ".repeat(80).trim(),
|
|
2031
|
+
]);
|
|
2032
|
+
const xml = defaultFeedRenderer(
|
|
2033
|
+
makeFeedData(
|
|
2034
|
+
makeRoot({
|
|
2035
|
+
threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) =>
|
|
2036
|
+
makeReply(n, { body: longBody }),
|
|
2037
|
+
),
|
|
2038
|
+
}),
|
|
2039
|
+
),
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
// Reply 1 leads and reply 7 is the hero: both render, so both can be cut.
|
|
2043
|
+
expect(xml).toContain(
|
|
2044
|
+
'<jant:post href="https://example.com/reply-1" format="note" published="2026-03-21T00:00:00.000Z" truncated="true"/>',
|
|
2045
|
+
);
|
|
2046
|
+
expect(xml).toContain(
|
|
2047
|
+
'<jant:post href="https://example.com/reply-7" format="note" published="2026-03-27T00:00:00.000Z" truncated="true"/>',
|
|
2048
|
+
);
|
|
2049
|
+
// Reply 3 is behind the gap. Its body is just as long, and its row says it
|
|
2050
|
+
// was folded rather than that it arrived whole.
|
|
2051
|
+
expect(xml).toContain(
|
|
2052
|
+
'<jant:post href="https://example.com/reply-3" format="note" published="2026-03-23T00:00:00.000Z" folded="true"/>',
|
|
2053
|
+
);
|
|
2054
|
+
// The entry still answers the single-card reader with one boolean.
|
|
2055
|
+
expect(xml).toContain("<jant:truncated/>");
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// Every other decision on a row is declared. This one used to be left to a
|
|
2059
|
+
// consumer reading the summary's shape, which misreads a photo with no
|
|
2060
|
+
// caption: the site renders it, it contributes no text, and it comes out
|
|
2061
|
+
// looking exactly like a post the fold hid.
|
|
2062
|
+
it("declares which posts the fold hid", () => {
|
|
2063
|
+
const xml = defaultFeedRenderer(
|
|
2064
|
+
makeFeedData(
|
|
2065
|
+
makeRoot({
|
|
2066
|
+
threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) => makeReply(n)),
|
|
2067
|
+
}),
|
|
2068
|
+
),
|
|
2069
|
+
);
|
|
2070
|
+
|
|
2071
|
+
const folded = [...xml.matchAll(/<jant:post [^>]*folded="true"[^>]*\/>/g)];
|
|
2072
|
+
expect(folded).toHaveLength(2);
|
|
2073
|
+
// The same two the gap stands for, and the same count `@hidden` reports.
|
|
2074
|
+
expect(xml).toContain(
|
|
2075
|
+
'<jant:post href="https://example.com/reply-3" format="note" published="2026-03-23T00:00:00.000Z" folded="true"/>',
|
|
2076
|
+
);
|
|
2077
|
+
expect(xml).toContain(
|
|
2078
|
+
'<jant:post href="https://example.com/reply-4" format="note" published="2026-03-24T00:00:00.000Z" folded="true"/>',
|
|
2079
|
+
);
|
|
2080
|
+
expect(xml).toContain('hidden="2"');
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
// A caption-less photo is a normal Jant post. It renders on the site, so its
|
|
2084
|
+
// row must not claim the fold hid it — a consumer that believed otherwise
|
|
2085
|
+
// would drop its attachments and the post would vanish.
|
|
2086
|
+
it("leaves a rendered post with no text unfolded", () => {
|
|
2087
|
+
const xml = defaultFeedRenderer(
|
|
2088
|
+
makeFeedData(
|
|
2089
|
+
makeRoot({
|
|
2090
|
+
threadReplies: [
|
|
2091
|
+
makePostView({
|
|
2092
|
+
id: "reply-1",
|
|
2093
|
+
permalink: "/reply-1",
|
|
2094
|
+
slug: "reply-1",
|
|
2095
|
+
media: [
|
|
2096
|
+
makeMediaView({
|
|
2097
|
+
url: "https://example.com/media/photo.jpg",
|
|
2098
|
+
thumbnailUrl: "https://example.com/media/photo.jpg",
|
|
2099
|
+
mimeType: "image/jpeg",
|
|
2100
|
+
}),
|
|
2101
|
+
],
|
|
2102
|
+
}),
|
|
2103
|
+
makeReply(2),
|
|
2104
|
+
],
|
|
2105
|
+
}),
|
|
2106
|
+
),
|
|
2107
|
+
);
|
|
2108
|
+
|
|
2109
|
+
expect(xml).toContain('hidden="0"');
|
|
2110
|
+
expect(xml).not.toContain('folded="true"');
|
|
2111
|
+
// It contributes nothing to the summary, which is exactly why the row has
|
|
2112
|
+
// to speak for it.
|
|
2113
|
+
expect(getSummary(xml)).not.toContain("https://example.com/reply-1");
|
|
2114
|
+
expect(xml).toContain('jant:post="https://example.com/reply-1"');
|
|
2115
|
+
});
|
|
2116
|
+
|
|
2117
|
+
// A reply carries chrome the entry's own fields carry for the root, so a
|
|
2118
|
+
// consumer drawing native cards has to drop it before redrawing from the
|
|
2119
|
+
// row. Matching it by shape is fragile — a body can open with a link.
|
|
2120
|
+
it("wraps a reply's title and source line in a header element", () => {
|
|
2121
|
+
const xml = defaultFeedRenderer(
|
|
2122
|
+
makeFeedData(
|
|
2123
|
+
makeRoot({
|
|
2124
|
+
threadReplies: [
|
|
2125
|
+
makeReply(1, {
|
|
2126
|
+
format: "link",
|
|
2127
|
+
title: "The AeroPress guide",
|
|
2128
|
+
url: "https://other.example/aeropress",
|
|
2129
|
+
}),
|
|
2130
|
+
],
|
|
2131
|
+
}),
|
|
2132
|
+
),
|
|
2133
|
+
);
|
|
2134
|
+
|
|
2135
|
+
expect(getSummary(xml)).toContain(
|
|
2136
|
+
'<header><p><a href="https://other.example/aeropress">other.example</a></p>' +
|
|
2137
|
+
'<h2><a href="https://other.example/aeropress">The AeroPress guide</a></h2></header>',
|
|
2138
|
+
);
|
|
2139
|
+
// The root's chrome stays in the entry's own fields, so its block has no
|
|
2140
|
+
// header to drop and a consumer needs no special case for it.
|
|
2141
|
+
expect(getSummary(xml)?.indexOf("<header>")).toBeGreaterThan(
|
|
2142
|
+
getSummary(xml)?.indexOf("Root body.") ?? -1,
|
|
2143
|
+
);
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
it("gives a reply with no title and no source no header at all", () => {
|
|
2147
|
+
const xml = defaultFeedRenderer(
|
|
2148
|
+
makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
|
|
2149
|
+
);
|
|
2150
|
+
|
|
2151
|
+
expect(getSummary(xml)).not.toContain("<header>");
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
it("lists no thread rows on a lone post", () => {
|
|
2155
|
+
const xml = defaultFeedRenderer(makeFeedData(makeRoot()));
|
|
2156
|
+
|
|
2157
|
+
expect(xml).not.toContain("<jant:post");
|
|
2158
|
+
expect(xml).not.toContain("jant:post=");
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
it("names the post carrying every attachment in a thread, the root's included", () => {
|
|
2162
|
+
const xml = defaultFeedRenderer(
|
|
2163
|
+
makeFeedData(
|
|
2164
|
+
makeRoot({
|
|
2165
|
+
media: [
|
|
2166
|
+
makeMediaView({
|
|
2167
|
+
id: "med_root",
|
|
2168
|
+
url: "https://example.com/media/beans.jpg",
|
|
2169
|
+
thumbnailUrl: "https://example.com/media/beans.jpg",
|
|
2170
|
+
mimeType: "image/jpeg",
|
|
2171
|
+
}),
|
|
2172
|
+
],
|
|
2173
|
+
threadReplies: [
|
|
2174
|
+
makeReply(1, {
|
|
2175
|
+
media: [
|
|
2176
|
+
makeMediaView({
|
|
2177
|
+
id: "med_reply",
|
|
2178
|
+
url: "https://example.com/media/cup.jpg",
|
|
2179
|
+
thumbnailUrl: "https://example.com/media/cup.jpg",
|
|
2180
|
+
mimeType: "image/jpeg",
|
|
2181
|
+
}),
|
|
2182
|
+
],
|
|
2183
|
+
}),
|
|
2184
|
+
],
|
|
2185
|
+
}),
|
|
2186
|
+
),
|
|
2187
|
+
);
|
|
2188
|
+
|
|
2189
|
+
// The root's file names its post too. Guessing wrong here hangs a photo
|
|
2190
|
+
// under the wrong post, which is not what a missing attribute should mean.
|
|
2191
|
+
expect(xml).toContain(
|
|
2192
|
+
'<media:content url="https://example.com/media/beans.jpg" type="image/jpeg" medium="image" jant:post="https://example.com/post-1"/>',
|
|
2193
|
+
);
|
|
2194
|
+
expect(xml).toContain(
|
|
2195
|
+
'<media:content url="https://example.com/media/cup.jpg" type="image/jpeg" medium="image" jant:post="https://example.com/reply-1"/>',
|
|
2196
|
+
);
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
// A lone post's entry has exactly one post, so the attribute would only
|
|
2200
|
+
// repeat `<id>` on every file.
|
|
2201
|
+
it("leaves a lone post's attachment unattributed", () => {
|
|
2202
|
+
const xml = defaultFeedRenderer(
|
|
2203
|
+
makeFeedData(
|
|
2204
|
+
makeRoot({
|
|
2205
|
+
media: [
|
|
2206
|
+
makeMediaView({
|
|
2207
|
+
url: "https://example.com/media/beans.jpg",
|
|
2208
|
+
thumbnailUrl: "https://example.com/media/beans.jpg",
|
|
2209
|
+
mimeType: "image/jpeg",
|
|
2210
|
+
}),
|
|
2211
|
+
],
|
|
2212
|
+
}),
|
|
2213
|
+
),
|
|
2214
|
+
);
|
|
2215
|
+
|
|
2216
|
+
expect(xml).toContain("<media:content");
|
|
2217
|
+
expect(xml).not.toContain("jant:post=");
|
|
2218
|
+
});
|
|
2219
|
+
});
|