@jant/core 0.6.14 → 0.6.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/commands/import-site.js +88 -13
- package/bin/commands/migrate.js +55 -0
- package/bin/commands/posts/rebuild-html.js +184 -0
- package/bin/commands/site/snapshot/import.js +14 -10
- package/bin/lib/d1-query.js +23 -5
- package/bin/lib/migration-runner.js +29 -7
- package/bin/lib/node-database.js +32 -0
- package/bin/lib/site-snapshot.js +347 -46
- package/bin/lib/sql-export.js +1 -1
- package/bin/lib/thread-collection-migration.js +326 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +7 -7
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +11 -10
- package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
- package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
- package/src/__tests__/export-hugo-build.test.ts +63 -4
- package/src/__tests__/export-import-roundtrip.test.ts +6 -4
- package/src/__tests__/export-service.test.ts +142 -9
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +2 -0
- package/src/__tests__/import-site-command.test.ts +65 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/vite-config.test.ts +45 -0
- package/src/app.tsx +16 -3
- package/src/client/__tests__/collection-created-notice.test.ts +36 -0
- package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
- package/src/client/__tests__/collection-page-actions.test.ts +71 -2
- package/src/client/__tests__/compose-bridge.test.ts +316 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/feed-video-player.test.ts +16 -1
- package/src/client/__tests__/footnote-rail.test.ts +472 -0
- package/src/client/__tests__/hydrate-partial.test.ts +3 -0
- package/src/client/__tests__/toast.test.ts +82 -1
- package/src/client/collection-created-notice.ts +66 -0
- package/src/client/collection-form-bridge.ts +17 -0
- package/src/client/collection-navigation.ts +44 -0
- package/src/client/collection-page-actions.ts +57 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
- package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
- package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
- package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
- package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-manager-types.ts +9 -3
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +55 -3
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +14 -7
- package/src/client/components/jant-compose-dialog.ts +1542 -694
- package/src/client/components/jant-compose-editor.ts +237 -99
- package/src/client/components/jant-compose-fullscreen.ts +27 -9
- package/src/client/components/jant-config-editor.ts +722 -0
- package/src/client/components/jant-media-lightbox.ts +100 -18
- package/src/client/components/jant-nav-manager.ts +1283 -54
- package/src/client/components/jant-post-form.ts +3 -3
- package/src/client/components/jant-post-menu.ts +177 -95
- package/src/client/components/nav-manager-types.ts +51 -2
- package/src/client/components/post-form-types.ts +1 -1
- package/src/client/compose-bridge.ts +147 -123
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/feed-video-player.ts +62 -2
- package/src/client/footnote-rail.ts +478 -0
- package/src/client/hydrate-partial.ts +2 -0
- package/src/client/media-lightbox-events.ts +63 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/site-header-fragment.ts +38 -0
- package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
- package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
- package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
- package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
- package/src/client/tiptap/bubble-menu.ts +88 -10
- package/src/client/tiptap/continuous-lists.ts +102 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +14 -5
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/markdown-clipboard.ts +608 -11
- package/src/client/tiptap/slash-commands.ts +8 -1
- package/src/client/tiptap/structural-keymap.ts +211 -0
- package/src/client/tiptap/tab-indent.ts +8 -22
- package/src/client/tiptap/table-control-labels.ts +30 -0
- package/src/client/tiptap/table-controls.ts +537 -0
- package/src/client/tiptap/table-size-picker.ts +254 -0
- package/src/client/toast.ts +133 -39
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/d1-query.test.ts +43 -1
- package/src/db/__tests__/migration-runner.test.ts +63 -0
- package/src/db/__tests__/migrations.test.ts +469 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0027_old_blue_blade.sql +39 -0
- package/src/db/migrations/0028_superb_post.sql +2 -0
- package/src/db/migrations/0029_boring_magma.sql +1 -0
- package/src/db/migrations/0030_typical_vivisector.sql +50 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0027_snapshot.json +2511 -0
- package/src/db/migrations/meta/0028_snapshot.json +2511 -0
- package/src/db/migrations/meta/0029_snapshot.json +2519 -0
- package/src/db/migrations/meta/0030_snapshot.json +2541 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +35 -0
- package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
- package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
- package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
- package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
- package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +35 -0
- package/src/db/pg/schema.ts +46 -20
- package/src/db/rehearsal-fixtures/demo-current.json +39 -4
- package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
- package/src/db/schema.ts +48 -20
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +222 -51
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +216 -45
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +216 -45
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +513 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +515 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +515 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/api-settings.test.ts +299 -0
- package/src/lib/__tests__/constants.test.ts +5 -0
- package/src/lib/__tests__/feed-policy.test.ts +25 -0
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
- package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
- package/src/lib/__tests__/markdown.test.ts +24 -6
- package/src/lib/__tests__/navigation.test.ts +59 -2
- package/src/lib/__tests__/post-body-html.test.ts +176 -0
- package/src/lib/__tests__/post-display.test.ts +64 -0
- package/src/lib/__tests__/resolve-config.test.ts +113 -1
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/summary.test.ts +82 -0
- package/src/lib/__tests__/timeline.test.ts +109 -70
- package/src/lib/__tests__/timezones.test.ts +31 -3
- package/src/lib/__tests__/tiptap-render.test.ts +268 -9
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/__tests__/view.test.ts +53 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
- package/src/lib/api-settings.ts +239 -12
- package/src/lib/collection-sort.ts +7 -5
- package/src/lib/constants.ts +2 -0
- package/src/lib/feed-path.ts +25 -0
- package/src/lib/feed-policy.ts +64 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +15 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +193 -5
- package/src/lib/markdown.ts +10 -3
- package/src/lib/navigation.ts +16 -3
- package/src/lib/post-body-html.ts +131 -0
- package/src/lib/post-display.ts +47 -8
- package/src/lib/render.tsx +9 -2
- package/src/lib/resolve-config.ts +100 -16
- package/src/lib/schemas.ts +133 -1
- package/src/lib/settings-paths.ts +5 -0
- package/src/lib/site-skill.ts +78 -0
- package/src/lib/summary.ts +16 -5
- package/src/lib/timeline.ts +137 -131
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +359 -83
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +58 -20
- package/src/lib/worker-response-cache.ts +11 -18
- package/src/middleware/__tests__/public-content-access.test.ts +91 -0
- package/src/middleware/config.ts +2 -2
- package/src/middleware/public-content-access.ts +87 -0
- package/src/node/__tests__/cli-export.test.ts +14 -0
- package/src/node/__tests__/cli-migrate.test.ts +6 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
- package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
- package/src/node/index.ts +2 -1
- package/src/preset.css +16 -2
- package/src/routes/__tests__/compose.test.ts +51 -0
- package/src/routes/__tests__/site-skill.test.ts +112 -0
- package/src/routes/api/__tests__/collections.test.ts +91 -19
- package/src/routes/api/__tests__/mcp.test.ts +65 -1
- package/src/routes/api/__tests__/nav-items.test.ts +91 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/public-access.test.ts +81 -0
- package/src/routes/api/__tests__/settings.test.ts +217 -0
- package/src/routes/api/collections.ts +22 -26
- package/src/routes/api/export.ts +2 -0
- package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
- package/src/routes/api/internal/post-body-html.ts +43 -0
- package/src/routes/api/internal/sites.ts +24 -0
- package/src/routes/api/nav-items.ts +24 -1
- package/src/routes/api/posts.ts +14 -10
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +246 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +48 -24
- package/src/routes/api/search.ts +3 -0
- package/src/routes/api/settings.ts +33 -0
- package/src/routes/auth/__tests__/setup.test.ts +7 -4
- package/src/routes/compose.tsx +8 -3
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
- package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
- package/src/routes/pages/__tests__/collections.test.ts +9 -9
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +176 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +90 -22
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +86 -11
- package/src/routes/site-skill.ts +18 -0
- package/src/services/__tests__/auth.test.ts +7 -5
- package/src/services/__tests__/collection.test.ts +190 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +249 -39
- package/src/services/__tests__/post.test.ts +844 -5
- package/src/services/__tests__/search.test.ts +48 -1
- package/src/services/__tests__/settings.test.ts +8 -0
- package/src/services/__tests__/site-admin.test.ts +121 -1
- package/src/services/auth.ts +2 -2
- package/src/services/bootstrap.ts +3 -2
- package/src/services/collection.ts +224 -167
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +46 -25
- package/src/services/export-theme/layouts/_default/list.html +18 -4
- package/src/services/export-theme/layouts/_default/rss.xml +8 -12
- package/src/services/export-theme/layouts/featured/list.html +5 -4
- package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
- package/src/services/export-theme/styles/main.css +30 -2
- package/src/services/export.ts +94 -85
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +1142 -342
- package/src/services/search.ts +17 -2
- package/src/services/settings.ts +2 -10
- package/src/services/site-admin.ts +63 -43
- package/src/style-cjk-jp.css +1 -1
- package/src/style-cjk-kr.css +1 -1
- package/src/style-cjk-tc.css +1 -1
- package/src/style-cjk.css +1 -1
- package/src/styles/components.css +540 -14
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +76 -10
- package/src/styles/ui.css +2227 -1347
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +243 -12
- package/src/types/constants.ts +22 -1
- package/src/types/entities.ts +14 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +20 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +22 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +109 -19
- package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
- package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
- package/src/ui/dash/settings/GeneralContent.tsx +15 -5
- package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
- package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
- package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
- package/src/ui/feed/LinkCard.tsx +2 -12
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +10 -20
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/feed/QuoteCard.tsx +2 -12
- package/src/ui/feed/ThreadPreview.tsx +26 -34
- package/src/ui/feed/TimelineFeed.tsx +2 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
- package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
- package/src/ui/feed/thread-preview-state.ts +5 -7
- package/src/ui/font-themes.ts +74 -26
- package/src/ui/layouts/BaseLayout.tsx +22 -8
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/CollectionPage.tsx +101 -22
- package/src/ui/pages/CollectionsPage.tsx +3 -1
- package/src/ui/pages/PostPage.tsx +39 -12
- package/src/ui/pages/SearchPage.tsx +6 -14
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
- package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -9
- package/src/ui/shared/CollectionsManager.tsx +17 -7
- package/src/ui/shared/DraftPreviewBar.tsx +49 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +38 -13
- package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
- package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
- package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
- package/src/ui/shared/collection-management-labels.ts +33 -2
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +52 -0
- package/dist/app-DZDlmh7C.js +0 -6
- package/dist/client/_assets/client-Cevfpm8H.css +0 -2
- package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
- package/src/client/tiptap/wrapping-input-rules.ts +0 -102
|
@@ -4,13 +4,16 @@ import {
|
|
|
4
4
|
createTestDatabase,
|
|
5
5
|
DEFAULT_TEST_SITE_ID,
|
|
6
6
|
} from "../../__tests__/helpers/db.js";
|
|
7
|
-
import {
|
|
7
|
+
import { threadCollections, posts, sites } from "../../db/schema.js";
|
|
8
8
|
import { createPostService } from "../post.js";
|
|
9
9
|
import { createMediaService } from "../media.js";
|
|
10
10
|
import { createCollectionService } from "../collection.js";
|
|
11
|
+
import { createNavItemService } from "../navigation.js";
|
|
11
12
|
import type { Database } from "../../db/index.js";
|
|
12
13
|
import { createPathService } from "../path.js";
|
|
13
14
|
import type { MediaService } from "../media.js";
|
|
15
|
+
import { POST_BODY_HTML_VERSION } from "../../lib/post-body-html.js";
|
|
16
|
+
import type BetterSqlite3 from "better-sqlite3";
|
|
14
17
|
|
|
15
18
|
function createMockStorage() {
|
|
16
19
|
const files = new Map<string, { body: Uint8Array; contentType?: string }>();
|
|
@@ -44,12 +47,14 @@ function createMockStorage() {
|
|
|
44
47
|
|
|
45
48
|
describe("PostService", () => {
|
|
46
49
|
let db: Database;
|
|
50
|
+
let sqlite: BetterSqlite3.Database;
|
|
47
51
|
let postService: ReturnType<typeof createPostService>;
|
|
48
52
|
let collectionService: ReturnType<typeof createCollectionService>;
|
|
49
53
|
|
|
50
54
|
beforeEach(() => {
|
|
51
55
|
const testDb = createTestDatabase();
|
|
52
56
|
db = testDb.db as unknown as Database;
|
|
57
|
+
sqlite = testDb.sqlite;
|
|
53
58
|
postService = createPostService(
|
|
54
59
|
db,
|
|
55
60
|
{ slugIdLength: 5 },
|
|
@@ -152,6 +157,26 @@ describe("PostService", () => {
|
|
|
152
157
|
expect(post.bodyHtml).toContain("<strong>bold</strong>");
|
|
153
158
|
});
|
|
154
159
|
|
|
160
|
+
it("writes current namespaced body HTML projection metadata", async () => {
|
|
161
|
+
const post = await postService.create({
|
|
162
|
+
format: "note",
|
|
163
|
+
bodyMarkdown: "Body[^1]\n\n[^1]: Definition",
|
|
164
|
+
});
|
|
165
|
+
const row = await db
|
|
166
|
+
.select({
|
|
167
|
+
bodyHtml: posts.bodyHtml,
|
|
168
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
169
|
+
})
|
|
170
|
+
.from(posts)
|
|
171
|
+
.where(eq(posts.id, post.id))
|
|
172
|
+
.limit(1);
|
|
173
|
+
|
|
174
|
+
expect(row[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
|
|
175
|
+
expect(row[0]?.bodyHtml).toMatch(/id="fn-[a-z0-9]{13}-1"/);
|
|
176
|
+
expect(row[0]?.bodyHtml).not.toContain(post.id);
|
|
177
|
+
expect(post.bodyHtml).toContain('role="doc-endnotes"');
|
|
178
|
+
});
|
|
179
|
+
|
|
155
180
|
it("sets publishedAt and timestamps", async () => {
|
|
156
181
|
const post = await postService.create({
|
|
157
182
|
format: "note",
|
|
@@ -344,6 +369,16 @@ describe("PostService", () => {
|
|
|
344
369
|
).rejects.toThrow("Parent post not found");
|
|
345
370
|
});
|
|
346
371
|
|
|
372
|
+
it("rejects reserved paths that would otherwise become aliases", async () => {
|
|
373
|
+
await expect(
|
|
374
|
+
postService.create({
|
|
375
|
+
format: "note",
|
|
376
|
+
bodyMarkdown: "reserved path",
|
|
377
|
+
path: "skill.md",
|
|
378
|
+
}),
|
|
379
|
+
).rejects.toThrow('Path "skill.md" is reserved and cannot be used');
|
|
380
|
+
});
|
|
381
|
+
|
|
347
382
|
it("rolls back the post insert when slug persistence fails inside the batch", async () => {
|
|
348
383
|
await postService.create({
|
|
349
384
|
format: "note",
|
|
@@ -683,6 +718,55 @@ describe("PostService", () => {
|
|
|
683
718
|
expect(notFeatured[0]?.bodyText).toBe("normal post");
|
|
684
719
|
});
|
|
685
720
|
|
|
721
|
+
it("orders Featured Post lists and cursors by publishedAt", async () => {
|
|
722
|
+
const oldest = await postService.create({
|
|
723
|
+
format: "note",
|
|
724
|
+
bodyMarkdown: "oldest",
|
|
725
|
+
featured: true,
|
|
726
|
+
publishedAt: 1000,
|
|
727
|
+
});
|
|
728
|
+
const middle = await postService.create({
|
|
729
|
+
format: "note",
|
|
730
|
+
bodyMarkdown: "middle",
|
|
731
|
+
featured: true,
|
|
732
|
+
publishedAt: 2000,
|
|
733
|
+
});
|
|
734
|
+
const newest = await postService.create({
|
|
735
|
+
format: "note",
|
|
736
|
+
bodyMarkdown: "newest",
|
|
737
|
+
featured: true,
|
|
738
|
+
publishedAt: 3000,
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
await db
|
|
742
|
+
.update(posts)
|
|
743
|
+
.set({ featuredAt: 9000 })
|
|
744
|
+
.where(eq(posts.id, oldest.id));
|
|
745
|
+
await db
|
|
746
|
+
.update(posts)
|
|
747
|
+
.set({ featuredAt: 8000 })
|
|
748
|
+
.where(eq(posts.id, middle.id));
|
|
749
|
+
await db
|
|
750
|
+
.update(posts)
|
|
751
|
+
.set({ featuredAt: 7000 })
|
|
752
|
+
.where(eq(posts.id, newest.id));
|
|
753
|
+
|
|
754
|
+
const firstPage = await postService.list({
|
|
755
|
+
featured: true,
|
|
756
|
+
ignorePinnedSort: true,
|
|
757
|
+
limit: 2,
|
|
758
|
+
});
|
|
759
|
+
const secondPage = await postService.list({
|
|
760
|
+
featured: true,
|
|
761
|
+
ignorePinnedSort: true,
|
|
762
|
+
cursor: middle.id,
|
|
763
|
+
limit: 2,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
expect(firstPage.map((post) => post.id)).toEqual([newest.id, middle.id]);
|
|
767
|
+
expect(secondPage.map((post) => post.id)).toEqual([oldest.id]);
|
|
768
|
+
});
|
|
769
|
+
|
|
686
770
|
it("excludes posts hidden from Latest when requested", async () => {
|
|
687
771
|
await postService.create({
|
|
688
772
|
format: "note",
|
|
@@ -1072,9 +1156,9 @@ describe("PostService", () => {
|
|
|
1072
1156
|
rating: i + 1,
|
|
1073
1157
|
});
|
|
1074
1158
|
|
|
1075
|
-
await db.insert(
|
|
1159
|
+
await db.insert(threadCollections).values({
|
|
1076
1160
|
siteId: DEFAULT_TEST_SITE_ID,
|
|
1077
|
-
|
|
1161
|
+
threadId: post.id,
|
|
1078
1162
|
collectionId: collection.id,
|
|
1079
1163
|
createdAt: 100 + i,
|
|
1080
1164
|
});
|
|
@@ -1298,6 +1382,76 @@ describe("PostService", () => {
|
|
|
1298
1382
|
}
|
|
1299
1383
|
});
|
|
1300
1384
|
|
|
1385
|
+
it("updates the shared Thread collections when editing a child", async () => {
|
|
1386
|
+
const firstCollection = await collectionService.create({
|
|
1387
|
+
slug: "first-thread-collection",
|
|
1388
|
+
title: "First",
|
|
1389
|
+
});
|
|
1390
|
+
const secondCollection = await collectionService.create({
|
|
1391
|
+
slug: "second-thread-collection",
|
|
1392
|
+
title: "Second",
|
|
1393
|
+
});
|
|
1394
|
+
const root = await postService.create({
|
|
1395
|
+
format: "note",
|
|
1396
|
+
bodyMarkdown: "root",
|
|
1397
|
+
collectionIds: [firstCollection.id],
|
|
1398
|
+
});
|
|
1399
|
+
const child = await postService.create({
|
|
1400
|
+
format: "note",
|
|
1401
|
+
bodyMarkdown: "child",
|
|
1402
|
+
replyToId: root.id,
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
expect(
|
|
1406
|
+
(await collectionService.getCollectionsByPostId(child.id)).map(
|
|
1407
|
+
(collection) => collection.id,
|
|
1408
|
+
),
|
|
1409
|
+
).toEqual([firstCollection.id]);
|
|
1410
|
+
|
|
1411
|
+
await postService.update(child.id, {
|
|
1412
|
+
collectionIds: [secondCollection.id],
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const [rootCollections, childCollections, rows] = await Promise.all([
|
|
1416
|
+
collectionService.getCollectionsByPostId(root.id),
|
|
1417
|
+
collectionService.getCollectionsByPostId(child.id),
|
|
1418
|
+
db.select().from(threadCollections),
|
|
1419
|
+
]);
|
|
1420
|
+
expect(rootCollections.map((collection) => collection.id)).toEqual([
|
|
1421
|
+
secondCollection.id,
|
|
1422
|
+
]);
|
|
1423
|
+
expect(childCollections.map((collection) => collection.id)).toEqual([
|
|
1424
|
+
secondCollection.id,
|
|
1425
|
+
]);
|
|
1426
|
+
expect(rows).toEqual([
|
|
1427
|
+
expect.objectContaining({
|
|
1428
|
+
threadId: root.id,
|
|
1429
|
+
collectionId: secondCollection.id,
|
|
1430
|
+
}),
|
|
1431
|
+
]);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it("rejects Collection membership while creating a child", async () => {
|
|
1435
|
+
const collection = await collectionService.create({
|
|
1436
|
+
slug: "reply-create-collection",
|
|
1437
|
+
title: "Reply Create Collection",
|
|
1438
|
+
});
|
|
1439
|
+
const root = await postService.create({
|
|
1440
|
+
format: "note",
|
|
1441
|
+
bodyMarkdown: "root",
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
await expect(
|
|
1445
|
+
postService.create({
|
|
1446
|
+
format: "note",
|
|
1447
|
+
bodyMarkdown: "child",
|
|
1448
|
+
replyToId: root.id,
|
|
1449
|
+
collectionIds: [collection.id],
|
|
1450
|
+
}),
|
|
1451
|
+
).rejects.toThrow("Cannot set Collections while creating a Thread reply");
|
|
1452
|
+
expect(await collectionService.getThreadIds(collection.id)).toEqual([]);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1301
1455
|
it("sets publishedAt when publishing a draft", async () => {
|
|
1302
1456
|
const post = await postService.create({
|
|
1303
1457
|
format: "note",
|
|
@@ -1316,6 +1470,9 @@ describe("PostService", () => {
|
|
|
1316
1470
|
expect(published?.status).toBe("published");
|
|
1317
1471
|
expect(published?.publishedAt).toBeTypeOf("number");
|
|
1318
1472
|
expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
|
|
1473
|
+
expect(
|
|
1474
|
+
(await postService.list({ status: "draft" })).map((draft) => draft.id),
|
|
1475
|
+
).not.toContain(post.id);
|
|
1319
1476
|
});
|
|
1320
1477
|
|
|
1321
1478
|
it("clears publishedAt when converting a published post back to draft", async () => {
|
|
@@ -1413,11 +1570,17 @@ describe("PostService", () => {
|
|
|
1413
1570
|
expect(updated?.pinnedAt).toBeTypeOf("number");
|
|
1414
1571
|
});
|
|
1415
1572
|
|
|
1416
|
-
it("updates slug", async () => {
|
|
1573
|
+
it("updates slug and its page navigation URL", async () => {
|
|
1417
1574
|
const post = await postService.create({
|
|
1418
1575
|
format: "note",
|
|
1419
|
-
|
|
1576
|
+
title: "Test page",
|
|
1420
1577
|
slug: "old-slug",
|
|
1578
|
+
visibility: "latest_hidden",
|
|
1579
|
+
});
|
|
1580
|
+
const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
|
|
1581
|
+
const navItem = await navItemService.create({
|
|
1582
|
+
type: "page",
|
|
1583
|
+
postId: post.id,
|
|
1421
1584
|
});
|
|
1422
1585
|
|
|
1423
1586
|
const updated = await postService.update(post.id, {
|
|
@@ -1425,6 +1588,25 @@ describe("PostService", () => {
|
|
|
1425
1588
|
});
|
|
1426
1589
|
|
|
1427
1590
|
expect(updated?.slug).toBe("new-slug");
|
|
1591
|
+
expect(await navItemService.getById(navItem.id)).toMatchObject({
|
|
1592
|
+
label: "Test page",
|
|
1593
|
+
url: "/new-slug",
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it("removes page navigation when the page becomes a draft", async () => {
|
|
1598
|
+
const post = await postService.create({
|
|
1599
|
+
format: "note",
|
|
1600
|
+
title: "Temporary page",
|
|
1601
|
+
slug: "temporary-page",
|
|
1602
|
+
visibility: "latest_hidden",
|
|
1603
|
+
});
|
|
1604
|
+
const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
|
|
1605
|
+
await navItemService.create({ type: "page", postId: post.id });
|
|
1606
|
+
|
|
1607
|
+
await postService.update(post.id, { status: "draft" });
|
|
1608
|
+
|
|
1609
|
+
expect(await navItemService.list()).toEqual([]);
|
|
1428
1610
|
});
|
|
1429
1611
|
|
|
1430
1612
|
it("updates quoteText and rating", async () => {
|
|
@@ -1601,6 +1783,21 @@ describe("PostService", () => {
|
|
|
1601
1783
|
expect(found).toBeNull();
|
|
1602
1784
|
});
|
|
1603
1785
|
|
|
1786
|
+
it("cascade deletes the post's page navigation item", async () => {
|
|
1787
|
+
const post = await postService.create({
|
|
1788
|
+
format: "note",
|
|
1789
|
+
title: "Disposable page",
|
|
1790
|
+
slug: "disposable-page",
|
|
1791
|
+
visibility: "latest_hidden",
|
|
1792
|
+
});
|
|
1793
|
+
const navItemService = createNavItemService(db, DEFAULT_TEST_SITE_ID);
|
|
1794
|
+
await navItemService.create({ type: "page", postId: post.id });
|
|
1795
|
+
|
|
1796
|
+
await postService.delete(post.id);
|
|
1797
|
+
|
|
1798
|
+
expect(await navItemService.list()).toEqual([]);
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1604
1801
|
it("returns false for non-existent post", async () => {
|
|
1605
1802
|
const result = await postService.delete(9999);
|
|
1606
1803
|
expect(result).toBe(false);
|
|
@@ -1662,6 +1859,92 @@ describe("PostService", () => {
|
|
|
1662
1859
|
});
|
|
1663
1860
|
});
|
|
1664
1861
|
|
|
1862
|
+
describe("createThreadWithAttachments", () => {
|
|
1863
|
+
// No attachments in these cases, so only the up-front id check is reached.
|
|
1864
|
+
const deps = {
|
|
1865
|
+
media: { validateIds: async () => [] },
|
|
1866
|
+
} as unknown as Parameters<
|
|
1867
|
+
typeof postService.createThreadWithAttachments
|
|
1868
|
+
>[1];
|
|
1869
|
+
|
|
1870
|
+
it("dates every reply from the root when the root is backdated", async () => {
|
|
1871
|
+
const backdated = 1710000000; // 2024-03-09
|
|
1872
|
+
|
|
1873
|
+
const created = await postService.createThreadWithAttachments(
|
|
1874
|
+
[
|
|
1875
|
+
{
|
|
1876
|
+
data: {
|
|
1877
|
+
format: "note",
|
|
1878
|
+
bodyMarkdown: "root",
|
|
1879
|
+
publishedAt: backdated,
|
|
1880
|
+
},
|
|
1881
|
+
attachments: [],
|
|
1882
|
+
},
|
|
1883
|
+
{ data: { format: "note", bodyMarkdown: "second" }, attachments: [] },
|
|
1884
|
+
{ data: { format: "note", bodyMarkdown: "third" }, attachments: [] },
|
|
1885
|
+
],
|
|
1886
|
+
deps,
|
|
1887
|
+
undefined,
|
|
1888
|
+
);
|
|
1889
|
+
|
|
1890
|
+
// Without inheritance the replies would land on today and the thread
|
|
1891
|
+
// would read as if it spanned two years.
|
|
1892
|
+
expect(created.map((p) => p.publishedAt)).toEqual([
|
|
1893
|
+
backdated,
|
|
1894
|
+
backdated,
|
|
1895
|
+
backdated,
|
|
1896
|
+
]);
|
|
1897
|
+
// Order still comes from createdAt/id, so the chain is intact.
|
|
1898
|
+
expect(created[1].replyToId).toBe(created[0].id);
|
|
1899
|
+
expect(created[2].replyToId).toBe(created[1].id);
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
it("keeps a reply's own date when one is given", async () => {
|
|
1903
|
+
const rootAt = 1710000000;
|
|
1904
|
+
const replyAt = 1720000000;
|
|
1905
|
+
|
|
1906
|
+
const created = await postService.createThreadWithAttachments(
|
|
1907
|
+
[
|
|
1908
|
+
{
|
|
1909
|
+
data: { format: "note", bodyMarkdown: "root", publishedAt: rootAt },
|
|
1910
|
+
attachments: [],
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
data: {
|
|
1914
|
+
format: "note",
|
|
1915
|
+
bodyMarkdown: "second",
|
|
1916
|
+
publishedAt: replyAt,
|
|
1917
|
+
},
|
|
1918
|
+
attachments: [],
|
|
1919
|
+
},
|
|
1920
|
+
],
|
|
1921
|
+
deps,
|
|
1922
|
+
undefined,
|
|
1923
|
+
);
|
|
1924
|
+
|
|
1925
|
+
expect(created.map((p) => p.publishedAt)).toEqual([rootAt, replyAt]);
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
it("leaves draft threads undated", async () => {
|
|
1929
|
+
const created = await postService.createThreadWithAttachments(
|
|
1930
|
+
[
|
|
1931
|
+
{
|
|
1932
|
+
data: { format: "note", bodyMarkdown: "root", status: "draft" },
|
|
1933
|
+
attachments: [],
|
|
1934
|
+
},
|
|
1935
|
+
{
|
|
1936
|
+
data: { format: "note", bodyMarkdown: "second", status: "draft" },
|
|
1937
|
+
attachments: [],
|
|
1938
|
+
},
|
|
1939
|
+
],
|
|
1940
|
+
deps,
|
|
1941
|
+
undefined,
|
|
1942
|
+
);
|
|
1943
|
+
|
|
1944
|
+
expect(created.map((p) => p.publishedAt)).toEqual([null, null]);
|
|
1945
|
+
});
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1665
1948
|
describe("threads", () => {
|
|
1666
1949
|
it("sets threadId on reply to a root post", async () => {
|
|
1667
1950
|
const root = await postService.create({
|
|
@@ -1721,6 +2004,143 @@ describe("PostService", () => {
|
|
|
1721
2004
|
);
|
|
1722
2005
|
});
|
|
1723
2006
|
|
|
2007
|
+
it("refuses to feature an unpublished post", async () => {
|
|
2008
|
+
await expect(
|
|
2009
|
+
postService.create({
|
|
2010
|
+
format: "note",
|
|
2011
|
+
bodyMarkdown: "not ready",
|
|
2012
|
+
status: "draft",
|
|
2013
|
+
featured: true,
|
|
2014
|
+
}),
|
|
2015
|
+
).rejects.toThrow("Publish this post before featuring it.");
|
|
2016
|
+
|
|
2017
|
+
const draft = await postService.create({
|
|
2018
|
+
format: "note",
|
|
2019
|
+
bodyMarkdown: "not ready",
|
|
2020
|
+
status: "draft",
|
|
2021
|
+
});
|
|
2022
|
+
await expect(
|
|
2023
|
+
postService.update(draft.id, { featured: true }),
|
|
2024
|
+
).rejects.toThrow("Publish this post before featuring it.");
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
it("keeps a featured flag through a trip back to draft", async () => {
|
|
2028
|
+
const post = await postService.create({
|
|
2029
|
+
format: "note",
|
|
2030
|
+
bodyMarkdown: "published",
|
|
2031
|
+
featured: true,
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
const unpublished = await postService.update(post.id, {
|
|
2035
|
+
status: "draft",
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
expect(unpublished?.status).toBe("draft");
|
|
2039
|
+
expect(unpublished?.featuredAt).not.toBeNull();
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it("publishes a reply whose parent is still a draft", async () => {
|
|
2043
|
+
const root = await postService.create({
|
|
2044
|
+
format: "note",
|
|
2045
|
+
bodyMarkdown: "root",
|
|
2046
|
+
});
|
|
2047
|
+
const draft = await postService.create({
|
|
2048
|
+
format: "note",
|
|
2049
|
+
bodyMarkdown: "unfinished",
|
|
2050
|
+
replyToId: root.id,
|
|
2051
|
+
status: "draft",
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
const next = await postService.create({
|
|
2055
|
+
format: "note",
|
|
2056
|
+
bodyMarkdown: "continues past the draft",
|
|
2057
|
+
replyToId: draft.id,
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
// Readers never saw the draft, so publishing past it reads as
|
|
2061
|
+
// continuous. The draft stays parked until it is dealt with on its own.
|
|
2062
|
+
expect(next.status).toBe("published");
|
|
2063
|
+
expect(draft.status).toBe("draft");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("publishes only the post asked for, leaving other drafts alone", async () => {
|
|
2067
|
+
const root = await postService.create({
|
|
2068
|
+
format: "note",
|
|
2069
|
+
bodyMarkdown: "root",
|
|
2070
|
+
});
|
|
2071
|
+
const first = await postService.create({
|
|
2072
|
+
format: "note",
|
|
2073
|
+
bodyMarkdown: "first draft",
|
|
2074
|
+
replyToId: root.id,
|
|
2075
|
+
status: "draft",
|
|
2076
|
+
});
|
|
2077
|
+
const second = await postService.create({
|
|
2078
|
+
format: "note",
|
|
2079
|
+
bodyMarkdown: "second draft",
|
|
2080
|
+
replyToId: first.id,
|
|
2081
|
+
status: "draft",
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
await postService.update(second.id, { status: "published" });
|
|
2085
|
+
|
|
2086
|
+
const statusById = new Map(
|
|
2087
|
+
(await postService.getThread(root.id)).map((p) => [p.id, p.status]),
|
|
2088
|
+
);
|
|
2089
|
+
expect(statusById.get(first.id)).toBe("draft");
|
|
2090
|
+
expect(statusById.get(second.id)).toBe("published");
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it("keeps a draft between two published replies", async () => {
|
|
2094
|
+
const root = await postService.create({
|
|
2095
|
+
format: "note",
|
|
2096
|
+
bodyMarkdown: "root",
|
|
2097
|
+
});
|
|
2098
|
+
const middle = await postService.create({
|
|
2099
|
+
format: "note",
|
|
2100
|
+
bodyMarkdown: "middle",
|
|
2101
|
+
replyToId: root.id,
|
|
2102
|
+
});
|
|
2103
|
+
const last = await postService.create({
|
|
2104
|
+
format: "note",
|
|
2105
|
+
bodyMarkdown: "last",
|
|
2106
|
+
replyToId: middle.id,
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
await postService.update(middle.id, { status: "draft" });
|
|
2110
|
+
|
|
2111
|
+
const statusById = new Map(
|
|
2112
|
+
(await postService.getThread(root.id)).map((p) => [p.id, p.status]),
|
|
2113
|
+
);
|
|
2114
|
+
// Unpublishing one post touches only that post. The public thread simply
|
|
2115
|
+
// skips it; nothing else in the chain changes.
|
|
2116
|
+
expect(statusById.get(root.id)).toBe("published");
|
|
2117
|
+
expect(statusById.get(middle.id)).toBe("draft");
|
|
2118
|
+
expect(statusById.get(last.id)).toBe("published");
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
it("names the draft when an unpublished reply holds the thread tail", async () => {
|
|
2122
|
+
const root = await postService.create({
|
|
2123
|
+
format: "note",
|
|
2124
|
+
bodyMarkdown: "root",
|
|
2125
|
+
});
|
|
2126
|
+
await postService.create({
|
|
2127
|
+
format: "note",
|
|
2128
|
+
bodyMarkdown: "unfinished",
|
|
2129
|
+
replyToId: root.id,
|
|
2130
|
+
status: "draft",
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
await expect(
|
|
2134
|
+
postService.create({
|
|
2135
|
+
format: "note",
|
|
2136
|
+
bodyMarkdown: "reply",
|
|
2137
|
+
replyToId: root.id,
|
|
2138
|
+
}),
|
|
2139
|
+
).rejects.toThrow(
|
|
2140
|
+
"This thread ends with an unpublished draft. Finish that draft or discard it, then reply.",
|
|
2141
|
+
);
|
|
2142
|
+
});
|
|
2143
|
+
|
|
1724
2144
|
it("inherits status from root post", async () => {
|
|
1725
2145
|
const root = await postService.create({
|
|
1726
2146
|
format: "note",
|
|
@@ -1831,6 +2251,57 @@ describe("PostService", () => {
|
|
|
1831
2251
|
expect(thread[0]?.bodyText).toBe("root");
|
|
1832
2252
|
});
|
|
1833
2253
|
|
|
2254
|
+
it("getThreadPosition counts the chain from the root down to a post", async () => {
|
|
2255
|
+
const root = await postService.create({
|
|
2256
|
+
format: "note",
|
|
2257
|
+
bodyMarkdown: "root",
|
|
2258
|
+
});
|
|
2259
|
+
const reply1 = await postService.create({
|
|
2260
|
+
format: "note",
|
|
2261
|
+
bodyMarkdown: "reply1",
|
|
2262
|
+
replyToId: root.id,
|
|
2263
|
+
});
|
|
2264
|
+
const reply2 = await postService.create({
|
|
2265
|
+
format: "note",
|
|
2266
|
+
bodyMarkdown: "reply2",
|
|
2267
|
+
replyToId: reply1.id,
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
expect(await postService.getThreadPosition(root.id)).toBe(1);
|
|
2271
|
+
expect(await postService.getThreadPosition(reply1.id)).toBe(2);
|
|
2272
|
+
expect(await postService.getThreadPosition(reply2.id)).toBe(3);
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
it("getThreadPosition walks the reply chain rather than counting members", async () => {
|
|
2276
|
+
// `create` only allows replying to the tail, so today a thread is a
|
|
2277
|
+
// straight chain and depth happens to equal its size. The walk is what
|
|
2278
|
+
// makes the two agree — counting `threadId` members would give the same
|
|
2279
|
+
// answer here and the wrong one the moment a draft chain is incomplete.
|
|
2280
|
+
const root = await postService.create({
|
|
2281
|
+
format: "note",
|
|
2282
|
+
bodyMarkdown: "root",
|
|
2283
|
+
});
|
|
2284
|
+
const tail = await postService.create({
|
|
2285
|
+
format: "note",
|
|
2286
|
+
bodyMarkdown: "tail",
|
|
2287
|
+
replyToId: root.id,
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
// A second post filed under the same thread without joining the chain:
|
|
2291
|
+
// it is not above the tail, so it must not push the tail's position.
|
|
2292
|
+
await postService.create({
|
|
2293
|
+
format: "note",
|
|
2294
|
+
bodyMarkdown: "unrelated",
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
expect(await postService.getThreadPosition(tail.id)).toBe(2);
|
|
2298
|
+
expect(await postService.getThread(root.id)).toHaveLength(2);
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
it("getThreadPosition returns 0 for a post that does not exist", async () => {
|
|
2302
|
+
expect(await postService.getThreadPosition("pst_missing")).toBe(0);
|
|
2303
|
+
});
|
|
2304
|
+
|
|
1834
2305
|
it("getThread excludes deleted posts", async () => {
|
|
1835
2306
|
const root = await postService.create({
|
|
1836
2307
|
format: "note",
|
|
@@ -2286,6 +2757,374 @@ describe("PostService", () => {
|
|
|
2286
2757
|
});
|
|
2287
2758
|
});
|
|
2288
2759
|
|
|
2760
|
+
describe("rebuildBodyHtml", () => {
|
|
2761
|
+
async function createFootnotePost(label: string) {
|
|
2762
|
+
return postService.create({
|
|
2763
|
+
format: "note",
|
|
2764
|
+
bodyMarkdown: `Body ${label}[^1]\n\n[^1]: Definition ${label}`,
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function legacyFootnoteBody(): string {
|
|
2769
|
+
return JSON.stringify({
|
|
2770
|
+
type: "doc",
|
|
2771
|
+
content: [
|
|
2772
|
+
{
|
|
2773
|
+
type: "paragraph",
|
|
2774
|
+
content: [
|
|
2775
|
+
{ type: "text", text: "Body " },
|
|
2776
|
+
{
|
|
2777
|
+
type: "text",
|
|
2778
|
+
text: "[1]",
|
|
2779
|
+
marks: [{ type: "link", attrs: { href: "#fn-1" } }],
|
|
2780
|
+
},
|
|
2781
|
+
],
|
|
2782
|
+
},
|
|
2783
|
+
{ type: "horizontalRule" },
|
|
2784
|
+
{
|
|
2785
|
+
type: "orderedList",
|
|
2786
|
+
content: [
|
|
2787
|
+
{
|
|
2788
|
+
type: "listItem",
|
|
2789
|
+
content: [
|
|
2790
|
+
{
|
|
2791
|
+
type: "paragraph",
|
|
2792
|
+
content: [
|
|
2793
|
+
{ type: "text", text: "Definition " },
|
|
2794
|
+
{
|
|
2795
|
+
type: "text",
|
|
2796
|
+
text: "↩︎",
|
|
2797
|
+
marks: [{ type: "link", attrs: { href: "#fnref-1" } }],
|
|
2798
|
+
},
|
|
2799
|
+
],
|
|
2800
|
+
},
|
|
2801
|
+
],
|
|
2802
|
+
},
|
|
2803
|
+
],
|
|
2804
|
+
},
|
|
2805
|
+
],
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
it("resolves stale stored HTML on reads before the rebuild writes", async () => {
|
|
2810
|
+
const post = await createFootnotePost("stale");
|
|
2811
|
+
await db
|
|
2812
|
+
.update(posts)
|
|
2813
|
+
.set({
|
|
2814
|
+
bodyHtml: '<span class="sidenote">legacy</span>',
|
|
2815
|
+
bodyHtmlVersion: 1,
|
|
2816
|
+
})
|
|
2817
|
+
.where(eq(posts.id, post.id));
|
|
2818
|
+
|
|
2819
|
+
const resolved = await postService.getById(post.id);
|
|
2820
|
+
expect(resolved?.bodyHtml).toContain('role="doc-noteref"');
|
|
2821
|
+
expect(resolved?.bodyHtml).toContain('role="doc-endnotes"');
|
|
2822
|
+
expect(resolved?.bodyHtml).not.toContain("legacy");
|
|
2823
|
+
|
|
2824
|
+
const stored = await db
|
|
2825
|
+
.select({ bodyHtml: posts.bodyHtml })
|
|
2826
|
+
.from(posts)
|
|
2827
|
+
.where(eq(posts.id, post.id))
|
|
2828
|
+
.limit(1);
|
|
2829
|
+
expect(stored[0]?.bodyHtml).toContain("legacy");
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
it("supports dry-run, preserves timestamps, and is idempotent", async () => {
|
|
2833
|
+
const post = await createFootnotePost("rebuild");
|
|
2834
|
+
await db
|
|
2835
|
+
.update(posts)
|
|
2836
|
+
.set({
|
|
2837
|
+
bodyHtml: '<span class="sidenote">legacy</span>',
|
|
2838
|
+
bodyHtmlVersion: 1,
|
|
2839
|
+
})
|
|
2840
|
+
.where(eq(posts.id, post.id));
|
|
2841
|
+
|
|
2842
|
+
const before = await db
|
|
2843
|
+
.select({
|
|
2844
|
+
updatedAt: posts.updatedAt,
|
|
2845
|
+
publishedAt: posts.publishedAt,
|
|
2846
|
+
})
|
|
2847
|
+
.from(posts)
|
|
2848
|
+
.where(eq(posts.id, post.id))
|
|
2849
|
+
.limit(1);
|
|
2850
|
+
|
|
2851
|
+
const dryRun = await postService.rebuildBodyHtml({ dryRun: true });
|
|
2852
|
+
expect(dryRun).toMatchObject({
|
|
2853
|
+
processed: 1,
|
|
2854
|
+
wouldRebuild: 1,
|
|
2855
|
+
rebuilt: 0,
|
|
2856
|
+
skipped: 0,
|
|
2857
|
+
conflicted: 0,
|
|
2858
|
+
failed: 0,
|
|
2859
|
+
done: true,
|
|
2860
|
+
targetVersion: POST_BODY_HTML_VERSION,
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
const firstPass = await postService.rebuildBodyHtml();
|
|
2864
|
+
expect(firstPass).toMatchObject({
|
|
2865
|
+
wouldRebuild: 1,
|
|
2866
|
+
rebuilt: 1,
|
|
2867
|
+
failed: 0,
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
const stored = await db
|
|
2871
|
+
.select({
|
|
2872
|
+
bodyHtml: posts.bodyHtml,
|
|
2873
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
2874
|
+
updatedAt: posts.updatedAt,
|
|
2875
|
+
publishedAt: posts.publishedAt,
|
|
2876
|
+
})
|
|
2877
|
+
.from(posts)
|
|
2878
|
+
.where(eq(posts.id, post.id))
|
|
2879
|
+
.limit(1);
|
|
2880
|
+
expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
|
|
2881
|
+
expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
|
|
2882
|
+
expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
|
|
2883
|
+
expect(stored[0]?.publishedAt).toBe(before[0]?.publishedAt);
|
|
2884
|
+
|
|
2885
|
+
const secondPass = await postService.rebuildBodyHtml();
|
|
2886
|
+
expect(secondPass).toMatchObject({
|
|
2887
|
+
wouldRebuild: 0,
|
|
2888
|
+
rebuilt: 0,
|
|
2889
|
+
skipped: 1,
|
|
2890
|
+
failed: 0,
|
|
2891
|
+
});
|
|
2892
|
+
});
|
|
2893
|
+
|
|
2894
|
+
it("atomically upgrades historical generic footnotes and their projections", async () => {
|
|
2895
|
+
const post = await postService.create({
|
|
2896
|
+
format: "note",
|
|
2897
|
+
title: "Legacy",
|
|
2898
|
+
bodyMarkdown: "Placeholder",
|
|
2899
|
+
});
|
|
2900
|
+
const legacyBody = legacyFootnoteBody();
|
|
2901
|
+
await db
|
|
2902
|
+
.update(posts)
|
|
2903
|
+
.set({
|
|
2904
|
+
body: legacyBody,
|
|
2905
|
+
bodyHtml:
|
|
2906
|
+
'<p>Body <a href="#fn-1">[1]</a></p><hr><ol><li><p>Definition <a href="#fnref-1">↩︎</a></p></li></ol>',
|
|
2907
|
+
bodyHtmlVersion: 3,
|
|
2908
|
+
bodyText: "Body [1] #fn-1 Definition ↩︎ #fnref-1",
|
|
2909
|
+
summary: "Body [1]\n\nDefinition ↩︎",
|
|
2910
|
+
})
|
|
2911
|
+
.where(eq(posts.id, post.id));
|
|
2912
|
+
const before = await db
|
|
2913
|
+
.select({ updatedAt: posts.updatedAt, body: posts.body })
|
|
2914
|
+
.from(posts)
|
|
2915
|
+
.where(eq(posts.id, post.id))
|
|
2916
|
+
.limit(1);
|
|
2917
|
+
|
|
2918
|
+
const dryRun = await postService.rebuildBodyHtml({
|
|
2919
|
+
dryRun: true,
|
|
2920
|
+
summaryConfig: { maxParagraphs: 5, maxChars: 500 },
|
|
2921
|
+
});
|
|
2922
|
+
expect(dryRun).toMatchObject({
|
|
2923
|
+
wouldRebuild: 1,
|
|
2924
|
+
rebuilt: 0,
|
|
2925
|
+
wouldUpgradeFootnotes: 1,
|
|
2926
|
+
upgradedFootnotes: 0,
|
|
2927
|
+
failed: 0,
|
|
2928
|
+
});
|
|
2929
|
+
expect(
|
|
2930
|
+
await db
|
|
2931
|
+
.select({ body: posts.body })
|
|
2932
|
+
.from(posts)
|
|
2933
|
+
.where(eq(posts.id, post.id))
|
|
2934
|
+
.limit(1),
|
|
2935
|
+
).toEqual([{ body: legacyBody }]);
|
|
2936
|
+
|
|
2937
|
+
const result = await postService.rebuildBodyHtml({
|
|
2938
|
+
summaryConfig: { maxParagraphs: 5, maxChars: 500 },
|
|
2939
|
+
});
|
|
2940
|
+
expect(result).toMatchObject({
|
|
2941
|
+
rebuilt: 1,
|
|
2942
|
+
wouldUpgradeFootnotes: 1,
|
|
2943
|
+
upgradedFootnotes: 1,
|
|
2944
|
+
conflicted: 0,
|
|
2945
|
+
failed: 0,
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
const stored = await db
|
|
2949
|
+
.select({
|
|
2950
|
+
body: posts.body,
|
|
2951
|
+
bodyHtml: posts.bodyHtml,
|
|
2952
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
2953
|
+
bodyText: posts.bodyText,
|
|
2954
|
+
summary: posts.summary,
|
|
2955
|
+
updatedAt: posts.updatedAt,
|
|
2956
|
+
})
|
|
2957
|
+
.from(posts)
|
|
2958
|
+
.where(eq(posts.id, post.id))
|
|
2959
|
+
.limit(1);
|
|
2960
|
+
expect(stored[0]?.body).toContain('"type":"footnoteReference"');
|
|
2961
|
+
expect(stored[0]?.body).toContain('"type":"footnoteDefinition"');
|
|
2962
|
+
expect(stored[0]?.bodyHtml).toContain('role="doc-noteref"');
|
|
2963
|
+
expect(stored[0]?.bodyHtml).toContain('role="doc-endnotes"');
|
|
2964
|
+
expect(stored[0]?.bodyText).toBe("Body Definition");
|
|
2965
|
+
expect(stored[0]?.summary).toBe("Body");
|
|
2966
|
+
expect(stored[0]?.bodyHtmlVersion).toBe(POST_BODY_HTML_VERSION);
|
|
2967
|
+
expect(stored[0]?.updatedAt).toBe(before[0]?.updatedAt);
|
|
2968
|
+
|
|
2969
|
+
await expect(postService.rebuildBodyHtml()).resolves.toMatchObject({
|
|
2970
|
+
wouldRebuild: 0,
|
|
2971
|
+
wouldUpgradeFootnotes: 0,
|
|
2972
|
+
upgradedFootnotes: 0,
|
|
2973
|
+
skipped: 1,
|
|
2974
|
+
});
|
|
2975
|
+
});
|
|
2976
|
+
|
|
2977
|
+
it("reports malformed canonical bodies without marking them current", async () => {
|
|
2978
|
+
const post = await createFootnotePost("invalid");
|
|
2979
|
+
await db
|
|
2980
|
+
.update(posts)
|
|
2981
|
+
.set({
|
|
2982
|
+
body: "not json",
|
|
2983
|
+
bodyHtml: "<p>legacy fallback</p>",
|
|
2984
|
+
bodyHtmlVersion: 1,
|
|
2985
|
+
})
|
|
2986
|
+
.where(eq(posts.id, post.id));
|
|
2987
|
+
|
|
2988
|
+
const result = await postService.rebuildBodyHtml();
|
|
2989
|
+
expect(result.failed).toBe(1);
|
|
2990
|
+
expect(result.failures[0]?.postId).toBe(post.id);
|
|
2991
|
+
expect(result.rebuilt).toBe(0);
|
|
2992
|
+
|
|
2993
|
+
const stored = await db
|
|
2994
|
+
.select({
|
|
2995
|
+
bodyHtml: posts.bodyHtml,
|
|
2996
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
2997
|
+
})
|
|
2998
|
+
.from(posts)
|
|
2999
|
+
.where(eq(posts.id, post.id))
|
|
3000
|
+
.limit(1);
|
|
3001
|
+
expect(stored[0]).toEqual({
|
|
3002
|
+
bodyHtml: "<p>legacy fallback</p>",
|
|
3003
|
+
bodyHtmlVersion: 1,
|
|
3004
|
+
});
|
|
3005
|
+
expect((await postService.getById(post.id))?.bodyHtml).toBe(
|
|
3006
|
+
"<p>legacy fallback</p>",
|
|
3007
|
+
);
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
it("paginates deterministically", async () => {
|
|
3011
|
+
const created = [];
|
|
3012
|
+
for (const label of ["one", "two", "three"]) {
|
|
3013
|
+
created.push(await createFootnotePost(label));
|
|
3014
|
+
}
|
|
3015
|
+
await db
|
|
3016
|
+
.update(posts)
|
|
3017
|
+
.set({ bodyHtmlVersion: 1 })
|
|
3018
|
+
.where(eq(posts.siteId, DEFAULT_TEST_SITE_ID));
|
|
3019
|
+
|
|
3020
|
+
const first = await postService.rebuildBodyHtml({ limit: 2 });
|
|
3021
|
+
expect(first.processed).toBe(2);
|
|
3022
|
+
expect(first.done).toBe(false);
|
|
3023
|
+
expect(first.nextCursor).not.toBeNull();
|
|
3024
|
+
|
|
3025
|
+
const second = await postService.rebuildBodyHtml({
|
|
3026
|
+
limit: 2,
|
|
3027
|
+
cursor: first.nextCursor ?? undefined,
|
|
3028
|
+
});
|
|
3029
|
+
expect(second.processed).toBe(1);
|
|
3030
|
+
expect(second.done).toBe(true);
|
|
3031
|
+
expect(first.rebuilt + second.rebuilt).toBe(created.length);
|
|
3032
|
+
});
|
|
3033
|
+
|
|
3034
|
+
it("lets a concurrent canonical-body edit win the compare-and-swap", async () => {
|
|
3035
|
+
const post = await createFootnotePost("conflict");
|
|
3036
|
+
await db
|
|
3037
|
+
.update(posts)
|
|
3038
|
+
.set({ bodyHtmlVersion: 1 })
|
|
3039
|
+
.where(eq(posts.id, post.id));
|
|
3040
|
+
|
|
3041
|
+
const concurrentBody = JSON.stringify({
|
|
3042
|
+
type: "doc",
|
|
3043
|
+
content: [
|
|
3044
|
+
{
|
|
3045
|
+
type: "paragraph",
|
|
3046
|
+
content: [{ type: "text", text: "Concurrent edit" }],
|
|
3047
|
+
},
|
|
3048
|
+
],
|
|
3049
|
+
});
|
|
3050
|
+
let injected = false;
|
|
3051
|
+
const conflictDb = new Proxy(db, {
|
|
3052
|
+
get(target, property, receiver) {
|
|
3053
|
+
if (property === "update") {
|
|
3054
|
+
return (...args: Parameters<Database["update"]>) => {
|
|
3055
|
+
if (!injected) {
|
|
3056
|
+
injected = true;
|
|
3057
|
+
sqlite
|
|
3058
|
+
.prepare("UPDATE post SET body = ? WHERE id = ?")
|
|
3059
|
+
.run(concurrentBody, post.id);
|
|
3060
|
+
}
|
|
3061
|
+
return target.update(...args);
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
const value = Reflect.get(target, property, receiver) as unknown;
|
|
3066
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
3067
|
+
},
|
|
3068
|
+
});
|
|
3069
|
+
const conflictService = createPostService(
|
|
3070
|
+
conflictDb,
|
|
3071
|
+
{ slugIdLength: 5 },
|
|
3072
|
+
DEFAULT_TEST_SITE_ID,
|
|
3073
|
+
);
|
|
3074
|
+
|
|
3075
|
+
const result = await conflictService.rebuildBodyHtml();
|
|
3076
|
+
|
|
3077
|
+
expect(result).toMatchObject({ rebuilt: 0, conflicted: 1 });
|
|
3078
|
+
const stored = await db
|
|
3079
|
+
.select({ body: posts.body, version: posts.bodyHtmlVersion })
|
|
3080
|
+
.from(posts)
|
|
3081
|
+
.where(eq(posts.id, post.id))
|
|
3082
|
+
.limit(1);
|
|
3083
|
+
expect(stored[0]).toEqual({ body: concurrentBody, version: 1 });
|
|
3084
|
+
});
|
|
3085
|
+
|
|
3086
|
+
it("never widens a rebuild to another site", async () => {
|
|
3087
|
+
const defaultPost = await createFootnotePost("default");
|
|
3088
|
+
const secondSiteId = "sit_second000000000000000000000";
|
|
3089
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
3090
|
+
await db.insert(sites).values({
|
|
3091
|
+
id: secondSiteId,
|
|
3092
|
+
key: "second",
|
|
3093
|
+
status: "active",
|
|
3094
|
+
createdAt: timestamp,
|
|
3095
|
+
updatedAt: timestamp,
|
|
3096
|
+
});
|
|
3097
|
+
const secondService = createPostService(
|
|
3098
|
+
db,
|
|
3099
|
+
{ slugIdLength: 5 },
|
|
3100
|
+
secondSiteId,
|
|
3101
|
+
);
|
|
3102
|
+
const secondPost = await secondService.create({
|
|
3103
|
+
format: "note",
|
|
3104
|
+
bodyMarkdown: "Second[^1]\n\n[^1]: Other site",
|
|
3105
|
+
});
|
|
3106
|
+
await db
|
|
3107
|
+
.update(posts)
|
|
3108
|
+
.set({ bodyHtmlVersion: 1 })
|
|
3109
|
+
.where(eq(posts.id, defaultPost.id));
|
|
3110
|
+
await db
|
|
3111
|
+
.update(posts)
|
|
3112
|
+
.set({ bodyHtmlVersion: 1 })
|
|
3113
|
+
.where(eq(posts.id, secondPost.id));
|
|
3114
|
+
|
|
3115
|
+
const result = await postService.rebuildBodyHtml();
|
|
3116
|
+
expect(result.processed).toBe(1);
|
|
3117
|
+
|
|
3118
|
+
const rows = await db
|
|
3119
|
+
.select({ id: posts.id, version: posts.bodyHtmlVersion })
|
|
3120
|
+
.from(posts);
|
|
3121
|
+
expect(rows.find((row) => row.id === defaultPost.id)?.version).toBe(
|
|
3122
|
+
POST_BODY_HTML_VERSION,
|
|
3123
|
+
);
|
|
3124
|
+
expect(rows.find((row) => row.id === secondPost.id)?.version).toBe(1);
|
|
3125
|
+
});
|
|
3126
|
+
});
|
|
3127
|
+
|
|
2289
3128
|
describe("listForSitemap", () => {
|
|
2290
3129
|
it("returns published non-reply non-private non-deleted posts", async () => {
|
|
2291
3130
|
const root = await postService.create({
|