@jant/core 0.6.14 → 0.6.15
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 +81 -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-BxCOR3Uc.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-B_eGKN5p.css +2 -0
- package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
- package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
- package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/node.js +5 -5
- 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__/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 +64 -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 +18 -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__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
- package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
- 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-types.ts +8 -0
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +2 -1
- package/src/client/components/jant-compose-dialog.ts +85 -21
- package/src/client/components/jant-compose-editor.ts +13 -7
- package/src/client/components/jant-compose-fullscreen.ts +24 -4
- 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 +86 -74
- 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 +4 -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/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__/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/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 +8 -6
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -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-collection-migration.test.ts +364 -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/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/_journal.json +28 -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/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/_journal.json +28 -0
- package/src/db/pg/schema.ts +28 -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 +28 -20
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +133 -30
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +128 -25
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +128 -25
- 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__/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 +8 -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__/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__/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/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +10 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/markdown-manager.ts +188 -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 +7 -1
- 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 +98 -122
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +355 -81
- package/src/lib/view.ts +30 -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__/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 +2 -2
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +208 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +41 -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 +2 -1
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +92 -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__/preview.test.ts +174 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +27 -7
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +83 -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 +159 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +109 -30
- package/src/services/__tests__/post.test.ts +567 -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 +226 -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 +91 -80
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/post.ts +874 -271
- package/src/services/search.ts +8 -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 -0
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +14 -9
- package/src/styles/ui.css +829 -29
- 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 +9 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +8 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +16 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +92 -2
- 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/NoteCard.tsx +10 -20
- 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/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 +7 -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 +33 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +3 -1
- 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/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +46 -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
package/src/lib/api-settings.ts
CHANGED
|
@@ -1,31 +1,258 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_FIELDS,
|
|
3
|
+
type Bindings,
|
|
4
|
+
type ConfigEditorDefinition,
|
|
5
|
+
type ConfigEditorFieldState,
|
|
6
|
+
type ConfigEditorKey,
|
|
7
|
+
type ConfigEditorResettableKey,
|
|
8
|
+
type ConfigEditorVisibleKey,
|
|
9
|
+
type ConfigKey,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
import { getSupportedLocaleEntries } from "../i18n/supported-locales.js";
|
|
2
12
|
import { SETTINGS_KEYS } from "./constants.js";
|
|
3
|
-
import {
|
|
13
|
+
import { getEnvString } from "./env.js";
|
|
14
|
+
import { normalizeEditableSettingValue } from "./schemas.js";
|
|
15
|
+
import { getTimeZoneOptions } from "./timezones.js";
|
|
4
16
|
|
|
5
17
|
export const demoLockedSettingKeys = new Set<ConfigKey>(["NOINDEX"]);
|
|
6
18
|
|
|
7
|
-
/** Config keys
|
|
19
|
+
/** Config keys explicitly approved for runtime editing. */
|
|
8
20
|
export const editableSettingKeys = Object.entries(CONFIG_FIELDS)
|
|
21
|
+
.filter(([, field]) => "editor" in field)
|
|
22
|
+
.map(([key]) => key as ConfigEditorKey);
|
|
23
|
+
|
|
24
|
+
/** Safe settings shown in Config Editor, including dedicated-page links. */
|
|
25
|
+
export const configEditorVisibleKeys = Object.entries(CONFIG_FIELDS)
|
|
26
|
+
.filter(([, field]) => "editor" in field || "configEditorLink" in field)
|
|
27
|
+
.map(([key]) => key as ConfigEditorVisibleKey);
|
|
28
|
+
|
|
29
|
+
/** Config Editor keys whose DB override can be safely removed directly. */
|
|
30
|
+
export const resettableConfigEditorKeys = Object.entries(CONFIG_FIELDS)
|
|
9
31
|
.filter(
|
|
10
|
-
([, field]) =>
|
|
32
|
+
([, field]) =>
|
|
33
|
+
"editor" in field ||
|
|
34
|
+
("configEditorLink" in field &&
|
|
35
|
+
"resettable" in field.configEditorLink &&
|
|
36
|
+
field.configEditorLink.resettable === true),
|
|
11
37
|
)
|
|
12
|
-
.map(([key]) => key as
|
|
38
|
+
.map(([key]) => key as ConfigEditorResettableKey);
|
|
39
|
+
|
|
40
|
+
export function isEditableSettingKey(key: string): key is ConfigEditorKey {
|
|
41
|
+
return editableSettingKeys.includes(key as ConfigEditorKey);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isResettableConfigEditorKey(
|
|
45
|
+
key: string,
|
|
46
|
+
): key is ConfigEditorResettableKey {
|
|
47
|
+
return resettableConfigEditorKeys.includes(key as ConfigEditorResettableKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tryNormalizeResolvedSettingValue(
|
|
51
|
+
key: ConfigEditorKey,
|
|
52
|
+
value: string,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
return normalizeEditableSettingValue(key, value);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getEditableSettingFallbackValue(
|
|
62
|
+
key: ConfigEditorKey,
|
|
63
|
+
env?: Bindings,
|
|
64
|
+
allSettings: Record<string, string> = {},
|
|
65
|
+
): string {
|
|
66
|
+
const field = CONFIG_FIELDS[key];
|
|
67
|
+
const envKeys = "envKeys" in field ? field.envKeys : undefined;
|
|
68
|
+
const envValue = env ? getEnvString(env, ...(envKeys ?? [])) : undefined;
|
|
69
|
+
if (envValue) {
|
|
70
|
+
const normalizedEnv = tryNormalizeResolvedSettingValue(key, envValue);
|
|
71
|
+
if (normalizedEnv !== undefined) return normalizedEnv;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const normalizedDefault = tryNormalizeResolvedSettingValue(
|
|
75
|
+
key,
|
|
76
|
+
field.defaultValue,
|
|
77
|
+
);
|
|
78
|
+
if (normalizedDefault !== undefined) return normalizedDefault;
|
|
79
|
+
|
|
80
|
+
if ("fallbackKey" in field && field.fallbackKey) {
|
|
81
|
+
return getEditableSettingValue(allSettings, field.fallbackKey, env);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(`Missing valid Config Editor fallback for ${key}`);
|
|
85
|
+
}
|
|
13
86
|
|
|
14
87
|
export function getEditableSettingValue(
|
|
15
88
|
allSettings: Record<string, string>,
|
|
16
|
-
key:
|
|
89
|
+
key: ConfigEditorKey,
|
|
90
|
+
env?: Bindings,
|
|
17
91
|
): string {
|
|
18
|
-
const
|
|
19
|
-
|
|
92
|
+
const fallbackValue = getEditableSettingFallbackValue(key, env, allSettings);
|
|
93
|
+
const value = Object.hasOwn(allSettings, key)
|
|
94
|
+
? allSettings[key]
|
|
95
|
+
: fallbackValue;
|
|
96
|
+
return (
|
|
97
|
+
tryNormalizeResolvedSettingValue(key, value ?? fallbackValue) ??
|
|
98
|
+
fallbackValue
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getEditorConstraints(
|
|
103
|
+
key: ConfigEditorKey,
|
|
104
|
+
definition: ConfigEditorDefinition,
|
|
105
|
+
value: string,
|
|
106
|
+
fallbackValue: string,
|
|
107
|
+
): Pick<
|
|
108
|
+
ConfigEditorFieldState,
|
|
109
|
+
"maxLength" | "min" | "max" | "step" | "options"
|
|
110
|
+
> {
|
|
111
|
+
switch (definition.type) {
|
|
112
|
+
case "boolean":
|
|
113
|
+
return {};
|
|
114
|
+
case "string":
|
|
115
|
+
return { maxLength: definition.maxLength };
|
|
116
|
+
case "number":
|
|
117
|
+
return {
|
|
118
|
+
min: definition.min,
|
|
119
|
+
max: definition.max,
|
|
120
|
+
step: definition.step,
|
|
121
|
+
};
|
|
122
|
+
case "enum":
|
|
123
|
+
if (definition.options) return { options: definition.options };
|
|
124
|
+
if (definition.optionsSource === "contentLanguage") {
|
|
125
|
+
const options = getSupportedLocaleEntries().map((entry) => entry.tag);
|
|
126
|
+
for (const candidate of [fallbackValue, value]) {
|
|
127
|
+
if (candidate && !options.includes(candidate))
|
|
128
|
+
options.push(candidate);
|
|
129
|
+
}
|
|
130
|
+
return { options };
|
|
131
|
+
}
|
|
132
|
+
if (definition.optionsSource === "timeZone") {
|
|
133
|
+
const options = getTimeZoneOptions(value).map((entry) => entry.value);
|
|
134
|
+
for (const candidate of [fallbackValue, value]) {
|
|
135
|
+
if (candidate && !options.includes(candidate))
|
|
136
|
+
options.push(candidate);
|
|
137
|
+
}
|
|
138
|
+
return { options };
|
|
139
|
+
}
|
|
140
|
+
return { options: [value] };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildLinkedConfigEditorField(
|
|
145
|
+
allSettings: Record<string, string>,
|
|
146
|
+
env: Bindings,
|
|
147
|
+
key: ConfigEditorVisibleKey,
|
|
148
|
+
): ConfigEditorFieldState {
|
|
149
|
+
const field = CONFIG_FIELDS[key];
|
|
150
|
+
if (!("configEditorLink" in field)) {
|
|
151
|
+
throw new Error(`Missing Config Editor link metadata for ${key}`);
|
|
152
|
+
}
|
|
153
|
+
const definition = field.configEditorLink;
|
|
154
|
+
const resettable =
|
|
155
|
+
"resettable" in definition && definition.resettable === true;
|
|
156
|
+
const modified = Object.hasOwn(allSettings, key);
|
|
157
|
+
const storedValue = allSettings[key] ?? "";
|
|
158
|
+
|
|
159
|
+
if (definition.display === "configured") {
|
|
160
|
+
const envKeys = "envKeys" in field ? field.envKeys : [];
|
|
161
|
+
const fallbackRawValue =
|
|
162
|
+
getEnvString(env, ...envKeys) || field.defaultValue;
|
|
163
|
+
const effectiveValue = modified ? storedValue : fallbackRawValue;
|
|
164
|
+
return {
|
|
165
|
+
key,
|
|
166
|
+
mode: "link",
|
|
167
|
+
type: definition.type,
|
|
168
|
+
value: effectiveValue.trim() ? "true" : "false",
|
|
169
|
+
fallbackValue: fallbackRawValue.trim() ? "true" : "false",
|
|
170
|
+
modified,
|
|
171
|
+
locked: false,
|
|
172
|
+
settingsPath: definition.settingsPath,
|
|
173
|
+
display: definition.display,
|
|
174
|
+
...(resettable && { resettable: true }),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const fieldEnvKeys = "envKeys" in field ? field.envKeys : [];
|
|
179
|
+
let fallbackValue: string =
|
|
180
|
+
("fallbackValue" in definition ? definition.fallbackValue : undefined) ??
|
|
181
|
+
getEnvString(env, ...fieldEnvKeys) ??
|
|
182
|
+
field.defaultValue;
|
|
183
|
+
if ("fallbackKey" in definition && definition.fallbackKey) {
|
|
184
|
+
const fallbackField = CONFIG_FIELDS[definition.fallbackKey];
|
|
185
|
+
const envKeys = "envKeys" in fallbackField ? fallbackField.envKeys : [];
|
|
186
|
+
fallbackValue = getEnvString(env, ...envKeys) || fallbackField.defaultValue;
|
|
187
|
+
}
|
|
188
|
+
const value = storedValue.trim() || fallbackValue;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
key,
|
|
192
|
+
mode: "link",
|
|
193
|
+
type: definition.type,
|
|
194
|
+
value: definition.type === "boolean" ? String(value === "true") : value,
|
|
195
|
+
fallbackValue,
|
|
196
|
+
modified,
|
|
197
|
+
locked: false,
|
|
198
|
+
settingsPath: definition.settingsPath,
|
|
199
|
+
display: definition.display,
|
|
200
|
+
...(resettable && { resettable: true }),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function buildConfigEditorFields(
|
|
205
|
+
allSettings: Record<string, string>,
|
|
206
|
+
env: Bindings,
|
|
207
|
+
demoMode: boolean,
|
|
208
|
+
): ConfigEditorFieldState[] {
|
|
209
|
+
return configEditorVisibleKeys.map((key) => {
|
|
210
|
+
if ("configEditorLink" in CONFIG_FIELDS[key]) {
|
|
211
|
+
return buildLinkedConfigEditorField(allSettings, env, key);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!isEditableSettingKey(key)) {
|
|
215
|
+
throw new Error(`Missing Config Editor metadata for ${key}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const definition: ConfigEditorDefinition = CONFIG_FIELDS[key].editor;
|
|
219
|
+
const fallbackValue = getEditableSettingFallbackValue(
|
|
220
|
+
key,
|
|
221
|
+
env,
|
|
222
|
+
allSettings,
|
|
223
|
+
);
|
|
224
|
+
const value =
|
|
225
|
+
demoMode && key === SETTINGS_KEYS.NOINDEX
|
|
226
|
+
? "true"
|
|
227
|
+
: getEditableSettingValue(allSettings, key, env);
|
|
228
|
+
const fallbackKey = (CONFIG_FIELDS[key] as { fallbackKey?: "PAGE_SIZE" })
|
|
229
|
+
.fallbackKey;
|
|
230
|
+
const base = {
|
|
231
|
+
key,
|
|
232
|
+
mode: "edit" as const,
|
|
233
|
+
type: definition.type,
|
|
234
|
+
value,
|
|
235
|
+
fallbackValue,
|
|
236
|
+
modified: Object.hasOwn(allSettings, key),
|
|
237
|
+
locked: demoMode && demoLockedSettingKeys.has(key),
|
|
238
|
+
...(fallbackKey ? { fallbackKey } : {}),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
...base,
|
|
243
|
+
...getEditorConstraints(key, definition, value, fallbackValue),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
20
246
|
}
|
|
21
247
|
|
|
22
248
|
export function buildEditableSettingsResponse(
|
|
23
249
|
allSettings: Record<string, string>,
|
|
24
250
|
demoMode: boolean,
|
|
251
|
+
env?: Bindings,
|
|
25
252
|
): Record<string, string> {
|
|
26
253
|
const result: Record<string, string> = {};
|
|
27
254
|
for (const key of editableSettingKeys) {
|
|
28
|
-
result[key] = getEditableSettingValue(allSettings, key);
|
|
255
|
+
result[key] = getEditableSettingValue(allSettings, key, env);
|
|
29
256
|
}
|
|
30
257
|
if (demoMode) {
|
|
31
258
|
result.NOINDEX = "true";
|
|
@@ -38,14 +265,14 @@ export function partitionEditableSettingUpdates(
|
|
|
38
265
|
updates: Record<string, string>,
|
|
39
266
|
demoMode: boolean,
|
|
40
267
|
): {
|
|
41
|
-
filteredUpdates: Partial<Record<
|
|
268
|
+
filteredUpdates: Partial<Record<ConfigEditorKey, string>>;
|
|
42
269
|
rejectedKeys: string[];
|
|
43
270
|
} {
|
|
44
|
-
const filteredUpdates: Partial<Record<
|
|
271
|
+
const filteredUpdates: Partial<Record<ConfigEditorKey, string>> = {};
|
|
45
272
|
const rejectedKeys: string[] = [];
|
|
46
273
|
|
|
47
274
|
for (const [key, value] of Object.entries(updates)) {
|
|
48
|
-
const configKey = key as
|
|
275
|
+
const configKey = key as ConfigEditorKey;
|
|
49
276
|
|
|
50
277
|
if (
|
|
51
278
|
demoMode &&
|
|
@@ -18,9 +18,9 @@ export function isRatingSortOrder(
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Returns true when a collection has enough rated
|
|
21
|
+
* Returns true when a collection has enough rated Threads to make rating sort useful.
|
|
22
22
|
*
|
|
23
|
-
* @param
|
|
23
|
+
* @param ratedThreadCount - Number of Threads with at least one rating
|
|
24
24
|
* @returns Whether rating sort should be shown to readers
|
|
25
25
|
*
|
|
26
26
|
* @example
|
|
@@ -28,13 +28,15 @@ export function isRatingSortOrder(
|
|
|
28
28
|
* supportsCollectionRatingSort(2);
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
|
-
export function supportsCollectionRatingSort(
|
|
32
|
-
|
|
31
|
+
export function supportsCollectionRatingSort(
|
|
32
|
+
ratedThreadCount: number,
|
|
33
|
+
): boolean {
|
|
34
|
+
return ratedThreadCount > 1;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Resolves the sort order for a collection page, falling back when rating
|
|
37
|
-
* sorting is requested but the collection does not have enough rated
|
|
39
|
+
* sorting is requested but the collection does not have enough rated Threads.
|
|
38
40
|
*
|
|
39
41
|
* @param requestedSort - Sort order from the request query
|
|
40
42
|
* @param defaultSort - Collection default sort order
|
package/src/lib/constants.ts
CHANGED
|
@@ -22,12 +22,14 @@ export const RESERVED_PATHS = [
|
|
|
22
22
|
"reset",
|
|
23
23
|
"collections",
|
|
24
24
|
"compose",
|
|
25
|
+
"preview",
|
|
25
26
|
"new",
|
|
26
27
|
"static",
|
|
27
28
|
"assets",
|
|
28
29
|
"_assets",
|
|
29
30
|
"healthz",
|
|
30
31
|
"readyz",
|
|
32
|
+
"skill.md",
|
|
31
33
|
] as const;
|
|
32
34
|
|
|
33
35
|
export type ReservedPath = (typeof RESERVED_PATHS)[number];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const NON_FEED_PATH_PREFIXES = ["/api", "/settings", "/compose", "/_"];
|
|
2
|
+
|
|
3
|
+
function hasPathPrefix(path: string, prefix: string): boolean {
|
|
4
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether an internal application path is an Atom feed endpoint.
|
|
9
|
+
*
|
|
10
|
+
* @param path - Request pathname after any configured site prefix is removed
|
|
11
|
+
* @returns Whether the path is a canonical or legacy feed URL
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* isRssFeedPath("/reading/feed"); // true
|
|
15
|
+
* isRssFeedPath("/api/posts"); // false
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function isRssFeedPath(path: string): boolean {
|
|
19
|
+
if (path === "/feed" || path.startsWith("/feed/")) return true;
|
|
20
|
+
if (NON_FEED_PATH_PREFIXES.some((prefix) => hasPathPrefix(path, prefix))) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return path.endsWith("/feed") || path.endsWith("/feed/atom.xml");
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Post } from "../types.js";
|
|
2
|
+
import { now, toISOString } from "./time.js";
|
|
3
|
+
|
|
4
|
+
/** Cache policy for dynamic Atom feed responses. */
|
|
5
|
+
export const RSS_FEED_CACHE_CONTROL = "public, max-age=60";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert an RSS publication delay into the exclusive upper bound expected by
|
|
9
|
+
* Post service queries.
|
|
10
|
+
*
|
|
11
|
+
* Posts published exactly `delaySeconds` ago are eligible. Because publication
|
|
12
|
+
* timestamps use whole seconds while `publishedBefore` is exclusive, the bound
|
|
13
|
+
* is one second after the latest eligible timestamp.
|
|
14
|
+
*
|
|
15
|
+
* @param delaySeconds - Non-negative publication delay in seconds
|
|
16
|
+
* @param currentTime - Current Unix timestamp, injectable for deterministic use
|
|
17
|
+
* @returns Exclusive `publishedBefore` timestamp for RSS queries
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* getRssPublishedBefore(300, 1_000); // 701, so publishedAt <= 700 is eligible
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function getRssPublishedBefore(
|
|
24
|
+
delaySeconds: number,
|
|
25
|
+
currentTime = now(),
|
|
26
|
+
): number {
|
|
27
|
+
return currentTime - delaySeconds + 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the Atom `updated` timestamp for a Thread entry from content that is
|
|
32
|
+
* actually present in the feed.
|
|
33
|
+
*
|
|
34
|
+
* @param root - Thread root used as a fallback when no Thread rows are loaded
|
|
35
|
+
* @param thread - Eligible Thread Posts included in the Atom entry
|
|
36
|
+
* @param additionalTimestamps - Other entry updates, such as Collection membership
|
|
37
|
+
* @returns Latest update timestamp as an ISO 8601 string
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* getFeedEntryUpdatedAt(root, [root, reply], [collectedAt]);
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function getFeedEntryUpdatedAt(
|
|
44
|
+
root: Pick<Post, "publishedAt" | "updatedAt">,
|
|
45
|
+
thread: readonly Pick<Post, "publishedAt" | "updatedAt">[] | undefined,
|
|
46
|
+
additionalTimestamps: readonly (number | null | undefined)[] = [],
|
|
47
|
+
): string {
|
|
48
|
+
let updatedAt = Math.max(root.updatedAt, root.publishedAt ?? root.updatedAt);
|
|
49
|
+
|
|
50
|
+
for (const post of thread ?? []) {
|
|
51
|
+
updatedAt = Math.max(
|
|
52
|
+
updatedAt,
|
|
53
|
+
post.updatedAt,
|
|
54
|
+
post.publishedAt ?? post.updatedAt,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
for (const timestamp of additionalTimestamps) {
|
|
58
|
+
if (timestamp !== null && timestamp !== undefined) {
|
|
59
|
+
updatedAt = Math.max(updatedAt, timestamp);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return toISOString(updatedAt);
|
|
64
|
+
}
|
package/src/lib/footnotes.ts
CHANGED
|
@@ -18,6 +18,16 @@ export interface FootnoteDefinitionTokenData {
|
|
|
18
18
|
contentMarkdown: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface LegacyFootnoteUpgradeResult {
|
|
22
|
+
doc: JSONContent;
|
|
23
|
+
upgraded: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LegacyFootnoteReference {
|
|
27
|
+
label: string;
|
|
28
|
+
node: JSONContent;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export function normalizeFootnoteLabel(label: unknown): string {
|
|
22
32
|
if (typeof label !== "string") return "";
|
|
23
33
|
return label.trim().replace(/\s+/g, " ");
|
|
@@ -82,6 +92,260 @@ export function normalizeFootnoteArtifacts(doc: JSONContent): JSONContent {
|
|
|
82
92
|
return content.length === doc.content.length ? doc : { ...doc, content };
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
function getLinkHref(node: JSONContent): string {
|
|
96
|
+
const linkMarks = (node.marks ?? []).filter((mark) => mark.type === "link");
|
|
97
|
+
if (linkMarks.length !== 1) return "";
|
|
98
|
+
|
|
99
|
+
const href = linkMarks[0]?.attrs?.href;
|
|
100
|
+
return typeof href === "string" ? href.trim() : "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decodeHashTarget(href: string): string {
|
|
104
|
+
if (!href.startsWith("#")) return "";
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
return decodeURIComponent(href.slice(1));
|
|
108
|
+
} catch {
|
|
109
|
+
return href.slice(1);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeLegacyTargetId(value: string): string {
|
|
114
|
+
return value.replace(/^user-content-/i, "").toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getLegacyReferenceLabel(node: JSONContent): string {
|
|
118
|
+
if (node.type !== "text" || typeof node.text !== "string") return "";
|
|
119
|
+
|
|
120
|
+
const text = node.text.trim();
|
|
121
|
+
const displayMatch = text.match(/^\[\^?([^\]\n]+)\]$/);
|
|
122
|
+
const label = normalizeFootnoteLabel(displayMatch?.[1] ?? text);
|
|
123
|
+
if (!label) return "";
|
|
124
|
+
|
|
125
|
+
const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
|
|
126
|
+
const expectedTargetId = `fn-${getFootnoteDomId(label)}`.toLowerCase();
|
|
127
|
+
return targetId === expectedTargetId ? label : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectLegacyReferences(
|
|
131
|
+
node: JSONContent,
|
|
132
|
+
references: LegacyFootnoteReference[],
|
|
133
|
+
): void {
|
|
134
|
+
const label = getLegacyReferenceLabel(node);
|
|
135
|
+
if (label) {
|
|
136
|
+
references.push({ label, node });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const child of node.content ?? []) {
|
|
141
|
+
collectLegacyReferences(child, references);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isBacklinkText(value: string | undefined): boolean {
|
|
146
|
+
return /^(?:↩(?:︎)?|↵)(?:\s*\d+)?$/.test(value?.trim() ?? "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getLegacyBacklinkLabel(
|
|
150
|
+
node: JSONContent,
|
|
151
|
+
labels: readonly string[],
|
|
152
|
+
): string {
|
|
153
|
+
if (node.type !== "text" || !isBacklinkText(node.text)) return "";
|
|
154
|
+
|
|
155
|
+
const targetId = normalizeLegacyTargetId(decodeHashTarget(getLinkHref(node)));
|
|
156
|
+
if (!targetId.startsWith("fnref-")) return "";
|
|
157
|
+
|
|
158
|
+
for (const label of labels) {
|
|
159
|
+
const base = `fnref-${getFootnoteDomId(label)}`.toLowerCase();
|
|
160
|
+
const occurrence = targetId.startsWith(`${base}-`)
|
|
161
|
+
? targetId.slice(base.length + 1)
|
|
162
|
+
: "";
|
|
163
|
+
if (targetId === base || /^\d+$/.test(occurrence)) {
|
|
164
|
+
return label;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return "";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function collectLegacyBacklinkLabels(
|
|
172
|
+
node: JSONContent,
|
|
173
|
+
labels: readonly string[],
|
|
174
|
+
found: Set<string>,
|
|
175
|
+
): void {
|
|
176
|
+
const label = getLegacyBacklinkLabel(node, labels);
|
|
177
|
+
if (label) {
|
|
178
|
+
found.add(getFootnoteLabelKey(label));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const child of node.content ?? []) {
|
|
183
|
+
collectLegacyBacklinkLabels(child, labels, found);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function removeLegacyBacklinks(
|
|
188
|
+
node: JSONContent,
|
|
189
|
+
label: string,
|
|
190
|
+
): JSONContent | null {
|
|
191
|
+
if (
|
|
192
|
+
getFootnoteLabelKey(getLegacyBacklinkLabel(node, [label])) ===
|
|
193
|
+
getFootnoteLabelKey(label)
|
|
194
|
+
) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!node.content) return node;
|
|
199
|
+
|
|
200
|
+
const content = node.content.flatMap((child) => {
|
|
201
|
+
const normalized = removeLegacyBacklinks(child, label);
|
|
202
|
+
return normalized ? [normalized] : [];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (content.length === node.content.length) {
|
|
206
|
+
const unchanged = content.every(
|
|
207
|
+
(child, index) => child === node.content?.[index],
|
|
208
|
+
);
|
|
209
|
+
if (unchanged) return node;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return content.length > 0 ? { ...node, content } : { ...node, content: [] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function replaceLegacyReferences(
|
|
216
|
+
node: JSONContent,
|
|
217
|
+
labelsByNode: Map<JSONContent, string>,
|
|
218
|
+
): JSONContent {
|
|
219
|
+
const label = labelsByNode.get(node);
|
|
220
|
+
if (label) {
|
|
221
|
+
return { type: "footnoteReference", attrs: { label } };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!node.content) return node;
|
|
225
|
+
|
|
226
|
+
const content = node.content.map((child) =>
|
|
227
|
+
replaceLegacyReferences(child, labelsByNode),
|
|
228
|
+
);
|
|
229
|
+
const unchanged = content.every(
|
|
230
|
+
(child, index) => child === node.content?.[index],
|
|
231
|
+
);
|
|
232
|
+
return unchanged ? node : { ...node, content };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hasCanonicalFootnoteNode(node: JSONContent): boolean {
|
|
236
|
+
if (node.type === "footnoteReference" || node.type === "footnoteDefinition") {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (node.content ?? []).some(hasCanonicalFootnoteNode);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Upgrades the historical footnote shape that old rich-text pastes stored as
|
|
245
|
+
* ordinary fragment links plus a trailing ordered list.
|
|
246
|
+
*
|
|
247
|
+
* Conversion is deliberately all-or-nothing. The document must end in an
|
|
248
|
+
* `hr` and ordered list, every definition item must contain a matching
|
|
249
|
+
* `#fnref-*` backlink, and every `#fn-*` reference must have exactly one
|
|
250
|
+
* definition label. This keeps normal fragment links and numbered lists
|
|
251
|
+
* untouched.
|
|
252
|
+
*
|
|
253
|
+
* @param doc - Parsed TipTap document
|
|
254
|
+
* @returns The canonical document and whether a legacy footnote set changed
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* const result = upgradeLegacyFootnotes({
|
|
258
|
+
* type: "doc",
|
|
259
|
+
* content: [],
|
|
260
|
+
* });
|
|
261
|
+
* // { doc: { type: "doc", content: [] }, upgraded: false }
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
export function upgradeLegacyFootnotes(
|
|
265
|
+
doc: JSONContent,
|
|
266
|
+
): LegacyFootnoteUpgradeResult {
|
|
267
|
+
if (
|
|
268
|
+
doc.type !== "doc" ||
|
|
269
|
+
!Array.isArray(doc.content) ||
|
|
270
|
+
doc.content.length < 3 ||
|
|
271
|
+
hasCanonicalFootnoteNode(doc)
|
|
272
|
+
) {
|
|
273
|
+
return { doc, upgraded: false };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const definitionList = doc.content.at(-1);
|
|
277
|
+
const separator = doc.content.at(-2);
|
|
278
|
+
if (
|
|
279
|
+
definitionList?.type !== "orderedList" ||
|
|
280
|
+
separator?.type !== "horizontalRule" ||
|
|
281
|
+
!Array.isArray(definitionList.content) ||
|
|
282
|
+
definitionList.content.length === 0 ||
|
|
283
|
+
definitionList.content.some((item) => item.type !== "listItem")
|
|
284
|
+
) {
|
|
285
|
+
return { doc, upgraded: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const bodyNodes = doc.content.slice(0, -2);
|
|
289
|
+
const references: LegacyFootnoteReference[] = [];
|
|
290
|
+
for (const node of bodyNodes) collectLegacyReferences(node, references);
|
|
291
|
+
if (references.length === 0) return { doc, upgraded: false };
|
|
292
|
+
|
|
293
|
+
const labelsByKey = new Map<string, string>();
|
|
294
|
+
for (const reference of references) {
|
|
295
|
+
const key = getFootnoteLabelKey(reference.label);
|
|
296
|
+
if (!labelsByKey.has(key)) labelsByKey.set(key, reference.label);
|
|
297
|
+
}
|
|
298
|
+
const labels = [...labelsByKey.values()].sort(
|
|
299
|
+
(left, right) =>
|
|
300
|
+
getFootnoteDomId(right).length - getFootnoteDomId(left).length,
|
|
301
|
+
);
|
|
302
|
+
if (labels.length !== definitionList.content.length) {
|
|
303
|
+
return { doc, upgraded: false };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const definitions: JSONContent[] = [];
|
|
307
|
+
const definedLabels = new Set<string>();
|
|
308
|
+
for (const item of definitionList.content) {
|
|
309
|
+
const backlinkLabels = new Set<string>();
|
|
310
|
+
collectLegacyBacklinkLabels(item, labels, backlinkLabels);
|
|
311
|
+
if (backlinkLabels.size !== 1) return { doc, upgraded: false };
|
|
312
|
+
|
|
313
|
+
const key = [...backlinkLabels][0];
|
|
314
|
+
if (!key) return { doc, upgraded: false };
|
|
315
|
+
const label = labelsByKey.get(key);
|
|
316
|
+
if (!label || definedLabels.has(key)) return { doc, upgraded: false };
|
|
317
|
+
|
|
318
|
+
definedLabels.add(key);
|
|
319
|
+
const content = (item.content ?? []).flatMap((child) => {
|
|
320
|
+
const normalized = removeLegacyBacklinks(child, label);
|
|
321
|
+
return normalized ? [normalized] : [];
|
|
322
|
+
});
|
|
323
|
+
definitions.push({
|
|
324
|
+
type: "footnoteDefinition",
|
|
325
|
+
attrs: { label },
|
|
326
|
+
content: content.length > 0 ? content : [{ type: "paragraph" }],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (definedLabels.size !== labelsByKey.size) {
|
|
331
|
+
return { doc, upgraded: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const labelsByNode = new Map(
|
|
335
|
+
references.map((reference) => [reference.node, reference.label] as const),
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
doc: {
|
|
339
|
+
...doc,
|
|
340
|
+
content: [
|
|
341
|
+
...bodyNodes.map((node) => replaceLegacyReferences(node, labelsByNode)),
|
|
342
|
+
...definitions,
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
upgraded: true,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
85
349
|
/**
|
|
86
350
|
* Parses one Markdown footnote definition from the start of `src`.
|
|
87
351
|
*
|