@jant/core 0.6.15 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/routes/api/posts.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Bindings } from "../../types.js";
|
|
|
7
7
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import {
|
|
10
|
+
ContentLanguageSchema,
|
|
10
11
|
CreatePostApiSchema,
|
|
11
12
|
UpdatePostApiSchema,
|
|
12
13
|
FormatSchema,
|
|
@@ -16,7 +17,10 @@ import {
|
|
|
16
17
|
} from "../../lib/schemas.js";
|
|
17
18
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
18
19
|
import { toApiAttachment, toApiPost } from "../../lib/api-posts.js";
|
|
20
|
+
import { getPostDisplayTitle } from "../../lib/post-meta.js";
|
|
19
21
|
import { assertFound, NotFoundError, parseIdParam } from "../../lib/errors.js";
|
|
22
|
+
import { AddressQuerySchema, requestInternalPath } from "../../lib/address.js";
|
|
23
|
+
import { toPublicPath } from "../../lib/url.js";
|
|
20
24
|
import { ID_PREFIX } from "../../lib/ids.js";
|
|
21
25
|
import { triggerGitHubSyncInline } from "../../lib/github-sync-trigger.js";
|
|
22
26
|
|
|
@@ -131,12 +135,15 @@ postsApiRoutes.get("/:id/content", requireAuthApi(), async (c) => {
|
|
|
131
135
|
postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
|
|
132
136
|
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
133
137
|
|
|
134
|
-
// Fetch post, media, and
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Fetch post, media, collections and thread position in parallel (all keyed
|
|
139
|
+
// by the same id)
|
|
140
|
+
const [post, mediaList, threadCollections, threadPosition] =
|
|
141
|
+
await Promise.all([
|
|
142
|
+
c.var.services.posts.getById(id),
|
|
143
|
+
c.var.services.media.getByPostId(id),
|
|
144
|
+
c.var.services.collections.getCollectionsByPostId(id),
|
|
145
|
+
c.var.services.posts.getThreadPosition(id),
|
|
146
|
+
]);
|
|
140
147
|
const foundPost = assertFound(post, "Post");
|
|
141
148
|
const {
|
|
142
149
|
r2PublicUrl,
|
|
@@ -147,8 +154,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
|
|
|
147
154
|
} = c.var.appConfig;
|
|
148
155
|
const collectionIds = threadCollections.map((col) => col.id);
|
|
149
156
|
|
|
150
|
-
return c.json(
|
|
151
|
-
toApiPost(foundPost, {
|
|
157
|
+
return c.json({
|
|
158
|
+
...toApiPost(foundPost, {
|
|
152
159
|
collectionIds,
|
|
153
160
|
attachments: mediaList.map((m) =>
|
|
154
161
|
toApiAttachment(
|
|
@@ -161,7 +168,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
|
|
|
161
168
|
),
|
|
162
169
|
),
|
|
163
170
|
}),
|
|
164
|
-
|
|
171
|
+
threadPosition,
|
|
172
|
+
});
|
|
165
173
|
});
|
|
166
174
|
|
|
167
175
|
// Create post (requires auth)
|
|
@@ -192,6 +200,8 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
192
200
|
collectionEntries: body.collectionEntries,
|
|
193
201
|
replyToId: body.replyToId,
|
|
194
202
|
quietReply: body.quietReply,
|
|
203
|
+
language: body.language,
|
|
204
|
+
translationOfId: body.translationOfId,
|
|
195
205
|
publishedAt: body.publishedAt,
|
|
196
206
|
},
|
|
197
207
|
body.attachments,
|
|
@@ -265,6 +275,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
265
275
|
collectionIds: body.collectionIds,
|
|
266
276
|
collectionEntries: body.collectionEntries,
|
|
267
277
|
publishedAt: body.publishedAt,
|
|
278
|
+
language: body.language,
|
|
268
279
|
},
|
|
269
280
|
body.attachments,
|
|
270
281
|
{
|
|
@@ -309,6 +320,158 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
309
320
|
);
|
|
310
321
|
});
|
|
311
322
|
|
|
323
|
+
// =============================================================================
|
|
324
|
+
// Language and translations
|
|
325
|
+
// =============================================================================
|
|
326
|
+
|
|
327
|
+
const SetLanguageSchema = z.object({ language: ContentLanguageSchema });
|
|
328
|
+
const TranslationCandidatesQuerySchema = z.object({
|
|
329
|
+
q: z.string().trim().min(1).max(200),
|
|
330
|
+
limit: z.coerce.number().int().min(1).max(20).optional().default(8),
|
|
331
|
+
});
|
|
332
|
+
const LinkTranslationSchema = z.object({ postId: PostIdSchema });
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Set the content language of a whole Thread.
|
|
336
|
+
*
|
|
337
|
+
* Thread-wide because `post.language` is uniform inside a Thread — see the
|
|
338
|
+
* service method for why every language filter depends on that.
|
|
339
|
+
*/
|
|
340
|
+
postsApiRoutes.put("/:id/language", requireAuthApi(), async (c) => {
|
|
341
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
342
|
+
const { language } = parseValidated(SetLanguageSchema, await c.req.json());
|
|
343
|
+
|
|
344
|
+
await c.var.services.posts.setThreadLanguage(id, language);
|
|
345
|
+
await triggerGitHubSyncInline(c);
|
|
346
|
+
|
|
347
|
+
return c.json({ success: true, language });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/** List the Thread roots this post is a translation of. */
|
|
351
|
+
postsApiRoutes.get("/:id/translations", requireAuthApi(), async (c) => {
|
|
352
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
353
|
+
const post = assertFound(await c.var.services.posts.getById(id), "Post");
|
|
354
|
+
const translations = await c.var.services.posts.listTranslations(
|
|
355
|
+
post.threadId,
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
return c.json({
|
|
359
|
+
translations: translations.map((translation) => ({
|
|
360
|
+
id: translation.id,
|
|
361
|
+
slug: translation.slug,
|
|
362
|
+
title: translation.title,
|
|
363
|
+
// What to show in a list: notes are usually untitled, and a slug is not
|
|
364
|
+
// a name.
|
|
365
|
+
label: getPostDisplayTitle(translation) || translation.slug,
|
|
366
|
+
language: translation.language,
|
|
367
|
+
})),
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Posts this one could be linked to as a translation.
|
|
373
|
+
*
|
|
374
|
+
* The eligibility rules live in the service; the menu only renders what comes
|
|
375
|
+
* back, so it never offers a post the link would refuse.
|
|
376
|
+
*/
|
|
377
|
+
postsApiRoutes.get(
|
|
378
|
+
"/:id/translations/candidates",
|
|
379
|
+
requireAuthApi(),
|
|
380
|
+
async (c) => {
|
|
381
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
382
|
+
const { q, limit } = parseValidated(
|
|
383
|
+
TranslationCandidatesQuerySchema,
|
|
384
|
+
c.req.query(),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const candidates = await c.var.services.posts.listTranslationCandidates(
|
|
388
|
+
id,
|
|
389
|
+
{
|
|
390
|
+
query: q,
|
|
391
|
+
limit,
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
return c.json({
|
|
396
|
+
candidates: candidates.map((post) => ({
|
|
397
|
+
id: post.id,
|
|
398
|
+
slug: post.slug,
|
|
399
|
+
title: post.title,
|
|
400
|
+
label: getPostDisplayTitle(post) || post.slug,
|
|
401
|
+
language: post.language,
|
|
402
|
+
})),
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The Thread a pasted address names, and whether it can be linked to this one.
|
|
409
|
+
*
|
|
410
|
+
* Separate from the candidate search so a URL never quietly becomes search
|
|
411
|
+
* words: this endpoint only ever answers about one address, and answers with a
|
|
412
|
+
* reason when the Thread it names is not eligible.
|
|
413
|
+
*/
|
|
414
|
+
postsApiRoutes.get("/:id/translations/resolve", requireAuthApi(), async (c) => {
|
|
415
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
416
|
+
const { url } = parseValidated(AddressQuerySchema, c.req.query());
|
|
417
|
+
|
|
418
|
+
const path = requestInternalPath(c, url);
|
|
419
|
+
if (path === null) {
|
|
420
|
+
return c.json({ resolution: { kind: "external", address: url.trim() } });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const resolution = await c.var.services.posts.resolveTranslationCandidate(
|
|
424
|
+
id,
|
|
425
|
+
path,
|
|
426
|
+
);
|
|
427
|
+
const address = toPublicPath(path, c.var.appConfig.sitePathPrefix);
|
|
428
|
+
|
|
429
|
+
if (resolution.status === "ok") {
|
|
430
|
+
const post = resolution.post;
|
|
431
|
+
return c.json({
|
|
432
|
+
resolution: {
|
|
433
|
+
kind: "ok",
|
|
434
|
+
address,
|
|
435
|
+
candidate: {
|
|
436
|
+
id: post.id,
|
|
437
|
+
slug: post.slug,
|
|
438
|
+
label: getPostDisplayTitle(post) || post.slug,
|
|
439
|
+
language: post.language,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return c.json({
|
|
446
|
+
resolution: {
|
|
447
|
+
kind: resolution.status,
|
|
448
|
+
address,
|
|
449
|
+
...("language" in resolution ? { language: resolution.language } : {}),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
/** Link two already-published Threads as translations of each other. */
|
|
455
|
+
postsApiRoutes.post("/:id/translations", requireAuthApi(), async (c) => {
|
|
456
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
457
|
+
const { postId } = parseValidated(LinkTranslationSchema, await c.req.json());
|
|
458
|
+
|
|
459
|
+
await c.var.services.posts.linkTranslation(id, postId);
|
|
460
|
+
await triggerGitHubSyncInline(c);
|
|
461
|
+
|
|
462
|
+
return c.json({ success: true });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/** Take this Thread out of its translation group. */
|
|
466
|
+
postsApiRoutes.delete("/:id/translations", requireAuthApi(), async (c) => {
|
|
467
|
+
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
468
|
+
|
|
469
|
+
await c.var.services.posts.unlinkTranslation(id);
|
|
470
|
+
await triggerGitHubSyncInline(c);
|
|
471
|
+
|
|
472
|
+
return c.json({ success: true });
|
|
473
|
+
});
|
|
474
|
+
|
|
312
475
|
// Delete post (requires auth)
|
|
313
476
|
postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
314
477
|
const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
|
|
@@ -52,6 +52,117 @@ describe("Public Archive API Routes", () => {
|
|
|
52
52
|
expect(hidden.visibility).toBe("latest_hidden");
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
it("answers the visibility question it was asked", async () => {
|
|
56
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
57
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
58
|
+
|
|
59
|
+
const plain = await services.posts.create({
|
|
60
|
+
format: "note",
|
|
61
|
+
title: "Plain root",
|
|
62
|
+
bodyMarkdown: "plain",
|
|
63
|
+
});
|
|
64
|
+
const hidden = await services.posts.create({
|
|
65
|
+
format: "note",
|
|
66
|
+
title: "Hidden root",
|
|
67
|
+
bodyMarkdown: "hidden",
|
|
68
|
+
visibility: "latest_hidden",
|
|
69
|
+
});
|
|
70
|
+
const featured = await services.posts.create({
|
|
71
|
+
format: "note",
|
|
72
|
+
title: "Featured root",
|
|
73
|
+
bodyMarkdown: "featured",
|
|
74
|
+
featured: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const hiddenOnly = await app.request(
|
|
78
|
+
"/api/public/archive?visibility=hidden",
|
|
79
|
+
);
|
|
80
|
+
expect(hiddenOnly.status).toBe(200);
|
|
81
|
+
const hiddenBody = await hiddenOnly.json();
|
|
82
|
+
expect(hiddenBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
83
|
+
hidden.id,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const featuredOnly = await app.request(
|
|
87
|
+
"/api/public/archive?visibility=featured",
|
|
88
|
+
);
|
|
89
|
+
const featuredBody = await featuredOnly.json();
|
|
90
|
+
expect(featuredBody.posts.map((p: { id: string }) => p.id)).toEqual([
|
|
91
|
+
featured.id,
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const publicOnly = await app.request(
|
|
95
|
+
"/api/public/archive?visibility=public",
|
|
96
|
+
);
|
|
97
|
+
const publicBody = await publicOnly.json();
|
|
98
|
+
const publicIds = publicBody.posts.map((p: { id: string }) => p.id);
|
|
99
|
+
expect(publicIds).toContain(plain.id);
|
|
100
|
+
expect(publicIds).not.toContain(hidden.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The failure this guards: an unknown key is stripped by the schema, so a
|
|
104
|
+
// caller asking for something this endpoint cannot serve used to get the
|
|
105
|
+
// whole archive back and no way to tell.
|
|
106
|
+
it("rejects a visibility it cannot serve instead of widening", async () => {
|
|
107
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
108
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
109
|
+
|
|
110
|
+
await services.posts.create({
|
|
111
|
+
format: "note",
|
|
112
|
+
title: "Plain root",
|
|
113
|
+
bodyMarkdown: "plain",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
for (const value of ["private", "nonsense"]) {
|
|
117
|
+
const res = await app.request(
|
|
118
|
+
`/api/public/archive?visibility=${value}`,
|
|
119
|
+
);
|
|
120
|
+
expect(res.status).toBe(400);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// `latest_hidden` is the stored spelling of `hidden`, which the page and
|
|
125
|
+
// the feed both read. This endpoint used to be the one surface that called
|
|
126
|
+
// it nonsense — a value it can serve perfectly, rejected only because it
|
|
127
|
+
// kept a private copy of the vocabulary.
|
|
128
|
+
it("reads the stored spelling of hidden, like every other surface", async () => {
|
|
129
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
130
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
131
|
+
|
|
132
|
+
await services.posts.create({
|
|
133
|
+
format: "note",
|
|
134
|
+
title: "Plain root",
|
|
135
|
+
bodyMarkdown: "plain",
|
|
136
|
+
});
|
|
137
|
+
const hidden = await services.posts.create({
|
|
138
|
+
format: "note",
|
|
139
|
+
title: "Hidden root",
|
|
140
|
+
bodyMarkdown: "hidden",
|
|
141
|
+
visibility: "latest_hidden",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const res = await app.request(
|
|
145
|
+
"/api/public/archive?visibility=latest_hidden",
|
|
146
|
+
);
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
const body = await res.json();
|
|
149
|
+
expect(body.posts.map((p: { id: string }) => p.id)).toEqual([hidden.id]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("rejects a parameter it does not know", async () => {
|
|
153
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
154
|
+
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
155
|
+
|
|
156
|
+
await services.posts.create({
|
|
157
|
+
format: "note",
|
|
158
|
+
title: "Plain root",
|
|
159
|
+
bodyMarkdown: "plain",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const res = await app.request("/api/public/archive?formta=note");
|
|
163
|
+
expect(res.status).toBe(400);
|
|
164
|
+
});
|
|
165
|
+
|
|
55
166
|
it("supports format and limit filters with cursor", async () => {
|
|
56
167
|
const { app, services } = createTestApp({ authenticated: false });
|
|
57
168
|
app.route("/api/public/archive", publicArchiveApiRoutes);
|
|
@@ -53,6 +53,44 @@ describe("Public Posts API Routes", () => {
|
|
|
53
53
|
expect(body.posts[0]).not.toHaveProperty("body");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
// Consumers need both axes: `lastActivityAt` answers "when was this
|
|
57
|
+
// announced", `threadUpdatedAt` answers "when did it actually change".
|
|
58
|
+
// Exposing only the first left no way to tell a quiet addition happened.
|
|
59
|
+
it("reports both thread activity timestamps", async () => {
|
|
60
|
+
const { app, services } = createTestApp({ authenticated: false });
|
|
61
|
+
app.route("/api/public/posts", publicPostsApiRoutes);
|
|
62
|
+
|
|
63
|
+
const root = await services.posts.create({
|
|
64
|
+
format: "note",
|
|
65
|
+
title: "Quietly extended",
|
|
66
|
+
bodyMarkdown: "root",
|
|
67
|
+
publishedAt: 1000,
|
|
68
|
+
});
|
|
69
|
+
await services.posts.create({
|
|
70
|
+
format: "note",
|
|
71
|
+
bodyMarkdown: "quiet addendum",
|
|
72
|
+
replyToId: root.id,
|
|
73
|
+
publishedAt: 3000,
|
|
74
|
+
quietReply: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const res = await app.request("/api/public/posts");
|
|
78
|
+
expect(res.status).toBe(200);
|
|
79
|
+
const body = (await res.json()) as {
|
|
80
|
+
posts: {
|
|
81
|
+
title: string | null;
|
|
82
|
+
quietReply: boolean;
|
|
83
|
+
lastActivityAt: number;
|
|
84
|
+
threadUpdatedAt: number;
|
|
85
|
+
}[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const entry = body.posts.find((p) => p.title === "Quietly extended");
|
|
89
|
+
expect(entry?.lastActivityAt).toBe(1000);
|
|
90
|
+
expect(entry?.threadUpdatedAt).toBe(3000);
|
|
91
|
+
expect(entry?.quietReply).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
56
94
|
it("supports format and limit filters", async () => {
|
|
57
95
|
const { app, services } = createTestApp({ authenticated: false });
|
|
58
96
|
app.route("/api/public/posts", publicPostsApiRoutes);
|
|
@@ -2,8 +2,17 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { Bindings } from "../../../types.js";
|
|
4
4
|
import type { AppVariables } from "../../../types/app-context.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../../types.js";
|
|
6
|
+
import { ContentLanguageSchema, parseValidated } from "../../../lib/schemas.js";
|
|
7
|
+
import type { DimensionContext } from "../../../lib/filter-dimensions.js";
|
|
8
|
+
import {
|
|
9
|
+
buildCollectionVocabulary,
|
|
10
|
+
EMPTY_COLLECTION_VOCABULARY,
|
|
11
|
+
parsePostFilterSelectionStrict,
|
|
12
|
+
readCollectionSlugs,
|
|
13
|
+
toPostFilters,
|
|
14
|
+
} from "../../../lib/filter-dimensions.js";
|
|
15
|
+
import { ValidationError } from "../../../lib/errors.js";
|
|
7
16
|
import { toPublicPost } from "./posts.js";
|
|
8
17
|
import { requirePublicApiEnabled } from "../../../middleware/public-content-access.js";
|
|
9
18
|
|
|
@@ -13,104 +22,91 @@ export const publicArchiveApiRoutes = new Hono<Env>();
|
|
|
13
22
|
|
|
14
23
|
publicArchiveApiRoutes.use("*", requirePublicApiEnabled());
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.enum(["any", "none"])
|
|
24
|
-
.transform((value) => value === "any");
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The endpoint's own parameters — everything that is not a filter dimension.
|
|
27
|
+
*
|
|
28
|
+
* The dimensions themselves are read by the shared registry, so this endpoint
|
|
29
|
+
* cannot drift from the page it mirrors. They are listed here only so the strict
|
|
30
|
+
* parser can tell them apart from a parameter nobody understands.
|
|
31
|
+
*/
|
|
26
32
|
const ListPublicArchiveQuerySchema = z.object({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
year: z.coerce.number().int().min(1971).optional(),
|
|
30
|
-
// Kinds list (image,video,...) or presence words: any = posts with any
|
|
31
|
-
// attachment, none = posts without attachments.
|
|
32
|
-
media: z
|
|
33
|
-
.string()
|
|
34
|
-
.optional()
|
|
35
|
-
.transform((value, ctx) => {
|
|
36
|
-
if (!value) return undefined;
|
|
37
|
-
if (value === "any" || value === "none") return value;
|
|
38
|
-
const parts = value
|
|
39
|
-
.split(",")
|
|
40
|
-
.map((part) => part.trim())
|
|
41
|
-
.filter((part) => part.length > 0);
|
|
42
|
-
if (parts.length === 0) return undefined;
|
|
43
|
-
const result = z.array(MediaKindSchema).safeParse(parts);
|
|
44
|
-
if (!result.success) {
|
|
45
|
-
ctx.addIssue({
|
|
46
|
-
code: z.ZodIssueCode.custom,
|
|
47
|
-
message: INVALID_MEDIA_MESSAGE,
|
|
48
|
-
});
|
|
49
|
-
return z.NEVER;
|
|
50
|
-
}
|
|
51
|
-
return result.data;
|
|
52
|
-
}),
|
|
53
|
-
title: PresenceSchema.optional(),
|
|
54
|
-
replies: PresenceSchema.optional(),
|
|
55
|
-
// Deprecated: use media=any|none and title=any|none instead.
|
|
56
|
-
hasMedia: BoolFlagSchema.optional(),
|
|
57
|
-
hasTitle: BoolFlagSchema.optional(),
|
|
33
|
+
/** Restrict to one content language. See `/api/public/posts`. */
|
|
34
|
+
lang: ContentLanguageSchema.optional(),
|
|
58
35
|
cursor: z.string().optional(),
|
|
59
36
|
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
|
|
60
37
|
content: z.enum(["markdown"]).optional(),
|
|
61
38
|
});
|
|
62
39
|
|
|
40
|
+
const ENDPOINT_PARAMS = ["lang", "cursor", "limit", "content"] as const;
|
|
41
|
+
|
|
42
|
+
const INVALID_VISIBILITY_MESSAGE =
|
|
43
|
+
"Invalid visibility value. Allowed: " +
|
|
44
|
+
PUBLIC_ARCHIVE_VISIBILITIES.map((value) =>
|
|
45
|
+
value === "latest_hidden" ? "hidden" : value,
|
|
46
|
+
).join(", ");
|
|
47
|
+
|
|
63
48
|
publicArchiveApiRoutes.get("/", async (c) => {
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
media,
|
|
69
|
-
title,
|
|
70
|
-
replies,
|
|
71
|
-
hasMedia,
|
|
72
|
-
hasTitle,
|
|
73
|
-
cursor,
|
|
74
|
-
limit,
|
|
75
|
-
content,
|
|
76
|
-
} = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
|
|
49
|
+
const { lang, cursor, limit, content } = parseValidated(
|
|
50
|
+
ListPublicArchiveQuerySchema,
|
|
51
|
+
c.req.query(),
|
|
52
|
+
);
|
|
77
53
|
|
|
78
|
-
const
|
|
79
|
-
const mediaPresence =
|
|
80
|
-
media === "any" ? true : media === "none" ? false : undefined;
|
|
54
|
+
const read = (key: string): string | undefined => c.req.query(key);
|
|
81
55
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
56
|
+
// Resolving a collection slug costs a round trip, so it is paid for only when
|
|
57
|
+
// the caller named one.
|
|
58
|
+
const slugs = readCollectionSlugs(read);
|
|
59
|
+
const ctx: DimensionContext = {
|
|
60
|
+
collections:
|
|
61
|
+
slugs.length > 0
|
|
62
|
+
? buildCollectionVocabulary(await c.var.services.collections.list())
|
|
63
|
+
: EMPTY_COLLECTION_VOCABULARY,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const parsed = parsePostFilterSelectionStrict(
|
|
67
|
+
read,
|
|
68
|
+
Object.keys(c.req.query()),
|
|
69
|
+
ctx,
|
|
70
|
+
{ allow: ENDPOINT_PARAMS },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// A collection slug that names nothing is answered as an empty result rather
|
|
74
|
+
// than a rejection: the caller's words were understood, and this site simply
|
|
75
|
+
// holds no such collection.
|
|
76
|
+
if (
|
|
77
|
+
!parsed.ok &&
|
|
78
|
+
parsed.issues.every((issue) => issue.param === "collection")
|
|
79
|
+
) {
|
|
80
|
+
return c.json({ posts: [], nextCursor: null });
|
|
81
|
+
}
|
|
82
|
+
if (!parsed.ok) {
|
|
83
|
+
const first = parsed.issues[0];
|
|
84
|
+
throw new ValidationError(first?.message ?? "Validation failed");
|
|
92
85
|
}
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
// `private` names a set no anonymous caller may see. Rejected rather than
|
|
88
|
+
// dropped: silently widening the result to the whole archive would answer a
|
|
89
|
+
// different question under the caller's own words.
|
|
90
|
+
const visibility = parsed.selection.visibility;
|
|
91
|
+
if (
|
|
92
|
+
visibility !== undefined &&
|
|
93
|
+
!(PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(visibility)
|
|
94
|
+
) {
|
|
95
|
+
throw new ValidationError(INVALID_VISIBILITY_MESSAGE);
|
|
96
|
+
}
|
|
98
97
|
|
|
99
98
|
const posts = await c.var.services.posts.list({
|
|
100
|
-
|
|
101
|
-
collectionIds,
|
|
99
|
+
lang,
|
|
102
100
|
status: "published",
|
|
103
101
|
cursor: cursor ?? undefined,
|
|
104
102
|
limit,
|
|
105
103
|
excludePrivate: true,
|
|
106
104
|
excludeLatestHidden: false,
|
|
107
105
|
excludeReplies: true,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
hasTitle: title ?? hasTitle,
|
|
113
|
-
hasReplies: replies,
|
|
106
|
+
// Ordering is fixed at newest-published-first here, so the year filter
|
|
107
|
+
// stays on the publication axis — the page's `?sort=` switch has no
|
|
108
|
+
// counterpart on a cursor keyed off the post id.
|
|
109
|
+
...toPostFilters(parsed.selection, { yearAxis: "published" }),
|
|
114
110
|
ignorePinnedSort: true,
|
|
115
111
|
});
|
|
116
112
|
|
|
@@ -5,6 +5,7 @@ import type { AppVariables } from "../../../types/app-context.js";
|
|
|
5
5
|
import { getCollectionPagePath } from "../../../lib/collection-paths.js";
|
|
6
6
|
import {
|
|
7
7
|
CollectionSortOrderSchema,
|
|
8
|
+
ContentLanguageSchema,
|
|
8
9
|
FormatSchema,
|
|
9
10
|
parseValidated,
|
|
10
11
|
} from "../../../lib/schemas.js";
|
|
@@ -31,6 +32,11 @@ publicPostsApiRoutes.use("*", requirePublicApiEnabled());
|
|
|
31
32
|
|
|
32
33
|
const ListPublicPostsQuerySchema = z.object({
|
|
33
34
|
format: FormatSchema.optional(),
|
|
35
|
+
/**
|
|
36
|
+
* Restrict to one content language. Machine surfaces name the language
|
|
37
|
+
* explicitly; browsing surfaces express it as a URL prefix instead.
|
|
38
|
+
*/
|
|
39
|
+
lang: ContentLanguageSchema.optional(),
|
|
34
40
|
collection: z.string().optional(),
|
|
35
41
|
sort: CollectionSortOrderSchema.optional(),
|
|
36
42
|
cursor: z.string().optional(),
|
|
@@ -61,10 +67,25 @@ export type PublicPostBaseResponse = {
|
|
|
61
67
|
previewImageUrl: string | null;
|
|
62
68
|
replyToId: string | null;
|
|
63
69
|
threadId: string;
|
|
70
|
+
/**
|
|
71
|
+
* BCP 47 content language, or null on a post written before the site
|
|
72
|
+
* enabled multilingual content.
|
|
73
|
+
*/
|
|
74
|
+
language: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Shared by every translation of this Thread. Null when the Thread has no
|
|
77
|
+
* translations.
|
|
78
|
+
*/
|
|
79
|
+
translationGroupId: string | null;
|
|
80
|
+
/** Reply published without announcing its Thread. Always false on roots. */
|
|
81
|
+
quietReply: boolean;
|
|
64
82
|
pinnedAt: number | null;
|
|
65
83
|
featuredAt: number | null;
|
|
66
84
|
publishedAt: number | null;
|
|
85
|
+
/** Root only: newest post in the Thread, quiet replies excluded. */
|
|
67
86
|
lastActivityAt: number;
|
|
87
|
+
/** Root only: newest post in the Thread, quiet replies included. */
|
|
88
|
+
threadUpdatedAt: number;
|
|
68
89
|
createdAt: number;
|
|
69
90
|
updatedAt: number;
|
|
70
91
|
attachments: ReturnType<typeof toApiAttachment>[];
|
|
@@ -86,8 +107,7 @@ export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
|
|
|
86
107
|
};
|
|
87
108
|
|
|
88
109
|
export type PublicPostResponse =
|
|
89
|
-
|
|
|
90
|
-
| PublicPostMarkdownResponse;
|
|
110
|
+
PublicPostRenderedResponse | PublicPostMarkdownResponse;
|
|
91
111
|
|
|
92
112
|
function isPublicDetailVisible(post: Post | null): post is Post {
|
|
93
113
|
return (
|
|
@@ -146,10 +166,14 @@ export function toPublicPost(
|
|
|
146
166
|
previewImageUrl,
|
|
147
167
|
replyToId: post.replyToId,
|
|
148
168
|
threadId: post.threadId,
|
|
169
|
+
language: post.language,
|
|
170
|
+
translationGroupId: post.translationGroupId,
|
|
171
|
+
quietReply: post.quietReply,
|
|
149
172
|
pinnedAt: post.pinnedAt,
|
|
150
173
|
featuredAt: post.featuredAt,
|
|
151
174
|
publishedAt: post.publishedAt,
|
|
152
175
|
lastActivityAt: post.lastActivityAt,
|
|
176
|
+
threadUpdatedAt: post.threadUpdatedAt,
|
|
153
177
|
createdAt: post.createdAt,
|
|
154
178
|
updatedAt: post.updatedAt,
|
|
155
179
|
attachments: mediaList.map((media) =>
|
|
@@ -197,10 +221,8 @@ export function toPublicPost(
|
|
|
197
221
|
}
|
|
198
222
|
|
|
199
223
|
publicPostsApiRoutes.get("/", async (c) => {
|
|
200
|
-
const { format, collection, sort, cursor, limit, content } =
|
|
201
|
-
ListPublicPostsQuerySchema,
|
|
202
|
-
c.req.query(),
|
|
203
|
-
);
|
|
224
|
+
const { format, lang, collection, sort, cursor, limit, content } =
|
|
225
|
+
parseValidated(ListPublicPostsQuerySchema, c.req.query());
|
|
204
226
|
|
|
205
227
|
// Resolve collection slug(s) — accepts comma-separated (e.g. "tech,art")
|
|
206
228
|
// or "+" separated (e.g. "tech+art"), matching the page URL convention.
|
|
@@ -258,6 +280,7 @@ publicPostsApiRoutes.get("/", async (c) => {
|
|
|
258
280
|
excludePrivate: true,
|
|
259
281
|
excludeLatestHidden: true,
|
|
260
282
|
rootFormat: format,
|
|
283
|
+
lang,
|
|
261
284
|
sortOrder,
|
|
262
285
|
cursor: cursor ?? undefined,
|
|
263
286
|
limit,
|
|
@@ -265,6 +288,7 @@ publicPostsApiRoutes.get("/", async (c) => {
|
|
|
265
288
|
)
|
|
266
289
|
: await c.var.services.posts.list({
|
|
267
290
|
format,
|
|
291
|
+
lang,
|
|
268
292
|
status: "published",
|
|
269
293
|
cursor: cursor ?? undefined,
|
|
270
294
|
limit,
|