@jant/core 0.6.16 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +60 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +23 -0
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-types.ts +75 -1
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +13 -0
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +78 -72
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +16 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +159 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +23 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +5 -0
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +471 -369
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +178 -17
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post.test.ts +46 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +278 -19
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1087 -7219
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +282 -289
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +20 -11
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { d as sanitizeRichTextHref, f as sanitizeUrl, v as toPublicPath, x as __exportAll } from "./url-CbLAtlZe.js";
|
|
2
1
|
import { strToU8, zipSync } from "fflate";
|
|
3
2
|
import { Extension, Node } from "@tiptap/core";
|
|
4
3
|
import { MarkdownManager } from "@tiptap/markdown";
|
|
@@ -7,6 +6,529 @@ import Link from "@tiptap/extension-link";
|
|
|
7
6
|
import StarterKit from "@tiptap/starter-kit";
|
|
8
7
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
9
8
|
import { sql } from "drizzle-orm";
|
|
9
|
+
//#region \0rolldown/runtime.js
|
|
10
|
+
var __create = Object.create;
|
|
11
|
+
var __defProp = Object.defineProperty;
|
|
12
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
13
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
14
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
15
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
16
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
17
|
+
var __exportAll = (all, no_symbols) => {
|
|
18
|
+
let target = {};
|
|
19
|
+
for (var name in all) __defProp(target, name, {
|
|
20
|
+
get: all[name],
|
|
21
|
+
enumerable: true
|
|
22
|
+
});
|
|
23
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
24
|
+
return target;
|
|
25
|
+
};
|
|
26
|
+
var __copyProps = (to, from, except, desc) => {
|
|
27
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
28
|
+
key = keys[i];
|
|
29
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
30
|
+
get: ((k) => from[k]).bind(null, key),
|
|
31
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return to;
|
|
35
|
+
};
|
|
36
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
37
|
+
value: mod,
|
|
38
|
+
enumerable: true
|
|
39
|
+
}) : target, mod));
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/lib/html.ts
|
|
42
|
+
/**
|
|
43
|
+
* HTML Utilities
|
|
44
|
+
*/ /**
|
|
45
|
+
* Escape HTML special characters for safe insertion into HTML strings.
|
|
46
|
+
*
|
|
47
|
+
* @param str - The string to escape
|
|
48
|
+
* @returns The escaped string
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* escapeHtml('<script>alert("xss")<\/script>')
|
|
52
|
+
* // '<script>alert("xss")</script>'
|
|
53
|
+
* ```
|
|
54
|
+
*/ function escapeHtml(str) {
|
|
55
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lib/url.ts
|
|
59
|
+
var url_exports = /* @__PURE__ */ __exportAll({
|
|
60
|
+
buildSiteUrl: () => buildSiteUrl,
|
|
61
|
+
extractDisplayDomain: () => extractDisplayDomain,
|
|
62
|
+
extractDomain: () => extractDomain,
|
|
63
|
+
getSiteOrigin: () => getSiteOrigin$1,
|
|
64
|
+
getSitePathPrefix: () => getSitePathPrefix$1,
|
|
65
|
+
isFullUrl: () => isFullUrl,
|
|
66
|
+
isSafeAbsoluteUrl: () => isSafeAbsoluteUrl,
|
|
67
|
+
isSafeInternalRedirect: () => isSafeInternalRedirect,
|
|
68
|
+
looksLikeAddress: () => looksLikeAddress,
|
|
69
|
+
normalizePath: () => normalizePath,
|
|
70
|
+
normalizeSitePathPrefix: () => normalizeSitePathPrefix,
|
|
71
|
+
normalizeSiteUrl: () => normalizeSiteUrl,
|
|
72
|
+
sanitizeRichTextHref: () => sanitizeRichTextHref,
|
|
73
|
+
sanitizeUrl: () => sanitizeUrl,
|
|
74
|
+
stripSitePathPrefix: () => stripSitePathPrefix,
|
|
75
|
+
toAbsoluteAssetUrl: () => toAbsoluteAssetUrl,
|
|
76
|
+
toAbsoluteSiteUrl: () => toAbsoluteSiteUrl,
|
|
77
|
+
toInternalPath: () => toInternalPath,
|
|
78
|
+
toPublicHref: () => toPublicHref,
|
|
79
|
+
toPublicPath: () => toPublicPath,
|
|
80
|
+
toSameSitePath: () => toSameSitePath
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* URL Utilities
|
|
84
|
+
*/ function normalizeSitePathname(pathname) {
|
|
85
|
+
if (pathname === "/" || pathname === "") return "";
|
|
86
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
87
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a configured public site path prefix.
|
|
91
|
+
*
|
|
92
|
+
* @param sitePathPrefix - Prefix such as `/blog` or `blog`
|
|
93
|
+
* @returns Normalized prefix like `/blog`, or an empty string when rooted
|
|
94
|
+
*/ function normalizeSitePathPrefix(sitePathPrefix) {
|
|
95
|
+
return normalizeSitePathname(sitePathPrefix);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Extracts the hostname (domain) from a URL string.
|
|
99
|
+
*
|
|
100
|
+
* Parses a full URL and returns just the hostname portion (e.g., "example.com" from
|
|
101
|
+
* "https://example.com/path"). Returns `null` if the URL is malformed or cannot be parsed.
|
|
102
|
+
*
|
|
103
|
+
* @param url - The full URL string to extract the domain from
|
|
104
|
+
* @returns The hostname/domain if valid, or `null` if parsing fails
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* const domain = extractDomain("https://www.example.com/path");
|
|
109
|
+
* // Returns: "www.example.com"
|
|
110
|
+
*
|
|
111
|
+
* const invalid = extractDomain("not-a-url");
|
|
112
|
+
* // Returns: null
|
|
113
|
+
* ```
|
|
114
|
+
*/ function extractDomain(url) {
|
|
115
|
+
try {
|
|
116
|
+
return new URL(url).hostname;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Normalizes a path by removing slashes and converting to lowercase.
|
|
123
|
+
*
|
|
124
|
+
* Trims whitespace, converts to lowercase, removes leading and trailing slashes,
|
|
125
|
+
* and collapses multiple consecutive slashes into single slashes. Used to create
|
|
126
|
+
* consistent path representations for routing and storage.
|
|
127
|
+
*
|
|
128
|
+
* @param path - The path string to normalize
|
|
129
|
+
* @returns The normalized path string
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const normalized = normalizePath(" /About/Contact// ");
|
|
134
|
+
* // Returns: "about/contact"
|
|
135
|
+
* ```
|
|
136
|
+
*/ function normalizePath(path) {
|
|
137
|
+
return path.trim().toLowerCase().replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Checks if a string is a full URL with HTTP or HTTPS protocol.
|
|
141
|
+
*
|
|
142
|
+
* Validates whether a string starts with "http://" or "https://", indicating it's
|
|
143
|
+
* a full URL rather than a relative path. Useful for distinguishing between internal
|
|
144
|
+
* paths and external URLs.
|
|
145
|
+
*
|
|
146
|
+
* @param str - The string to check
|
|
147
|
+
* @returns `true` if the string starts with http:// or https://, `false` otherwise
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* isFullUrl("https://example.com"); // Returns: true
|
|
152
|
+
* isFullUrl("/about"); // Returns: false
|
|
153
|
+
* isFullUrl("example.com"); // Returns: false
|
|
154
|
+
* ```
|
|
155
|
+
*/ function isFullUrl(str) {
|
|
156
|
+
return str.startsWith("http://") || str.startsWith("https://");
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* If a full URL points at the site's own host, return its same-site path
|
|
160
|
+
* (`pathname` + `search` + `hash`). Returns `null` when the input is not a full
|
|
161
|
+
* URL, is unparseable, or points at a different host.
|
|
162
|
+
*
|
|
163
|
+
* Self-referential absolute links — e.g. a nav item set to
|
|
164
|
+
* `https://example.com/about` on `example.com` — should behave like the
|
|
165
|
+
* internal path `/about`: no external-link icon, no `target="_blank"`.
|
|
166
|
+
*
|
|
167
|
+
* Matching is by **hostname**, not full origin: scheme and port differences are
|
|
168
|
+
* treated as same-site. This keeps the check intuitive ("same domain") and
|
|
169
|
+
* robust in dev, where the site is often served over `http://host:<port>` while
|
|
170
|
+
* a nav link stores the canonical `https://host` URL. In production (canonical
|
|
171
|
+
* https + default port) hostname match is equivalent to origin match.
|
|
172
|
+
*
|
|
173
|
+
* @param url - Candidate URL (full URL or relative path)
|
|
174
|
+
* @param siteOrigin - The site's own origin, e.g. `https://example.com`
|
|
175
|
+
* @returns The same-site path, or `null` when the URL is external/non-absolute
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* toSameSitePath("https://example.com/about", "https://example.com"); // "/about"
|
|
180
|
+
* toSameSitePath("https://example.com/about", "http://example.com:8787"); // "/about"
|
|
181
|
+
* toSameSitePath("https://other.com/about", "https://example.com"); // null
|
|
182
|
+
* toSameSitePath("/about", "https://example.com"); // null
|
|
183
|
+
* ```
|
|
184
|
+
*/ function toSameSitePath(url, siteOrigin) {
|
|
185
|
+
if (!siteOrigin || !isFullUrl(url)) return null;
|
|
186
|
+
let parsed;
|
|
187
|
+
let reference;
|
|
188
|
+
try {
|
|
189
|
+
parsed = new URL(url);
|
|
190
|
+
reference = new URL(siteOrigin);
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (parsed.hostname !== reference.hostname) return null;
|
|
195
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Any `scheme:` at the start. `mailto:` and `tel:` address something real but
|
|
199
|
+
* never a page here, and an unrecognized scheme must not be read as a path.
|
|
200
|
+
*/ var ANY_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
201
|
+
/**
|
|
202
|
+
* Whether a string is meant as an address rather than as search words.
|
|
203
|
+
*
|
|
204
|
+
* Deliberately narrow — a leading slash or a full `http(s)` URL. Anything
|
|
205
|
+
* looser would swallow real queries: a search for "about" is not a search for
|
|
206
|
+
* `/about`.
|
|
207
|
+
*
|
|
208
|
+
* @param input - What the author typed or pasted
|
|
209
|
+
* @returns True when the input should be resolved instead of searched
|
|
210
|
+
* @example
|
|
211
|
+
* looksLikeAddress("/about"); // true
|
|
212
|
+
* looksLikeAddress("https://example.com/about"); // true
|
|
213
|
+
* looksLikeAddress("about"); // false
|
|
214
|
+
*/ function looksLikeAddress(input) {
|
|
215
|
+
const trimmed = input.trim();
|
|
216
|
+
return trimmed.startsWith("/") || isFullUrl(trimmed);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Convert an address the author pasted into an internal app path.
|
|
220
|
+
*
|
|
221
|
+
* Accepts what a browser address bar would produce — a full URL on one of the
|
|
222
|
+
* site's own origins, or a path — and returns the path the router resolves
|
|
223
|
+
* against: no site path prefix, no language prefix, rooted at `/`. Returns
|
|
224
|
+
* `null` for anything that is not a page on this site, so callers can tell an
|
|
225
|
+
* external link apart from a bad one.
|
|
226
|
+
*
|
|
227
|
+
* Case is left alone; storage-level normalization belongs to the path
|
|
228
|
+
* registry, which lowercases on its own terms.
|
|
229
|
+
*
|
|
230
|
+
* A full URL has to sit inside the deployment to count. A typed path does not:
|
|
231
|
+
* `/about` on a site served from `/blog` is the About page, because that is
|
|
232
|
+
* what someone typing it means.
|
|
233
|
+
*
|
|
234
|
+
* @param input - A full URL, or a path
|
|
235
|
+
* @param options - The site's origins, prefixes, and what to keep
|
|
236
|
+
* @returns Internal path rooted at `/`, or `null` when the address is not ours
|
|
237
|
+
* @example
|
|
238
|
+
* toInternalPath("https://example.com/blog/en/about", {
|
|
239
|
+
* siteOrigins: ["https://example.com"],
|
|
240
|
+
* sitePathPrefix: "/blog",
|
|
241
|
+
* languagePrefixes: ["en"],
|
|
242
|
+
* }); // "/about"
|
|
243
|
+
* toInternalPath("https://other.com/about", { siteOrigins: ["https://example.com"] }); // null
|
|
244
|
+
*/ function toInternalPath(input, options = {}) {
|
|
245
|
+
const value = input.trim();
|
|
246
|
+
if (!value) return null;
|
|
247
|
+
const { siteOrigins = [], sitePathPrefix = "", languagePrefixes = [], keepQuery = false } = options;
|
|
248
|
+
const isFull = isFullUrl(value);
|
|
249
|
+
let candidate = null;
|
|
250
|
+
if (isFull) {
|
|
251
|
+
for (const origin of siteOrigins) {
|
|
252
|
+
const path = toSameSitePath(value, origin);
|
|
253
|
+
if (path !== null) {
|
|
254
|
+
candidate = path;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (candidate === null) return null;
|
|
259
|
+
} else if (value.startsWith("//") || ANY_SCHEME_RE.test(value) || value.startsWith("#")) return null;
|
|
260
|
+
else candidate = value;
|
|
261
|
+
let url;
|
|
262
|
+
try {
|
|
263
|
+
url = new URL(candidate, "https://jant.invalid");
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const withoutSite = stripSitePathPrefix(url.pathname, normalizeSitePathPrefix(sitePathPrefix));
|
|
268
|
+
if (withoutSite === null && isFull) return null;
|
|
269
|
+
const internal = stripLanguagePrefix(withoutSite ?? url.pathname, languagePrefixes);
|
|
270
|
+
const trimmed = internal.length > 1 ? internal.replace(/\/+$/, "") || "/" : internal;
|
|
271
|
+
return keepQuery ? `${trimmed}${url.search}${url.hash}` : trimmed;
|
|
272
|
+
}
|
|
273
|
+
/** Drop a leading `/en`-style segment when the site serves that language. */ function stripLanguagePrefix(pathname, languagePrefixes) {
|
|
274
|
+
if (languagePrefixes.length === 0) return pathname;
|
|
275
|
+
const rest = pathname.startsWith("/") ? pathname.slice(1) : pathname;
|
|
276
|
+
const slash = rest.indexOf("/");
|
|
277
|
+
const first = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase();
|
|
278
|
+
if (!first) return pathname;
|
|
279
|
+
if (!languagePrefixes.some((prefix) => prefix.toLowerCase() === first)) return pathname;
|
|
280
|
+
return slash === -1 ? "/" : rest.slice(slash);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Extracts a human-friendly domain name from a URL for display purposes.
|
|
284
|
+
*
|
|
285
|
+
* Parses the URL, strips common prefixes (`www.`, `m.`, `mobile.`), and returns
|
|
286
|
+
* a clean domain. Returns `null` if the URL is malformed.
|
|
287
|
+
*
|
|
288
|
+
* @param url - The full URL string
|
|
289
|
+
* @returns A display-friendly domain string, or `null` if parsing fails
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```ts
|
|
293
|
+
* extractDisplayDomain("https://www.example.com/path");
|
|
294
|
+
* // Returns: "example.com"
|
|
295
|
+
*
|
|
296
|
+
* extractDisplayDomain("https://m.wikipedia.org/wiki/Test");
|
|
297
|
+
* // Returns: "wikipedia.org"
|
|
298
|
+
*
|
|
299
|
+
* extractDisplayDomain("https://blog.example.com");
|
|
300
|
+
* // Returns: "blog.example.com"
|
|
301
|
+
* ```
|
|
302
|
+
*/ function extractDisplayDomain(url) {
|
|
303
|
+
const hostname = extractDomain(url);
|
|
304
|
+
if (!hostname) return null;
|
|
305
|
+
return hostname.replace(/^(?:www|m|mobile)\./, "");
|
|
306
|
+
}
|
|
307
|
+
var SAFE_URL_PROTOCOLS = /* @__PURE__ */ new Set([
|
|
308
|
+
"http:",
|
|
309
|
+
"https:",
|
|
310
|
+
"mailto:"
|
|
311
|
+
]);
|
|
312
|
+
var SAFE_RICH_TEXT_HREF_PROTOCOLS = /* @__PURE__ */ new Set([
|
|
313
|
+
...SAFE_URL_PROTOCOLS,
|
|
314
|
+
"tel:",
|
|
315
|
+
"sms:"
|
|
316
|
+
]);
|
|
317
|
+
function sanitizeUrlWithProtocols(url, protocols) {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = new URL(url, "https://placeholder.invalid");
|
|
320
|
+
if (protocols.has(parsed.protocol)) return url;
|
|
321
|
+
return "";
|
|
322
|
+
} catch {
|
|
323
|
+
return "";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Checks whether a string is a safe absolute URL.
|
|
328
|
+
*
|
|
329
|
+
* Accepts absolute URLs that use `http:`, `https:`, or `mailto:`. Relative
|
|
330
|
+
* paths are rejected because external post/source URLs must be fully qualified.
|
|
331
|
+
*
|
|
332
|
+
* @param url - The URL string to validate
|
|
333
|
+
* @returns `true` when the URL is absolute and uses an allowed protocol
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```ts
|
|
337
|
+
* isSafeAbsoluteUrl("https://example.com"); // true
|
|
338
|
+
* isSafeAbsoluteUrl("mailto:hello@example.com"); // true
|
|
339
|
+
* isSafeAbsoluteUrl("example.com"); // false
|
|
340
|
+
* ```
|
|
341
|
+
*/ function isSafeAbsoluteUrl(url) {
|
|
342
|
+
const trimmed = url.trim();
|
|
343
|
+
if (!trimmed) return false;
|
|
344
|
+
try {
|
|
345
|
+
const parsed = new URL(trimmed);
|
|
346
|
+
return SAFE_URL_PROTOCOLS.has(parsed.protocol);
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Sanitizes a URL by ensuring it uses a safe protocol.
|
|
353
|
+
*
|
|
354
|
+
* Returns the URL unchanged if it uses an allowed protocol (http:, https:, mailto:)
|
|
355
|
+
* or is a relative path. Returns an empty string for dangerous protocols like
|
|
356
|
+
* `javascript:`, `data:`, or `vbscript:`.
|
|
357
|
+
*
|
|
358
|
+
* @param url - The URL string to sanitize
|
|
359
|
+
* @returns The original URL if safe, or an empty string if the protocol is disallowed
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* sanitizeUrl("https://example.com"); // "https://example.com"
|
|
364
|
+
* sanitizeUrl("/about"); // "/about"
|
|
365
|
+
* sanitizeUrl("javascript:alert(1)"); // ""
|
|
366
|
+
* sanitizeUrl("data:text/html,<h1>Hi</h1>"); // ""
|
|
367
|
+
* ```
|
|
368
|
+
*/ function sanitizeUrl(url) {
|
|
369
|
+
return sanitizeUrlWithProtocols(url, SAFE_URL_PROTOCOLS);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Sanitizes a rich-text link destination.
|
|
373
|
+
*
|
|
374
|
+
* In addition to safe web and email URLs, rich-text links may launch the
|
|
375
|
+
* device's phone or messaging app through `tel:` and `sms:`. Relative links
|
|
376
|
+
* remain valid, while executable and resource-only protocols are rejected.
|
|
377
|
+
*
|
|
378
|
+
* @param url - The link destination to sanitize
|
|
379
|
+
* @returns The original URL if safe for an anchor, or an empty string otherwise
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* ```ts
|
|
383
|
+
* sanitizeRichTextHref("sms:+15551234567"); // "sms:+15551234567"
|
|
384
|
+
* sanitizeRichTextHref("javascript:alert(1)"); // ""
|
|
385
|
+
* ```
|
|
386
|
+
*/ function sanitizeRichTextHref(url) {
|
|
387
|
+
return sanitizeUrlWithProtocols(url, SAFE_RICH_TEXT_HREF_PROTOCOLS);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Normalize a public site URL by stripping any trailing slash from the path.
|
|
391
|
+
*
|
|
392
|
+
* @param siteUrl - Full site URL, optionally with a path prefix
|
|
393
|
+
* @returns Normalized site URL, or an empty string when not configured
|
|
394
|
+
*/ function normalizeSiteUrl(siteUrl) {
|
|
395
|
+
const trimmed = siteUrl.trim();
|
|
396
|
+
if (!trimmed) return "";
|
|
397
|
+
const parsed = new URL(trimmed);
|
|
398
|
+
parsed.pathname = normalizeSitePathname(parsed.pathname) || "/";
|
|
399
|
+
if (parsed.pathname !== "/") parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
400
|
+
return parsed.toString().replace(/\/$/, parsed.pathname === "/" ? "/" : "");
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Extract the public path prefix from a full site URL.
|
|
404
|
+
*
|
|
405
|
+
* @param siteUrl - Full site URL
|
|
406
|
+
* @returns Prefix like `/blog`, or an empty string when the site is rooted
|
|
407
|
+
*/ function getSitePathPrefix$1(siteUrl) {
|
|
408
|
+
if (!siteUrl.trim()) return "";
|
|
409
|
+
return normalizeSitePathname(new URL(siteUrl).pathname);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Extract the origin from a full site URL.
|
|
413
|
+
*
|
|
414
|
+
* @param siteUrl - Full site URL
|
|
415
|
+
* @returns Origin like `https://example.com`, or an empty string when missing
|
|
416
|
+
*/ function getSiteOrigin$1(siteUrl) {
|
|
417
|
+
if (!siteUrl.trim()) return "";
|
|
418
|
+
return new URL(siteUrl).origin;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Build a normalized public site URL from an origin and optional path prefix.
|
|
422
|
+
*
|
|
423
|
+
* @param siteOrigin - Public site origin, such as `https://example.com`
|
|
424
|
+
* @param sitePathPrefix - Public site path prefix, such as `/blog`
|
|
425
|
+
* @returns Normalized site URL, or an empty string when no origin is configured
|
|
426
|
+
*/ function buildSiteUrl(siteOrigin, sitePathPrefix = "") {
|
|
427
|
+
const trimmedOrigin = siteOrigin.trim();
|
|
428
|
+
if (!trimmedOrigin) return "";
|
|
429
|
+
const origin = new URL(trimmedOrigin).origin;
|
|
430
|
+
return normalizeSiteUrl(`${origin}${normalizeSitePathPrefix(sitePathPrefix) || "/"}`);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Prefix an internal app path with the public site path prefix.
|
|
434
|
+
*
|
|
435
|
+
* Internal paths are always rooted at `/` and never include the deployment
|
|
436
|
+
* prefix. This helper converts them to public-facing paths.
|
|
437
|
+
*
|
|
438
|
+
* @param path - Internal app path, such as `/settings`
|
|
439
|
+
* @param sitePathPrefix - Public site path prefix, such as `/blog`
|
|
440
|
+
* @returns Public path
|
|
441
|
+
*/ function toPublicPath(path, sitePathPrefix = "") {
|
|
442
|
+
if (!path) return sitePathPrefix || "/";
|
|
443
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
444
|
+
if (!sitePathPrefix) return normalizedPath;
|
|
445
|
+
if (normalizedPath === sitePathPrefix || normalizedPath.startsWith(`${sitePathPrefix}/`)) return normalizedPath;
|
|
446
|
+
if (normalizedPath === "/") return sitePathPrefix;
|
|
447
|
+
return `${sitePathPrefix}${normalizedPath}`;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Convert an app-local href to its public path while leaving external URLs
|
|
451
|
+
* unchanged.
|
|
452
|
+
*
|
|
453
|
+
* @param href - Internal app path or external URL
|
|
454
|
+
* @param sitePathPrefix - Public site path prefix
|
|
455
|
+
* @returns Public-facing href
|
|
456
|
+
*/ function toPublicHref(href, sitePathPrefix = "") {
|
|
457
|
+
if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("//") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("#")) return href;
|
|
458
|
+
return toPublicPath(href, sitePathPrefix);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Check whether a path is a safe same-origin redirect target.
|
|
462
|
+
*
|
|
463
|
+
* Accepts only paths that start with a single `/` (no protocol-relative
|
|
464
|
+
* `//host`, no scheme, no control characters). Callers should use this to
|
|
465
|
+
* validate user-supplied `redirect` query parameters before issuing a
|
|
466
|
+
* `Location` header.
|
|
467
|
+
*
|
|
468
|
+
* @param path - Candidate redirect path
|
|
469
|
+
* @returns `true` when the path is safe to use as an internal redirect
|
|
470
|
+
*
|
|
471
|
+
* @example
|
|
472
|
+
* ```ts
|
|
473
|
+
* isSafeInternalRedirect("/settings") // true
|
|
474
|
+
* isSafeInternalRedirect("//evil.example") // false
|
|
475
|
+
* isSafeInternalRedirect("https://evil.example") // false
|
|
476
|
+
* ```
|
|
477
|
+
*/ function isSafeInternalRedirect(path) {
|
|
478
|
+
if (typeof path !== "string") return false;
|
|
479
|
+
if (!path.startsWith("/")) return false;
|
|
480
|
+
if (path.startsWith("//")) return false;
|
|
481
|
+
if (path.startsWith("/\\")) return false;
|
|
482
|
+
for (let i = 0; i < path.length; i += 1) {
|
|
483
|
+
const code = path.charCodeAt(i);
|
|
484
|
+
if (code < 32 || code === 127) return false;
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Remove the site path prefix from a public request pathname.
|
|
490
|
+
*
|
|
491
|
+
* @param pathname - Public request pathname
|
|
492
|
+
* @param sitePathPrefix - Configured public site path prefix
|
|
493
|
+
* @returns Internal pathname, or `null` when the request is outside the site
|
|
494
|
+
*/ function stripSitePathPrefix(pathname, sitePathPrefix) {
|
|
495
|
+
if (!sitePathPrefix) return pathname || "/";
|
|
496
|
+
if (pathname === sitePathPrefix) return "/";
|
|
497
|
+
if (pathname.startsWith(`${sitePathPrefix}/`)) return pathname.slice(sitePathPrefix.length) || "/";
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Convert an internal or public path into an absolute site URL.
|
|
502
|
+
*
|
|
503
|
+
* @param path - Internal app path or already-public path
|
|
504
|
+
* @param siteUrl - Normalized site URL
|
|
505
|
+
* @param sitePathPrefix - Public site path prefix
|
|
506
|
+
* @returns Absolute URL when siteUrl is configured, otherwise the original path
|
|
507
|
+
*/ function toAbsoluteSiteUrl(path, siteUrl, sitePathPrefix = "") {
|
|
508
|
+
if (!siteUrl) return toPublicPath(path, sitePathPrefix);
|
|
509
|
+
return new URL(toPublicPath(path, sitePathPrefix), siteUrl).toString();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Resolve a possibly-relative asset URL to an absolute URL, leaving
|
|
513
|
+
* already-absolute (`http(s):`) and protocol-relative (`//host`) URLs
|
|
514
|
+
* untouched. Use for assets — like media — whose stored URL may be either an
|
|
515
|
+
* app-local path or a full CDN URL.
|
|
516
|
+
*
|
|
517
|
+
* @param url - Asset URL: an internal path or an already-absolute URL
|
|
518
|
+
* @param siteUrl - Normalized site URL
|
|
519
|
+
* @param sitePathPrefix - Public site path prefix
|
|
520
|
+
* @returns Absolute URL, or the original value when it is already absolute
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```ts
|
|
524
|
+
* toAbsoluteAssetUrl("/m/a.png", "https://site.com"); // "https://site.com/m/a.png"
|
|
525
|
+
* toAbsoluteAssetUrl("https://cdn.example/a.png", "x"); // "https://cdn.example/a.png"
|
|
526
|
+
* ```
|
|
527
|
+
*/ function toAbsoluteAssetUrl(url, siteUrl, sitePathPrefix = "") {
|
|
528
|
+
if (isFullUrl(url) || url.startsWith("//")) return url;
|
|
529
|
+
return toAbsoluteSiteUrl(url, siteUrl, sitePathPrefix);
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
10
532
|
//#region src/lib/favicon.ts
|
|
11
533
|
/**
|
|
12
534
|
* Convert an ArrayBuffer to a base64 string.
|
|
@@ -1428,29 +1950,91 @@ var VISIBILITIES = [
|
|
|
1428
1950
|
"latest_hidden",
|
|
1429
1951
|
"private"
|
|
1430
1952
|
];
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1953
|
+
/**
|
|
1954
|
+
* Layouts the archive page can render in. `list` renders the same timeline the
|
|
1955
|
+
* rest of the nav trio uses; `grid` renders the tile catalogue.
|
|
1956
|
+
*
|
|
1957
|
+
* Named "layout", not "view": on the archive, `view` is reserved for the
|
|
1958
|
+
* saved-selection concept, and the URL param follows the same split
|
|
1959
|
+
* (`?layout=grid`, with `?view=` kept only as the legacy spelling).
|
|
1960
|
+
*/ var ARCHIVE_LAYOUTS = ["list", "grid"];
|
|
1961
|
+
/**
|
|
1962
|
+
* Visibility values a signed-out reader may select on the archive.
|
|
1963
|
+
*
|
|
1964
|
+
* `featured` is a virtual member: it is a separate flag rather than a stored
|
|
1965
|
+
* visibility, and it lives here because the filter bar presents the two as one
|
|
1966
|
+
* dimension.
|
|
1967
|
+
*
|
|
1968
|
+
* `private` is deliberately absent. It names a set only the author can see, so
|
|
1969
|
+
* a reader who asks for it has to be told so — handing back a different set
|
|
1970
|
+
* under the same name is the one answer that is never right.
|
|
1971
|
+
*/ var PUBLIC_ARCHIVE_VISIBILITIES = [
|
|
1972
|
+
"public",
|
|
1973
|
+
"featured",
|
|
1974
|
+
"latest_hidden"
|
|
1436
1975
|
];
|
|
1437
|
-
var
|
|
1976
|
+
/** Every visibility the author can select, in filter-bar order. */ var ARCHIVE_VISIBILITIES = [...PUBLIC_ARCHIVE_VISIBILITIES, "private"];
|
|
1977
|
+
/**
|
|
1978
|
+
* The range a `year` filter may name, inclusive at both ends.
|
|
1979
|
+
*
|
|
1980
|
+
* Unix timestamps start in 1970, so anything at or below it is a parse failure
|
|
1981
|
+
* rather than a very old archive.
|
|
1982
|
+
*
|
|
1983
|
+
* The ceiling is not pedantry. A year is translated into a pair of bounds with
|
|
1984
|
+
* `Date.UTC`, which returns `NaN` past year 275760 — and a `NaN` bound is a
|
|
1985
|
+
* comparison every row silently fails, so the page renders empty with nothing
|
|
1986
|
+
* to explain it. Four digits is the largest year a post can honestly carry, and
|
|
1987
|
+
* it is bounded in three places at once: the URL parser, the stored condition's
|
|
1988
|
+
* validator, and the table CHECK in both dialects.
|
|
1989
|
+
*/ var EARLIEST_FILTERABLE_YEAR = 1971;
|
|
1990
|
+
var LATEST_FILTERABLE_YEAR = 9999;
|
|
1991
|
+
/**
|
|
1992
|
+
* How a chronological query orders its results.
|
|
1993
|
+
*
|
|
1994
|
+
* `newest` and `oldest` read whichever time axis `PostFilters.sortBy` selects,
|
|
1995
|
+
* so "newest" on the activity axis means "last gained a post" and on the
|
|
1996
|
+
* published axis means "published last".
|
|
1997
|
+
*
|
|
1998
|
+
* Not to be confused with the archive's `?sort=`, which names a *time axis*
|
|
1999
|
+
* (`published` / `updated`) and always runs newest-first. Two vocabularies, and
|
|
2000
|
+
* mixing them is how month headers stop agreeing with the order beneath them.
|
|
2001
|
+
*/ var SORT_ORDERS = [
|
|
1438
2002
|
"newest",
|
|
1439
2003
|
"oldest",
|
|
1440
2004
|
"rating_desc"
|
|
1441
2005
|
];
|
|
2006
|
+
/**
|
|
2007
|
+
* The orders a collection offers its readers — the same three, deliberately.
|
|
2008
|
+
*
|
|
2009
|
+
* Kept as its own name because the two vocabularies answer different questions
|
|
2010
|
+
* (one orders a query, one is stored on a collection row and shown in a menu),
|
|
2011
|
+
* and pointing them at one array is what keeps them from drifting apart the way
|
|
2012
|
+
* the smart collection's fourth order once did.
|
|
2013
|
+
*/ var COLLECTION_SORT_ORDERS = SORT_ORDERS;
|
|
2014
|
+
/**
|
|
2015
|
+
* How a smart collection orders the posts its conditions gather.
|
|
2016
|
+
*
|
|
2017
|
+
* Identical to a manual collection's orders, and for the same reasons: on both,
|
|
2018
|
+
* `newest` means the Thread that last gained a post, not the Thread published
|
|
2019
|
+
* last. A smart collection briefly carried a fourth order named `updated` that
|
|
2020
|
+
* said the same thing in different words; folding it into `newest` left one
|
|
2021
|
+
* menu the reader can learn once.
|
|
2022
|
+
*/ var SMART_COLLECTION_SORT_ORDERS = COLLECTION_SORT_ORDERS;
|
|
1442
2023
|
var NAV_ITEM_TYPES = [
|
|
1443
2024
|
"link",
|
|
1444
2025
|
"system",
|
|
1445
2026
|
"collection",
|
|
2027
|
+
"smart_collection",
|
|
1446
2028
|
"page"
|
|
1447
2029
|
];
|
|
2030
|
+
var NAV_ITEM_PLACEMENTS = ["header", "more"];
|
|
1448
2031
|
var SYSTEM_NAV_KEY_VALUES = [
|
|
1449
2032
|
"latest",
|
|
1450
2033
|
"featured",
|
|
1451
2034
|
"collections",
|
|
1452
2035
|
"archive",
|
|
1453
2036
|
"rss",
|
|
2037
|
+
"subscribe",
|
|
1454
2038
|
"settings"
|
|
1455
2039
|
];
|
|
1456
2040
|
var SYSTEM_NAV_KEYS = {
|
|
@@ -1467,34 +2051,80 @@ var SYSTEM_NAV_KEYS = {
|
|
|
1467
2051
|
collections: {
|
|
1468
2052
|
defaultLabel: "Collections",
|
|
1469
2053
|
url: "/collections",
|
|
1470
|
-
defaultPlacement: "
|
|
2054
|
+
defaultPlacement: "more"
|
|
1471
2055
|
},
|
|
1472
2056
|
archive: {
|
|
1473
|
-
defaultLabel: "
|
|
2057
|
+
defaultLabel: "All",
|
|
1474
2058
|
url: "/archive",
|
|
1475
2059
|
defaultPlacement: "header"
|
|
1476
2060
|
},
|
|
1477
2061
|
rss: {
|
|
1478
|
-
defaultLabel: "
|
|
2062
|
+
defaultLabel: "RSS",
|
|
1479
2063
|
url: "/feed",
|
|
1480
2064
|
defaultPlacement: "more"
|
|
1481
2065
|
},
|
|
2066
|
+
subscribe: {
|
|
2067
|
+
defaultLabel: "Subscribe",
|
|
2068
|
+
url: "/subscribe",
|
|
2069
|
+
defaultPlacement: "more"
|
|
2070
|
+
},
|
|
1482
2071
|
settings: {
|
|
1483
2072
|
defaultLabel: "Settings",
|
|
1484
2073
|
url: "/settings",
|
|
1485
2074
|
defaultPlacement: "more"
|
|
1486
2075
|
}
|
|
1487
2076
|
};
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
2077
|
+
/**
|
|
2078
|
+
* The system nav entries that exist only while RSS feeds do.
|
|
2079
|
+
*
|
|
2080
|
+
* `rss` addresses the Atom document; `subscribe` addresses the page that
|
|
2081
|
+
* explains it, which 404s without feeds. Both disappear from the rendered
|
|
2082
|
+
* navigation when feeds are switched off — while staying in the database, so
|
|
2083
|
+
* placement and custom labels survive being switched back on.
|
|
2084
|
+
*/ var FEED_NAV_KEYS = ["rss", "subscribe"];
|
|
2085
|
+
/**
|
|
2086
|
+
* Whether a system nav key is one of the feed entries.
|
|
2087
|
+
*
|
|
2088
|
+
* @param key - A system nav key, or nothing for a non-system item
|
|
2089
|
+
* @returns Whether the entry depends on feeds being enabled
|
|
2090
|
+
* @example
|
|
2091
|
+
* isFeedNavKey("subscribe"); // true
|
|
2092
|
+
* isFeedNavKey("archive"); // false
|
|
2093
|
+
*/ function isFeedNavKey(key) {
|
|
2094
|
+
return FEED_NAV_KEYS.includes(key ?? "");
|
|
2095
|
+
}
|
|
2096
|
+
var DEFAULT_NAVIGATION_PROFILE = {
|
|
2097
|
+
1: {
|
|
2098
|
+
version: 1,
|
|
2099
|
+
systemKeys: [
|
|
2100
|
+
"featured",
|
|
2101
|
+
"collections",
|
|
2102
|
+
"archive",
|
|
2103
|
+
"rss",
|
|
2104
|
+
"settings"
|
|
2105
|
+
]
|
|
2106
|
+
},
|
|
2107
|
+
2: {
|
|
2108
|
+
version: 2,
|
|
2109
|
+
systemKeys: [
|
|
2110
|
+
"featured",
|
|
2111
|
+
"archive",
|
|
2112
|
+
"collections",
|
|
2113
|
+
"rss",
|
|
2114
|
+
"settings"
|
|
2115
|
+
]
|
|
2116
|
+
},
|
|
2117
|
+
3: {
|
|
2118
|
+
version: 3,
|
|
2119
|
+
systemKeys: [
|
|
2120
|
+
"featured",
|
|
2121
|
+
"archive",
|
|
2122
|
+
"collections",
|
|
2123
|
+
"subscribe",
|
|
2124
|
+
"settings"
|
|
2125
|
+
]
|
|
2126
|
+
}
|
|
2127
|
+
}[3];
|
|
1498
2128
|
var MAX_MEDIA_ATTACHMENTS = 20;
|
|
1499
2129
|
var MAX_PINNED_POSTS = 3;
|
|
1500
2130
|
var MAX_COLLECTION_DESCRIPTION_LENGTH = 2e3;
|
|
@@ -1513,7 +2143,42 @@ var STORAGE_DRIVERS = [
|
|
|
1513
2143
|
"s3",
|
|
1514
2144
|
"local"
|
|
1515
2145
|
];
|
|
1516
|
-
|
|
2146
|
+
var PATH_KINDS = [
|
|
2147
|
+
"slug",
|
|
2148
|
+
"alias",
|
|
2149
|
+
"redirect",
|
|
2150
|
+
"archive"
|
|
2151
|
+
];
|
|
2152
|
+
/**
|
|
2153
|
+
* Row kinds the collections directory can hold.
|
|
2154
|
+
*
|
|
2155
|
+
* A `collection` row points at a collection and carries no text of its own; a
|
|
2156
|
+
* `divider` and a `link` carry text and point at nothing. The table CHECK in
|
|
2157
|
+
* both dialects is generated from this list, so a new kind is one edit here
|
|
2158
|
+
* plus the shape branch that describes which columns it fills.
|
|
2159
|
+
*/ var COLLECTION_DIRECTORY_ENTRY_TYPES = [
|
|
2160
|
+
"collection",
|
|
2161
|
+
"smart_collection",
|
|
2162
|
+
"divider",
|
|
2163
|
+
"link"
|
|
2164
|
+
];
|
|
2165
|
+
/** How long a collection nav item stays "fresh" after new content is added */ var COLLECTION_FRESHNESS_WINDOW_SECONDS = 172800;
|
|
2166
|
+
var SITE_STATUSES = ["active", "suspended"];
|
|
2167
|
+
var SITE_DOMAIN_KINDS = ["primary", "alias"];
|
|
2168
|
+
var SITE_MEMBER_ROLES = [
|
|
2169
|
+
"owner",
|
|
2170
|
+
"admin",
|
|
2171
|
+
"editor"
|
|
2172
|
+
];
|
|
2173
|
+
var UPLOAD_SESSION_STATES = [
|
|
2174
|
+
"pending",
|
|
2175
|
+
"uploaded",
|
|
2176
|
+
"completed",
|
|
2177
|
+
"aborted",
|
|
2178
|
+
"failed"
|
|
2179
|
+
];
|
|
2180
|
+
/** How a stored object is served: rendered in place, or downloaded. */ var CONTENT_DISPOSITIONS = ["inline", "attachment"];
|
|
2181
|
+
var GITHUB_APP_ACCOUNT_TYPES = ["User", "Organization"];
|
|
1517
2182
|
//#endregion
|
|
1518
2183
|
//#region src/types/config.ts
|
|
1519
2184
|
/**
|
|
@@ -1574,19 +2239,22 @@ var STORAGE_DRIVERS = [
|
|
|
1574
2239
|
]
|
|
1575
2240
|
}
|
|
1576
2241
|
},
|
|
1577
|
-
|
|
1578
|
-
defaultValue: "
|
|
2242
|
+
MULTILINGUAL_ENABLED: {
|
|
2243
|
+
defaultValue: "false",
|
|
1579
2244
|
envOnly: false,
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2245
|
+
configEditorLink: {
|
|
2246
|
+
type: "boolean",
|
|
2247
|
+
settingsPath: "/settings/language",
|
|
2248
|
+
display: "value"
|
|
2249
|
+
}
|
|
2250
|
+
},
|
|
2251
|
+
ADDITIONAL_LANGUAGES: {
|
|
2252
|
+
defaultValue: "",
|
|
2253
|
+
envOnly: false,
|
|
2254
|
+
configEditorLink: {
|
|
2255
|
+
type: "string",
|
|
2256
|
+
settingsPath: "/settings/language",
|
|
2257
|
+
display: "configured"
|
|
1590
2258
|
}
|
|
1591
2259
|
},
|
|
1592
2260
|
MAIN_RSS_FEED: {
|
|
@@ -1598,6 +2266,15 @@ var STORAGE_DRIVERS = [
|
|
|
1598
2266
|
options: ["featured", "latest"]
|
|
1599
2267
|
}
|
|
1600
2268
|
},
|
|
2269
|
+
ARCHIVE_DEFAULT_LAYOUT: {
|
|
2270
|
+
defaultValue: "list",
|
|
2271
|
+
envOnly: false,
|
|
2272
|
+
envKeys: ["ARCHIVE_DEFAULT_LAYOUT"],
|
|
2273
|
+
editor: {
|
|
2274
|
+
type: "enum",
|
|
2275
|
+
options: ["list", "grid"]
|
|
2276
|
+
}
|
|
2277
|
+
},
|
|
1601
2278
|
DEFAULT_THEME: {
|
|
1602
2279
|
defaultValue: "tufte",
|
|
1603
2280
|
envOnly: true,
|
|
@@ -1649,7 +2326,7 @@ var STORAGE_DRIVERS = [
|
|
|
1649
2326
|
envKeys: ["DEMO_MODE"]
|
|
1650
2327
|
},
|
|
1651
2328
|
PAGE_SIZE: {
|
|
1652
|
-
defaultValue: "
|
|
2329
|
+
defaultValue: "25",
|
|
1653
2330
|
envOnly: false,
|
|
1654
2331
|
envKeys: ["PAGE_SIZE"],
|
|
1655
2332
|
editor: {
|
|
@@ -1928,6 +2605,16 @@ var STORAGE_DRIVERS = [
|
|
|
1928
2605
|
envKeys: ["RSS_FEEDS_ENABLED"],
|
|
1929
2606
|
editor: { type: "boolean" }
|
|
1930
2607
|
},
|
|
2608
|
+
DISCOVER: {
|
|
2609
|
+
defaultValue: "",
|
|
2610
|
+
envOnly: false,
|
|
2611
|
+
envKeys: ["DISCOVER"]
|
|
2612
|
+
},
|
|
2613
|
+
DISCOVER_ANNOUNCE_STATE: {
|
|
2614
|
+
defaultValue: "",
|
|
2615
|
+
envOnly: false,
|
|
2616
|
+
internal: true
|
|
2617
|
+
},
|
|
1931
2618
|
DISCOVERY_COMPOSE_OPEN_SHORTCUT_AT: {
|
|
1932
2619
|
defaultValue: "",
|
|
1933
2620
|
envOnly: false,
|
|
@@ -1948,11 +2635,6 @@ var STORAGE_DRIVERS = [
|
|
|
1948
2635
|
envOnly: false,
|
|
1949
2636
|
internal: true
|
|
1950
2637
|
},
|
|
1951
|
-
DELETE_CSRF_TOKEN: {
|
|
1952
|
-
defaultValue: "",
|
|
1953
|
-
envOnly: false,
|
|
1954
|
-
internal: true
|
|
1955
|
-
},
|
|
1956
2638
|
GITHUB_SYNC_ENABLED: {
|
|
1957
2639
|
defaultValue: "false",
|
|
1958
2640
|
envOnly: false,
|
|
@@ -2003,27 +2685,36 @@ var STORAGE_DRIVERS = [
|
|
|
2003
2685
|
envOnly: false,
|
|
2004
2686
|
internal: true
|
|
2005
2687
|
},
|
|
2006
|
-
|
|
2688
|
+
/**
|
|
2689
|
+
* Unix-seconds timestamp captured when PENDING flips to "true".
|
|
2690
|
+
* Read alongside PENDING to detect a stuck flag from a crashed /
|
|
2691
|
+
* killed worker that never got to run its `finally` clause, so the
|
|
2692
|
+
* status card self-heals instead of showing "Syncing…" forever.
|
|
2693
|
+
*/ GITHUB_SYNC_PENDING_AT: {
|
|
2007
2694
|
defaultValue: "",
|
|
2008
2695
|
envOnly: false,
|
|
2009
2696
|
internal: true
|
|
2010
2697
|
},
|
|
2011
|
-
|
|
2698
|
+
/**
|
|
2699
|
+
* Set "true" when a trigger arrives while PENDING is already true.
|
|
2700
|
+
* The running sync consults this after its push and loops once more
|
|
2701
|
+
* so mid-push edits aren't lost.
|
|
2702
|
+
*/ GITHUB_SYNC_DIRTY: {
|
|
2012
2703
|
defaultValue: "",
|
|
2013
2704
|
envOnly: false,
|
|
2014
2705
|
internal: true
|
|
2015
2706
|
},
|
|
2016
|
-
GITHUB_SYNC_LAST_ERROR: {
|
|
2707
|
+
/** Last sync error message — surfaced on the status page when non-empty. */ GITHUB_SYNC_LAST_ERROR: {
|
|
2017
2708
|
defaultValue: "",
|
|
2018
2709
|
envOnly: false,
|
|
2019
2710
|
internal: true
|
|
2020
2711
|
},
|
|
2021
|
-
GITHUB_SYNC_AUTH_MODE: {
|
|
2712
|
+
/** "pat" (default) or "app" — indicates which auth path to use for sync. */ GITHUB_SYNC_AUTH_MODE: {
|
|
2022
2713
|
defaultValue: "pat",
|
|
2023
2714
|
envOnly: false,
|
|
2024
2715
|
internal: true
|
|
2025
2716
|
},
|
|
2026
|
-
GITHUB_SYNC_APP_INSTALLATION_ID: {
|
|
2717
|
+
/** GitHub App installation id (only when AUTH_MODE = "app"). */ GITHUB_SYNC_APP_INSTALLATION_ID: {
|
|
2027
2718
|
defaultValue: "",
|
|
2028
2719
|
envOnly: false,
|
|
2029
2720
|
internal: true
|
|
@@ -2038,12 +2729,12 @@ var STORAGE_DRIVERS = [
|
|
|
2038
2729
|
envOnly: true,
|
|
2039
2730
|
envKeys: ["GITHUB_APP_PRIVATE_KEY"]
|
|
2040
2731
|
},
|
|
2041
|
-
GITHUB_APP_SLUG: {
|
|
2732
|
+
/** App slug used to build the installation URL `github.com/apps/<slug>`. */ GITHUB_APP_SLUG: {
|
|
2042
2733
|
defaultValue: "",
|
|
2043
2734
|
envOnly: true,
|
|
2044
2735
|
envKeys: ["GITHUB_APP_SLUG"]
|
|
2045
2736
|
},
|
|
2046
|
-
GITHUB_APP_WEBHOOK_SECRET: {
|
|
2737
|
+
/** Optional app-level webhook secret (overrides per-site secret for App-mode sites). */ GITHUB_APP_WEBHOOK_SECRET: {
|
|
2047
2738
|
defaultValue: "",
|
|
2048
2739
|
envOnly: true,
|
|
2049
2740
|
envKeys: ["GITHUB_APP_WEBHOOK_SECRET"]
|
|
@@ -2078,7 +2769,7 @@ var STORAGE_DRIVERS = [
|
|
|
2078
2769
|
display: "configured"
|
|
2079
2770
|
}
|
|
2080
2771
|
},
|
|
2081
|
-
TELEGRAM_BOT_WEBHOOK_SECRET: {
|
|
2772
|
+
/** Per-site `secret_token` for a bring-your-own bot's webhook. */ TELEGRAM_BOT_WEBHOOK_SECRET: {
|
|
2082
2773
|
defaultValue: "",
|
|
2083
2774
|
envOnly: false,
|
|
2084
2775
|
internal: true
|
|
@@ -2090,6 +2781,388 @@ var THEME_MODES = [
|
|
|
2090
2781
|
"dark"
|
|
2091
2782
|
];
|
|
2092
2783
|
//#endregion
|
|
2784
|
+
//#region src/lib/display-text.ts
|
|
2785
|
+
/**
|
|
2786
|
+
* Normalize a user-visible text value.
|
|
2787
|
+
*
|
|
2788
|
+
* Trims surrounding whitespace and collapses empty or visually empty results
|
|
2789
|
+
* to `undefined` so callers can distinguish missing display copy from
|
|
2790
|
+
* intentionally present text.
|
|
2791
|
+
*
|
|
2792
|
+
* @param value - Candidate display text
|
|
2793
|
+
* @returns Trimmed text, or `undefined` when empty
|
|
2794
|
+
* @example
|
|
2795
|
+
* ```ts
|
|
2796
|
+
* normalizeDisplayText(" Hosted account ");
|
|
2797
|
+
* // Returns: "Hosted account"
|
|
2798
|
+
* ```
|
|
2799
|
+
*/ var VISUALLY_EMPTY_FORMATTING_CHARS = /\u200B|\u200C|\u200D|\u2060|\uFEFF/g;
|
|
2800
|
+
function normalizeDisplayText(value) {
|
|
2801
|
+
const normalized = value?.trim();
|
|
2802
|
+
if (!normalized) return;
|
|
2803
|
+
return normalized.replace(VISUALLY_EMPTY_FORMATTING_CHARS, "") ? normalized : void 0;
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Return the first non-empty display text from a list of candidates.
|
|
2807
|
+
*
|
|
2808
|
+
* Useful when UI copy needs a stable fallback chain such as configured label →
|
|
2809
|
+
* hostname → generic translated label.
|
|
2810
|
+
*
|
|
2811
|
+
* @param values - Candidate display text values in priority order
|
|
2812
|
+
* @returns The first trimmed non-empty candidate, or `undefined`
|
|
2813
|
+
* @example
|
|
2814
|
+
* ```ts
|
|
2815
|
+
* coalesceDisplayText(" ", undefined, "Hosted account");
|
|
2816
|
+
* // Returns: "Hosted account"
|
|
2817
|
+
* ```
|
|
2818
|
+
*/ function coalesceDisplayText(...values) {
|
|
2819
|
+
for (const value of values) {
|
|
2820
|
+
const normalized = normalizeDisplayText(value);
|
|
2821
|
+
if (normalized) return normalized;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
//#endregion
|
|
2825
|
+
//#region src/lib/env.ts
|
|
2826
|
+
var env_exports = /* @__PURE__ */ __exportAll({
|
|
2827
|
+
DEFAULT_APP_PORT: () => DEFAULT_APP_PORT,
|
|
2828
|
+
DEFAULT_DISCOVER_PING_URL: () => DEFAULT_DISCOVER_PING_URL,
|
|
2829
|
+
getAuthSecret: () => getAuthSecret,
|
|
2830
|
+
getConfiguredSingleSiteOrigin: () => getConfiguredSingleSiteOrigin,
|
|
2831
|
+
getConfiguredSingleSitePathPrefix: () => getConfiguredSingleSitePathPrefix,
|
|
2832
|
+
getConfiguredSingleSiteUrl: () => getConfiguredSingleSiteUrl,
|
|
2833
|
+
getConfiguredStorageDriver: () => getConfiguredStorageDriver,
|
|
2834
|
+
getCorsOrigins: () => getCorsOrigins,
|
|
2835
|
+
getDataDir: () => getDataDir,
|
|
2836
|
+
getDefaultStorageDriver: () => getDefaultStorageDriver,
|
|
2837
|
+
getDevApiToken: () => getDevApiToken,
|
|
2838
|
+
getDiscoverDefault: () => getDiscoverDefault,
|
|
2839
|
+
getDiscoverDirectoryBaseUrl: () => getDiscoverDirectoryBaseUrl,
|
|
2840
|
+
getDiscoverPingUrl: () => getDiscoverPingUrl,
|
|
2841
|
+
getEnvString: () => getEnvString,
|
|
2842
|
+
getGitHubAppConfig: () => getGitHubAppConfig,
|
|
2843
|
+
getHostedControlPlaneBaseUrl: () => getHostedControlPlaneBaseUrl,
|
|
2844
|
+
getHostedControlPlaneDomainCheckSecret: () => getHostedControlPlaneDomainCheckSecret,
|
|
2845
|
+
getHostedControlPlaneInternalBaseUrl: () => getHostedControlPlaneInternalBaseUrl,
|
|
2846
|
+
getHostedControlPlaneInternalToken: () => getHostedControlPlaneInternalToken,
|
|
2847
|
+
getHostedControlPlaneProviderLabel: () => getHostedControlPlaneProviderLabel,
|
|
2848
|
+
getHostedControlPlaneProviderName: () => getHostedControlPlaneProviderName,
|
|
2849
|
+
getHostedControlPlaneSsoSecret: () => getHostedControlPlaneSsoSecret,
|
|
2850
|
+
getInternalAdminToken: () => getInternalAdminToken,
|
|
2851
|
+
getLocalStoragePath: () => getLocalStoragePath,
|
|
2852
|
+
getPort: () => getPort,
|
|
2853
|
+
getSiteOrigin: () => getSiteOrigin,
|
|
2854
|
+
getSitePathPrefix: () => getSitePathPrefix,
|
|
2855
|
+
getSiteResolutionMode: () => getSiteResolutionMode,
|
|
2856
|
+
getSiteUrl: () => getSiteUrl,
|
|
2857
|
+
getStorageDriverEnv: () => getStorageDriverEnv,
|
|
2858
|
+
getTelegramBotPool: () => getTelegramBotPool,
|
|
2859
|
+
getTelegramWebhookSecret: () => getTelegramWebhookSecret,
|
|
2860
|
+
parsePortValue: () => parsePortValue,
|
|
2861
|
+
shouldTrustProxy: () => shouldTrustProxy,
|
|
2862
|
+
shouldUseSecureCookies: () => shouldUseSecureCookies
|
|
2863
|
+
});
|
|
2864
|
+
var DEFAULT_APP_PORT = 3e3;
|
|
2865
|
+
/** Jant's own directory, for a deployment that belongs to no other. */ var DEFAULT_DISCOVER_DIRECTORY_URL = "https://jant.me";
|
|
2866
|
+
/**
|
|
2867
|
+
* Where a directory receives announcements.
|
|
2868
|
+
*
|
|
2869
|
+
* The same path at every directory, which is what lets one base address stand
|
|
2870
|
+
* for the whole of it — and a long-lived public contract at Jant's own:
|
|
2871
|
+
* shipped self-hosted versions POST here for years, so it never moves.
|
|
2872
|
+
*/ var DISCOVER_PING_PATH = "/api/discover/ping";
|
|
2873
|
+
/** Where a deployment announces when nothing else names a directory. */ var DEFAULT_DISCOVER_PING_URL = `${DEFAULT_DISCOVER_DIRECTORY_URL}${DISCOVER_PING_PATH}`;
|
|
2874
|
+
function toEnvRecord(env) {
|
|
2875
|
+
return env ?? {};
|
|
2876
|
+
}
|
|
2877
|
+
function normalizeEnvScalar(value) {
|
|
2878
|
+
if (typeof value === "string") return value.length > 0 ? value : void 0;
|
|
2879
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : void 0;
|
|
2880
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
2881
|
+
}
|
|
2882
|
+
/**
|
|
2883
|
+
* Returns the first non-empty environment variable value from `keys`.
|
|
2884
|
+
*
|
|
2885
|
+
* Callers may provide multiple keys when a single semantic value can be
|
|
2886
|
+
* sourced from more than one binding at runtime.
|
|
2887
|
+
*/ function getEnvString(env, ...keys) {
|
|
2888
|
+
const record = toEnvRecord(env);
|
|
2889
|
+
for (const key of keys) {
|
|
2890
|
+
const value = normalizeEnvScalar(record[key]);
|
|
2891
|
+
if (value) return value;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Parse a TCP port from an environment value.
|
|
2896
|
+
*
|
|
2897
|
+
* @param rawPort - Raw environment value to parse
|
|
2898
|
+
* @param fallback - Port to use when `rawPort` is empty
|
|
2899
|
+
* @returns The parsed port number
|
|
2900
|
+
* @example
|
|
2901
|
+
* parsePortValue("3000");
|
|
2902
|
+
*/ function parsePortValue(rawPort, fallback = DEFAULT_APP_PORT) {
|
|
2903
|
+
if (!rawPort) return fallback;
|
|
2904
|
+
const port = Number.parseInt(rawPort, 10);
|
|
2905
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be an integer between 1 and 65535.");
|
|
2906
|
+
return port;
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* Resolve the configured application port from environment bindings.
|
|
2910
|
+
*
|
|
2911
|
+
* @param env - Runtime environment bindings
|
|
2912
|
+
* @param fallback - Port to use when `PORT` is not set
|
|
2913
|
+
* @returns The resolved application port
|
|
2914
|
+
* @example
|
|
2915
|
+
* getPort({ PORT: "3000" });
|
|
2916
|
+
*/ function getPort(env, fallback = DEFAULT_APP_PORT) {
|
|
2917
|
+
return parsePortValue(getEnvString(env, "PORT"), fallback);
|
|
2918
|
+
}
|
|
2919
|
+
function getSiteOrigin(env) {
|
|
2920
|
+
const configuredOrigin = getEnvString(env, "SITE_ORIGIN");
|
|
2921
|
+
return configuredOrigin ? new URL(configuredOrigin).origin : "";
|
|
2922
|
+
}
|
|
2923
|
+
function getSitePathPrefix(env) {
|
|
2924
|
+
const configuredPrefix = getEnvString(env, "SITE_PATH_PREFIX");
|
|
2925
|
+
return configuredPrefix ? normalizeSitePathPrefix(configuredPrefix) : "";
|
|
2926
|
+
}
|
|
2927
|
+
function getSiteUrl(env) {
|
|
2928
|
+
return buildSiteUrl(getSiteOrigin(env), getSitePathPrefix(env));
|
|
2929
|
+
}
|
|
2930
|
+
function getConfiguredSingleSiteOrigin(env) {
|
|
2931
|
+
return getSiteResolutionMode(env) === "single-site" ? getSiteOrigin(env) : "";
|
|
2932
|
+
}
|
|
2933
|
+
function getConfiguredSingleSitePathPrefix(env) {
|
|
2934
|
+
return getSiteResolutionMode(env) === "single-site" ? getSitePathPrefix(env) : "";
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Returns the configured public site URL only when the instance runs in
|
|
2938
|
+
* `single-site` mode.
|
|
2939
|
+
*
|
|
2940
|
+
* @param env - Runtime environment bindings
|
|
2941
|
+
* @returns The configured `SITE_ORIGIN` + `SITE_PATH_PREFIX`, or an empty
|
|
2942
|
+
* string in `host-based` mode
|
|
2943
|
+
* @example
|
|
2944
|
+
* getConfiguredSingleSiteUrl({ SITE_ORIGIN: "https://example.com" });
|
|
2945
|
+
*/ function getConfiguredSingleSiteUrl(env) {
|
|
2946
|
+
return getSiteResolutionMode(env) === "single-site" ? getSiteUrl(env) : "";
|
|
2947
|
+
}
|
|
2948
|
+
function getSiteResolutionMode(env) {
|
|
2949
|
+
return getEnvString(env, "SITE_RESOLUTION_MODE") === "host-based" ? "host-based" : "single-site";
|
|
2950
|
+
}
|
|
2951
|
+
function getAuthSecret(env) {
|
|
2952
|
+
return getEnvString(env, "AUTH_SECRET");
|
|
2953
|
+
}
|
|
2954
|
+
function getDevApiToken(env) {
|
|
2955
|
+
return getEnvString(env, "DEV_API_TOKEN");
|
|
2956
|
+
}
|
|
2957
|
+
function getInternalAdminToken(env) {
|
|
2958
|
+
return getEnvString(env, "INTERNAL_ADMIN_TOKEN");
|
|
2959
|
+
}
|
|
2960
|
+
function getHostedControlPlaneBaseUrl(env) {
|
|
2961
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_BASE_URL");
|
|
2962
|
+
}
|
|
2963
|
+
function getHostedControlPlaneDomainCheckSecret(env) {
|
|
2964
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_DOMAIN_CHECK_SECRET");
|
|
2965
|
+
}
|
|
2966
|
+
function getHostedControlPlaneInternalBaseUrl(env) {
|
|
2967
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_INTERNAL_BASE_URL") ?? getHostedControlPlaneBaseUrl(env);
|
|
2968
|
+
}
|
|
2969
|
+
function getHostedControlPlaneProviderName(env) {
|
|
2970
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_PROVIDER_NAME");
|
|
2971
|
+
}
|
|
2972
|
+
function getHostedControlPlaneProviderLabel(env) {
|
|
2973
|
+
const configuredName = normalizeDisplayText(getHostedControlPlaneProviderName(env));
|
|
2974
|
+
if (configuredName) return configuredName;
|
|
2975
|
+
const hostedControlPlaneBaseUrl = getHostedControlPlaneBaseUrl(env);
|
|
2976
|
+
if (!hostedControlPlaneBaseUrl) return;
|
|
2977
|
+
return new URL(hostedControlPlaneBaseUrl).hostname;
|
|
2978
|
+
}
|
|
2979
|
+
function getHostedControlPlaneSsoSecret(env) {
|
|
2980
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_SSO_SECRET");
|
|
2981
|
+
}
|
|
2982
|
+
function getHostedControlPlaneInternalToken(env) {
|
|
2983
|
+
return getEnvString(env, "HOSTED_CONTROL_PLANE_INTERNAL_TOKEN");
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Which directory this deployment belongs to, resolved against one base.
|
|
2987
|
+
*
|
|
2988
|
+
* `DISCOVER_PING_URL` is read presence-aware rather than through
|
|
2989
|
+
* `getEnvString`, because the two states that matter are spelled differently:
|
|
2990
|
+
* an **absent** binding means "work it out", while a binding set to the
|
|
2991
|
+
* **empty string** means "announce nowhere". `getEnvString` collapses both
|
|
2992
|
+
* into undefined, which would make the documented way of switching the ping
|
|
2993
|
+
* off silently fall back to switching it on.
|
|
2994
|
+
*
|
|
2995
|
+
* With nothing configured, a deployment that hosts blogs belongs to its own
|
|
2996
|
+
* control plane's directory. Naming that twice is what let the two drift: a
|
|
2997
|
+
* hosted deployment pointed at one control plane announced its blogs to
|
|
2998
|
+
* jant.me, which knows nothing about them, and the failure was recorded where
|
|
2999
|
+
* nobody reads it.
|
|
3000
|
+
*
|
|
3001
|
+
* @param env - Worker bindings or `process.env`
|
|
3002
|
+
* @param controlPlaneBaseUrl - The control plane to fall back to, public or
|
|
3003
|
+
* internal depending on who is asking
|
|
3004
|
+
* @returns A URL on the directory's origin, or undefined when there is none
|
|
3005
|
+
*/ function resolveDiscoverDirectory(env, controlPlaneBaseUrl) {
|
|
3006
|
+
const record = toEnvRecord(env);
|
|
3007
|
+
if (Object.hasOwn(record, "DISCOVER_PING_URL")) return normalizeEnvScalar(record["DISCOVER_PING_URL"])?.trim() || void 0;
|
|
3008
|
+
if (controlPlaneBaseUrl) try {
|
|
3009
|
+
return new URL(DISCOVER_PING_PATH, controlPlaneBaseUrl).toString();
|
|
3010
|
+
} catch {}
|
|
3011
|
+
return DEFAULT_DISCOVER_PING_URL;
|
|
3012
|
+
}
|
|
3013
|
+
/**
|
|
3014
|
+
* Where a site announces itself when its owner turns Jant Discover on.
|
|
3015
|
+
*
|
|
3016
|
+
* Server-to-server, so a hosted deployment reaches its control plane by the
|
|
3017
|
+
* internal address like every other core→cloud call — the ping carries no
|
|
3018
|
+
* secret, but the internal route is the one that works without depending on
|
|
3019
|
+
* the public hostname resolving and its certificate being trusted from inside.
|
|
3020
|
+
*
|
|
3021
|
+
* @param env - Worker bindings or `process.env`
|
|
3022
|
+
* @returns The endpoint to announce to, or undefined when announcing is off
|
|
3023
|
+
* @example
|
|
3024
|
+
* ```ts
|
|
3025
|
+
* getDiscoverPingUrl({}); // the default directory
|
|
3026
|
+
* getDiscoverPingUrl({ DISCOVER_PING_URL: "" }); // undefined — off
|
|
3027
|
+
* ```
|
|
3028
|
+
*/ function getDiscoverPingUrl(env) {
|
|
3029
|
+
return resolveDiscoverDirectory(env, getHostedControlPlaneInternalBaseUrl(env));
|
|
3030
|
+
}
|
|
3031
|
+
/**
|
|
3032
|
+
* The directory a site owner is sent to, for the links on the settings page.
|
|
3033
|
+
*
|
|
3034
|
+
* The public counterpart of `getDiscoverPingUrl`: same directory, the address
|
|
3035
|
+
* a browser can open. Both derive from the same configuration, so the page can
|
|
3036
|
+
* never link to one directory while announcing to another.
|
|
3037
|
+
*
|
|
3038
|
+
* @param env - Worker bindings or `process.env`
|
|
3039
|
+
* @returns The directory's base URL, or undefined when there is no directory
|
|
3040
|
+
* @example
|
|
3041
|
+
* ```ts
|
|
3042
|
+
* getDiscoverDirectoryBaseUrl({}); // "https://jant.me/"
|
|
3043
|
+
* getDiscoverDirectoryBaseUrl({ DISCOVER_PING_URL: "" }); // undefined
|
|
3044
|
+
* ```
|
|
3045
|
+
*/ function getDiscoverDirectoryBaseUrl(env) {
|
|
3046
|
+
const resolved = resolveDiscoverDirectory(env, getHostedControlPlaneBaseUrl(env));
|
|
3047
|
+
if (!resolved) return void 0;
|
|
3048
|
+
try {
|
|
3049
|
+
return new URL("/", resolved).toString();
|
|
3050
|
+
} catch {
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
/**
|
|
3055
|
+
* What this deployment lists in Jant Discover by default.
|
|
3056
|
+
*
|
|
3057
|
+
* Absent for a self-hosted site, and that is the point: with nothing stored
|
|
3058
|
+
* and nothing configured, a site declares `none` and stays out of every
|
|
3059
|
+
* directory until its owner turns it on. Hosted Jant sets `DISCOVER=latest`,
|
|
3060
|
+
* so the blogs it serves are listed without each owner having to ask.
|
|
3061
|
+
*
|
|
3062
|
+
* A default, not a choice — the site's own stored setting overrides it, and
|
|
3063
|
+
* so does `noindex`. See `resolveDiscoverMode`.
|
|
3064
|
+
*
|
|
3065
|
+
* @param env - Worker bindings or `process.env`
|
|
3066
|
+
* @returns The raw binding value, for `resolveDiscoverMode` to parse
|
|
3067
|
+
* @example
|
|
3068
|
+
* ```ts
|
|
3069
|
+
* getDiscoverDefault({}); // undefined — opt-in
|
|
3070
|
+
* getDiscoverDefault({ DISCOVER: "latest" }); // "latest"
|
|
3071
|
+
* ```
|
|
3072
|
+
*/ function getDiscoverDefault(env) {
|
|
3073
|
+
return getEnvString(env, "DISCOVER");
|
|
3074
|
+
}
|
|
3075
|
+
function getStorageDriverEnv(env) {
|
|
3076
|
+
return getEnvString(env, "STORAGE_DRIVER");
|
|
3077
|
+
}
|
|
3078
|
+
function getDataDir(env) {
|
|
3079
|
+
return getEnvString(env, "DATA_DIR");
|
|
3080
|
+
}
|
|
3081
|
+
function joinDataSubpath(dataDir, child) {
|
|
3082
|
+
return `${dataDir.replace(/[\\/]+$/, "")}/${child}`;
|
|
3083
|
+
}
|
|
3084
|
+
function getLocalStoragePath(env) {
|
|
3085
|
+
const explicit = getEnvString(env, "LOCAL_STORAGE_PATH");
|
|
3086
|
+
if (explicit) return explicit;
|
|
3087
|
+
const dataDir = getDataDir(env);
|
|
3088
|
+
return dataDir ? joinDataSubpath(dataDir, "media") : void 0;
|
|
3089
|
+
}
|
|
3090
|
+
function getDefaultStorageDriver(env) {
|
|
3091
|
+
const record = toEnvRecord(env);
|
|
3092
|
+
return record["NODE_SQLITE"] || record["NODE_DATABASE"] ? "local" : "r2";
|
|
3093
|
+
}
|
|
3094
|
+
function getConfiguredStorageDriver(env) {
|
|
3095
|
+
return getStorageDriverEnv(env) ?? getDefaultStorageDriver(env);
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Returns the parsed CORS origins configuration.
|
|
3099
|
+
*
|
|
3100
|
+
* @param env - Runtime environment bindings
|
|
3101
|
+
* @returns `"*"` to allow all origins, an array of allowed origin strings,
|
|
3102
|
+
* or `undefined` when CORS is disabled
|
|
3103
|
+
*
|
|
3104
|
+
* @example
|
|
3105
|
+
* ```ts
|
|
3106
|
+
* getCorsOrigins({ CORS_ORIGINS: "*" }); // "*"
|
|
3107
|
+
* getCorsOrigins({ CORS_ORIGINS: "https://a.com,chrome-extension://id" });
|
|
3108
|
+
* // ["https://a.com", "chrome-extension://id"]
|
|
3109
|
+
* getCorsOrigins({}); // "*"
|
|
3110
|
+
* ```
|
|
3111
|
+
*/ function getCorsOrigins(env) {
|
|
3112
|
+
const raw = getEnvString(env, "CORS_ORIGINS") ?? "*";
|
|
3113
|
+
if (!raw) return;
|
|
3114
|
+
if (raw === "*") return "*";
|
|
3115
|
+
const origins = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
3116
|
+
return origins.length > 0 ? origins : void 0;
|
|
3117
|
+
}
|
|
3118
|
+
function getGitHubAppConfig(env) {
|
|
3119
|
+
const appId = getEnvString(env, "GITHUB_APP_ID");
|
|
3120
|
+
const rawKey = getEnvString(env, "GITHUB_APP_PRIVATE_KEY");
|
|
3121
|
+
const slug = getEnvString(env, "GITHUB_APP_SLUG");
|
|
3122
|
+
if (!appId || !rawKey || !slug) return null;
|
|
3123
|
+
return {
|
|
3124
|
+
appId,
|
|
3125
|
+
privateKey: rawKey.includes("\\n") ? rawKey.replace(/\\n/g, "\n") : rawKey,
|
|
3126
|
+
slug,
|
|
3127
|
+
webhookSecret: getEnvString(env, "GITHUB_APP_WEBHOOK_SECRET")
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
function shouldTrustProxy(env) {
|
|
3131
|
+
return getEnvString(env, "TRUST_PROXY") === "true";
|
|
3132
|
+
}
|
|
3133
|
+
/**
|
|
3134
|
+
* Parses the platform-managed Telegram bot pool from `TELEGRAM_BOT_TOKENS`.
|
|
3135
|
+
*
|
|
3136
|
+
* The env value is a comma-separated list of `<bot_id>:<secret>` tokens. The
|
|
3137
|
+
* first entry is the public-facing bot (`bot1`); the rest are surfaced only
|
|
3138
|
+
* contextually when a binding code reaches an already-bound bot slot.
|
|
3139
|
+
*
|
|
3140
|
+
* @param env - Runtime environment bindings
|
|
3141
|
+
* @returns Parsed pool bots in declared order; empty when unset/invalid
|
|
3142
|
+
* @example
|
|
3143
|
+
* getTelegramBotPool({ TELEGRAM_BOT_TOKENS: "111:aaa,222:bbb" });
|
|
3144
|
+
*/ function getTelegramBotPool(env) {
|
|
3145
|
+
const raw = getEnvString(env, "TELEGRAM_BOT_TOKENS");
|
|
3146
|
+
if (!raw) return [];
|
|
3147
|
+
return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((token) => {
|
|
3148
|
+
return {
|
|
3149
|
+
botId: token.split(":")[0]?.trim() ?? "",
|
|
3150
|
+
token
|
|
3151
|
+
};
|
|
3152
|
+
}).filter((bot) => bot.botId.length > 0);
|
|
3153
|
+
}
|
|
3154
|
+
/**
|
|
3155
|
+
* Returns the shared `secret_token` used when registering every pool bot's
|
|
3156
|
+
* webhook. Only meaningful alongside `TELEGRAM_BOT_TOKENS`.
|
|
3157
|
+
*/ function getTelegramWebhookSecret(env) {
|
|
3158
|
+
return getEnvString(env, "TELEGRAM_WEBHOOK_SECRET");
|
|
3159
|
+
}
|
|
3160
|
+
function shouldUseSecureCookies(env, publicRequestUrl) {
|
|
3161
|
+
const siteOrigin = getConfiguredSingleSiteOrigin(env);
|
|
3162
|
+
if (siteOrigin) return new URL(siteOrigin).protocol === "https:";
|
|
3163
|
+
return new URL(publicRequestUrl).protocol === "https:";
|
|
3164
|
+
}
|
|
3165
|
+
//#endregion
|
|
2093
3166
|
//#region src/lib/time.ts
|
|
2094
3167
|
var time_exports = /* @__PURE__ */ __exportAll({
|
|
2095
3168
|
formatDate: () => formatDate,
|
|
@@ -2097,6 +3170,7 @@ var time_exports = /* @__PURE__ */ __exportAll({
|
|
|
2097
3170
|
formatRelativeTime: () => formatRelativeTime,
|
|
2098
3171
|
formatTime: () => formatTime,
|
|
2099
3172
|
formatYearMonth: () => formatYearMonth,
|
|
3173
|
+
formatYearMonthLabel: () => formatYearMonthLabel,
|
|
2100
3174
|
isWithinMonth: () => isWithinMonth,
|
|
2101
3175
|
now: () => now,
|
|
2102
3176
|
toISOString: () => toISOString
|
|
@@ -2122,7 +3196,7 @@ var time_exports = /* @__PURE__ */ __exportAll({
|
|
|
2122
3196
|
}
|
|
2123
3197
|
/**
|
|
2124
3198
|
* One month in seconds
|
|
2125
|
-
*/ var ONE_MONTH =
|
|
3199
|
+
*/ var ONE_MONTH = 2592e3;
|
|
2126
3200
|
/**
|
|
2127
3201
|
* Checks if a Unix timestamp is within the last 30 days.
|
|
2128
3202
|
*
|
|
@@ -2293,6 +3367,32 @@ function formatYearMonth(timestamp, timeZone = "UTC") {
|
|
|
2293
3367
|
}).formatToParts(date);
|
|
2294
3368
|
return `${parts.find((part) => part.type === "year")?.value ?? "1970"}-${parts.find((part) => part.type === "month")?.value ?? "01"}`;
|
|
2295
3369
|
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Formats a "YYYY-MM" grouping key as a readable month label.
|
|
3372
|
+
*
|
|
3373
|
+
* The label is always en-US, matching the archive's other month copy — the
|
|
3374
|
+
* grid header and the list marker read from this one function so their labels
|
|
3375
|
+
* cannot drift apart.
|
|
3376
|
+
*
|
|
3377
|
+
* @param yearMonth - Year-month string in "YYYY-MM" format
|
|
3378
|
+
* @returns Month label such as "February 2024", or null when the key is malformed
|
|
3379
|
+
*
|
|
3380
|
+
* @example
|
|
3381
|
+
* ```ts
|
|
3382
|
+
* const label = formatYearMonthLabel("2024-02");
|
|
3383
|
+
* // Returns: "February 2024"
|
|
3384
|
+
* ```
|
|
3385
|
+
*/ function formatYearMonthLabel(yearMonth) {
|
|
3386
|
+
const [year, month] = yearMonth.split("-");
|
|
3387
|
+
if (!year || !month) return null;
|
|
3388
|
+
const yearNumber = parseInt(year, 10);
|
|
3389
|
+
const monthNumber = parseInt(month, 10);
|
|
3390
|
+
if (!Number.isInteger(yearNumber) || !Number.isInteger(monthNumber)) return null;
|
|
3391
|
+
return new Date(yearNumber, monthNumber - 1).toLocaleDateString("en-US", {
|
|
3392
|
+
year: "numeric",
|
|
3393
|
+
month: "long"
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
2296
3396
|
//#endregion
|
|
2297
3397
|
//#region src/lib/footnotes.ts
|
|
2298
3398
|
/**
|
|
@@ -2567,23 +3667,6 @@ function hasCanonicalFootnoteNode(node) {
|
|
|
2567
3667
|
};
|
|
2568
3668
|
}
|
|
2569
3669
|
//#endregion
|
|
2570
|
-
//#region src/lib/html.ts
|
|
2571
|
-
/**
|
|
2572
|
-
* HTML Utilities
|
|
2573
|
-
*/ /**
|
|
2574
|
-
* Escape HTML special characters for safe insertion into HTML strings.
|
|
2575
|
-
*
|
|
2576
|
-
* @param str - The string to escape
|
|
2577
|
-
* @returns The escaped string
|
|
2578
|
-
* @example
|
|
2579
|
-
* ```ts
|
|
2580
|
-
* escapeHtml('<script>alert("xss")<\/script>')
|
|
2581
|
-
* // '<script>alert("xss")</script>'
|
|
2582
|
-
* ```
|
|
2583
|
-
*/ function escapeHtml(str) {
|
|
2584
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2585
|
-
}
|
|
2586
|
-
//#endregion
|
|
2587
3670
|
//#region src/lib/embed-providers.ts
|
|
2588
3671
|
/**
|
|
2589
3672
|
* Embed Provider Registry
|
|
@@ -3244,18 +4327,11 @@ function createRenderContext(footnotes) {
|
|
|
3244
4327
|
content: doc.content.slice(start, end)
|
|
3245
4328
|
});
|
|
3246
4329
|
}
|
|
3247
|
-
|
|
3248
|
-
//#region src/lib/summary.ts
|
|
3249
|
-
/**
|
|
3250
|
-
* Summary Extraction from Tiptap JSON
|
|
3251
|
-
*
|
|
3252
|
-
* Extracts plain-text and HTML summaries from a Tiptap JSON document
|
|
3253
|
-
* for use in feeds, meta descriptions, and article previews.
|
|
3254
|
-
*/
|
|
4330
|
+
var NOTE_SUMMARY_MAX_CHARS = 1500;
|
|
3255
4331
|
/**
|
|
3256
4332
|
* Block node types that carry user-visible content for summary extraction.
|
|
3257
4333
|
* Structural nodes (horizontalRule, moreBreak, image) are excluded.
|
|
3258
|
-
*/ var SUMMARY_BLOCK_TYPES = new Set([
|
|
4334
|
+
*/ var SUMMARY_BLOCK_TYPES = /* @__PURE__ */ new Set([
|
|
3259
4335
|
"paragraph",
|
|
3260
4336
|
"heading",
|
|
3261
4337
|
"bulletList",
|
|
@@ -3293,7 +4369,7 @@ function createRenderContext(footnotes) {
|
|
|
3293
4369
|
* Content-bearing TipTap node types whose text should be indexed for search.
|
|
3294
4370
|
* Block-level containers (bulletList, orderedList, table, etc.) are included
|
|
3295
4371
|
* because they recurse into child nodes that carry text.
|
|
3296
|
-
*/ var SEARCHABLE_TYPES = new Set([
|
|
4372
|
+
*/ var SEARCHABLE_TYPES = /* @__PURE__ */ new Set([
|
|
3297
4373
|
"doc",
|
|
3298
4374
|
"paragraph",
|
|
3299
4375
|
"heading",
|
|
@@ -3489,6 +4565,32 @@ function extractSummary(bodyJson, maxBlocks, maxChars) {
|
|
|
3489
4565
|
breakAtIndex: lastSelectedIdx + 1
|
|
3490
4566
|
};
|
|
3491
4567
|
}
|
|
4568
|
+
/**
|
|
4569
|
+
* Extract the summary the site's timeline shows for a post, at the boundary
|
|
4570
|
+
* the timeline uses.
|
|
4571
|
+
*
|
|
4572
|
+
* Wraps {@link extractSummaryHtml} with the limits that decide where a card
|
|
4573
|
+
* stops: a titled post is an article whose excerpt is a teaser, an untitled one
|
|
4574
|
+
* is a note whose body is the content and only needs a cap. Callers that need
|
|
4575
|
+
* that boundary — the feed renderer, the theme exporter — share this rather
|
|
4576
|
+
* than repeating five numbers each.
|
|
4577
|
+
*
|
|
4578
|
+
* @param bodyJson - TipTap JSON body, or null/undefined for a post without one
|
|
4579
|
+
* @param hasTitle - Whether the post has a title, which makes it an article
|
|
4580
|
+
* @param renderOptions - Renderer options, notably the footnote namespace
|
|
4581
|
+
* @returns The truncated HTML and whether content continues, or null when
|
|
4582
|
+
* there is no TipTap document to truncate
|
|
4583
|
+
* @example
|
|
4584
|
+
* extractTimelineSummary(post.body, !!post.title, { namespace: post.id });
|
|
4585
|
+
* // { html: "<p>Intro</p>", hasMore: true }
|
|
4586
|
+
*/ function extractTimelineSummary(bodyJson, hasTitle, renderOptions = {}) {
|
|
4587
|
+
if (!bodyJson) return null;
|
|
4588
|
+
const result = extractSummaryHtml(bodyJson, hasTitle ? 5 : 10, hasTitle ? 500 : NOTE_SUMMARY_MAX_CHARS, hasTitle ? 0 : 200, renderOptions);
|
|
4589
|
+
return result ? {
|
|
4590
|
+
html: result.html,
|
|
4591
|
+
hasMore: result.hasMore
|
|
4592
|
+
} : null;
|
|
4593
|
+
}
|
|
3492
4594
|
//#endregion
|
|
3493
4595
|
//#region src/lib/markdown-manager.ts
|
|
3494
4596
|
var MARKDOWN_MARKED_OPTIONS = {
|
|
@@ -4010,12 +5112,11 @@ var MarkdownFootnoteDefinition = Node.create({
|
|
|
4010
5112
|
}];
|
|
4011
5113
|
},
|
|
4012
5114
|
renderHTML({ node }) {
|
|
4013
|
-
const label = normalizeFootnoteLabel(node.attrs.label);
|
|
4014
5115
|
return [
|
|
4015
5116
|
"div",
|
|
4016
5117
|
{
|
|
4017
5118
|
"data-footnote-definition": "",
|
|
4018
|
-
"data-footnote-label": label,
|
|
5119
|
+
"data-footnote-label": normalizeFootnoteLabel(node.attrs.label),
|
|
4019
5120
|
class: "tiptap-footnote-definition"
|
|
4020
5121
|
},
|
|
4021
5122
|
0
|
|
@@ -4288,6 +5389,28 @@ function serializeMarkdownDocument(doc) {
|
|
|
4288
5389
|
return plain.slice(0, maxLength).trim() + "...";
|
|
4289
5390
|
}
|
|
4290
5391
|
//#endregion
|
|
5392
|
+
//#region src/lib/markdown-to-tiptap.ts
|
|
5393
|
+
/**
|
|
5394
|
+
* Markdown → TipTap JSON Conversion
|
|
5395
|
+
*
|
|
5396
|
+
* Converts Markdown strings to TipTap JSON documents using the official
|
|
5397
|
+
* Tiptap MarkdownManager and the same extension schema used elsewhere in Jant.
|
|
5398
|
+
*/
|
|
5399
|
+
/**
|
|
5400
|
+
* Converts a Markdown string to a TipTap JSON document string.
|
|
5401
|
+
*
|
|
5402
|
+
* @param markdown - Markdown source text
|
|
5403
|
+
* @returns Stringified TipTap JSON document
|
|
5404
|
+
*
|
|
5405
|
+
* @example
|
|
5406
|
+
* ```ts
|
|
5407
|
+
* const json = markdownToTiptapJson("Hello **world**");
|
|
5408
|
+
* // '{"type":"doc","content":[{"type":"paragraph","content":[...]}]}'
|
|
5409
|
+
* ```
|
|
5410
|
+
*/ function markdownToTiptapJson(markdown) {
|
|
5411
|
+
return JSON.stringify(parseMarkdownDocument(markdown));
|
|
5412
|
+
}
|
|
5413
|
+
//#endregion
|
|
4291
5414
|
//#region src/lib/tiptap-to-markdown.ts
|
|
4292
5415
|
/**
|
|
4293
5416
|
* Tiptap JSON → Markdown Converter
|
|
@@ -4316,6 +5439,279 @@ function serializeMarkdownDocument(doc) {
|
|
|
4316
5439
|
}
|
|
4317
5440
|
}
|
|
4318
5441
|
//#endregion
|
|
5442
|
+
//#region src/lib/github-app.ts
|
|
5443
|
+
var github_app_exports = /* @__PURE__ */ __exportAll({
|
|
5444
|
+
buildInstallUrl: () => buildInstallUrl,
|
|
5445
|
+
createAppJwt: () => createAppJwt,
|
|
5446
|
+
getInstallation: () => getInstallation,
|
|
5447
|
+
getInstallationToken: () => getInstallationToken,
|
|
5448
|
+
listInstallationReposPage: () => listInstallationReposPage,
|
|
5449
|
+
searchInstallationRepos: () => searchInstallationRepos
|
|
5450
|
+
});
|
|
5451
|
+
/**
|
|
5452
|
+
* GitHub App authentication helpers.
|
|
5453
|
+
*
|
|
5454
|
+
* Implements the App → installation token exchange using Web Crypto so it
|
|
5455
|
+
* runs unchanged in Cloudflare Workers and Node 18+.
|
|
5456
|
+
*
|
|
5457
|
+
* Flow:
|
|
5458
|
+
* 1. Sign a short-lived JWT with the App's RSA private key (RS256).
|
|
5459
|
+
* 2. Exchange the JWT for a per-installation access token.
|
|
5460
|
+
* 3. Cache the installation token in-process until shortly before expiry.
|
|
5461
|
+
*
|
|
5462
|
+
* The installation token can then be passed to `createGitHubClient` the same
|
|
5463
|
+
* way a PAT is — the downstream client is auth-agnostic.
|
|
5464
|
+
*/ var GITHUB_API = "https://api.github.com";
|
|
5465
|
+
var tokenCache = /* @__PURE__ */ new Map();
|
|
5466
|
+
var CACHE_SAFETY_MARGIN_SECONDS = 60;
|
|
5467
|
+
function cacheKey(appId, installationId) {
|
|
5468
|
+
return `${appId}:${installationId}`;
|
|
5469
|
+
}
|
|
5470
|
+
function base64UrlEncode(input) {
|
|
5471
|
+
let bytes;
|
|
5472
|
+
if (typeof input === "string") bytes = new TextEncoder().encode(input);
|
|
5473
|
+
else if (input instanceof Uint8Array) bytes = input;
|
|
5474
|
+
else bytes = new Uint8Array(input);
|
|
5475
|
+
let binary = "";
|
|
5476
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
5477
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5478
|
+
}
|
|
5479
|
+
/**
|
|
5480
|
+
* ASN.1 DER length encoding (short form for < 128, long form otherwise).
|
|
5481
|
+
* Used to wrap a PKCS#1 key inside a PKCS#8 envelope.
|
|
5482
|
+
*/ function encodeDerLength(len) {
|
|
5483
|
+
if (len < 128) return [len];
|
|
5484
|
+
if (len <= 255) return [129, len];
|
|
5485
|
+
if (len <= 65535) return [
|
|
5486
|
+
130,
|
|
5487
|
+
len >> 8 & 255,
|
|
5488
|
+
len & 255
|
|
5489
|
+
];
|
|
5490
|
+
throw new Error("Private key too large to encode");
|
|
5491
|
+
}
|
|
5492
|
+
/**
|
|
5493
|
+
* Wrap a PKCS#1 `RSAPrivateKey` DER blob in a PKCS#8 `PrivateKeyInfo` envelope.
|
|
5494
|
+
* Web Crypto's `importKey("pkcs8", ...)` only accepts PKCS#8, but GitHub
|
|
5495
|
+
* hands out PKCS#1 PEMs (`-----BEGIN RSA PRIVATE KEY-----`) by default, so
|
|
5496
|
+
* we wrap on the fly when we detect that header.
|
|
5497
|
+
*/ function wrapPkcs1AsPkcs8(pkcs1) {
|
|
5498
|
+
const rsaAlgorithmIdentifier = [
|
|
5499
|
+
48,
|
|
5500
|
+
13,
|
|
5501
|
+
6,
|
|
5502
|
+
9,
|
|
5503
|
+
42,
|
|
5504
|
+
134,
|
|
5505
|
+
72,
|
|
5506
|
+
134,
|
|
5507
|
+
247,
|
|
5508
|
+
13,
|
|
5509
|
+
1,
|
|
5510
|
+
1,
|
|
5511
|
+
1,
|
|
5512
|
+
5,
|
|
5513
|
+
0
|
|
5514
|
+
];
|
|
5515
|
+
const version = [
|
|
5516
|
+
2,
|
|
5517
|
+
1,
|
|
5518
|
+
0
|
|
5519
|
+
];
|
|
5520
|
+
const octetStringHeader = [4, ...encodeDerLength(pkcs1.length)];
|
|
5521
|
+
const bodyLen = version.length + rsaAlgorithmIdentifier.length + octetStringHeader.length + pkcs1.length;
|
|
5522
|
+
const outerHeader = [48, ...encodeDerLength(bodyLen)];
|
|
5523
|
+
const out = new Uint8Array(outerHeader.length + bodyLen);
|
|
5524
|
+
let off = 0;
|
|
5525
|
+
out.set(outerHeader, off);
|
|
5526
|
+
off += outerHeader.length;
|
|
5527
|
+
out.set(version, off);
|
|
5528
|
+
off += version.length;
|
|
5529
|
+
out.set(rsaAlgorithmIdentifier, off);
|
|
5530
|
+
off += rsaAlgorithmIdentifier.length;
|
|
5531
|
+
out.set(octetStringHeader, off);
|
|
5532
|
+
off += octetStringHeader.length;
|
|
5533
|
+
out.set(pkcs1, off);
|
|
5534
|
+
return out;
|
|
5535
|
+
}
|
|
5536
|
+
function pemToPkcs8(pem) {
|
|
5537
|
+
const isPkcs1 = /-----BEGIN RSA PRIVATE KEY-----/.test(pem);
|
|
5538
|
+
const cleaned = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
|
|
5539
|
+
if (!cleaned) throw new Error("Empty GitHub App private key");
|
|
5540
|
+
const binary = atob(cleaned);
|
|
5541
|
+
const rawDer = new Uint8Array(binary.length);
|
|
5542
|
+
for (let i = 0; i < binary.length; i++) rawDer[i] = binary.charCodeAt(i);
|
|
5543
|
+
const pkcs8 = isPkcs1 ? wrapPkcs1AsPkcs8(rawDer) : rawDer;
|
|
5544
|
+
const out = new ArrayBuffer(pkcs8.byteLength);
|
|
5545
|
+
new Uint8Array(out).set(pkcs8);
|
|
5546
|
+
return out;
|
|
5547
|
+
}
|
|
5548
|
+
async function importPrivateKey(pem) {
|
|
5549
|
+
return crypto.subtle.importKey("pkcs8", pemToPkcs8(pem), {
|
|
5550
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
5551
|
+
hash: "SHA-256"
|
|
5552
|
+
}, false, ["sign"]);
|
|
5553
|
+
}
|
|
5554
|
+
/**
|
|
5555
|
+
* Create a JWT signed with the GitHub App's private key.
|
|
5556
|
+
*
|
|
5557
|
+
* GitHub allows up to 10 minutes; we issue 9 minutes with a 60s backdated `iat`
|
|
5558
|
+
* to tolerate clock skew.
|
|
5559
|
+
*/ async function createAppJwt(appId, privateKeyPem) {
|
|
5560
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
5561
|
+
const header = {
|
|
5562
|
+
alg: "RS256",
|
|
5563
|
+
typ: "JWT"
|
|
5564
|
+
};
|
|
5565
|
+
const payload = {
|
|
5566
|
+
iat: now - 60,
|
|
5567
|
+
exp: now + 540,
|
|
5568
|
+
iss: appId
|
|
5569
|
+
};
|
|
5570
|
+
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
|
5571
|
+
const key = await importPrivateKey(privateKeyPem);
|
|
5572
|
+
return `${signingInput}.${base64UrlEncode(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput)))}`;
|
|
5573
|
+
}
|
|
5574
|
+
/**
|
|
5575
|
+
* Fetch (or reuse) a short-lived installation access token.
|
|
5576
|
+
*
|
|
5577
|
+
* Results are cached in-process until `CACHE_SAFETY_MARGIN_SECONDS` before the
|
|
5578
|
+
* real expiry so the same token can be reused across a burst of API calls.
|
|
5579
|
+
*/ async function getInstallationToken(app, installationId) {
|
|
5580
|
+
const key = cacheKey(app.appId, installationId);
|
|
5581
|
+
const cached = tokenCache.get(key);
|
|
5582
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
5583
|
+
if (cached && cached.expiresAt > nowSec) return cached.token;
|
|
5584
|
+
const jwt = await createAppJwt(app.appId, app.privateKey);
|
|
5585
|
+
const res = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
|
|
5586
|
+
method: "POST",
|
|
5587
|
+
headers: {
|
|
5588
|
+
Accept: "application/vnd.github+json",
|
|
5589
|
+
Authorization: `Bearer ${jwt}`,
|
|
5590
|
+
"User-Agent": "jant-github-sync",
|
|
5591
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
5592
|
+
}
|
|
5593
|
+
});
|
|
5594
|
+
if (!res.ok) {
|
|
5595
|
+
const body = await res.text();
|
|
5596
|
+
throw new Error(`GitHub App token exchange failed (${res.status}): ${body}`);
|
|
5597
|
+
}
|
|
5598
|
+
const data = await res.json();
|
|
5599
|
+
const expiresAt = Math.floor(new Date(data.expires_at).getTime() / 1e3) - CACHE_SAFETY_MARGIN_SECONDS;
|
|
5600
|
+
tokenCache.set(key, {
|
|
5601
|
+
token: data.token,
|
|
5602
|
+
expiresAt
|
|
5603
|
+
});
|
|
5604
|
+
return data.token;
|
|
5605
|
+
}
|
|
5606
|
+
/**
|
|
5607
|
+
* Build the URL users visit to install the GitHub App on their account/org.
|
|
5608
|
+
*
|
|
5609
|
+
* `state` is a CSRF token the caller should verify on the redirect back.
|
|
5610
|
+
*/ function buildInstallUrl(slug, state) {
|
|
5611
|
+
const params = new URLSearchParams({ state });
|
|
5612
|
+
return `https://github.com/apps/${encodeURIComponent(slug)}/installations/new?${params.toString()}`;
|
|
5613
|
+
}
|
|
5614
|
+
/**
|
|
5615
|
+
* Fetch metadata for a given installation, including the account it's
|
|
5616
|
+
* installed on. Uses an App JWT because the target endpoint is
|
|
5617
|
+
* `/app/installations/{id}`, which is App-scoped, not installation-scoped.
|
|
5618
|
+
*/ async function getInstallation(app, installationId) {
|
|
5619
|
+
const jwt = await createAppJwt(app.appId, app.privateKey);
|
|
5620
|
+
const res = await fetch(`${GITHUB_API}/app/installations/${encodeURIComponent(installationId)}`, { headers: {
|
|
5621
|
+
Accept: "application/vnd.github+json",
|
|
5622
|
+
Authorization: `Bearer ${jwt}`,
|
|
5623
|
+
"User-Agent": "jant-github-sync",
|
|
5624
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
5625
|
+
} });
|
|
5626
|
+
if (!res.ok) {
|
|
5627
|
+
const body = await res.text();
|
|
5628
|
+
throw new Error(`Fetching installation failed (${res.status}): ${body}`);
|
|
5629
|
+
}
|
|
5630
|
+
const data = await res.json();
|
|
5631
|
+
const type = data.account.type === "Organization" ? "Organization" : "User";
|
|
5632
|
+
return { account: {
|
|
5633
|
+
login: data.account.login,
|
|
5634
|
+
type,
|
|
5635
|
+
avatarUrl: data.account.avatar_url ?? ""
|
|
5636
|
+
} };
|
|
5637
|
+
}
|
|
5638
|
+
/**
|
|
5639
|
+
* Fetch a single page of installation repositories.
|
|
5640
|
+
*
|
|
5641
|
+
* Used by the picker for lazy-loading. Callers can either stop after the
|
|
5642
|
+
* first page (UI does local filtering) or keep paging for larger accounts.
|
|
5643
|
+
* For search, prefer `searchInstallationRepos` — this endpoint does not
|
|
5644
|
+
* support a query parameter.
|
|
5645
|
+
*/ async function listInstallationReposPage(app, installationId, page, perPage = 100) {
|
|
5646
|
+
const token = await getInstallationToken(app, installationId);
|
|
5647
|
+
const res = await fetch(`${GITHUB_API}/installation/repositories?per_page=${perPage}&page=${page}`, { headers: {
|
|
5648
|
+
Accept: "application/vnd.github+json",
|
|
5649
|
+
Authorization: `Bearer ${token}`,
|
|
5650
|
+
"User-Agent": "jant-github-sync",
|
|
5651
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
5652
|
+
} });
|
|
5653
|
+
if (!res.ok) {
|
|
5654
|
+
const body = await res.text();
|
|
5655
|
+
throw new Error(`Listing installation repos failed: ${res.status} ${body}`);
|
|
5656
|
+
}
|
|
5657
|
+
const data = await res.json();
|
|
5658
|
+
const repos = data.repositories.slice().sort((a, b) => {
|
|
5659
|
+
const at = Date.parse(a.updated_at ?? a.created_at ?? "") || 0;
|
|
5660
|
+
return (Date.parse(b.updated_at ?? b.created_at ?? "") || 0) - at;
|
|
5661
|
+
}).map((r) => ({
|
|
5662
|
+
fullName: r.full_name,
|
|
5663
|
+
name: r.name,
|
|
5664
|
+
private: r.private,
|
|
5665
|
+
defaultBranch: r.default_branch
|
|
5666
|
+
}));
|
|
5667
|
+
const hasMore = repos.length === perPage;
|
|
5668
|
+
return {
|
|
5669
|
+
repos,
|
|
5670
|
+
totalCount: data.total_count,
|
|
5671
|
+
hasMore,
|
|
5672
|
+
nextPage: hasMore ? page + 1 : null
|
|
5673
|
+
};
|
|
5674
|
+
}
|
|
5675
|
+
/**
|
|
5676
|
+
* Search the installation's repositories via `GET /search/repositories`.
|
|
5677
|
+
*
|
|
5678
|
+
* GitHub's Search API doesn't scope automatically to an installation, so
|
|
5679
|
+
* we filter server-side by the installation's account login. This keeps
|
|
5680
|
+
* results relevant while still covering repos past the first 100.
|
|
5681
|
+
*
|
|
5682
|
+
* The `q` string is wrapped (caller passes raw user input, we add the
|
|
5683
|
+
* scope qualifier). Uses the installation token for auth.
|
|
5684
|
+
*/ async function searchInstallationRepos(app, installationId, accountLogin, q, perPage = 30) {
|
|
5685
|
+
const token = await getInstallationToken(app, installationId);
|
|
5686
|
+
const fullQuery = `${q} in:name user:${accountLogin} fork:true`;
|
|
5687
|
+
const params = new URLSearchParams({
|
|
5688
|
+
q: fullQuery,
|
|
5689
|
+
per_page: String(perPage),
|
|
5690
|
+
sort: "updated",
|
|
5691
|
+
order: "desc"
|
|
5692
|
+
});
|
|
5693
|
+
const res = await fetch(`${GITHUB_API}/search/repositories?${params.toString()}`, { headers: {
|
|
5694
|
+
Accept: "application/vnd.github+json",
|
|
5695
|
+
Authorization: `Bearer ${token}`,
|
|
5696
|
+
"User-Agent": "jant-github-sync",
|
|
5697
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
5698
|
+
} });
|
|
5699
|
+
if (!res.ok) {
|
|
5700
|
+
const body = await res.text();
|
|
5701
|
+
throw new Error(`Searching repos failed: ${res.status} ${body}`);
|
|
5702
|
+
}
|
|
5703
|
+
const data = await res.json();
|
|
5704
|
+
return {
|
|
5705
|
+
repos: data.items.map((r) => ({
|
|
5706
|
+
fullName: r.full_name,
|
|
5707
|
+
name: r.name,
|
|
5708
|
+
private: r.private,
|
|
5709
|
+
defaultBranch: r.default_branch
|
|
5710
|
+
})),
|
|
5711
|
+
totalCount: data.total_count
|
|
5712
|
+
};
|
|
5713
|
+
}
|
|
5714
|
+
//#endregion
|
|
4319
5715
|
//#region src/db/thread-activity.ts
|
|
4320
5716
|
/**
|
|
4321
5717
|
* Thread activity — one definition, shared by every surface that orders by it.
|
|
@@ -4473,6 +5869,7 @@ function serializeMarkdownDocument(doc) {
|
|
|
4473
5869
|
"status",
|
|
4474
5870
|
"visibility",
|
|
4475
5871
|
"summary_text",
|
|
5872
|
+
"truncated",
|
|
4476
5873
|
"link_url",
|
|
4477
5874
|
"source_name",
|
|
4478
5875
|
"source_url",
|
|
@@ -4521,19 +5918,19 @@ function serializeMarkdownDocument(doc) {
|
|
|
4521
5918
|
}
|
|
4522
5919
|
//#endregion
|
|
4523
5920
|
//#region src/styles/tokens.css?raw
|
|
4524
|
-
var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n --font-cjk-serif-fallback: \"Jant Language Fallback\";\n --font-cjk-sans-fallback: \"Jant Language Fallback\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — UI buttons, controls\n * --type-sm 0.9rem (13.5px) — small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.8;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n --type-content-quote: calc(var(--type-content-body) * 1.16);\n --type-content-quote-leading: 1.4;\n\n /* Semantic aliases */\n --type-body-size: var(--type-content-body);\n --type-footnote-ref: calc(var(--type-body-size) * 0.75);\n --type-code: calc(var(--type-body-size) * 0.94);\n --type-code-block: calc(var(--type-body-size) * 0.9);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: calc(var(--type-content-body) * 1.36);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.7;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-medium);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-medium);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n --layout-content-width: 55%;\n --layout-sidenote-width: 50%;\n --layout-sidenote-gap: 10%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 24px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n /* Despite the name, this is the page colour and ~60 rules use it that way —\n including three that use it as *text* on a dark button. It cannot be\n lifted without repainting the whole site; `--site-raised-bg` below is the\n one that actually means \"above the page\". Read this as\n \"--site-default-surface\" until the call sites are triaged. */\n --site-elevated-bg: var(--background);\n /* What sits *above* the page: dialogs, popovers, menus, sheets. On a light\n page this is the page colour, because the separating is done by the\n backdrop dim and the drop shadows — both black over white. Neither works\n on a dark page: 30% black over `#110f0d` composites to `#0c0b09`, and a\n black shadow on near-black is nothing at all, which left the compose\n dialog at 1.03:1 against its own backdrop. So in dark mode this lifts\n instead (see the dark blocks below). Elevation there is lightness, not\n shadow. */\n --site-raised-bg: var(--site-page-bg);\n /* The edge of a raised layer. On a light page the fill already separates and\n this is just a hairline; on a dark one it does most of the work, because\n the fill has almost no room to lift into. Same formula as\n `--compose-control-border` in light so nothing moves there. */\n --site-raised-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-footnote-text: var(--site-reading-caption);\n --site-footnote-marker: color-mix(\n in oklch,\n var(--site-text-secondary) 88%,\n var(--site-page-bg)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-code-text: color-mix(\n in srgb,\n var(--site-reading-heading) 86%,\n var(--site-reading-body)\n );\n --site-code-bg: color-mix(in srgb, var(--site-reading-meta) 12%, transparent);\n --site-code-block-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 94%,\n var(--site-nav-hover-bg)\n );\n --site-code-block-border: color-mix(\n in srgb,\n var(--site-divider) 62%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n /* The composer is a floating sheet in every mode but one, so its paper is\n the raised surface. `.compose-page` — the full-page `/new` composer, which\n *is* the page — puts this back to `--site-page-bg` locally. */\n --compose-paper-bg: var(--site-raised-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n /* The tools row sits directly under the sentence being written, so its icons\n stay a step behind the body's own muted ink and only come forward when\n pointed at or switched on. 72% keeps the faded state above the 3:1\n non-text contrast floor. */\n --compose-tool-ink: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n@media (max-width: 760px) {\n :root {\n --site-padding: 1.875rem;\n }\n}\n\n/* Tufte two-column → single-column collapse.\n *\n * Below this width the post column is already narrowed to `min(100%, 35rem)`\n * (preset.css) and there is no room for a 45% sidenote gutter, so all content\n * goes full-width. Single images (MediaGallery getSingleVisualWidth) and link\n * previews (.link-preview) read this token directly, so they collapse here too.\n *\n * Keep this breakpoint in sync with the post-column collapse (preset.css /\n * ui.css feed-divider, both `max-width: 1024px`) and the sidenote float→inline\n * collapse (ui.css). They are one layout switch; do not let them drift apart. */\n@media (max-width: 1024px) {\n :root {\n --layout-content-width: 100%;\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n /* 12% of the foreground mixed into the page. The headroom here is small and\n bounded: `--site-divider` sits 1.36:1 above the page, and a surface lifted\n past it would swallow every hairline drawn on it. 12% lands at 1.24:1 —\n most of the range that exists, with the dividers still on top of it. */\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n /* Carries the separation the fill cannot: 12% of lift is most of the room\n the palette has, so the edge has to say the rest. */\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n}\n\n@media (max-width: 760px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading.\n Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).\n At 15px root × 0.8 content-scale: 29.4 / 22.2 / 18 / 15.6px.\n Body is floored to a 16px minimum below (max()) for readability;\n the floor still yields to calc() when the user zooms the root up. */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
|
|
5921
|
+
var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n /*\n * CJK fallback slots, kept in sync with `DEFAULT_FONT_CJK_*_FALLBACK` in\n * `ui/font-themes.ts` (a test asserts they match). A page whose language names\n * a CJK profile overrides both with a stack that leads on its own glyph\n * shapes; every other page keeps these, because the Latin families and the\n * `serif` / `sans-serif` generics around them have no Han coverage and would\n * otherwise drop the text to the OS last-resort font.\n */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Noto Serif SC\", \"Noto Serif CJK SC\",\n \"Songti TC\", PMingLiU, MingLiU, \"Noto Serif TC\", \"Noto Serif CJK TC\",\n \"Hiragino Mincho ProN\", \"Hiragino Mincho Pro\", \"Yu Mincho\", YuMincho,\n \"Noto Serif JP\", \"Noto Serif CJK JP\", \"MS PMincho\", \"MS Mincho\", Batang,\n \"Noto Serif KR\", \"Noto Serif CJK KR\", NanumMyeongjo;\n --font-cjk-sans-fallback:\n \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Noto Sans SC\",\n \"Noto Sans CJK SC\", \"PingFang TC\", \"Hiragino Sans CNS\",\n \"Microsoft JhengHei\", \"Noto Sans TC\", \"Noto Sans CJK TC\", \"Hiragino Sans\",\n \"Hiragino Kaku Gothic ProN\", \"Yu Gothic\", YuGothic, \"Noto Sans JP\",\n \"Noto Sans CJK JP\", Meiryo, \"Apple SD Gothic Neo\", \"Noto Sans KR\",\n \"Noto Sans CJK KR\", \"Malgun Gothic\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, var(--font-cjk-sans-fallback),\n sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — UI buttons, controls\n * --type-sm 0.9rem (13.5px) — small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.8;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n --type-content-quote: calc(var(--type-content-body) * 1.16);\n --type-content-quote-leading: 1.4;\n\n /* Semantic aliases */\n --type-body-size: var(--type-content-body);\n --type-footnote-ref: calc(var(--type-body-size) * 0.75);\n --type-code: calc(var(--type-body-size) * 0.94);\n --type-code-block: calc(var(--type-body-size) * 0.9);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: calc(var(--type-content-body) * 1.36);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.7;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-medium);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-medium);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n /*\n * Two widths, one column — keep the distinction straight.\n *\n * `--layout-content-width` is the Tufte proportion: how much of a page\n * section its content occupies, leaving the rest as sidenote gutter. Page\n * shells that should fill the frame once the gutter is gone (settings,\n * the collections directory) read this one, which is why it flattens to\n * 100% below 1024px.\n *\n * `--layout-reading-width` is the reading column a post's text actually\n * gets. It tracks the proportion while the gutter exists, then keeps a\n * measure of its own instead of going full-width. Anything that must line\n * up with body copy — the text blocks in preset.css, a lone image\n * (MediaGallery `getSingleVisualWidth`), a link preview, the feed divider\n * that centers over the column — reads this one. Before it existed those\n * consumers capped on the proportion and quietly outgrew the text between\n * 700px and 1024px.\n */\n --layout-content-width: 55%;\n --layout-reading-width: var(--layout-content-width);\n --layout-sidenote-width: 50%;\n --layout-sidenote-gap: 10%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 20px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n /* Despite the name, this is the page colour and ~60 rules use it that way —\n including three that use it as *text* on a dark button. It cannot be\n lifted without repainting the whole site; `--site-raised-bg` below is the\n one that actually means \"above the page\". Read this as\n \"--site-default-surface\" until the call sites are triaged. */\n --site-elevated-bg: var(--background);\n /* What sits *above* the page: dialogs, popovers, menus, sheets. On a light\n page this is the page colour, because the separating is done by the\n backdrop dim and the drop shadows — both black over white. Neither works\n on a dark page: 30% black over `#110f0d` composites to `#0c0b09`, and a\n black shadow on near-black is nothing at all, which left the compose\n dialog at 1.03:1 against its own backdrop. So in dark mode this lifts\n instead (see the dark blocks below). Elevation there is lightness, not\n shadow. */\n --site-raised-bg: var(--site-page-bg);\n /* The edge of a raised layer. On a light page the fill already separates and\n this is just a hairline; on a dark one it does most of the work, because\n the fill has almost no room to lift into. Same formula as\n `--compose-control-border` in light so nothing moves there. */\n --site-raised-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-footnote-text: var(--site-reading-caption);\n --site-footnote-marker: color-mix(\n in oklch,\n var(--site-text-secondary) 88%,\n var(--site-page-bg)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-code-text: color-mix(\n in srgb,\n var(--site-reading-heading) 86%,\n var(--site-reading-body)\n );\n --site-code-bg: color-mix(in srgb, var(--site-reading-meta) 12%, transparent);\n --site-code-block-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 94%,\n var(--site-nav-hover-bg)\n );\n --site-code-block-border: color-mix(\n in srgb,\n var(--site-divider) 62%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n /* The composer is a floating sheet in every mode but one, so its paper is\n the raised surface. `.compose-page` — the full-page `/new` composer, which\n *is* the page — puts this back to `--site-page-bg` locally. */\n --compose-paper-bg: var(--site-raised-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n /* The tools row sits directly under the sentence being written, so its icons\n stay a step behind the body's own muted ink and only come forward when\n pointed at or switched on. 72% keeps the faded state above the 3:1\n non-text contrast floor. */\n --compose-tool-ink: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n/* =========================================================================\n * Breakpoint ledger\n *\n * Every width breakpoint in the stylesheets comes from this list, and each\n * entry names the one job it does. `src/__tests__/stylesheet-breakpoints.test.ts`\n * fails the build on anything else, because a second number for a job that\n * already has one is how the phone switch drifted apart before: the composer\n * once went full-screen at 700px while its own controls took their touch\n * sizing at 760px, so a 750px window got thumb-sized buttons inside a windowed\n * dialog.\n *\n * 480px Header hamburger; the composer's post-meta pill sheds its words.\n * 580px Nav collapse tier-sm (hides the 3rd inline link).\n * 640px Media grid and lightbox chrome.\n * 700px THE PHONE SWITCH. Below it the layout is a phone's: the composer\n * is a full-screen sheet, the compose FAB appears, page gutters and\n * the content type scale go narrow. Written `max-width: 699px` on\n * the phone side and `min-width: 700px` on the desktop side, so the\n * two never both match at 700px itself.\n * 780px Nav collapse tier-md (also hides the 4th inline link).\n * 960px Nav collapse tier-lg (hides the 5th+ inline link into More).\n * 1024px Tufte two-column → single-column (see the note below).\n * 1079px Collections sidebar drops out.\n * 1200px Header search collapses to an icon.\n *\n * Touch is NOT a width. Thumb-sized controls and always-visible affordances\n * belong to `(hover: none) and (pointer: coarse)`, which is what actually\n * catches a phone or a tablet; a narrow desktop window is still a mouse.\n * Rules that want both say both.\n * ========================================================================= */\n\n@media (max-width: 699px) {\n :root {\n --site-padding: 1.875rem;\n }\n}\n\n/* Tufte two-column → single-column collapse.\n *\n * There is no room for a 45% sidenote gutter here, so page shells go\n * full-width — but the reading column does not follow them. It keeps a 35rem\n * measure, and everything sized to the text (preset.css blocks, single images,\n * link previews, the feed divider) tracks that through\n * `--layout-reading-width` rather than restating the number.\n *\n * Keep this breakpoint in sync with the sidenote float→inline collapse\n * (ui.css). They are one layout switch; do not let them drift apart. */\n@media (max-width: 1024px) {\n :root {\n --layout-content-width: 100%;\n --layout-reading-width: min(100%, 35rem);\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n /* 12% of the foreground mixed into the page. The headroom here is small and\n bounded: `--site-divider` sits 1.36:1 above the page, and a surface lifted\n past it would swallow every hairline drawn on it. 12% lands at 1.24:1 —\n most of the range that exists, with the dividers still on top of it. */\n --site-raised-bg: color-mix(\n in oklab,\n var(--background) 88%,\n var(--foreground)\n );\n /* Carries the separation the fill cannot: 12% of lift is most of the room\n the palette has, so the edge has to say the rest. */\n --site-raised-border: color-mix(\n in oklab,\n var(--site-raised-bg) 78%,\n var(--foreground)\n );\n}\n\n@media (max-width: 699px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading.\n Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).\n At 15px root × 0.8 content-scale: 29.4 / 22.2 / 18 / 15.6px.\n Body is floored to a 16px minimum below (max()) for readability;\n the floor still yields to calc() when the user zooms the root up. */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
|
|
4525
5922
|
//#endregion
|
|
4526
5923
|
//#region src/services/export-theme/theme.toml?raw
|
|
4527
5924
|
var theme_default = "name = \"jant\"\nlicense = \"MIT\"\nlicenselink = \"https://github.com/jant-me/jant/blob/main/LICENSE\"\ndescription = \"Default theme packaged with Jant exports.\"\nhomepage = \"https://jant.so\"\ntags = [\"blog\", \"microblog\", \"minimal\"]\nfeatures = [\"pagination\", \"aliases\"]\nmin_version = \"0.160.1\"\n\n[author]\n name = \"Jant\"\n homepage = \"https://jant.so\"\n";
|
|
4528
5925
|
//#endregion
|
|
4529
5926
|
//#region src/services/export-theme/styles/main.css?raw
|
|
4530
|
-
var main_default = "/*\n * Jant Hugo Export — main.css\n *\n * Fresh minimal text-first design. All colors, spacing, and type sizing\n * come from tokens.css (loaded first in the <head>). Color theme values\n * are supplied by theme.css; customizations live in custom.css.\n *\n * Load order in <head>: tokens.css → main.css → theme.css → custom.css\n *\n * This file intentionally avoids any hardcoded hex, rgb, or px color\n * values. Everything token-driven so the design evolves with the theme.\n */\n\n/* -------------------------------------------------------------------------\n * Reset + box model\n * ------------------------------------------------------------------------- */\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 15px;\n -webkit-text-size-adjust: 100%;\n text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--font-body);\n font-size: var(--type-body-size, var(--type-content-body));\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n background-color: var(--site-page-bg);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\nimg,\nvideo,\naudio,\niframe {\n max-width: 100%;\n height: auto;\n}\n\nfigure {\n margin: 0;\n}\n\nhr {\n border: 0;\n border-top: 1px solid var(--site-divider);\n margin: var(--space-xl) 0;\n}\n\n/* -------------------------------------------------------------------------\n * Typography\n * ------------------------------------------------------------------------- */\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-family: var(--font-heading);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n letter-spacing: var(--type-heading-tracking);\n color: var(--site-reading-heading);\n margin: 1.6em 0 0.6em;\n}\n\nh1 {\n font-size: var(--type-content-display);\n line-height: var(--type-display-leading);\n font-weight: var(--type-display-weight);\n letter-spacing: var(--type-display-tracking);\n margin: 4rem 0 1.5rem;\n}\n\nh2 {\n font-size: var(--type-content-title);\n margin: 2.1rem 0 1.4rem;\n}\n\nh3 {\n font-size: var(--type-content-subtitle);\n margin: 2rem 0 1.4rem;\n}\n\nh4 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n}\n\nh5,\nh6 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n color: var(--site-reading-meta);\n}\n\np {\n margin: 1.4rem 0;\n}\n\nsmall {\n font-size: var(--type-sm);\n}\n\ncode,\nkbd,\nsamp {\n font-family: var(--font-mono);\n font-size: var(--type-code);\n line-height: 1.42;\n}\n\ncode {\n color: var(--site-code-text);\n overflow-wrap: break-word;\n}\n\npre {\n font-family: var(--font-mono);\n font-size: var(--type-code-block);\n line-height: 1.5;\n padding: 1rem;\n overflow-x: auto;\n color: var(--site-code-text);\n background-color: var(--site-code-block-bg);\n border: 1px solid var(--site-code-block-border);\n border-radius: 6px;\n}\n\npre code {\n padding: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font-size: inherit;\n}\n\n:not(pre) > code {\n padding: 0.08em 0.28em;\n background-color: var(--site-code-bg);\n border-radius: 0.22em;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n}\n\n/* Blockquotes — tinted background card with quote icon (matches main site). */\nblockquote {\n position: relative;\n margin: 1.4rem 0;\n padding: 1.4rem 1rem 0.75rem;\n border: none;\n background: var(--site-blockquote-bg);\n border-radius: 6px;\n color: var(--site-blockquote-text);\n font-family: var(--font-blockquote);\n font-style: italic;\n font-weight: inherit;\n quotes: none;\n text-wrap: pretty;\n}\n\nblockquote::before {\n content: \"\";\n display: block;\n width: 1.3rem;\n height: 1.3rem;\n margin-bottom: 0.35rem;\n background: var(--site-blockquote-rail);\n opacity: 0.6;\n mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n mask-size: contain;\n mask-repeat: no-repeat;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n}\n\nblockquote :where(p:first-of-type)::before,\nblockquote :where(p:last-of-type)::after {\n content: none;\n}\n\nblockquote > :first-child {\n margin-top: 0;\n}\n\nblockquote > :last-child {\n margin-bottom: 0;\n}\n\nblockquote p {\n margin: 0;\n}\n\nblockquote p + p,\nblockquote ul,\nblockquote ol,\nblockquote pre {\n margin-top: 0.6em;\n}\n\nblockquote cite {\n font-style: normal;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* CJK: no italic for blockquotes (no true italic glyph) */\n:lang(zh) blockquote,\n:lang(ja) blockquote,\n:lang(ko) blockquote {\n font-style: normal;\n}\n\nul,\nol {\n padding-left: 1.4em;\n margin: 1.25em 0;\n}\n\nol ol {\n list-style-type: lower-alpha;\n margin-top: 0.4em;\n margin-bottom: 0.4em;\n}\n\nol ol ol {\n list-style-type: lower-roman;\n}\n\nli {\n margin-top: 0.5em;\n margin-bottom: 0.5em;\n}\n\n/* Normalize li > p so list spacing is controlled by li alone,\n regardless of whether the markdown renderer wraps li contents in <p>. */\nli > p:first-child {\n margin-top: 0;\n}\n\nli > p:last-child {\n margin-bottom: 0;\n}\n\nli > p:has(+ ol) {\n margin-bottom: 0;\n}\n\na {\n color: var(--site-reading-link);\n text-decoration: underline;\n text-decoration-color: var(--site-reading-link-underline);\n text-underline-offset: 0.15em;\n transition:\n color 0.2s ease,\n text-decoration-color 0.2s ease;\n}\n\na:hover,\na:focus {\n color: var(--site-reading-link-hover);\n text-decoration-color: currentColor;\n}\n\na:focus-visible {\n outline: 2px solid var(--site-accent);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\ntime {\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n/* -------------------------------------------------------------------------\n * Page layout — Tufte horizontal frame\n *\n * Mirrors the main site's `.site-page > header/main/footer` rule. Every\n * top-level section gets the same asymmetric padding so the reading column\n * aligns with the rest of the site.\n *\n * 12.5% left + 4% right = 16.5% padding → content = 83.5% of the box.\n * max-width = body-max-width / 0.835 so the inner content area exactly\n * equals `--layout-body-max-width` on wide viewports. min() caps keep\n * padding from growing beyond 210px / 67px. Mobile widens to 5% / 5%\n * and the 55% content column collapses to 100% (via tokens.css).\n * ------------------------------------------------------------------------- */\n\n.site-page {\n min-height: 100vh;\n min-height: 100dvh;\n background-color: var(--site-page-bg);\n}\n\n.site-page > header,\n.site-page > main,\n.site-page > footer,\n.site-page > .home-branding-credit {\n width: 100%;\n max-width: calc(var(--layout-body-max-width) / 0.835);\n padding-left: min(12.5%, 210px);\n padding-right: min(4%, 67px);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (max-width: 760px) {\n .site-page > header,\n .site-page > main,\n .site-page > footer,\n .site-page > .home-branding-credit {\n padding-left: max(5%, 28px);\n padding-right: 5%;\n }\n}\n\n.site-main {\n padding-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Header\n * ------------------------------------------------------------------------- */\n\n.site-header {\n padding-top: 24px;\n background-color: var(--site-page-bg);\n}\n\n@media (min-width: 700px) {\n .site-header {\n padding-top: 30px;\n }\n}\n\n.site-header-inner {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 0;\n}\n\n.site-header-top {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n flex-wrap: nowrap;\n min-height: 2.75rem;\n width: 100%;\n}\n\n.site-header-top-bordered {\n padding-bottom: 15px;\n}\n\n@media (min-width: 700px) {\n .site-header-top-bordered {\n padding-bottom: 18px;\n }\n}\n\n.site-logo {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: 10px;\n min-width: 0;\n padding: 0.15rem 0;\n font-family: var(--font-site-title);\n font-size: var(--type-subtitle);\n font-weight: var(--fw-regular);\n letter-spacing: -0.02em;\n line-height: 1.15;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo:hover,\n.site-logo:focus {\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo-avatar {\n box-sizing: border-box;\n width: calc(var(--avatar-size) + 4px);\n height: calc(var(--avatar-size) + 4px);\n border-radius: var(--avatar-radius);\n object-fit: cover;\n border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);\n flex: none;\n}\n\n.site-logo-text {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.site-header-nav {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n justify-content: flex-end;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n margin-left: auto;\n min-width: 0;\n font-family: var(--font-ui);\n}\n\n.site-header-link {\n display: inline-flex;\n flex: none;\n align-items: center;\n position: relative;\n min-height: 2rem;\n padding: 0.15rem 0;\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n white-space: nowrap;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n text-decoration: none;\n transition: color 0.15s;\n}\n\n.site-header-link:hover,\n.site-header-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n text-decoration: none;\n}\n\n.site-header-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n/* --- \"More\" dropdown ---------------------------------------------------- */\n\n.site-header-more {\n position: relative;\n display: inline-flex;\n align-items: center;\n}\n\n.site-header-more-responsive-only {\n display: none;\n}\n\n.site-header-more-btn {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n min-height: 2rem;\n padding: 0.15rem 0;\n border: none;\n background: transparent;\n cursor: pointer;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n transition: color 0.15s;\n}\n\n.site-header-more-btn svg {\n width: 0.82rem;\n height: 0.82rem;\n transition: transform 0.18s ease;\n}\n\n.site-header-more-btn:hover,\n.site-header-more-btn[aria-expanded=\"true\"] {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-btn[aria-expanded=\"true\"] svg {\n transform: rotate(180deg);\n}\n\n.site-header-more-popover {\n display: block;\n position: absolute;\n top: 100%;\n right: 0;\n margin-top: 0.6rem;\n min-width: 12.25rem;\n padding: 0.3rem 0;\n background: var(--site-page-bg);\n border: 0.5px solid color-mix(in srgb, var(--site-divider) 80%, transparent);\n border-radius: 0.4rem;\n box-shadow:\n 0 4px 20px -8px rgba(0, 0, 0, 0.12),\n 0 2px 6px -2px rgba(0, 0, 0, 0.06);\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n transform: translateY(-6px);\n transform-origin: top right;\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n visibility 0s linear 0.18s;\n z-index: 50;\n}\n\n.site-header-more-popover[aria-hidden=\"false\"] {\n opacity: 1;\n visibility: visible;\n pointer-events: auto;\n transform: translateY(0);\n transition-delay: 0s;\n}\n\n.site-header-more-link {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.35rem;\n padding: 0.45rem 1rem;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.15s,\n background-color 0.15s;\n}\n\n.site-header-more-link:hover,\n.site-header-more-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n background: color-mix(in srgb, var(--site-nav-hover-bg) 58%, transparent);\n text-decoration: none;\n}\n\n.site-header-more-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-link-responsive,\n.site-header-more-divider-responsive {\n display: none;\n}\n\n.site-header-more-divider {\n height: 0;\n margin: 0.35rem 0.75rem;\n border-top: 0.5px solid\n color-mix(in srgb, var(--site-divider) 60%, transparent);\n}\n\n/* --- Tiered responsive collapse ---------------------------------------- *\n *\n * ≤960px — 5th+ inline link collapses into More (tier-lg)\n * ≤780px — also 4th collapses (tier-md)\n * ≤580px — also 3rd collapses (tier-sm)\n * The first two links always stay inline. No hamburger on static export —\n * at very narrow widths 2 inline links + More button is the floor.\n */\n\n@media (max-width: 960px) {\n .site-header-link-collapse-lg {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-lg {\n display: inline-flex;\n }\n\n .site-header-more-link-show-lg {\n display: flex;\n }\n\n .site-header-more-divider-responsive {\n display: block;\n }\n}\n\n@media (max-width: 780px) {\n .site-header-link-collapse-md {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-md {\n display: inline-flex;\n }\n\n .site-header-more-link-show-md {\n display: flex;\n }\n}\n\n@media (max-width: 580px) {\n .site-header-link-collapse-sm {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-sm {\n display: inline-flex;\n }\n\n .site-header-more-link-show-sm {\n display: flex;\n }\n}\n\n/* -------------------------------------------------------------------------\n * Footer\n * ------------------------------------------------------------------------- */\n\n.site-footer {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n color: var(--site-text-secondary);\n font-size: var(--type-xs);\n background-color: var(--site-page-bg);\n}\n\n.site-footer-inner {\n border-top: 0.5px solid var(--site-divider);\n padding-top: var(--space-xl);\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.site-footer-content {\n color: var(--site-text-secondary);\n}\n\n.site-footer-content p {\n margin: 0 0 0.5em;\n}\n\n.site-footer-nav-list {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.home-branding-credit {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n text-align: center;\n color: var(--site-text-secondary);\n font-size: var(--type-base);\n}\n\n.home-branding-credit a {\n display: inline-flex;\n align-items: center;\n gap: 0.38rem;\n color: inherit;\n text-decoration: none;\n border-bottom: 0.5px solid\n color-mix(in srgb, var(--site-text-secondary) 45%, transparent);\n transition:\n color 160ms ease,\n border-color 160ms ease;\n}\n\n.home-branding-credit a:hover,\n.home-branding-credit a:focus-visible {\n color: var(--site-text-primary);\n border-color: currentColor;\n}\n\n/* -------------------------------------------------------------------------\n * Tufte content-width constraint\n *\n * Mirrors the main site's 55% rule: reading text occupies a narrow\n * column inside the Tufte frame, leaving a wide right margin that\n * would host sidenotes on the main site. Media (images, video, audio)\n * is NOT included — galleries intentionally span the full frame so\n * they can breathe.\n *\n * Mobile (<=760px): `--layout-content-width` collapses to 100% via\n * tokens.css. Tablet: cap at 35rem for readability.\n * ------------------------------------------------------------------------- */\n\n.section-header,\n.section-body,\n.post-card-title,\n.post-card-summary,\n.post-card-link-domain,\n.post-card-quote-content,\n.post-card-quote-attribution,\n.post-card-quote-commentary,\n.post-card-footer,\n.reply-title,\n.reply-body,\n.reply-link-domain,\n.reply-footer,\n.thread-title,\n.thread-body,\n.thread-link-domain,\n.thread-footer,\n.collection-directory,\n.pagination,\n.empty-state,\n.page-summary {\n width: var(--layout-content-width);\n max-width: 100%;\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .section-header,\n .section-body,\n .post-card-title,\n .post-card-summary,\n .post-card-link-domain,\n .post-card-quote-content,\n .post-card-quote-attribution,\n .post-card-quote-commentary,\n .post-card-footer,\n .reply-title,\n .reply-body,\n .reply-link-domain,\n .reply-footer,\n .thread-title,\n .thread-body,\n .thread-link-domain,\n .thread-footer,\n .collection-directory,\n .pagination,\n .empty-state,\n .page-summary {\n width: min(100%, 35rem);\n }\n}\n\n/* -------------------------------------------------------------------------\n * Section headers\n * ------------------------------------------------------------------------- */\n\n.section-header {\n margin-bottom: var(--space-xl);\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--site-border-light);\n}\n\n.section-title {\n margin: 0 0 0.25em;\n}\n\n.section-summary {\n margin: 0;\n color: var(--site-reading-meta);\n font-size: var(--type-secondary);\n}\n\n.section-meta {\n margin: 0.5em 0 0;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* -------------------------------------------------------------------------\n * Post list + post cards\n * ------------------------------------------------------------------------- */\n\n.post-list {\n display: flex;\n flex-direction: column;\n gap: calc(var(--space-xl) * 1.25);\n}\n\n.post-list-pinned {\n margin-bottom: calc(var(--space-xl) * 1.25);\n padding-bottom: calc(var(--space-xl) * 1.25);\n border-bottom: 1px solid var(--site-border-light);\n}\n\n/* Decorative divider between posts in a timeline feed. Mirrors the main\n site's `hr.feed-divider`: a trio of small chevron marks masked from the\n current text color, so it picks up the theme automatically.\n `margin-left` centers the divider within the 55% reading column so it\n visually sits at the middle of the post-card text stack. */\nhr.feed-divider {\n border: none;\n width: 30px;\n height: 9px;\n margin: 0;\n margin-left: calc(var(--layout-content-width) / 2 - 15px);\n color: var(--site-feed-divider-color);\n background-color: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n.post-card {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.post-card-title {\n font-family: var(--font-heading);\n font-size: var(--feed-note-title-size);\n font-weight: var(--type-heading-weight);\n line-height: var(--feed-note-title-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.post-card-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.post-card-title a:hover,\n.post-card-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-card-summary {\n margin: 0;\n color: var(--site-reading-body);\n}\n\n.post-card > .post-card-summary :is(h1, h2),\n.post-card > .post-card-quote-commentary :is(h1, h2) {\n font-size: calc(var(--type-content-body) * 1.12);\n font-weight: var(--fw-medium);\n margin-top: 1.45rem;\n margin-bottom: 0.55rem;\n}\n\n.post-card > .post-card-summary :is(h3, h4),\n.post-card > .post-card-quote-commentary :is(h3, h4) {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n margin-top: 1.2rem;\n margin-bottom: 0.45rem;\n}\n\n.post-card > .post-card-summary :is(h5, h6),\n.post-card > .post-card-quote-commentary :is(h5, h6) {\n font-size: var(--type-secondary);\n font-weight: var(--fw-medium);\n color: var(--site-text-secondary);\n margin-top: 1rem;\n margin-bottom: 0.35rem;\n}\n\n/* Link card domain row — shown ABOVE the title (matches main site's\n `.feed-link-domain` pattern). Inline flex with icon + host text. */\n.post-card-link-domain,\n.thread-link-domain,\n.reply-link-domain {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n max-width: 100%;\n margin: 0 0 0.4rem 0;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-regular);\n line-height: 1.3;\n color: var(--site-text-secondary);\n text-decoration: none;\n word-break: break-all;\n transition: color 0.18s ease;\n}\n\n.post-card-link-domain:hover,\n.post-card-link-domain:focus,\n.thread-link-domain:hover,\n.thread-link-domain:focus,\n.reply-link-domain:hover,\n.reply-link-domain:focus {\n color: var(--site-text-primary);\n}\n\n.post-card-link-domain-icon {\n width: 0.72rem;\n height: 0.72rem;\n flex-shrink: 0;\n}\n\n/* Slight extra breathing room below link-card titles to match main site. */\n.post-card-link-title,\n.thread-link-title,\n.reply-link-title {\n text-wrap: pretty;\n}\n\n.post-card-link-title a,\n.thread-link-title a,\n.reply-link-title a {\n text-decoration: none;\n}\n\n.post-card-link-title a:hover,\n.post-card-link-title a:focus,\n.thread-link-title a:hover,\n.thread-link-title a:focus,\n.reply-link-title a:hover,\n.reply-link-title a:focus {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.post-card-media,\n.reply-media,\n.thread-media {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.75rem;\n}\n\n.post-card-figure img,\n.reply-figure img,\n.thread-figure img {\n display: block;\n width: 100%;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n}\n\n.post-card-figure video,\n.reply-figure video,\n.thread-figure video {\n display: block;\n width: 100%;\n height: auto;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n background: #000;\n}\n\n.post-card-figure audio,\n.reply-figure audio,\n.thread-figure audio {\n display: block;\n width: 100%;\n}\n\n.post-card-figure-video a {\n position: relative;\n display: block;\n}\n\n.post-card-video-badge {\n position: absolute;\n left: 0.5rem;\n bottom: 0.5rem;\n padding: 0.125rem 0.5rem;\n font-size: var(--type-xs);\n color: #fff;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 999px;\n pointer-events: none;\n}\n\n.thread-file a,\n.reply-file a {\n color: var(--site-link);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Quote format — decorative mark, serif body, attribution line\n * ------------------------------------------------------------------------- */\n\n.post-card-quote {\n position: static;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: var(--site-reading-quote);\n font-family: inherit;\n font-style: normal;\n}\n\n/* Quote-format posts have their own decorative `.post-card-quote-mark`\n SVG inside the blockquote — suppress the global blockquote icon. */\n.post-card-quote::before {\n content: none;\n}\n\n.post-card-quote-mark {\n display: block;\n position: relative;\n width: 1.7rem;\n margin-bottom: -0.1rem;\n margin-left: -0.04rem;\n line-height: 0;\n pointer-events: none;\n color: color-mix(in srgb, var(--site-accent) 14%, var(--site-divider));\n opacity: 0.66;\n}\n\n.post-card-quote-mark svg {\n display: block;\n width: 100%;\n height: auto;\n}\n\n.post-card-quote-content {\n font-family: var(--font-serif);\n color: var(--site-text-primary);\n font-size: var(--type-content-quote);\n line-height: var(--type-content-quote-leading);\n white-space: pre-line;\n text-wrap: pretty;\n margin: 0;\n}\n\n.post-card-quote-attribution {\n display: flex;\n align-items: center;\n gap: 0.45rem;\n flex-wrap: wrap;\n margin-top: 0.95rem;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-style: normal;\n line-height: 1.3;\n}\n\n.post-card-quote-attribution::before {\n content: \"\";\n width: 0.9rem;\n height: 1px;\n background: color-mix(\n in srgb,\n var(--site-text-secondary) 38%,\n var(--site-divider)\n );\n}\n\n.post-card-quote-source {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: color-mix(\n in srgb,\n var(--site-text-secondary) 55%,\n transparent\n );\n text-underline-offset: 3px;\n}\n\n.post-card-quote-source:hover,\n.post-card-quote-source:focus {\n color: var(--site-text-primary);\n text-decoration-color: currentColor;\n}\n\n.post-card-quote-commentary {\n position: relative;\n margin-top: 1.1rem;\n padding-top: 0.95rem;\n color: color-mix(\n in srgb,\n var(--site-text-secondary) 84%,\n var(--site-text-primary)\n );\n text-wrap: pretty;\n}\n\n.post-card-quote-commentary::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent 0%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 16%,\n color-mix(in srgb, var(--site-divider) 78%, transparent) 50%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 84%,\n transparent 100%\n );\n}\n\n.post-card-quote-commentary.prose > :first-child {\n margin-top: 0;\n}\n\n.post-card-quote-commentary.prose > :last-child {\n margin-bottom: 0;\n}\n\n.post-card-quote-commentary p {\n margin: 0;\n}\n\n.post-card-quote-commentary p + p,\n.post-card-quote-commentary ul,\n.post-card-quote-commentary ol,\n.post-card-quote-commentary blockquote,\n.post-card-quote-commentary pre {\n margin-top: 0.55rem;\n}\n\n/* Fade the post meta on quote cards so the quote body stays visually primary. */\n.post-card-quote ~ .post-card-footer,\n.post-card-quote-commentary + .post-card-footer {\n opacity: 0.72;\n}\n\n/* -------------------------------------------------------------------------\n * Post footer — meta (featured, time, external link, collections, pinned)\n * ------------------------------------------------------------------------- */\n\n.post-card-footer,\n.reply-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 10px;\n min-height: 2rem;\n font-size: var(--type-ui-hint);\n}\n\n.post-footer-detail {\n margin-top: 24px;\n /* Match the feed/reply footer size so the root post's footer doesn't\n visually dominate. Main site uses a larger size because every post\n on the detail page is rendered at detail size; here only the root\n post gets this class, which otherwise creates a mismatch with the\n replies below. */\n font-size: var(--type-ui-hint);\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n min-width: 0;\n font-family: var(--font-ui);\n line-height: 1.35;\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta time {\n font-size: inherit;\n color: inherit;\n}\n\n.post-footer-featured {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: color-mix(\n in srgb,\n var(--search-mark-color) 72%,\n var(--site-text-secondary)\n );\n flex-shrink: 0;\n}\n\n.post-footer-featured svg {\n width: 1rem;\n height: 1rem;\n opacity: 0.9;\n}\n\n.post-footer-link {\n color: var(--site-text-secondary);\n text-decoration: none;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.post-footer-link:hover,\n.post-footer-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n.post-footer-external-link {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.6rem;\n height: 1.6rem;\n border-radius: 0.6rem;\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.18s ease,\n background-color 0.16s ease;\n flex-shrink: 0;\n}\n\n.post-footer-external-link:hover,\n.post-footer-external-link:focus {\n color: var(--site-text-primary);\n}\n\n.post-footer-external-link svg {\n width: 1rem;\n height: 1rem;\n}\n\n.post-collection-tags {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n min-width: 0;\n max-width: 100%;\n color: var(--site-text-secondary);\n}\n\n.post-collection-tag {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: inherit;\n text-decoration: none;\n min-width: 0;\n max-width: min(100%, 22ch);\n}\n\n.post-collection-tag:hover,\n.post-collection-tag:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-collection-tag-text {\n display: block;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.post-card-pin {\n font-size: var(--type-2xs);\n text-transform: uppercase;\n letter-spacing: var(--type-label-tracking);\n color: var(--site-accent);\n border: 1px solid var(--site-accent);\n padding: 0.1em 0.5em;\n border-radius: 999px;\n flex-shrink: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Thread preview (list pages: root context + hero latest reply)\n *\n * Mirrors the main site's `.thread-group.thread-group-preview` layout:\n * content sits flush-left inside the preview, and the vertical rail +\n * dot markers are positioned OUTSIDE the content (overflowing into the\n * container's left gutter) via a negative `left` on the rail/dots.\n * ------------------------------------------------------------------------- */\n\n.thread-preview {\n /* Rail position: negative = overflow outside content. Mirrors\n `--site-thread-rail-line-left` on the main site. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n --thread-item-spacing: 0.35rem;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread-preview {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail — subtle gradient so the line fades into\n whitespace at the top and tail. Sits outside the content via negative\n `left`. */\n.thread-preview::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n.thread-preview-context {\n position: relative;\n display: flex;\n flex-direction: column;\n /* Match runtime `.thread-group-preview .thread-item` which uses\n `padding: var(--site-thread-item-spacing) 0` — adjacent items have\n 2x the token between them, so the flex `gap` here is 2x to match. */\n gap: calc(var(--site-thread-item-spacing) * 2);\n margin: 0;\n padding: 0;\n border-left: 0;\n}\n\n/* Individual entry in a thread preview — wraps a full `.post-card` so\n the root, second, and penultimate replies render with their own\n title/body/footer. Dot marker sits on the rail at the card's\n vertical midpoint. */\n.thread-preview .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-preview .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n/* Gap \"N more posts\" row has no card so we place the marker at its\n vertical midpoint instead of the card's top area. Swap the single dot\n for a short column of small beads (a vertical \"⋮\") so the rail visibly\n \"skips\" a run of posts — mirrors the main site's `.thread-item-gap`. */\n.thread-preview .thread-item-gap {\n display: flex;\n align-items: center;\n padding: 0.15rem 0 0.35rem;\n}\n\n.thread-preview .thread-item-gap::before {\n top: 50%;\n transform: translateY(-50%);\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 - 2px -\n var(--thread-rail-indent)\n );\n width: 4px;\n height: 24px;\n border: 0;\n border-radius: 0;\n background: radial-gradient(\n circle,\n var(--site-threadline) 1.75px,\n transparent 2px\n );\n background-size: 4px 8px;\n background-repeat: repeat-y;\n background-position: center top;\n box-shadow: none;\n}\n\n/* Hidden-posts count — a calm route into the thread, matching the main\n site's `.thread-gap-link`: styled like a sibling of the Show more toggle\n with a small right-pointing chevron rather than an underline, firming up\n to the primary text colour on hover. */\n.thread-preview-gap {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n align-self: flex-start;\n margin: 0.15rem 0;\n padding: 0.25rem 0;\n color: var(--site-text-secondary);\n font-size: var(--type-thread-context-meta);\n font-weight: 500;\n line-height: 1.2;\n text-decoration: none;\n transition: color 0.18s ease;\n}\n\n.thread-preview-gap::after {\n content: \"\";\n width: 0.36em;\n height: 0.36em;\n border: 1.5px solid currentColor;\n border-left: 0;\n border-bottom: 0;\n opacity: 0.65;\n transform: rotate(45deg);\n transition:\n transform 0.18s ease,\n opacity 0.18s ease;\n}\n\n.thread-preview-gap:hover,\n.thread-preview-gap:focus {\n color: var(--site-text-primary);\n}\n\n.thread-preview-gap:hover::after,\n.thread-preview-gap:focus::after {\n opacity: 1;\n transform: translateX(2px) rotate(45deg);\n}\n\n/* Hero (latest reply) row: spacing above and below matches the main\n site's `.thread-item-hero`, and the dot is larger + accent-colored. */\n.thread-preview-hero {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-top: calc(var(--space-xl) * 1.1);\n margin-top: 0;\n}\n\n/* Featured timelines keep Post-level selection while grouping by Thread.\n Selected Root/Child Posts use the accent marker; context Posts retain the\n neutral rail marker. */\n.thread-preview .thread-item-featured::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview .thread-item-hero::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: calc(var(--space-xl) * 1.1 + 1.4rem);\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview-thread-link {\n align-self: flex-start;\n font-size: var(--type-sm);\n color: var(--site-reading-meta);\n text-decoration: none;\n}\n\n.thread-preview-thread-link:hover,\n.thread-preview-thread-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Thread (single post page with inline replies)\n * ------------------------------------------------------------------------- */\n\n.thread {\n /* Rail variables match `.thread-preview` so the detail-page rail\n shares the same visual position. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail spanning the entire thread (root post + replies).\n Only shown when the root post actually has replies — a lone post should\n not have a rail or dot. Matches `.thread-preview`. */\n.thread-has-replies::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n/* Dot marker for each post in the thread (root + replies). Shown only when\n there are replies so a solo post doesn't get an orphaned dot. */\n.thread .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-has-replies .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n.thread-item-root {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.thread-header {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.thread-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-display);\n font-weight: var(--type-display-weight);\n line-height: var(--type-display-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.thread-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.thread-title a:hover,\n.thread-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.thread-body {\n font-size: var(--type-content-body);\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n}\n\n.thread-body > :first-child {\n margin-top: 0;\n}\n\n.thread-body > :last-child {\n margin-bottom: 0;\n}\n\n.thread-replies {\n /* Flex container for replies. The rail and dots are provided by\n `.thread::before` and `.thread-item::before` so replies stay visually\n connected to the root post. */\n display: flex;\n flex-direction: column;\n gap: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Replies\n * ------------------------------------------------------------------------- */\n\n.reply {\n scroll-margin-top: 1.5rem;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.reply:target {\n background-color: var(--search-mark-bg);\n border-radius: 0.25rem;\n padding: 0.75rem 1rem;\n margin-left: -1rem;\n margin-right: -1rem;\n}\n\n.reply:target::before {\n background-color: var(--site-accent);\n}\n\n.reply-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-subtitle);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n margin: 0;\n color: var(--site-reading-heading);\n}\n\n.reply-body {\n color: var(--site-reading-body);\n}\n\n.reply-body > :first-child {\n margin-top: 0;\n}\n\n.reply-body > :last-child {\n margin-bottom: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Collections page\n *\n * Mirrors the main site's authenticated-less collections view\n * (`ui/pages/CollectionsPage.tsx` + `ui/shared/CollectionDirectory.tsx`):\n * a page-intro block with count, then a two-column grid per row where a\n * monospace sequence label sits beside the collection title, description,\n * and entry/activity meta.\n * ------------------------------------------------------------------------- */\n\n.collections-page-shell {\n position: relative;\n display: flex;\n flex-direction: column;\n width: var(--layout-content-width);\n max-width: 100%;\n gap: clamp(1.25rem, 3vw, 1.75rem);\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .collections-page-shell {\n width: min(100%, 35rem);\n }\n}\n\n@media (max-width: 760px) {\n .collections-page-shell {\n width: 100%;\n }\n}\n\n.collections-page-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n padding-bottom: 0.2rem;\n}\n\n.collections-page-heading {\n min-width: 0;\n flex: 1 1 18rem;\n}\n\n.page-intro {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding-bottom: 0.1rem;\n}\n\n.page-intro-title-row {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0.7rem;\n min-width: 0;\n}\n\n.page-intro-title {\n margin: 0;\n font-family: var(--font-heading);\n font-size: var(--type-title, 2rem);\n font-weight: var(--type-heading-weight);\n line-height: 1.15;\n letter-spacing: -0.01em;\n color: var(--site-text-primary);\n}\n\n.page-intro-meta-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n}\n\n.page-intro-meta,\n.page-intro-description {\n margin: 0;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-secondary);\n line-height: 1.3;\n}\n\n.collection-directory {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.collection-directory-item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: 0.75rem;\n padding: 0.95rem 0;\n background: transparent;\n text-decoration: none;\n}\n\n.collection-directory-main {\n --collection-directory-sequence-width: 3.5ch;\n --collection-directory-title-line-height: 1.18;\n min-width: 0;\n flex: 1;\n display: grid;\n grid-template-columns: var(--collection-directory-sequence-width) minmax(\n 0,\n 1fr\n );\n align-items: start;\n column-gap: 0.8rem;\n row-gap: 0.25rem;\n}\n\n.collection-directory-sequence {\n grid-column: 1;\n grid-row: 1;\n display: block;\n width: var(--collection-directory-sequence-width);\n padding-top: 0.2rem;\n font-family: var(--font-mono);\n font-size: var(--type-xs);\n font-variant-numeric: tabular-nums;\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: 0.14em;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-title-row {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n display: flex;\n align-items: flex-start;\n}\n\n.collection-directory-title-link {\n color: inherit;\n text-decoration: none;\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover,\n.collection-directory-title-link:focus-visible {\n color: var(--site-text-primary);\n}\n\n.collection-directory-title-link:hover .collection-directory-title,\n.collection-directory-title-link:focus-visible .collection-directory-title {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.collection-directory-title {\n min-width: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n font-family: var(--font-heading);\n font-size: var(--type-content-body);\n font-weight: var(--type-heading-weight);\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: -0.02em;\n text-wrap: pretty;\n}\n\n.collection-directory-title-marker {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 0.95rem;\n min-width: 0.95rem;\n height: 0.95rem;\n color: var(--site-text-secondary);\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover .collection-directory-title-marker,\n.collection-directory-title-link:focus-visible\n .collection-directory-title-marker {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description {\n grid-column: 2;\n grid-row: 2;\n margin: 0;\n color: color-mix(in srgb, var(--site-text-secondary) 80%, transparent);\n font-family: var(--font-body);\n font-size: var(--type-sm);\n line-height: 1.45;\n}\n\n.collection-directory-description :where(p) {\n margin-top: 0.25em;\n margin-bottom: 0.25em;\n}\n\n/* Links inside the description inherit the description color so they sit in\n the same gray tone as the copy around them (matches the main site's\n `.prose { --tw-prose-links: var(--site-content-link); }` which resolves\n to `inherit`). The underline stays, drawn in currentColor. */\n.collection-directory-description :where(a) {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: currentColor;\n text-underline-offset: 0.15em;\n}\n\n.collection-directory-description :where(a:hover),\n.collection-directory-description :where(a:focus-visible) {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description :where(p:first-child) {\n margin-top: 0;\n}\n\n.collection-directory-description :where(p:last-child) {\n margin-bottom: 0;\n}\n\n.collection-directory-description + .collection-directory-summary {\n grid-row: 3;\n}\n\n.collection-directory-summary {\n grid-column: 2;\n grid-row: 2;\n display: flex;\n min-width: 0;\n overflow: hidden;\n align-items: center;\n gap: 0.2rem 0.5rem;\n margin: 0;\n color: var(--site-reading-meta);\n font-family: var(--font-ui);\n font-size: var(--type-sm);\n line-height: 1.3;\n white-space: nowrap;\n}\n\n.collection-directory-meta {\n flex: 0 0 auto;\n color: inherit;\n}\n\n.collection-directory-meta-separator {\n flex: 0 0 auto;\n color: color-mix(in srgb, var(--site-divider) 88%, transparent);\n}\n\n.collection-directory-updated {\n flex: 0 0 auto;\n color: inherit;\n white-space: nowrap;\n}\n\n.collection-directory-divider {\n padding: 1.5rem 0 0.85rem;\n}\n\n.collection-directory-divider-row {\n display: flex;\n align-items: center;\n gap: 0.95rem;\n}\n\n.collection-directory-divider-text {\n font-family: var(--font-heading);\n font-size: var(--type-secondary);\n letter-spacing: 0;\n font-style: normal;\n white-space: nowrap;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-divider-line {\n flex: 1;\n height: 1px;\n border: none;\n margin: 0;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--site-divider) 100%, transparent),\n color-mix(in srgb, var(--site-divider) 54%, transparent) 34%,\n transparent 86%\n );\n}\n\n/* -------------------------------------------------------------------------\n * Pagination\n * ------------------------------------------------------------------------- */\n\n.pagination {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-wrap: wrap;\n gap: 1rem;\n padding: 1.5rem 0;\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n.pagination-link {\n color: var(--site-text-secondary);\n text-decoration: underline;\n text-underline-offset: 3px;\n transition: color 0.15s ease;\n}\n\n.pagination-link:hover,\n.pagination-link:focus {\n color: var(--site-text-primary);\n}\n\n.pagination-link.is-disabled {\n color: color-mix(in srgb, var(--site-text-secondary) 50%, transparent);\n cursor: default;\n text-decoration: none;\n}\n\n.pagination-current {\n color: var(--site-text-primary);\n font-weight: var(--fw-medium);\n}\n\n.pagination-ellipsis {\n color: var(--site-text-secondary);\n}\n\n/* -------------------------------------------------------------------------\n * Empty states + utility\n * ------------------------------------------------------------------------- */\n\n.empty-state {\n color: var(--site-reading-meta);\n font-style: italic;\n text-align: center;\n padding: var(--space-xl) 0;\n}\n\n.page,\n.section {\n display: block;\n}\n\n.page-summary,\n.section-summary {\n font-size: var(--type-secondary);\n color: var(--site-reading-meta);\n}\n\n/* -------------------------------------------------------------------------\n * Wider viewport refinements\n * ------------------------------------------------------------------------- */\n\n@media (min-width: 768px) {\n .site-main {\n padding-top: calc(var(--space-xl) * 1.5);\n padding-bottom: calc(var(--space-xl) * 1.5);\n }\n\n .post-card-media,\n .thread-media,\n .reply-media {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .post-card-media:has(> :only-child),\n .thread-media:has(> :only-child),\n .reply-media:has(> :only-child) {\n grid-template-columns: 1fr;\n }\n}\n";
|
|
5927
|
+
var main_default = "/*\n * Jant Hugo Export — main.css\n *\n * Fresh minimal text-first design. All colors, spacing, and type sizing\n * come from tokens.css (loaded first in the <head>). Color theme values\n * are supplied by theme.css; customizations live in custom.css.\n *\n * Load order in <head>: tokens.css → main.css → theme.css → custom.css\n *\n * This file intentionally avoids any hardcoded hex, rgb, or px color\n * values. Everything token-driven so the design evolves with the theme.\n */\n\n/* -------------------------------------------------------------------------\n * Reset + box model\n * ------------------------------------------------------------------------- */\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 15px;\n -webkit-text-size-adjust: 100%;\n text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n font-family: var(--font-body);\n font-size: var(--type-body-size, var(--type-content-body));\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n background-color: var(--site-page-bg);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n /* Every text node in an exported page is authored content, and authored text\n can contain a run with no break opportunity — a long URL, an ID, a word\n typed without spaces. Such a run does not wrap on its own and spills past\n the content column across the page. `break-word` splits it only when it\n cannot fit a line by itself, so ordinary text keeps its natural line breaks\n and intrinsic sizing is unaffected. */\n overflow-wrap: break-word;\n}\n\nimg,\nvideo,\naudio,\niframe {\n max-width: 100%;\n height: auto;\n}\n\nfigure {\n margin: 0;\n}\n\nhr {\n border: 0;\n border-top: 1px solid var(--site-divider);\n margin: var(--space-xl) 0;\n}\n\n/* -------------------------------------------------------------------------\n * Typography\n * ------------------------------------------------------------------------- */\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-family: var(--font-heading);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n letter-spacing: var(--type-heading-tracking);\n color: var(--site-reading-heading);\n margin: 1.6em 0 0.6em;\n}\n\nh1 {\n font-size: var(--type-content-display);\n line-height: var(--type-display-leading);\n font-weight: var(--type-display-weight);\n letter-spacing: var(--type-display-tracking);\n margin: 4rem 0 1.5rem;\n}\n\nh2 {\n font-size: var(--type-content-title);\n margin: 2.1rem 0 1.4rem;\n}\n\nh3 {\n font-size: var(--type-content-subtitle);\n margin: 2rem 0 1.4rem;\n}\n\nh4 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n}\n\nh5,\nh6 {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n color: var(--site-reading-meta);\n}\n\np {\n margin: 1.4rem 0;\n}\n\nsmall {\n font-size: var(--type-sm);\n}\n\ncode,\nkbd,\nsamp {\n font-family: var(--font-mono);\n font-size: var(--type-code);\n line-height: 1.42;\n}\n\ncode {\n color: var(--site-code-text);\n overflow-wrap: break-word;\n}\n\npre {\n font-family: var(--font-mono);\n font-size: var(--type-code-block);\n line-height: 1.5;\n padding: 1rem;\n overflow-x: auto;\n color: var(--site-code-text);\n background-color: var(--site-code-block-bg);\n border: 1px solid var(--site-code-block-border);\n border-radius: 6px;\n}\n\npre code {\n padding: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font-size: inherit;\n}\n\n:not(pre) > code {\n padding: 0.08em 0.28em;\n background-color: var(--site-code-bg);\n border-radius: 0.22em;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n}\n\n/* Blockquotes — tinted background card with quote icon (matches main site). */\nblockquote {\n position: relative;\n margin: 1.4rem 0;\n padding: 1.4rem 1rem 0.75rem;\n border: none;\n background: var(--site-blockquote-bg);\n border-radius: 6px;\n color: var(--site-blockquote-text);\n font-family: var(--font-blockquote);\n font-style: italic;\n font-weight: inherit;\n quotes: none;\n text-wrap: pretty;\n}\n\nblockquote::before {\n content: \"\";\n display: block;\n width: 1.3rem;\n height: 1.3rem;\n margin-bottom: 0.35rem;\n background: var(--site-blockquote-rail);\n opacity: 0.6;\n mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n mask-size: contain;\n mask-repeat: no-repeat;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg viewBox='0 0 96 96' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z'/%3E%3Cpath d='M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-size: contain;\n -webkit-mask-repeat: no-repeat;\n}\n\nblockquote :where(p:first-of-type)::before,\nblockquote :where(p:last-of-type)::after {\n content: none;\n}\n\nblockquote > :first-child {\n margin-top: 0;\n}\n\nblockquote > :last-child {\n margin-bottom: 0;\n}\n\nblockquote p {\n margin: 0;\n}\n\nblockquote p + p,\nblockquote ul,\nblockquote ol,\nblockquote pre {\n margin-top: 0.6em;\n}\n\nblockquote cite {\n font-style: normal;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* CJK: no italic for blockquotes (no true italic glyph) */\n:lang(zh) blockquote,\n:lang(ja) blockquote,\n:lang(ko) blockquote {\n font-style: normal;\n}\n\nul,\nol {\n padding-left: 1.4em;\n margin: 1.25em 0;\n}\n\nol ol {\n list-style-type: lower-alpha;\n margin-top: 0.4em;\n margin-bottom: 0.4em;\n}\n\nol ol ol {\n list-style-type: lower-roman;\n}\n\nli {\n margin-top: 0.5em;\n margin-bottom: 0.5em;\n}\n\n/* Normalize li > p so list spacing is controlled by li alone,\n regardless of whether the markdown renderer wraps li contents in <p>. */\nli > p:first-child {\n margin-top: 0;\n}\n\nli > p:last-child {\n margin-bottom: 0;\n}\n\nli > p:has(+ ol) {\n margin-bottom: 0;\n}\n\na {\n color: var(--site-reading-link);\n text-decoration: underline;\n text-decoration-color: var(--site-reading-link-underline);\n text-underline-offset: 0.15em;\n transition:\n color 0.2s ease,\n text-decoration-color 0.2s ease;\n}\n\na:hover,\na:focus {\n color: var(--site-reading-link-hover);\n text-decoration-color: currentColor;\n}\n\na:focus-visible {\n outline: 2px solid var(--site-accent);\n outline-offset: 2px;\n border-radius: 2px;\n}\n\ntime {\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n/* -------------------------------------------------------------------------\n * Page layout — Tufte horizontal frame\n *\n * Mirrors the main site's `.site-page > header/main/footer` rule. Every\n * top-level section gets the same asymmetric padding so the reading column\n * aligns with the rest of the site.\n *\n * 12.5% left + 4% right = 16.5% padding → content = 83.5% of the box.\n * max-width = body-max-width / 0.835 so the inner content area exactly\n * equals `--layout-body-max-width` on wide viewports. min() caps keep\n * padding from growing beyond 210px / 67px. Mobile widens to 5% / 5%\n * and the 55% content column collapses to 100% (via tokens.css).\n * ------------------------------------------------------------------------- */\n\n.site-page {\n min-height: 100vh;\n min-height: 100dvh;\n background-color: var(--site-page-bg);\n}\n\n.site-page > header,\n.site-page > main,\n.site-page > footer,\n.site-page > .home-branding-credit {\n width: 100%;\n max-width: calc(var(--layout-body-max-width) / 0.835);\n padding-left: min(12.5%, 210px);\n padding-right: min(4%, 67px);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (max-width: 760px) {\n .site-page > header,\n .site-page > main,\n .site-page > footer,\n .site-page > .home-branding-credit {\n padding-left: max(5%, 28px);\n padding-right: 5%;\n }\n}\n\n.site-main {\n padding-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Header\n * ------------------------------------------------------------------------- */\n\n.site-header {\n padding-top: 24px;\n background-color: var(--site-page-bg);\n}\n\n@media (min-width: 700px) {\n .site-header {\n padding-top: 30px;\n }\n}\n\n.site-header-inner {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: 0;\n}\n\n.site-header-top {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n flex-wrap: nowrap;\n min-height: 2.75rem;\n width: 100%;\n}\n\n.site-header-top-bordered {\n padding-bottom: 15px;\n}\n\n@media (min-width: 700px) {\n .site-header-top-bordered {\n padding-bottom: 18px;\n }\n}\n\n.site-logo {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: 10px;\n min-width: 0;\n padding: 0.15rem 0;\n font-family: var(--font-site-title);\n font-size: var(--type-subtitle);\n font-weight: var(--fw-regular);\n letter-spacing: -0.02em;\n line-height: 1.15;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo:hover,\n.site-logo:focus {\n color: var(--site-text-primary);\n text-decoration: none;\n}\n\n.site-logo-avatar {\n box-sizing: border-box;\n width: calc(var(--avatar-size) + 4px);\n height: calc(var(--avatar-size) + 4px);\n border-radius: var(--avatar-radius);\n object-fit: cover;\n border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);\n flex: none;\n}\n\n.site-logo-text {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.site-header-nav {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n justify-content: flex-end;\n gap: clamp(0.6rem, 2.2vw, 1rem);\n margin-left: auto;\n min-width: 0;\n font-family: var(--font-ui);\n}\n\n.site-header-link {\n display: inline-flex;\n flex: none;\n align-items: center;\n position: relative;\n min-height: 2rem;\n padding: 0.15rem 0;\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n white-space: nowrap;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n text-decoration: none;\n transition: color 0.15s;\n}\n\n.site-header-link:hover,\n.site-header-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n text-decoration: none;\n}\n\n.site-header-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n/* --- \"More\" dropdown ---------------------------------------------------- */\n\n.site-header-more {\n position: relative;\n display: inline-flex;\n align-items: center;\n}\n\n.site-header-more-responsive-only {\n display: none;\n}\n\n.site-header-more-btn {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n min-height: 2rem;\n padding: 0.15rem 0;\n border: none;\n background: transparent;\n cursor: pointer;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-medium);\n letter-spacing: 0.01em;\n line-height: 1;\n color: color-mix(in srgb, var(--site-text-secondary) 62%, transparent);\n transition: color 0.15s;\n}\n\n.site-header-more-btn svg {\n width: 0.82rem;\n height: 0.82rem;\n transition: transform 0.18s ease;\n}\n\n.site-header-more-btn:hover,\n.site-header-more-btn[aria-expanded=\"true\"] {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-btn[aria-expanded=\"true\"] svg {\n transform: rotate(180deg);\n}\n\n.site-header-more-popover {\n display: block;\n position: absolute;\n top: 100%;\n right: 0;\n margin-top: 0.6rem;\n min-width: 12.25rem;\n padding: 0.3rem 0;\n background: var(--site-page-bg);\n border: 0.5px solid color-mix(in srgb, var(--site-divider) 80%, transparent);\n border-radius: 0.4rem;\n box-shadow:\n 0 4px 20px -8px rgba(0, 0, 0, 0.12),\n 0 2px 6px -2px rgba(0, 0, 0, 0.06);\n opacity: 0;\n visibility: hidden;\n pointer-events: none;\n transform: translateY(-6px);\n transform-origin: top right;\n transition:\n opacity 0.18s ease,\n transform 0.18s ease,\n visibility 0s linear 0.18s;\n z-index: 50;\n}\n\n.site-header-more-popover[aria-hidden=\"false\"] {\n opacity: 1;\n visibility: visible;\n pointer-events: auto;\n transform: translateY(0);\n transition-delay: 0s;\n}\n\n.site-header-more-link {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.35rem;\n padding: 0.45rem 1rem;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.15s,\n background-color 0.15s;\n}\n\n.site-header-more-link:hover,\n.site-header-more-link:focus {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n background: color-mix(in srgb, var(--site-nav-hover-bg) 58%, transparent);\n text-decoration: none;\n}\n\n.site-header-more-link-active {\n color: color-mix(\n in srgb,\n var(--site-text-primary) 84%,\n var(--site-text-secondary)\n );\n}\n\n.site-header-more-link-responsive,\n.site-header-more-divider-responsive {\n display: none;\n}\n\n.site-header-more-divider {\n height: 0;\n margin: 0.35rem 0.75rem;\n border-top: 0.5px solid\n color-mix(in srgb, var(--site-divider) 60%, transparent);\n}\n\n/* --- Tiered responsive collapse ---------------------------------------- *\n *\n * ≤960px — 5th+ inline link collapses into More (tier-lg)\n * ≤780px — also 4th collapses (tier-md)\n * ≤580px — also 3rd collapses (tier-sm)\n * The first two links always stay inline. No hamburger on static export —\n * at very narrow widths 2 inline links + More button is the floor.\n */\n\n@media (max-width: 960px) {\n .site-header-link-collapse-lg {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-lg {\n display: inline-flex;\n }\n\n .site-header-more-link-show-lg {\n display: flex;\n }\n\n .site-header-more-divider-responsive {\n display: block;\n }\n}\n\n@media (max-width: 780px) {\n .site-header-link-collapse-md {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-md {\n display: inline-flex;\n }\n\n .site-header-more-link-show-md {\n display: flex;\n }\n}\n\n@media (max-width: 580px) {\n .site-header-link-collapse-sm {\n display: none;\n }\n\n .site-header-more-responsive-only.site-header-more-tier-sm {\n display: inline-flex;\n }\n\n .site-header-more-link-show-sm {\n display: flex;\n }\n}\n\n/* -------------------------------------------------------------------------\n * Footer\n * ------------------------------------------------------------------------- */\n\n.site-footer {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n color: var(--site-text-secondary);\n font-size: var(--type-xs);\n background-color: var(--site-page-bg);\n}\n\n.site-footer-inner {\n border-top: 0.5px solid var(--site-divider);\n padding-top: var(--space-xl);\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.site-footer-content {\n color: var(--site-text-secondary);\n}\n\n.site-footer-content p {\n margin: 0 0 0.5em;\n}\n\n.site-footer-nav-list {\n display: flex;\n flex-wrap: wrap;\n gap: 1rem;\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.home-branding-credit {\n margin-top: var(--space-xl);\n padding-bottom: var(--space-xl);\n text-align: center;\n color: var(--site-text-secondary);\n font-size: var(--type-base);\n}\n\n.home-branding-credit a {\n display: inline-flex;\n align-items: center;\n gap: 0.38rem;\n color: inherit;\n text-decoration: none;\n border-bottom: 0.5px solid\n color-mix(in srgb, var(--site-text-secondary) 45%, transparent);\n transition:\n color 160ms ease,\n border-color 160ms ease;\n}\n\n.home-branding-credit a:hover,\n.home-branding-credit a:focus-visible {\n color: var(--site-text-primary);\n border-color: currentColor;\n}\n\n/* -------------------------------------------------------------------------\n * Tufte content-width constraint\n *\n * Mirrors the main site's 55% rule: reading text occupies a narrow\n * column inside the Tufte frame, leaving a wide right margin that\n * would host sidenotes on the main site. Media (images, video, audio)\n * is NOT included — galleries intentionally span the full frame so\n * they can breathe.\n *\n * Mobile (<=760px): `--layout-content-width` collapses to 100% via\n * tokens.css. Tablet: cap at 35rem for readability.\n * ------------------------------------------------------------------------- */\n\n.section-header,\n.section-body,\n.post-card-title,\n.post-card-summary,\n.post-card-link-domain,\n.post-card-quote-content,\n.post-card-quote-attribution,\n.post-card-quote-commentary,\n.post-card-footer,\n.reply-title,\n.reply-body,\n.reply-link-domain,\n.reply-footer,\n.thread-title,\n.thread-body,\n.thread-link-domain,\n.thread-footer,\n.collection-directory,\n.pagination,\n.empty-state,\n.page-summary {\n width: var(--layout-content-width);\n max-width: 100%;\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .section-header,\n .section-body,\n .post-card-title,\n .post-card-summary,\n .post-card-link-domain,\n .post-card-quote-content,\n .post-card-quote-attribution,\n .post-card-quote-commentary,\n .post-card-footer,\n .reply-title,\n .reply-body,\n .reply-link-domain,\n .reply-footer,\n .thread-title,\n .thread-body,\n .thread-link-domain,\n .thread-footer,\n .collection-directory,\n .pagination,\n .empty-state,\n .page-summary {\n width: min(100%, 35rem);\n }\n}\n\n/* -------------------------------------------------------------------------\n * Section headers\n * ------------------------------------------------------------------------- */\n\n.section-header {\n margin-bottom: var(--space-xl);\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--site-border-light);\n}\n\n.section-title {\n margin: 0 0 0.25em;\n}\n\n.section-summary {\n margin: 0;\n color: var(--site-reading-meta);\n font-size: var(--type-secondary);\n}\n\n.section-meta {\n margin: 0.5em 0 0;\n color: var(--site-reading-meta);\n font-size: var(--type-sm);\n}\n\n/* -------------------------------------------------------------------------\n * Post list + post cards\n * ------------------------------------------------------------------------- */\n\n.post-list {\n display: flex;\n flex-direction: column;\n gap: calc(var(--space-xl) * 1.25);\n}\n\n.post-list-pinned {\n margin-bottom: calc(var(--space-xl) * 1.25);\n padding-bottom: calc(var(--space-xl) * 1.25);\n border-bottom: 1px solid var(--site-border-light);\n}\n\n/* Decorative divider between posts in a timeline feed. Mirrors the main\n site's `hr.feed-divider`: a trio of small chevron marks masked from the\n current text color, so it picks up the theme automatically.\n `margin-left` centers the divider within the 55% reading column so it\n visually sits at the middle of the post-card text stack. */\nhr.feed-divider {\n border: none;\n width: 30px;\n height: 9px;\n margin: 0;\n margin-left: calc(var(--layout-content-width) / 2 - 15px);\n color: var(--site-feed-divider-color);\n background-color: currentColor;\n -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 13'%3E%3Cpath fill='black' transform='translate(0,0) rotate(90 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(16,0) rotate(100 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3Cpath fill='black' transform='translate(32,0) rotate(80 6 6.5)' d='M6.765.5.177 6.093l2.61 5.966 8.39-3.17L6.765.5Z'/%3E%3C/svg%3E\");\n -webkit-mask-repeat: no-repeat;\n mask-repeat: no-repeat;\n -webkit-mask-position: center;\n mask-position: center;\n -webkit-mask-size: contain;\n mask-size: contain;\n}\n\n.post-card {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.post-card-title {\n font-family: var(--font-heading);\n font-size: var(--feed-note-title-size);\n font-weight: var(--type-heading-weight);\n line-height: var(--feed-note-title-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.post-card-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.post-card-title a:hover,\n.post-card-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-card-summary {\n margin: 0;\n color: var(--site-reading-body);\n}\n\n.post-card > .post-card-summary :is(h1, h2),\n.post-card > .post-card-quote-commentary :is(h1, h2) {\n font-size: calc(var(--type-content-body) * 1.12);\n font-weight: var(--fw-medium);\n margin-top: 1.45rem;\n margin-bottom: 0.55rem;\n}\n\n.post-card > .post-card-summary :is(h3, h4),\n.post-card > .post-card-quote-commentary :is(h3, h4) {\n font-size: var(--type-content-body);\n font-weight: var(--fw-medium);\n margin-top: 1.2rem;\n margin-bottom: 0.45rem;\n}\n\n.post-card > .post-card-summary :is(h5, h6),\n.post-card > .post-card-quote-commentary :is(h5, h6) {\n font-size: var(--type-secondary);\n font-weight: var(--fw-medium);\n color: var(--site-text-secondary);\n margin-top: 1rem;\n margin-bottom: 0.35rem;\n}\n\n/* Link card domain row — shown ABOVE the title (matches main site's\n `.feed-link-domain` pattern). Inline flex with icon + host text. */\n.post-card-link-domain,\n.thread-link-domain,\n.reply-link-domain {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n max-width: 100%;\n margin: 0 0 0.4rem 0;\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-weight: var(--fw-regular);\n line-height: 1.3;\n color: var(--site-text-secondary);\n text-decoration: none;\n word-break: break-all;\n transition: color 0.18s ease;\n}\n\n.post-card-link-domain:hover,\n.post-card-link-domain:focus,\n.thread-link-domain:hover,\n.thread-link-domain:focus,\n.reply-link-domain:hover,\n.reply-link-domain:focus {\n color: var(--site-text-primary);\n}\n\n.post-card-link-domain-icon {\n width: 0.72rem;\n height: 0.72rem;\n flex-shrink: 0;\n}\n\n/* Slight extra breathing room below link-card titles to match main site. */\n.post-card-link-title,\n.thread-link-title,\n.reply-link-title {\n text-wrap: pretty;\n}\n\n.post-card-link-title a,\n.thread-link-title a,\n.reply-link-title a {\n text-decoration: none;\n}\n\n.post-card-link-title a:hover,\n.post-card-link-title a:focus,\n.thread-link-title a:hover,\n.thread-link-title a:focus,\n.reply-link-title a:hover,\n.reply-link-title a:focus {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.post-card-media,\n.reply-media,\n.thread-media {\n display: grid;\n grid-template-columns: 1fr;\n gap: 0.75rem;\n}\n\n.post-card-figure img,\n.reply-figure img,\n.thread-figure img {\n display: block;\n width: 100%;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n}\n\n.post-card-figure video,\n.reply-figure video,\n.thread-figure video {\n display: block;\n width: 100%;\n height: auto;\n border-radius: var(--media-radius);\n border: 1px solid var(--site-media-outline);\n background: #000;\n}\n\n.post-card-figure audio,\n.reply-figure audio,\n.thread-figure audio {\n display: block;\n width: 100%;\n}\n\n.post-card-figure-video a {\n position: relative;\n display: block;\n}\n\n.post-card-video-badge {\n position: absolute;\n left: 0.5rem;\n bottom: 0.5rem;\n padding: 0.125rem 0.5rem;\n font-size: var(--type-xs);\n color: #fff;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 999px;\n pointer-events: none;\n}\n\n.thread-file a,\n.reply-file a {\n color: var(--site-link);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Quote format — decorative mark, serif body, attribution line\n * ------------------------------------------------------------------------- */\n\n.post-card-quote {\n position: static;\n margin: 0;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: var(--site-reading-quote);\n font-family: inherit;\n font-style: normal;\n}\n\n/* Quote-format posts have their own decorative `.post-card-quote-mark`\n SVG inside the blockquote — suppress the global blockquote icon. */\n.post-card-quote::before {\n content: none;\n}\n\n.post-card-quote-mark {\n display: block;\n position: relative;\n width: 1.7rem;\n margin-bottom: -0.1rem;\n margin-left: -0.04rem;\n line-height: 0;\n pointer-events: none;\n color: color-mix(in srgb, var(--site-accent) 14%, var(--site-divider));\n opacity: 0.66;\n}\n\n.post-card-quote-mark svg {\n display: block;\n width: 100%;\n height: auto;\n}\n\n.post-card-quote-content {\n font-family: var(--font-serif);\n color: var(--site-text-primary);\n font-size: var(--type-content-quote);\n line-height: var(--type-content-quote-leading);\n white-space: pre-line;\n text-wrap: pretty;\n margin: 0;\n}\n\n.post-card-quote-attribution {\n display: flex;\n align-items: center;\n gap: 0.45rem;\n flex-wrap: wrap;\n /* The name and source are flex items, which refuse to shrink below their\n min-content width. The inherited `break-word` leaves min-content at the\n longest word and the item still overflows; `anywhere` also shrinks\n min-content, which is what lets the row wrap. */\n overflow-wrap: anywhere;\n margin-top: 0.95rem;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-ui-meta);\n font-style: normal;\n line-height: 1.3;\n}\n\n.post-card-quote-attribution::before {\n content: \"\";\n width: 0.9rem;\n height: 1px;\n background: color-mix(\n in srgb,\n var(--site-text-secondary) 38%,\n var(--site-divider)\n );\n}\n\n.post-card-quote-source {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: color-mix(\n in srgb,\n var(--site-text-secondary) 55%,\n transparent\n );\n text-underline-offset: 3px;\n}\n\n.post-card-quote-source:hover,\n.post-card-quote-source:focus {\n color: var(--site-text-primary);\n text-decoration-color: currentColor;\n}\n\n.post-card-quote-commentary {\n position: relative;\n margin-top: 1.1rem;\n padding-top: 0.95rem;\n color: color-mix(\n in srgb,\n var(--site-text-secondary) 84%,\n var(--site-text-primary)\n );\n text-wrap: pretty;\n}\n\n.post-card-quote-commentary::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n height: 1px;\n background: linear-gradient(\n 90deg,\n transparent 0%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 16%,\n color-mix(in srgb, var(--site-divider) 78%, transparent) 50%,\n color-mix(in srgb, var(--site-divider) 48%, transparent) 84%,\n transparent 100%\n );\n}\n\n.post-card-quote-commentary.prose > :first-child {\n margin-top: 0;\n}\n\n.post-card-quote-commentary.prose > :last-child {\n margin-bottom: 0;\n}\n\n.post-card-quote-commentary p {\n margin: 0;\n}\n\n.post-card-quote-commentary p + p,\n.post-card-quote-commentary ul,\n.post-card-quote-commentary ol,\n.post-card-quote-commentary blockquote,\n.post-card-quote-commentary pre {\n margin-top: 0.55rem;\n}\n\n/* Fade the post meta on quote cards so the quote body stays visually primary. */\n.post-card-quote ~ .post-card-footer,\n.post-card-quote-commentary + .post-card-footer {\n opacity: 0.72;\n}\n\n/* -------------------------------------------------------------------------\n * Post footer — meta (featured, time, external link, collections, pinned)\n * ------------------------------------------------------------------------- */\n\n.post-card-footer,\n.reply-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 10px;\n min-height: 2rem;\n font-size: var(--type-ui-hint);\n}\n\n.post-footer-detail {\n margin-top: 24px;\n /* Match the feed/reply footer size so the root post's footer doesn't\n visually dominate. Main site uses a larger size because every post\n on the detail page is rendered at detail size; here only the root\n post gets this class, which otherwise creates a mismatch with the\n replies below. */\n font-size: var(--type-ui-hint);\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n min-width: 0;\n font-family: var(--font-ui);\n line-height: 1.35;\n color: var(--site-text-secondary);\n}\n\n.post-footer-meta time {\n font-size: inherit;\n color: inherit;\n}\n\n.post-footer-featured {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: color-mix(\n in srgb,\n var(--search-mark-color) 72%,\n var(--site-text-secondary)\n );\n flex-shrink: 0;\n}\n\n.post-footer-featured svg {\n width: 1rem;\n height: 1rem;\n opacity: 0.9;\n}\n\n.post-footer-link {\n color: var(--site-text-secondary);\n text-decoration: none;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.post-footer-link:hover,\n.post-footer-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n.post-footer-external-link {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.6rem;\n height: 1.6rem;\n border-radius: 0.6rem;\n color: var(--site-text-secondary);\n text-decoration: none;\n transition:\n color 0.18s ease,\n background-color 0.16s ease;\n flex-shrink: 0;\n}\n\n.post-footer-external-link:hover,\n.post-footer-external-link:focus {\n color: var(--site-text-primary);\n}\n\n.post-footer-external-link svg {\n width: 1rem;\n height: 1rem;\n}\n\n.post-collection-tags {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n flex-wrap: wrap;\n min-width: 0;\n max-width: 100%;\n color: var(--site-text-secondary);\n}\n\n.post-collection-tag {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n color: inherit;\n text-decoration: none;\n min-width: 0;\n max-width: min(100%, 22ch);\n}\n\n.post-collection-tag:hover,\n.post-collection-tag:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.post-collection-tag-text {\n display: block;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.post-card-pin {\n font-size: var(--type-2xs);\n text-transform: uppercase;\n letter-spacing: var(--type-label-tracking);\n color: var(--site-accent);\n border: 1px solid var(--site-accent);\n padding: 0.1em 0.5em;\n border-radius: 999px;\n flex-shrink: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Thread preview (list pages: root context + hero latest reply)\n *\n * Mirrors the main site's `.thread-group.thread-group-preview` layout:\n * content sits flush-left inside the preview, and the vertical rail +\n * dot markers are positioned OUTSIDE the content (overflowing into the\n * container's left gutter) via a negative `left` on the rail/dots.\n * ------------------------------------------------------------------------- */\n\n.thread-preview {\n /* Rail position: negative = overflow outside content. Mirrors\n `--site-thread-rail-line-left` on the main site. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n --thread-item-spacing: 0.35rem;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread-preview {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail — subtle gradient so the line fades into\n whitespace at the top and tail. Sits outside the content via negative\n `left`. */\n.thread-preview::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n.thread-preview-context {\n position: relative;\n display: flex;\n flex-direction: column;\n /* Match runtime `.thread-group-preview .thread-item` which uses\n `padding: var(--site-thread-item-spacing) 0` — adjacent items have\n 2x the token between them, so the flex `gap` here is 2x to match. */\n gap: calc(var(--site-thread-item-spacing) * 2);\n margin: 0;\n padding: 0;\n border-left: 0;\n}\n\n/* Individual entry in a thread preview — wraps a full `.post-card` so\n the root, second, and penultimate replies render with their own\n title/body/footer. Dot marker sits on the rail at the card's\n vertical midpoint. */\n.thread-preview .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-preview .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n/* Gap \"N more posts\" row has no card so we place the marker at its\n vertical midpoint instead of the card's top area. Swap the single dot\n for a short column of small beads (a vertical \"⋮\") so the rail visibly\n \"skips\" a run of posts — mirrors the main site's `.thread-item-gap`. */\n.thread-preview .thread-item-gap {\n display: flex;\n align-items: center;\n padding: 0.15rem 0 0.35rem;\n}\n\n.thread-preview .thread-item-gap::before {\n top: 50%;\n transform: translateY(-50%);\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 - 2px -\n var(--thread-rail-indent)\n );\n width: 4px;\n height: 24px;\n border: 0;\n border-radius: 0;\n background: radial-gradient(\n circle,\n var(--site-threadline) 1.75px,\n transparent 2px\n );\n background-size: 4px 8px;\n background-repeat: repeat-y;\n background-position: center top;\n box-shadow: none;\n}\n\n/* Hidden-posts count — a calm route into the thread, matching the main\n site's `.thread-gap-link`: styled like a sibling of the Show more toggle\n with a small right-pointing chevron rather than an underline, firming up\n to the primary text colour on hover. */\n.thread-preview-gap {\n display: inline-flex;\n align-items: center;\n gap: 0.4rem;\n align-self: flex-start;\n margin: 0.15rem 0;\n padding: 0.25rem 0;\n color: var(--site-text-secondary);\n font-size: var(--type-thread-context-meta);\n font-weight: 500;\n line-height: 1.2;\n text-decoration: none;\n transition: color 0.18s ease;\n}\n\n.thread-preview-gap::after {\n content: \"\";\n width: 0.36em;\n height: 0.36em;\n border: 1.5px solid currentColor;\n border-left: 0;\n border-bottom: 0;\n opacity: 0.65;\n transform: rotate(45deg);\n transition:\n transform 0.18s ease,\n opacity 0.18s ease;\n}\n\n.thread-preview-gap:hover,\n.thread-preview-gap:focus {\n color: var(--site-text-primary);\n}\n\n.thread-preview-gap:hover::after,\n.thread-preview-gap:focus::after {\n opacity: 1;\n transform: translateX(2px) rotate(45deg);\n}\n\n/* Hero (latest reply) row: spacing above and below matches the main\n site's `.thread-item-hero`, and the dot is larger + accent-colored. */\n.thread-preview-hero {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n padding-top: calc(var(--space-xl) * 1.1);\n margin-top: 0;\n}\n\n/* Featured timelines keep Post-level selection while grouping by Thread.\n Selected Root/Child Posts use the accent marker; context Posts retain the\n neutral rail marker. */\n.thread-preview .thread-item-featured::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview .thread-item-hero::before {\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-hero-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: calc(var(--space-xl) * 1.1 + 1.4rem);\n width: var(--thread-hero-dot-size);\n height: var(--thread-hero-dot-size);\n background-color: var(--site-accent);\n border-width: var(--thread-hero-dot-border-width);\n}\n\n.thread-preview-thread-link {\n align-self: flex-start;\n font-size: var(--type-sm);\n color: var(--site-reading-meta);\n text-decoration: none;\n}\n\n.thread-preview-thread-link:hover,\n.thread-preview-thread-link:focus {\n color: var(--site-text-primary);\n text-decoration: underline;\n}\n\n/* -------------------------------------------------------------------------\n * Thread (single post page with inline replies)\n * ------------------------------------------------------------------------- */\n\n.thread {\n /* Rail variables match `.thread-preview` so the detail-page rail\n shares the same visual position. */\n --thread-rail-left: -27px;\n --thread-rail-width: 1px;\n --thread-rail-indent: 0px;\n --thread-dot-size: 10px;\n --thread-dot-ring-width: 2px;\n --thread-dot-border-width: 2px;\n --thread-hero-dot-size: 14px;\n --thread-hero-dot-border-width: 3px;\n\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 1.25rem;\n padding-left: var(--thread-rail-indent);\n}\n\n@media (max-width: 760px) {\n .thread {\n --thread-rail-left: -11px;\n --thread-rail-indent: 8px;\n }\n}\n\n/* Continuous vertical rail spanning the entire thread (root post + replies).\n Only shown when the root post actually has replies — a lone post should\n not have a rail or dot. Matches `.thread-preview`. */\n.thread-has-replies::before {\n content: \"\";\n position: absolute;\n left: var(--thread-rail-left);\n top: 0;\n bottom: 0;\n width: var(--thread-rail-width);\n background: linear-gradient(\n 180deg,\n transparent 0,\n color-mix(in srgb, var(--site-threadline) 85%, transparent) 8%,\n color-mix(in srgb, var(--site-threadline) 55%, transparent) 100%\n );\n pointer-events: none;\n}\n\n/* Dot marker for each post in the thread (root + replies). Shown only when\n there are replies so a solo post doesn't get an orphaned dot. */\n.thread .thread-item {\n position: relative;\n min-width: 0;\n max-width: 100%;\n}\n\n.thread-has-replies .thread-item::before {\n content: \"\";\n position: absolute;\n left: calc(\n var(--thread-rail-left) + var(--thread-rail-width) / 2 -\n var(--thread-dot-size) / 2 - var(--thread-rail-indent)\n );\n top: 1.4rem;\n width: var(--thread-dot-size);\n height: var(--thread-dot-size);\n border-radius: 50%;\n background-color: var(--site-threadline);\n border: var(--thread-dot-border-width) solid var(--site-page-bg);\n box-shadow: 0 0 0 var(--thread-dot-ring-width) var(--site-thread-dot-ring);\n z-index: 1;\n}\n\n.thread-item-root {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n.thread-header {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.thread-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-display);\n font-weight: var(--type-display-weight);\n line-height: var(--type-display-leading);\n margin: 0;\n color: var(--site-reading-title);\n}\n\n.thread-title a {\n color: inherit;\n text-decoration: none;\n}\n\n.thread-title a:hover,\n.thread-title a:focus {\n text-decoration: underline;\n text-underline-offset: 0.18em;\n}\n\n.thread-body {\n font-size: var(--type-content-body);\n line-height: var(--type-body-leading);\n color: var(--site-reading-body);\n}\n\n.thread-body > :first-child {\n margin-top: 0;\n}\n\n.thread-body > :last-child {\n margin-bottom: 0;\n}\n\n.thread-replies {\n /* Flex container for replies. The rail and dots are provided by\n `.thread::before` and `.thread-item::before` so replies stay visually\n connected to the root post. */\n display: flex;\n flex-direction: column;\n gap: var(--space-xl);\n}\n\n/* -------------------------------------------------------------------------\n * Replies\n * ------------------------------------------------------------------------- */\n\n.reply {\n scroll-margin-top: 1.5rem;\n display: flex;\n flex-direction: column;\n gap: 0.6rem;\n}\n\n.reply:target {\n background-color: var(--search-mark-bg);\n border-radius: 0.25rem;\n padding: 0.75rem 1rem;\n margin-left: -1rem;\n margin-right: -1rem;\n}\n\n.reply:target::before {\n background-color: var(--site-accent);\n}\n\n.reply-title {\n font-family: var(--font-heading);\n font-size: var(--type-content-subtitle);\n font-weight: var(--type-heading-weight);\n line-height: var(--type-heading-leading);\n margin: 0;\n color: var(--site-reading-heading);\n}\n\n.reply-body {\n color: var(--site-reading-body);\n}\n\n.reply-body > :first-child {\n margin-top: 0;\n}\n\n.reply-body > :last-child {\n margin-bottom: 0;\n}\n\n/* -------------------------------------------------------------------------\n * Collections page\n *\n * Mirrors the main site's authenticated-less collections view\n * (`ui/pages/CollectionsPage.tsx` + `ui/shared/CollectionDirectory.tsx`):\n * a page-intro block with count, then a two-column grid per row where a\n * monospace sequence label sits beside the collection title, description,\n * and entry/activity meta.\n * ------------------------------------------------------------------------- */\n\n.collections-page-shell {\n position: relative;\n display: flex;\n flex-direction: column;\n width: var(--layout-content-width);\n max-width: 100%;\n gap: clamp(1.25rem, 3vw, 1.75rem);\n}\n\n@media (min-width: 761px) and (max-width: 1024px) {\n .collections-page-shell {\n width: min(100%, 35rem);\n }\n}\n\n@media (max-width: 760px) {\n .collections-page-shell {\n width: 100%;\n }\n}\n\n.collections-page-header {\n position: relative;\n display: flex;\n align-items: flex-start;\n padding-bottom: 0.2rem;\n}\n\n.collections-page-heading {\n min-width: 0;\n flex: 1 1 18rem;\n}\n\n.page-intro {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding-bottom: 0.1rem;\n}\n\n.page-intro-title-row {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0.7rem;\n min-width: 0;\n}\n\n.page-intro-title {\n margin: 0;\n font-family: var(--font-heading);\n font-size: var(--type-title, 2rem);\n font-weight: var(--type-heading-weight);\n line-height: 1.15;\n letter-spacing: -0.01em;\n color: var(--site-text-primary);\n}\n\n.page-intro-meta-row {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n}\n\n.page-intro-meta,\n.page-intro-description {\n margin: 0;\n color: var(--site-text-secondary);\n font-family: var(--font-ui);\n font-size: var(--type-secondary);\n line-height: 1.3;\n}\n\n.collection-directory {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n list-style: none;\n padding: 0;\n margin: 0;\n}\n\n.collection-directory-item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: 0.75rem;\n padding: 0.95rem 0;\n background: transparent;\n text-decoration: none;\n}\n\n.collection-directory-main {\n --collection-directory-sequence-width: 3.5ch;\n --collection-directory-title-line-height: 1.18;\n min-width: 0;\n flex: 1;\n display: grid;\n grid-template-columns: var(--collection-directory-sequence-width) minmax(\n 0,\n 1fr\n );\n align-items: start;\n column-gap: 0.8rem;\n row-gap: 0.25rem;\n}\n\n.collection-directory-sequence {\n grid-column: 1;\n grid-row: 1;\n display: block;\n width: var(--collection-directory-sequence-width);\n padding-top: 0.2rem;\n font-family: var(--font-mono);\n font-size: var(--type-xs);\n font-variant-numeric: tabular-nums;\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: 0.14em;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-title-row {\n grid-column: 2;\n grid-row: 1;\n min-width: 0;\n display: flex;\n align-items: flex-start;\n}\n\n.collection-directory-title-link {\n color: inherit;\n text-decoration: none;\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover,\n.collection-directory-title-link:focus-visible {\n color: var(--site-text-primary);\n}\n\n.collection-directory-title-link:hover .collection-directory-title,\n.collection-directory-title-link:focus-visible .collection-directory-title {\n text-decoration: underline;\n text-underline-offset: 3px;\n}\n\n.collection-directory-title {\n min-width: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n font-family: var(--font-heading);\n font-size: var(--type-content-body);\n font-weight: var(--type-heading-weight);\n line-height: var(--collection-directory-title-line-height);\n letter-spacing: -0.02em;\n text-wrap: pretty;\n}\n\n.collection-directory-title-marker {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 0.95rem;\n min-width: 0.95rem;\n height: 0.95rem;\n color: var(--site-text-secondary);\n transition: color 0.15s ease;\n}\n\n.collection-directory-title-link:hover .collection-directory-title-marker,\n.collection-directory-title-link:focus-visible\n .collection-directory-title-marker {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description {\n grid-column: 2;\n grid-row: 2;\n margin: 0;\n color: color-mix(in srgb, var(--site-text-secondary) 80%, transparent);\n font-family: var(--font-body);\n font-size: var(--type-sm);\n line-height: 1.45;\n}\n\n.collection-directory-description :where(p) {\n margin-top: 0.25em;\n margin-bottom: 0.25em;\n}\n\n/* Links inside the description inherit the description color so they sit in\n the same gray tone as the copy around them (matches the main site's\n `.prose { --tw-prose-links: var(--site-content-link); }` which resolves\n to `inherit`). The underline stays, drawn in currentColor. */\n.collection-directory-description :where(a) {\n color: inherit;\n text-decoration: underline;\n text-decoration-color: currentColor;\n text-underline-offset: 0.15em;\n}\n\n.collection-directory-description :where(a:hover),\n.collection-directory-description :where(a:focus-visible) {\n color: var(--site-text-primary);\n}\n\n.collection-directory-description :where(p:first-child) {\n margin-top: 0;\n}\n\n.collection-directory-description :where(p:last-child) {\n margin-bottom: 0;\n}\n\n.collection-directory-description + .collection-directory-summary {\n grid-row: 3;\n}\n\n.collection-directory-summary {\n grid-column: 2;\n grid-row: 2;\n display: flex;\n min-width: 0;\n overflow: hidden;\n align-items: center;\n gap: 0.2rem 0.5rem;\n margin: 0;\n color: var(--site-reading-meta);\n font-family: var(--font-ui);\n font-size: var(--type-sm);\n line-height: 1.3;\n white-space: nowrap;\n}\n\n.collection-directory-meta {\n flex: 0 0 auto;\n color: inherit;\n}\n\n.collection-directory-meta-separator {\n flex: 0 0 auto;\n color: color-mix(in srgb, var(--site-divider) 88%, transparent);\n}\n\n.collection-directory-updated {\n flex: 0 0 auto;\n color: inherit;\n white-space: nowrap;\n}\n\n.collection-directory-divider {\n padding: 1.5rem 0 0.85rem;\n}\n\n.collection-directory-divider-row {\n display: flex;\n align-items: center;\n gap: 0.95rem;\n}\n\n.collection-directory-divider-text {\n font-family: var(--font-heading);\n font-size: var(--type-secondary);\n letter-spacing: 0;\n font-style: normal;\n white-space: nowrap;\n color: var(--site-text-secondary);\n}\n\n.collection-directory-divider-line {\n flex: 1;\n height: 1px;\n border: none;\n margin: 0;\n background: linear-gradient(\n 90deg,\n color-mix(in srgb, var(--site-divider) 100%, transparent),\n color-mix(in srgb, var(--site-divider) 54%, transparent) 34%,\n transparent 86%\n );\n}\n\n/* -------------------------------------------------------------------------\n * Pagination\n * ------------------------------------------------------------------------- */\n\n.pagination {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n flex-wrap: wrap;\n gap: 1rem;\n padding: 1.5rem 0;\n font-size: var(--type-sm);\n font-variant-numeric: tabular-nums;\n}\n\n.pagination-link {\n color: var(--site-text-secondary);\n text-decoration: underline;\n text-underline-offset: 3px;\n transition: color 0.15s ease;\n}\n\n.pagination-link:hover,\n.pagination-link:focus {\n color: var(--site-text-primary);\n}\n\n.pagination-link.is-disabled {\n color: color-mix(in srgb, var(--site-text-secondary) 50%, transparent);\n cursor: default;\n text-decoration: none;\n}\n\n.pagination-current {\n color: var(--site-text-primary);\n font-weight: var(--fw-medium);\n}\n\n.pagination-ellipsis {\n color: var(--site-text-secondary);\n}\n\n/* -------------------------------------------------------------------------\n * Empty states + utility\n * ------------------------------------------------------------------------- */\n\n.empty-state {\n color: var(--site-reading-meta);\n font-style: italic;\n text-align: center;\n padding: var(--space-xl) 0;\n}\n\n.page,\n.section {\n display: block;\n}\n\n.page-summary,\n.section-summary {\n font-size: var(--type-secondary);\n color: var(--site-reading-meta);\n}\n\n/* -------------------------------------------------------------------------\n * Wider viewport refinements\n * ------------------------------------------------------------------------- */\n\n@media (min-width: 768px) {\n .site-main {\n padding-top: calc(var(--space-xl) * 1.5);\n padding-bottom: calc(var(--space-xl) * 1.5);\n }\n\n .post-card-media,\n .thread-media,\n .reply-media {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n\n .post-card-media:has(> :only-child),\n .thread-media:has(> :only-child),\n .reply-media:has(> :only-child) {\n grid-template-columns: 1fr;\n }\n}\n";
|
|
4531
5928
|
//#endregion
|
|
4532
5929
|
//#region src/services/export-theme/assets/client-site.js?raw
|
|
4533
|
-
var client_site_default$1 = "var e=null,t=0,n=!1,r=null,i=null,a=0;function o(e){return!isFinite(e)||e<0?`0:00`:`${Math.floor(e/60)}:${String(Math.floor(e%60)).padStart(2,`0`)}`}function s(e){return e.querySelector(`audio.media-audio-el`)}function c(e){return e.querySelector(`[data-audio-range]`)}function l(e){return e.querySelector(`[data-audio-waveform]`)}function u(e,t){let n=`${(t*100).toFixed(1)}%`;e.style.background=`linear-gradient(to right, var(--site-text-primary) ${n}, transparent ${n})`}var d=new WeakMap,f=new WeakSet;async function p(e,t){let n=await(await fetch(e)).arrayBuffer(),r=new AudioContext;try{let e=(await r.decodeAudioData(n)).getChannelData(0),i=Math.max(1,Math.floor(e.length/t)),a=Array(t);for(let n=0;n<t;n++){let t=0,r=n*i,o=Math.min(r+i,e.length);for(let n=r;n<o;n++){let r=Math.abs(e[n]);r>t&&(t=r)}a[n]=t}let o=0;for(let e of a)e>o&&(o=e);if(o>0)for(let e=0;e<t;e++)a[e]/=o;return a}finally{await r.close()}}function ee(e=document){let t=e.querySelectorAll(`[data-audio-peaks]`),n=[];for(let e of t){let t=e.dataset.audioPeaks;if(!t)continue;let r=e.closest(`.media-audio-card`);if(!(!r||d.has(r)))try{let e=JSON.parse(t);if(!Array.isArray(e))continue;d.set(r,e),r.classList.add(`has-waveform`),n.push(r)}catch{}}n.length>0&&requestAnimationFrame(()=>{for(let e of n)m(e,0)})}async function te(e){if(d.has(e)||f.has(e))return;f.add(e);let t=e.querySelector(`audio.media-audio-el source`),n=l(e);if(!t?.src||!n)return;let r=n.getBoundingClientRect().width,i=Math.max(20,Math.floor(r/3));try{let n=await p(t.src,i);d.set(e,n),e.classList.add(`has-waveform`);let r=s(e),a=r?.duration??0;m(e,r&&isFinite(a)&&a>0?r.currentTime/a:0)}catch{}}function m(e,t){let n=d.get(e),r=l(e);if(!n||!r)return;let i=window.devicePixelRatio||1,a=r.getBoundingClientRect(),o=Math.round(a.width*i),s=Math.round(a.height*i);if(o===0||s===0)return;(r.width!==o||r.height!==s)&&(r.width=o,r.height=s);let c=r.getContext(`2d`);if(!c)return;c.clearRect(0,0,o,s);let u=n.length,f=o/u,p=Math.max(1,Math.round(f*.6)),ee=Math.round(2*i),te=s*.85,m=getComputedStyle(r).getPropertyValue(`--site-text-primary`).trim()||`#000`;for(let e=0;e<u;e++){let r=Math.round(e*f+(f-p)/2),a=Math.max(ee,Math.round(n[e]*te)),o=Math.round((s-a)/2);c.globalAlpha=(e+.5)/u<=t?.9:.2,c.fillStyle=m;let l=Math.min(p/2,i);c.beginPath(),c.roundRect(r,o,p,a,l),c.fill()}c.globalAlpha=1}function ne(e,t){let{currentTime:r,duration:i}=t,a=isFinite(i)&&i>0,s=a?r/i:0;if(!n){let t=c(e);t&&a&&(t.value=String(Math.round(s*1e3)),u(t,s)),d.has(e)&&m(e,s);let n=e.querySelector(`[data-audio-time]`);n&&(n.textContent=a?`${o(r)} / ${o(i)}`:o(r))}}function re(){if(!e)return;let n=s(e);!n||n.paused||(ne(e,n),t=requestAnimationFrame(re))}function ie(){if(e){let t=s(e);t&&!t.paused&&t.pause(),e.classList.remove(`is-playing`),e=null}cancelAnimationFrame(t)}async function ae(n){let r=s(n);if(r)if(e&&e!==n&&ie(),r.paused){e=n,n.classList.add(`is-playing`);try{await r.play()}catch{n.classList.remove(`is-playing`),e=null;return}t=requestAnimationFrame(re),te(n)}else r.pause(),n.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null}function oe(e){let t=e.closest(`.media-audio-card`);if(!t)return;let n=s(t);if(!n)return;let r=Number(e.value)/1e3,i=n.duration;isFinite(i)&&i>0&&(n.currentTime=r*i)}function se(e,t){let n=e.getBoundingClientRect();a=Math.max(0,Math.min(1,(t.clientX-n.left)/n.width));let r=e.closest(`.media-audio-card`);if(!r)return;m(r,a);let i=s(r),c=r.querySelector(`[data-audio-time]`);if(i&&c){let e=i.duration;isFinite(e)&&e>0&&(c.textContent=`${o(a*e)} / ${o(e)}`)}}async function ce(n){let r=n.closest(`.media-audio-card`);if(!r)return;let i=s(r);if(i)if(i.paused){e&&e!==r&&ie(),e=r,r.classList.add(`is-playing`);try{await i.play()}catch{r.classList.remove(`is-playing`),e=null;return}let n=i.duration;isFinite(n)&&n>0&&(i.currentTime=a*n),t=requestAnimationFrame(re),te(r)}else{let e=i.duration;isFinite(e)&&e>0&&(i.currentTime=a*e)}}document.addEventListener(`pointerdown`,e=>{let t=e.target;t.matches(`[data-audio-range]`)?(n=!0,r=t):t.matches(`[data-audio-waveform]`)&&(n=!0,i=t,se(t,e))},!0),document.addEventListener(`pointermove`,e=>{n&&i&&se(i,e)},!0),document.addEventListener(`pointerup`,()=>{n&&(r?(oe(r),r=null):i&&=(ce(i),null)),n=!1},!0),document.addEventListener(`pointercancel`,()=>{r=null,i=null,n=!1},!0),document.addEventListener(`click`,e=>{let t=e.target.closest(`[data-audio-play]`);if(!t)return;e.preventDefault();let n=t.closest(`.media-audio-card`);n&&ae(n)}),document.addEventListener(`input`,e=>{let t=e.target;if(!t.matches(`[data-audio-range]`))return;let n=t.closest(`.media-audio-card`);if(!n)return;let r=s(n);if(!r)return;let i=Number(t.value)/1e3;u(t,i);let a=r.duration,c=n.querySelector(`[data-audio-time]`);c&&isFinite(a)&&a>0&&(c.textContent=`${o(i*a)} / ${o(a)}`)},!0),document.addEventListener(`ended`,n=>{let r=n.target;if(!r.closest)return;let i=r.closest(`.media-audio-card`);if(!i)return;i.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null;let a=c(i);a&&(a.value=`0`,u(a,0)),d.has(i)&&m(i,0);let o=i.querySelector(`[data-audio-time]`);o&&(o.textContent=`0:00`)},!0),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>ee()):ee();var h=`jant:media-lightbox-toggle`,le=`jant:media-video-playback-intent`,g=new Set;function _(e){let t=e?.trim();return!!t&&g.has(t)}function ue(e,t){let n=e?.trim();n&&(t?g.add(n):g.delete(n),document.dispatchEvent(new CustomEvent(le,{detail:{mediaId:n,paused:t}})))}var de=`75% 0px`,fe=.6,v=.25,pe=160,y=new Set,me=new WeakMap,he=new WeakSet,b=null,x=null,S=null,ge=!1,C=null,w=null,T=null;function _e(e){return e.length===0?null:[...e].sort((e,t)=>t.visibleArea===e.visibleArea?e.centerDistance===t.centerDistance?t.intersectionRatio-e.intersectionRatio:e.centerDistance-t.centerDistance:t.visibleArea-e.visibleArea)[0]??null}function ve(){return{x:(globalThis.innerWidth||document.documentElement.clientWidth||0)/2,y:(globalThis.innerHeight||document.documentElement.clientHeight||0)/2}}function ye(e){return me.get(e)}function be(e){return _(e)}function E(e){return be(e.dataset.feedVideoId)}function xe(e){return e.closest(`.media-video-wrap`)?.querySelector(`[data-feed-video-mute-toggle]`)??null}function D(e){let t=xe(e);if(!t)return;let n=S!==e||e.muted;t.dataset.muted=n?`true`:`false`,t.setAttribute(`aria-label`,n?`Play with sound`:`Mute video`)}function Se(){for(let e of y)D(e)}function Ce(e){if(he.has(e))return;let t=e.dataset.videoSrc;t&&(e.getAttribute(`src`)!==t&&(e.src=t),e.load(),he.add(e))}function O(e){e?.pause()}function we(e){if(E(e)){O(e);return}Ce(e),e.muted=S!==e,e.playsInline=!0,e.loop=!0,D(e),e.play().catch(()=>{})}function Te(){for(let e of y)e.isConnected||(w?.unobserve(e),T?.unobserve(e),y.delete(e),e===b&&(b=null),e===x&&(x=null),e===S&&(S=null))}function Ee(){if(C=null,Te(),document.hidden||ge){O(b);return}let e=[];for(let t of y){let n=ye(t);n&&(E(t)||n.intersectionRatio<fe||e.push({video:t,...n}))}let t=null;if(x?.isConnected&&!E(x)){let e=ye(x);e&&e.intersectionRatio>v?t=x:(!e||e.intersectionRatio<=v)&&(x=null)}else x&&E(x)&&(x=null);if(t||=_e(e)?.video??null,!t){let e=b?ye(b):void 0;if(b&&!E(b)&&e&&e.intersectionRatio>v)return;O(b),b=null;return}t!==b&&(O(b),b=t),we(t);for(let e of y)e!==t&&(O(e),D(e))}function k(){C!==null&&globalThis.clearTimeout(C),C=globalThis.setTimeout(Ee,pe)}function De(e){let t=ve();for(let n of e){let e=n.target,r=n.boundingClientRect,i=r.left+r.width/2,a=r.top+r.height/2,o=n.intersectionRect.width*n.intersectionRect.height,s=Math.hypot(i-t.x,a-t.y);me.set(e,{intersectionRatio:n.intersectionRatio,visibleArea:o,centerDistance:s})}k()}function Oe(e){for(let t of e)t.isIntersecting&&Ce(t.target)}function ke(){w&&T||globalThis.IntersectionObserver!==void 0&&(w=new globalThis.IntersectionObserver(De,{threshold:[0,v,fe,1]}),T=new globalThis.IntersectionObserver(Oe,{rootMargin:de,threshold:0}))}function Ae(e){y.has(e)||(ke(),!(!w||!T)&&(y.add(e),w.observe(e),T.observe(e),xe(e)?.addEventListener(`click`,je),D(e)))}function je(e){e.preventDefault(),e.stopPropagation();let t=e.currentTarget.closest(`.media-video-wrap`)?.querySelector(`[data-feed-short-video]`);if(!t)return;let n=t.dataset.feedVideoId?.trim();n&&ue(n,!1),x=t,S!==t||t.muted?(S&&S!==t&&(S.muted=!0),S=t,b!==t&&(O(b),b=t),we(t)):(S=null,t.muted=!0,D(t)),Se(),k()}function Me(e=document){let t=e.querySelectorAll(`[data-feed-short-video]`);for(let e of t)Ae(e);k()}document.addEventListener(h,e=>{ge=e.detail?.open===!0,ge&&O(b),k()}),document.addEventListener(le,e=>{let t=e.detail,n=t?.mediaId?.trim();if(!(!n||typeof t.paused!=`boolean`)){for(let e of y)e.dataset.feedVideoId?.trim()===n&&t.paused&&(O(e),b===e&&(b=null),x===e&&(x=null));k()}}),document.addEventListener(`visibilitychange`,()=>{document.hidden&&O(b),k()}),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>Me(),{once:!0}):queueMicrotask(()=>Me());var Ne=4;function Pe(e){return e.querySelector(`[data-post-media]`)}function Fe(e){let t=Pe(e);if(!t)return;let{scrollLeft:n,scrollWidth:r,clientWidth:i}=t;e.classList.toggle(`can-scroll-start`,n>Ne),e.classList.toggle(`can-scroll-end`,n+i<r-Ne)}function Ie(e){return Math.max(160,Math.round(e.clientWidth*.85))}function A(e,t){e.scrollBy({left:t*Ie(e),behavior:`smooth`})}function j(e){if(e.dataset.scrollHintReady===`1`)return;let t=Pe(e);t&&(e.dataset.scrollHintReady=`1`,Fe(e),t.addEventListener(`scroll`,()=>Fe(e),{passive:!0}),e.querySelector(`.media-gallery-nav-prev`)?.addEventListener(`click`,()=>A(t,-1)),e.querySelector(`.media-gallery-nav-next`)?.addEventListener(`click`,()=>A(t,1)),t.addEventListener(`keydown`,e=>{if(e.target===t)switch(e.key){case`ArrowRight`:e.preventDefault(),A(t,1);break;case`ArrowLeft`:e.preventDefault(),A(t,-1);break;case`Home`:e.preventDefault(),t.scrollTo({left:0,behavior:`smooth`});break;case`End`:e.preventDefault(),t.scrollTo({left:t.scrollWidth,behavior:`smooth`});break}}))}function Le(){document.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(j)}var Re=new globalThis.MutationObserver(e=>{for(let t of e)for(let e of t.addedNodes)e instanceof HTMLElement&&(e.matches(`.media-gallery-scroll-wrap`)&&j(e),e.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(j))});document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>{Le(),Re.observe(document.body,{childList:!0,subtree:!0})}):(Le(),Re.observe(document.body,{childList:!0,subtree:!0}));var ze=new WeakMap,Be=new WeakMap;function Ve(e){let t=e.querySelector(`.site-header-more-btn`),n=e.querySelector(`.site-header-more-popover`);if(!t||!n||t.dataset.moreInitialized===`true`)return;t.dataset.moreInitialized=`true`;let r=new AbortController,i=r.signal;ze.set(t,r);function a(){n.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:t.parentElement}}))}function o(e=!1){n.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),e&&t.focus()}t.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),t.getAttribute(`aria-expanded`)===`true`?o():a()},{signal:i}),document.addEventListener(`click`,e=>{e.target instanceof Node&&(t.parentElement?.contains(e.target)||o())},{signal:i}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&n.getAttribute(`aria-hidden`)===`false`&&o(!0)},{signal:i}),document.addEventListener(`basecoat:popover`,e=>{e.detail?.source!==t.parentElement&&o()},{signal:i})}var He=`jant:nav-fresh-visits`;function Ue(){let e=document.createElement(`span`);return e.className=`site-header-link-fresh`,e.setAttribute(`aria-hidden`,`true`),e.textContent=`*`,e}function We(e){try{let t=JSON.parse(localStorage.getItem(He)||`{}`),n=location.pathname,r=e.querySelectorAll(`[data-fresh-at]`);for(let e of r){let r=new URL(e.href).pathname,i=parseInt(e.dataset.freshAt,10);if(r===n)t[r]=Math.floor(Date.now()/1e3);else{let n=t[r];if(!n||n<i){let t=e.querySelector(`svg`);e.insertBefore(Ue(),t)}}}localStorage.setItem(He,JSON.stringify(t))}catch{}}function Ge(e=document){let t=e.querySelector(`.site-header-hamburger`),n=e.querySelector(`#site-nav-drawer`),r=e.querySelector(`.site-nav-drawer-backdrop`),i=n?.querySelector(`.site-nav-drawer-close`);if(We(e),Ve(e),!t||!n||!r||t.dataset.drawerInitialized===`true`)return;t.dataset.drawerInitialized=`true`;let a=new AbortController,o=a.signal;Be.set(t,a);function s(){n.setAttribute(`aria-hidden`,`false`),n.removeAttribute(`inert`),r.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.documentElement.classList.add(`drawer-open`);let e=n.querySelector(`.site-nav-drawer-close`)??n.querySelector(`a[href], button`);e&&e.focus()}function c(e=!0){n.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),document.documentElement.classList.remove(`drawer-open`),n.addEventListener(`transitionend`,()=>{n.getAttribute(`aria-hidden`)===`true`&&n.setAttribute(`inert`,``)},{once:!0,signal:o}),e&&t.focus()}t.addEventListener(`click`,()=>{t.getAttribute(`aria-expanded`)===`true`?c():s()},{signal:o}),i?.addEventListener(`click`,()=>c(),{signal:o}),r.addEventListener(`click`,()=>c(),{signal:o}),n.addEventListener(`click`,e=>{e.target instanceof Element&&e.target.closest(`a[href]`)&&c(!1)},{signal:o}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),c())},{signal:o})}Ge();var M=globalThis,N=M.ShadowRoot&&(M.ShadyCSS===void 0||M.ShadyCSS.nativeShadow)&&`adoptedStyleSheets`in Document.prototype&&`replace`in CSSStyleSheet.prototype,Ke=Symbol(),qe=new WeakMap,Je=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==Ke)throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(N&&e===void 0){let n=t!==void 0&&t.length===1;n&&(e=qe.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&qe.set(t,e))}return e}toString(){return this.cssText}},Ye=e=>new Je(typeof e==`string`?e:e+``,void 0,Ke),Xe=(e,t)=>{if(N)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let n of t){let t=document.createElement(`style`),r=M.litNonce;r!==void 0&&t.setAttribute(`nonce`,r),t.textContent=n.cssText,e.appendChild(t)}},Ze=N?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t=``;for(let n of e.cssRules)t+=n.cssText;return Ye(t)})(e):e,{is:Qe,defineProperty:$e,getOwnPropertyDescriptor:et,getOwnPropertyNames:tt,getOwnPropertySymbols:nt,getPrototypeOf:rt}=Object,P=globalThis,it=P.trustedTypes,at=it?it.emptyScript:``,ot=P.reactiveElementPolyfillSupport,F=(e,t)=>e,I={toAttribute(e,t){switch(t){case Boolean:e=e?at:null;break;case Object:case Array:e=e==null?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=e!==null;break;case Number:n=e===null?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch{n=null}}return n}},st=(e,t)=>!Qe(e,t),ct={attribute:!0,type:String,converter:I,reflect:!1,useDefault:!1,hasChanged:st};Symbol.metadata??=Symbol(`metadata`),P.litPropertyMetadata??=new WeakMap;var L=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=ct){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let n=Symbol(),r=this.getPropertyDescriptor(e,n,t);r!==void 0&&$e(this.prototype,e,r)}}static getPropertyDescriptor(e,t,n){let{get:r,set:i}=et(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:r,set(t){let a=r?.call(this);i?.call(this,t),this.requestUpdate(e,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??ct}static _$Ei(){if(this.hasOwnProperty(F(`elementProperties`)))return;let e=rt(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(F(`finalized`)))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(F(`properties`))){let e=this.properties,t=[...tt(e),...nt(e)];for(let n of t)this.createProperty(n,e[n])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let n=this._$Eu(e,t);n!==void 0&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let n=new Set(e.flat(1/0).reverse());for(let e of n)t.unshift(Ze(e))}else e!==void 0&&t.push(Ze(e));return t}static _$Eu(e,t){let n=t.attribute;return!1===n?void 0:typeof n==`string`?n:typeof e==`string`?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return Xe(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){let n=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,n);if(r!==void 0&&!0===n.reflect){let i=(n.converter?.toAttribute===void 0?I:n.converter).toAttribute(t,n.type);this._$Em=e,i==null?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(e,t){let n=this.constructor,r=n._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let e=n.getPropertyOptions(r),i=typeof e.converter==`function`?{fromAttribute:e.converter}:e.converter?.fromAttribute===void 0?I:e.converter;this._$Em=r;let a=i.fromAttribute(t,e.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(e,t,n,r=!1,i){if(e!==void 0){let a=this.constructor;if(!1===r&&(i=this[e]),n??=a.getPropertyOptions(e),!((n.hasChanged??st)(i,t)||n.useDefault&&n.reflect&&i===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,a??t??this[e]),!0!==i||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===r&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}let e=this.constructor.elementProperties;if(e.size>0)for(let[t,n]of e){let{wrapped:e}=n,r=this[t];!0!==e||this._$AL.has(t)||r===void 0||this.C(t,void 0,n,r)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};L.elementStyles=[],L.shadowRootOptions={mode:`open`},L[F(`elementProperties`)]=new Map,L[F(`finalized`)]=new Map,ot?.({ReactiveElement:L}),(P.reactiveElementVersions??=[]).push(`2.1.2`);var lt=globalThis,ut=e=>e,R=lt.trustedTypes,dt=R?R.createPolicy(`lit-html`,{createHTML:e=>e}):void 0,ft=`$lit$`,z=`lit$${Math.random().toFixed(9).slice(2)}$`,pt=`?`+z,mt=`<${pt}>`,B=document,V=()=>B.createComment(``),H=e=>e===null||typeof e!=`object`&&typeof e!=`function`,ht=Array.isArray,gt=e=>ht(e)||typeof e?.[Symbol.iterator]==`function`,_t=`[ \n\\f\\r]`,U=/<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g,vt=/-->/g,yt=/>/g,W=RegExp(`>|${_t}(?:([^\\\\s\"'>=/]+)(${_t}*=${_t}*(?:[^ \\t\\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`,`g`),bt=/'/g,xt=/\"/g,St=/^(?:script|style|textarea|title)$/i,Ct=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),G=Ct(1),K=Ct(2),q=Symbol.for(`lit-noChange`),J=Symbol.for(`lit-nothing`),wt=new WeakMap,Y=B.createTreeWalker(B,129);function Tt(e,t){if(!ht(e)||!e.hasOwnProperty(`raw`))throw Error(`invalid template strings array`);return dt===void 0?t:dt.createHTML(t)}var Et=(e,t)=>{let n=e.length-1,r=[],i,a=t===2?`<svg>`:t===3?`<math>`:``,o=U;for(let t=0;t<n;t++){let n=e[t],s,c,l=-1,u=0;for(;u<n.length&&(o.lastIndex=u,c=o.exec(n),c!==null);)u=o.lastIndex,o===U?c[1]===`!--`?o=vt:c[1]===void 0?c[2]===void 0?c[3]!==void 0&&(o=W):(St.test(c[2])&&(i=RegExp(`</`+c[2],`g`)),o=W):o=yt:o===W?c[0]===`>`?(o=i??U,l=-1):c[1]===void 0?l=-2:(l=o.lastIndex-c[2].length,s=c[1],o=c[3]===void 0?W:c[3]===`\"`?xt:bt):o===xt||o===bt?o=W:o===vt||o===yt?o=U:(o=W,i=void 0);let d=o===W&&e[t+1].startsWith(`/>`)?` `:``;a+=o===U?n+mt:l>=0?(r.push(s),n.slice(0,l)+ft+n.slice(l)+z+d):n+z+(l===-2?t:d)}return[Tt(e,a+(e[n]||`<?>`)+(t===2?`</svg>`:t===3?`</math>`:``)),r]},Dt=class e{constructor({strings:t,_$litType$:n},r){let i;this.parts=[];let a=0,o=0,s=t.length-1,c=this.parts,[l,u]=Et(t,n);if(this.el=e.createElement(l,r),Y.currentNode=this.el.content,n===2||n===3){let e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;(i=Y.nextNode())!==null&&c.length<s;){if(i.nodeType===1){if(i.hasAttributes())for(let e of i.getAttributeNames())if(e.endsWith(ft)){let t=u[o++],n=i.getAttribute(e).split(z),r=/([.?@])?(.*)/.exec(t);c.push({type:1,index:a,name:r[2],strings:n,ctor:r[1]===`.`?At:r[1]===`?`?jt:r[1]===`@`?Mt:Z}),i.removeAttribute(e)}else e.startsWith(z)&&(c.push({type:6,index:a}),i.removeAttribute(e));if(St.test(i.tagName)){let e=i.textContent.split(z),t=e.length-1;if(t>0){i.textContent=R?R.emptyScript:``;for(let n=0;n<t;n++)i.append(e[n],V()),Y.nextNode(),c.push({type:2,index:++a});i.append(e[t],V())}}}else if(i.nodeType===8)if(i.data===pt)c.push({type:2,index:a});else{let e=-1;for(;(e=i.data.indexOf(z,e+1))!==-1;)c.push({type:7,index:a}),e+=z.length-1}a++}}static createElement(e,t){let n=B.createElement(`template`);return n.innerHTML=e,n}};function X(e,t,n=e,r){if(t===q)return t;let i=r===void 0?n._$Cl:n._$Co?.[r],a=H(t)?void 0:t._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),a===void 0?i=void 0:(i=new a(e),i._$AT(e,n,r)),r===void 0?n._$Cl=i:(n._$Co??=[])[r]=i),i!==void 0&&(t=X(e,i._$AS(e,t.values),i,r)),t}var Ot=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:n}=this._$AD,r=(e?.creationScope??B).importNode(t,!0);Y.currentNode=r;let i=Y.nextNode(),a=0,o=0,s=n[0];for(;s!==void 0;){if(a===s.index){let t;s.type===2?t=new kt(i,i.nextSibling,this,e):s.type===1?t=new s.ctor(i,s.name,s.strings,this,e):s.type===6&&(t=new Nt(i,this,e)),this._$AV.push(t),s=n[++o]}a!==s?.index&&(i=Y.nextNode(),a++)}return Y.currentNode=B,r}p(e){let t=0;for(let n of this._$AV)n!==void 0&&(n.strings===void 0?n._$AI(e[t]):(n._$AI(e,n,t),t+=n.strings.length-2)),t++}},kt=class e{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,n,r){this.type=2,this._$AH=J,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=X(this,e,t),H(e)?e===J||e==null||e===``?(this._$AH!==J&&this._$AR(),this._$AH=J):e!==this._$AH&&e!==q&&this._(e):e._$litType$===void 0?e.nodeType===void 0?gt(e)?this.k(e):this._(e):this.T(e):this.$(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==J&&H(this._$AH)?this._$AA.nextSibling.data=e:this.T(B.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:n}=e,r=typeof n==`number`?this._$AC(e):(n.el===void 0&&(n.el=Dt.createElement(Tt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(t);else{let e=new Ot(r,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=wt.get(e.strings);return t===void 0&&wt.set(e.strings,t=new Dt(e)),t}k(t){ht(this._$AH)||(this._$AH=[],this._$AR());let n=this._$AH,r,i=0;for(let a of t)i===n.length?n.push(r=new e(this.O(V()),this.O(V()),this,this.options)):r=n[i],r._$AI(a),i++;i<n.length&&(this._$AR(r&&r._$AB.nextSibling,i),n.length=i)}_$AR(e=this._$AA.nextSibling,t){for(this._$AP?.(!1,!0,t);e!==this._$AB;){let t=ut(e).nextSibling;ut(e).remove(),e=t}}setConnected(e){this._$AM===void 0&&(this._$Cv=e,this._$AP?.(e))}},Z=class{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(e,t,n,r,i){this.type=1,this._$AH=J,this._$AN=void 0,this.element=e,this.name=t,this._$AM=r,this.options=i,n.length>2||n[0]!==``||n[1]!==``?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=J}_$AI(e,t=this,n,r){let i=this.strings,a=!1;if(i===void 0)e=X(this,e,t,0),a=!H(e)||e!==this._$AH&&e!==q,a&&(this._$AH=e);else{let r=e,o,s;for(e=i[0],o=0;o<i.length-1;o++)s=X(this,r[n+o],t,o),s===q&&(s=this._$AH[o]),a||=!H(s)||s!==this._$AH[o],s===J?e=J:e!==J&&(e+=(s??``)+i[o+1]),this._$AH[o]=s}a&&!r&&this.j(e)}j(e){e===J?this.element.removeAttribute(this.name):this.element.setAttribute(this.name,e??``)}},At=class extends Z{constructor(){super(...arguments),this.type=3}j(e){this.element[this.name]=e===J?void 0:e}},jt=class extends Z{constructor(){super(...arguments),this.type=4}j(e){this.element.toggleAttribute(this.name,!!e&&e!==J)}},Mt=class extends Z{constructor(e,t,n,r,i){super(e,t,n,r,i),this.type=5}_$AI(e,t=this){if((e=X(this,e,t,0)??J)===q)return;let n=this._$AH,r=e===J&&n!==J||e.capture!==n.capture||e.once!==n.once||e.passive!==n.passive,i=e!==J&&(n===J||r);r&&this.element.removeEventListener(this.name,this,n),i&&this.element.addEventListener(this.name,this,e),this._$AH=e}handleEvent(e){typeof this._$AH==`function`?this._$AH.call(this.options?.host??this.element,e):this._$AH.handleEvent(e)}},Nt=class{constructor(e,t,n){this.element=e,this.type=6,this._$AN=void 0,this._$AM=t,this.options=n}get _$AU(){return this._$AM._$AU}_$AI(e){X(this,e)}},Pt=lt.litHtmlPolyfillSupport;Pt?.(Dt,kt),(lt.litHtmlVersions??=[]).push(`3.3.2`);var Ft=(e,t,n)=>{let r=n?.renderBefore??t,i=r._$litPart$;if(i===void 0){let e=n?.renderBefore??null;r._$litPart$=i=new kt(t.insertBefore(V(),e),e,void 0,n??{})}return i._$AI(e),i},It=globalThis,Q=class extends L{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=Ft(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return q}};Q._$litElement$=!0,Q.finalized=!0,It.litElementHydrateSupport?.({LitElement:Q});var Lt=It.litElementPolyfillSupport;Lt?.({LitElement:Q}),(It.litElementVersions??=[]).push(`4.2.2`);function Rt(e){if(!e.mimeType?.startsWith(`video/`)||!Number.isFinite(e.durationSeconds)||!e.durationSeconds||e.durationSeconds<=0||e.durationSeconds>15)return!1;let t=e.size;return!(typeof t==`number`&&t>12582912)}var zt=640,Bt=8,Vt=72,Ht=16,Ut=704,Wt=.9,Gt=.85;function $(e){if(!(!Number.isFinite(e)||!e||e<=0))return e}function Kt(){return{width:globalThis.innerWidth||document.documentElement.clientWidth||0,height:globalThis.innerHeight||document.documentElement.clientHeight||0}}function qt(e,t){let n=e<=zt?Bt:Vt;return{width:Math.max(0,e-n*2),height:Math.max(0,t-Ht*2)}}function Jt(e,t,n){let r=$(e?.width),i=$(e?.height);if(!r||!i)return null;let a=qt(t,n);if(a.width<=0||a.height<=0)return null;let o=Math.min(a.width/r,a.height/i);return{width:Math.max(1,Math.round(r*o)),height:Math.max(1,Math.round(i*o))}}function Yt(e,t,n){if(!e||e.mimeType?.startsWith(`video/`)||!$(e.width)||!$(e.height)||t<=0||n<=0)return!1;let r=$(e.width),i=$(e.height);if(!r||!i)return!1;let a=t<=zt,o=qt(t,n),s=o.width,c=o.height;if(s<=0||c<=0)return!1;let l=r/i,u=Math.min(s,c*l);return l<Wt&&u<(a?s:Math.min(s,Ut))*Gt}var Xt=class extends Q{static properties={_images:{state:!0},_currentIndex:{state:!0},_open:{state:!0},_viewportWidth:{state:!0},_viewportHeight:{state:!0},_videoCurrentTime:{state:!0},_videoDuration:{state:!0},_videoMuted:{state:!0},_videoPaused:{state:!0},_imageZoomed:{state:!0}};createRenderRoot(){return this.innerHTML=``,this}constructor(){super();let e=Kt();this._images=[],this._currentIndex=0,this._open=!1,this._viewportWidth=e.width,this._viewportHeight=e.height,this._videoCurrentTime=0,this._videoDuration=0,this._videoMuted=!1,this._videoPaused=!1,this._imageZoomed=!1}connectedCallback(){super.connectedCallback(),document.addEventListener(`click`,this.#e),window.addEventListener(`resize`,this.#l),this.#u()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener(`click`,this.#e),window.removeEventListener(`resize`,this.#l)}open(e,t){this.#u(),this.#d(),this._images=e,this._currentIndex=Math.max(0,Math.min(t,e.length-1)),this.#m(this._images[this._currentIndex]),this._imageZoomed=!1,this._open=!0,document.dispatchEvent(new CustomEvent(h,{detail:{open:!0}})),this.updateComplete.then(()=>{this.querySelector(`.media-lightbox`)?.showModal(),this.#f()})}close(){this.#d(),this.querySelector(`.media-lightbox`)?.close(),this._open=!1,document.dispatchEvent(new CustomEvent(h,{detail:{open:!1}}))}#e=e=>{let t=e.target,n=t.closest(`[data-post-media] a[data-lightbox-index]`);if(n){let t=n.closest(`[data-lightbox-group]`);if(!t)return;e.preventDefault();let r=parseInt(n.dataset.lightboxIndex??`0`,10);try{let e=JSON.parse(t.dataset.lightboxGroup??`[]`);e.length>0&&this.open(e,r)}catch{}return}let r=t.closest(`[data-post-body] img`);if(r){e.preventDefault();let t=r.closest(`[data-post-body]`);if(!t)return;let n=Array.from(t.querySelectorAll(`img`)),i=n.map(e=>({url:e.src,alt:e.alt||``,width:$(e.naturalWidth||Number(e.getAttribute(`width`))),height:$(e.naturalHeight||Number(e.getAttribute(`height`)))})),a=n.indexOf(r);i.length>0&&this.open(i,Math.max(0,a))}};#t(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex-1+this._images.length)%this._images.length)}#n(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex+1)%this._images.length)}#r=e=>{let t=this._images[this._currentIndex];Yt(t,this._viewportWidth,this._viewportHeight)&&(e.stopPropagation(),this._imageZoomed=!this._imageZoomed)};#i=e=>{let t=e,n=e.target;if(t.key===`Escape`){e.preventDefault(),this.close();return}if(n instanceof HTMLInputElement||n instanceof HTMLButtonElement||n instanceof HTMLVideoElement)return;if(!this._images[this._currentIndex]?.mimeType?.startsWith(`video/`)){t.key===`ArrowLeft`?(e.preventDefault(),this.#t()):t.key===`ArrowRight`&&(e.preventDefault(),this.#n());return}let r=this.querySelector(`.media-lightbox-video`);r&&this.#a(t,r)};#a(e,t){let n=Number.isFinite(t.duration)&&t.duration>0?t.duration:null,r=e=>{let r=n==null?Math.max(0,e):Math.max(0,Math.min(e,n));t.currentTime=r,this._videoCurrentTime=r},i=e.key,a=i.toLowerCase();if(i===` `||a===`k`)e.preventDefault(),this.#x(t);else if(i===`ArrowLeft`)e.preventDefault(),r(t.currentTime-2);else if(i===`ArrowRight`)e.preventDefault(),r(t.currentTime+2);else if(i===`Home`)e.preventDefault(),r(0);else if(i===`End`)n!=null&&(e.preventDefault(),r(n));else if(i.length===1&&i>=`0`&&i<=`9`)n!=null&&(e.preventDefault(),r(Number(i)/10*n));else if(i===`ArrowUp`)e.preventDefault(),t.volume=Math.min(1,t.volume+.05);else if(i===`ArrowDown`)e.preventDefault(),t.volume=Math.max(0,t.volume-.05);else if(a===`m`){e.preventDefault();let n=!t.muted;t.muted=n,this._videoMuted=n}else a===`f`&&(e.preventDefault(),this.#o(t))}#o(e){let t=document,n=e;if(document.fullscreenElement??t.webkitFullscreenElement){document.exitFullscreen?document.exitFullscreen().catch(()=>{}):t.webkitExitFullscreen?.();return}e.requestFullscreen?e.requestFullscreen().catch(()=>{}):n.webkitRequestFullscreen?n.webkitRequestFullscreen():n.webkitEnterFullscreen&&n.webkitEnterFullscreen()}#s=e=>{let t=e.target;(t===e.currentTarget||t.classList.contains(`media-lightbox-content`)||t.classList.contains(`media-lightbox-stage`))&&this.close()};#c=()=>{this.#d(),this._open&&document.dispatchEvent(new CustomEvent(h,{detail:{open:!1}})),this._open=!1};#l=()=>{this.#u()};#u(){let e=Kt();e.width===this._viewportWidth&&e.height===this._viewportHeight||(this._viewportWidth=e.width,this._viewportHeight=e.height)}#d(){this.querySelector(`.media-lightbox-video`)?.pause()}#f(){this.querySelector(`.media-lightbox-content`)?.focus()}#p=()=>{this.querySelector(`.media-lightbox-content`)?.focus({preventScroll:!0})};#m(e){this._videoCurrentTime=0,this._videoDuration=e?.durationSeconds&&e.durationSeconds>0?e.durationSeconds:0,this._videoMuted=!1,this._videoPaused=_(e?.id)}#h(){let e=this._images[this._currentIndex];if(!Rt(e)){this.#m(e);return}let t=this.querySelector(`.media-lightbox-video`);if(t){if(t.currentTime=0,t.muted=this._videoMuted,this._videoPaused=_(e.id),this._videoPaused){t.pause();return}t.play().catch(()=>{this._videoPaused=!0})}}#g=e=>{let t=e.currentTarget;Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration),this._videoCurrentTime=t.currentTime,t.muted=this._videoMuted};#_=e=>{let t=e.currentTarget;this._videoCurrentTime=t.currentTime,Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration)};#v=()=>{this._videoPaused=!1};#y=()=>{this._videoPaused=!0};#b(e){let t=this._images[this._currentIndex],n=t?.id?.trim();!n||!Rt(t)||ue(n,e)}#x(e){if(e.paused){this._videoPaused=!1,this.#b(!1),e.play().catch(()=>{this._videoPaused=!0,this.#b(!0)});return}e.pause(),this._videoPaused=!0,this.#b(!0)}#S=()=>{let e=this.querySelector(`.media-lightbox-video`);e&&this.#x(e)};#C=e=>{let t=e.currentTarget,n=this.querySelector(`.media-lightbox-video`),r=Number.parseFloat(t.value);!n||!Number.isFinite(r)||r<0||(n.currentTime=r,this._videoCurrentTime=r)};#w=()=>{this._videoMuted=!this._videoMuted;let e=this.querySelector(`.media-lightbox-video`);e&&(e.muted=this._videoMuted)};updated(e){if(super.updated(e),!this._open||!e.has(`_currentIndex`)&&!e.has(`_open`)&&!e.has(`_imageZoomed`))return;let t=this.querySelector(`.media-lightbox-stage`);t&&(t.scrollTop=0,t.scrollLeft=0,(e.has(`_currentIndex`)||e.has(`_open`))&&(this.#h(),this.#f()))}render(){if(!this._open)return J;let e=this._images[this._currentIndex],t=this._images.length>1,n=e?.mimeType?.startsWith(`video/`),r=Rt(e),i=Yt(e,this._viewportWidth,this._viewportHeight),a=i&&this._imageZoomed,o=r?Jt(e,this._viewportWidth,this._viewportHeight):null,s=r&&!!o&&o.height>o.width,c=o?`--media-lightbox-short-width:${o.width}px;--media-lightbox-short-height:${o.height}px;`:J,l=this._videoDuration>0?this._videoDuration:e?.durationSeconds??1,u=Math.min(this._videoCurrentTime,l),d=l>0?u/l*100:0;return G`\n <dialog\n class=${`media-lightbox${r?` media-lightbox-short`:``}`}\n @keydown=${this.#i}\n @click=${this.#s}\n @close=${this.#c}\n >\n <div class=\"media-lightbox-content\" tabindex=\"-1\">\n <button\n type=\"button\"\n class=\"media-lightbox-close\"\n @click=${()=>this.close()}\n aria-label=\"Close\"\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </button>\n\n ${t?G`<div class=\"media-lightbox-counter\">\n ${this._currentIndex+1} / ${this._images.length}\n </div>`:J}\n <div\n class=${`media-lightbox-stage${a?` media-lightbox-stage-scroll`:``}`}\n >\n ${n?r?G`<div\n class=${`media-lightbox-short-frame${o?` media-lightbox-short-frame-contained`:``}${s?` media-lightbox-short-frame-portrait`:` media-lightbox-short-frame-landscape`}`}\n style=${c}\n >\n <div class=\"media-lightbox-short-viewport\">\n <video\n class=\"media-lightbox-video media-lightbox-video-short\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n ?autoplay=${!this._videoPaused}\n playsinline\n loop\n ?muted=${this._videoMuted}\n @click=${this.#S}\n @focus=${this.#p}\n @loadedmetadata=${this.#g}\n @timeupdate=${this.#_}\n @play=${this.#v}\n @pause=${this.#y}\n ></video>\n </div>\n <div\n class=${`media-lightbox-short-controls${s?` media-lightbox-short-controls-portrait`:``}`}\n >\n <button\n type=\"button\"\n class=\"media-lightbox-short-playback\"\n @click=${this.#S}\n aria-label=${this._videoPaused?`Play video`:`Pause video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n aria-hidden=\"true\"\n >\n ${this._videoPaused?K`<path d=\"M8 5v14l11-7z\" />`:K`<path d=\"M6 5h4v14H6zM14 5h4v14h-4z\" />`}\n </svg>\n </button>\n <input\n class=\"media-lightbox-short-progress\"\n type=\"range\"\n min=\"0\"\n max=${l}\n step=\"0.01\"\n .value=${String(u)}\n style=${`--media-progress:${d}%`}\n aria-label=\"Video progress\"\n @input=${this.#C}\n />\n <button\n type=\"button\"\n class=\"media-lightbox-short-mute\"\n @click=${this.#w}\n aria-label=${this._videoMuted?`Unmute video`:`Mute video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 48 48\"\n fill=\"currentColor\"\n color=\"#fff\"\n aria-hidden=\"true\"\n >\n ${this._videoMuted?K`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z\" />\n `:K`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1V1.4c0-1.3-1.6-1.9-2.5-1L10.2 13.3H1.5z\" />\n <path d=\"M30.1 15.9c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 4.1 3.5 4.1 11.7s-4.1 11.7-4.1 11.7c-.6.6-1.6.6-2.2 0l-1.4-1.4c-.6-.6-.6-1.6 0-2.2 0 0 2.6-2 2.6-8.1s-2.6-8.1-2.6-8.1z\" />\n <path d=\"M37.2 8c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 5.7 5.1 5.7 19.6s-5.7 19.6-5.7 19.6c-.6.6-1.6.6-2.2 0L37.2 42c-.6-.6-.6-1.6 0-2.2 0 0 4.3-4.4 4.3-15.8S37.2 8 37.2 8z\" />\n `}\n </svg>\n </button>\n </div>\n </div>`:G`<video\n class=\"media-lightbox-video\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n controls\n autoplay\n playsinline\n @focus=${this.#p}\n ></video>`:G`<img\n class=${`media-lightbox-img${i?` media-lightbox-img-zoomable`:``}${a?` media-lightbox-img-scroll`:``}`}\n src=${e?.url??``}\n alt=${e?.alt??``}\n @click=${this.#r}\n />`}\n </div>\n ${t?G`\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-prev\"\n @click=${()=>this.#t()}\n aria-label=\"Previous\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m15 18-6-6 6-6\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-next\"\n @click=${()=>this.#n()}\n aria-label=\"Next\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m9 18 6-6-6-6\" />\n </svg>\n </button>\n `:J}\n </div>\n </dialog>\n `}};customElements.define(`jant-media-lightbox`,Xt);";
|
|
5930
|
+
var client_site_default$1 = "var e=null,t=0,n=!1,r=null,i=null,a=0;function o(e){return!isFinite(e)||e<0?`0:00`:`${Math.floor(e/60)}:${String(Math.floor(e%60)).padStart(2,`0`)}`}function s(e){return e.querySelector(`audio.media-audio-el`)}function c(e){return e.querySelector(`[data-audio-range]`)}function l(e){return e.querySelector(`[data-audio-waveform]`)}function u(e,t){let n=`${(t*100).toFixed(1)}%`;e.style.background=`linear-gradient(to right, var(--site-text-primary) ${n}, transparent ${n})`}var d=new WeakMap,f=new WeakSet;async function p(e,t){let n=await(await fetch(e)).arrayBuffer(),r=new AudioContext;try{let e=(await r.decodeAudioData(n)).getChannelData(0),i=Math.max(1,Math.floor(e.length/t)),a=Array(t);for(let n=0;n<t;n++){let t=0,r=n*i,o=Math.min(r+i,e.length);for(let n=r;n<o;n++){let r=Math.abs(e[n]);r>t&&(t=r)}a[n]=t}let o=0;for(let e of a)e>o&&(o=e);if(o>0)for(let e=0;e<t;e++)a[e]/=o;return a}finally{await r.close()}}function ee(e=document){let t=e.querySelectorAll(`[data-audio-peaks]`),n=[];for(let e of t){let t=e.dataset.audioPeaks;if(!t)continue;let r=e.closest(`.media-audio-card`);if(r&&!d.has(r))try{let e=JSON.parse(t);if(!Array.isArray(e))continue;d.set(r,e),r.classList.add(`has-waveform`),n.push(r)}catch{}}n.length>0&&requestAnimationFrame(()=>{for(let e of n)h(e,0)})}async function m(e){if(d.has(e)||f.has(e))return;f.add(e);let t=e.querySelector(`audio.media-audio-el source`),n=l(e);if(!t?.src||!n)return;let r=n.getBoundingClientRect().width,i=Math.max(20,Math.floor(r/3));try{let n=await p(t.src,i);d.set(e,n),e.classList.add(`has-waveform`);let r=s(e),a=r?.duration??0;h(e,r&&isFinite(a)&&a>0?r.currentTime/a:0)}catch{}}function h(e,t){let n=d.get(e),r=l(e);if(!n||!r)return;let i=window.devicePixelRatio||1,a=r.getBoundingClientRect(),o=Math.round(a.width*i),s=Math.round(a.height*i);if(o===0||s===0)return;(r.width!==o||r.height!==s)&&(r.width=o,r.height=s);let c=r.getContext(`2d`);if(!c)return;c.clearRect(0,0,o,s);let u=n.length,f=o/u,p=Math.max(1,Math.round(f*.6)),ee=Math.round(2*i),m=s*.85,h=getComputedStyle(r).getPropertyValue(`--site-text-primary`).trim()||`#000`;for(let e=0;e<u;e++){let r=Math.round(e*f+(f-p)/2),a=Math.max(ee,Math.round(n[e]*m)),o=Math.round((s-a)/2);c.globalAlpha=(e+.5)/u<=t?.9:.2,c.fillStyle=h;let l=Math.min(p/2,i);c.beginPath(),c.roundRect(r,o,p,a,l),c.fill()}c.globalAlpha=1}function te(e,t){let{currentTime:r,duration:i}=t,a=isFinite(i)&&i>0,s=a?r/i:0;if(!n){let t=c(e);t&&a&&(t.value=String(Math.round(s*1e3)),u(t,s)),d.has(e)&&h(e,s);let n=e.querySelector(`[data-audio-time]`);n&&(n.textContent=a?`${o(r)} / ${o(i)}`:o(r))}}function ne(){if(!e)return;let n=s(e);n&&!n.paused&&(te(e,n),t=requestAnimationFrame(ne))}function re(){if(e){let t=s(e);t&&!t.paused&&t.pause(),e.classList.remove(`is-playing`),e=null}cancelAnimationFrame(t)}async function ie(n){let r=s(n);if(r){if(e&&e!==n&&re(),r.paused){e=n,n.classList.add(`is-playing`);try{await r.play()}catch{n.classList.remove(`is-playing`),e=null;return}t=requestAnimationFrame(ne),m(n)}else r.pause(),n.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null}}function ae(e){let t=e.closest(`.media-audio-card`);if(!t)return;let n=s(t);if(!n)return;let r=Number(e.value)/1e3,i=n.duration;isFinite(i)&&i>0&&(n.currentTime=r*i)}function oe(e,t){let n=e.getBoundingClientRect();a=Math.max(0,Math.min(1,(t.clientX-n.left)/n.width));let r=e.closest(`.media-audio-card`);if(!r)return;h(r,a);let i=s(r),c=r.querySelector(`[data-audio-time]`);if(i&&c){let e=i.duration;isFinite(e)&&e>0&&(c.textContent=`${o(a*e)} / ${o(e)}`)}}async function se(n){let r=n.closest(`.media-audio-card`);if(!r)return;let i=s(r);if(i){if(i.paused){e&&e!==r&&re(),e=r,r.classList.add(`is-playing`);try{await i.play()}catch{r.classList.remove(`is-playing`),e=null;return}let n=i.duration;isFinite(n)&&n>0&&(i.currentTime=a*n),t=requestAnimationFrame(ne),m(r)}else{let e=i.duration;isFinite(e)&&e>0&&(i.currentTime=a*e)}}}document.addEventListener(`pointerdown`,e=>{let t=e.target;t.matches(`[data-audio-range]`)?(n=!0,r=t):t.matches(`[data-audio-waveform]`)&&(n=!0,i=t,oe(t,e))},!0),document.addEventListener(`pointermove`,e=>{n&&i&&oe(i,e)},!0),document.addEventListener(`pointerup`,()=>{n&&(r?(ae(r),r=null):i&&=(se(i),null)),n=!1},!0),document.addEventListener(`pointercancel`,()=>{r=null,i=null,n=!1},!0),document.addEventListener(`click`,e=>{let t=e.target.closest(`[data-audio-play]`);if(!t)return;e.preventDefault();let n=t.closest(`.media-audio-card`);n&&ie(n)}),document.addEventListener(`input`,e=>{let t=e.target;if(!t.matches(`[data-audio-range]`))return;let n=t.closest(`.media-audio-card`);if(!n)return;let r=s(n);if(!r)return;let i=Number(t.value)/1e3;u(t,i);let a=r.duration,c=n.querySelector(`[data-audio-time]`);c&&isFinite(a)&&a>0&&(c.textContent=`${o(i*a)} / ${o(a)}`)},!0),document.addEventListener(`ended`,n=>{let r=n.target;if(!r.closest)return;let i=r.closest(`.media-audio-card`);if(!i)return;i.classList.remove(`is-playing`),cancelAnimationFrame(t),e=null;let a=c(i);a&&(a.value=`0`,u(a,0)),d.has(i)&&h(i,0);let o=i.querySelector(`[data-audio-time]`);o&&(o.textContent=`0:00`)},!0),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>ee()):ee();var g=`jant:media-lightbox-toggle`,ce=`jant:media-video-playback-intent`,le=new Set;function ue(e){let t=e?.trim();return!!t&&le.has(t)}function de(e,t){let n=e?.trim();n&&(t?le.add(n):le.delete(n),document.dispatchEvent(new CustomEvent(ce,{detail:{mediaId:n,paused:t}})))}var fe=`75% 0px`,pe=.6,_=.25,me=160,v=new Set,he=new WeakMap,ge=new WeakSet,y=null,b=null,x=null,_e=!1,S=null,C=null,w=null;function ve(e){return e.length===0?null:[...e].sort((e,t)=>t.visibleArea===e.visibleArea?e.centerDistance===t.centerDistance?t.intersectionRatio-e.intersectionRatio:e.centerDistance-t.centerDistance:t.visibleArea-e.visibleArea)[0]??null}function ye(){return{x:(globalThis.innerWidth||document.documentElement.clientWidth||0)/2,y:(globalThis.innerHeight||document.documentElement.clientHeight||0)/2}}function be(e){return he.get(e)}function xe(e){return ue(e)}function T(e){return xe(e.dataset.feedVideoId)}function Se(e){return e.closest(`.media-video-wrap`)?.querySelector(`[data-feed-video-mute-toggle]`)??null}function E(e){let t=Se(e);if(!t)return;let n=x!==e||e.muted;t.dataset.muted=n?`true`:`false`,t.setAttribute(`aria-label`,n?`Play with sound`:`Mute video`)}function Ce(){for(let e of v)E(e)}function we(e){if(ge.has(e))return;let t=e.dataset.videoSrc;t&&(e.getAttribute(`src`)!==t&&(e.src=t),e.load(),ge.add(e))}function D(e){e?.pause()}function Te(e){if(T(e)){D(e);return}we(e),e.muted=x!==e,e.playsInline=!0,e.loop=!0,E(e),e.play().catch(()=>{})}function Ee(){for(let e of v)e.isConnected||(C?.unobserve(e),w?.unobserve(e),v.delete(e),e===y&&(y=null),e===b&&(b=null),e===x&&(x=null))}function De(){if(S=null,Ee(),document.hidden||_e){D(y);return}let e=[];for(let t of v){let n=be(t);n&&(T(t)||n.intersectionRatio<pe||e.push({video:t,...n}))}let t=null;if(b?.isConnected&&!T(b)){let e=be(b);e&&e.intersectionRatio>_?t=b:(!e||e.intersectionRatio<=_)&&(b=null)}else b&&T(b)&&(b=null);if(t||=ve(e)?.video??null,!t){let e=y?be(y):void 0;if(y&&!T(y)&&e&&e.intersectionRatio>_)return;D(y),y=null;return}t!==y&&(D(y),y=t),Te(t);for(let e of v)e!==t&&(D(e),E(e))}function O(){S!==null&&globalThis.clearTimeout(S),S=globalThis.setTimeout(De,me)}function Oe(e){let t=ye();for(let n of e){let e=n.target,r=n.boundingClientRect,i=r.left+r.width/2,a=r.top+r.height/2,o=n.intersectionRect.width*n.intersectionRect.height,s=Math.hypot(i-t.x,a-t.y);he.set(e,{intersectionRatio:n.intersectionRatio,visibleArea:o,centerDistance:s})}O()}function ke(e){for(let t of e)t.isIntersecting&&we(t.target)}function Ae(){C&&w||globalThis.IntersectionObserver!==void 0&&(C=new globalThis.IntersectionObserver(Oe,{threshold:[0,_,pe,1]}),w=new globalThis.IntersectionObserver(ke,{rootMargin:fe,threshold:0}))}function je(e){v.has(e)||(Ae(),C&&w&&(v.add(e),C.observe(e),w.observe(e),Se(e)?.addEventListener(`click`,Me),E(e)))}function Me(e){e.preventDefault(),e.stopPropagation();let t=e.currentTarget.closest(`.media-video-wrap`)?.querySelector(`[data-feed-short-video]`);if(!t)return;let n=t.dataset.feedVideoId?.trim();n&&de(n,!1),b=t,x!==t||t.muted?(x&&x!==t&&(x.muted=!0),x=t,y!==t&&(D(y),y=t),Te(t)):(x=null,t.muted=!0,E(t)),Ce(),O()}function Ne(e=document){let t=e.querySelectorAll(`[data-feed-short-video]`);for(let e of t)je(e);O()}document.addEventListener(g,e=>{_e=e.detail?.open===!0,_e&&D(y),O()}),document.addEventListener(ce,e=>{let t=e.detail,n=t?.mediaId?.trim();if(n&&typeof t.paused==`boolean`){for(let e of v)e.dataset.feedVideoId?.trim()===n&&t.paused&&(D(e),y===e&&(y=null),b===e&&(b=null));O()}}),document.addEventListener(`visibilitychange`,()=>{document.hidden&&D(y),O()}),document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>Ne(),{once:!0}):queueMicrotask(()=>Ne());var Pe=4;function Fe(e){return e.querySelector(`[data-post-media]`)}function Ie(e){let t=Fe(e);if(!t)return;let{scrollLeft:n,scrollWidth:r,clientWidth:i}=t;e.classList.toggle(`can-scroll-start`,n>Pe),e.classList.toggle(`can-scroll-end`,n+i<r-Pe)}function Le(e){return Math.max(160,Math.round(e.clientWidth*.85))}function k(e,t){e.scrollBy({left:t*Le(e),behavior:`smooth`})}function Re(e,t){if(!(`ResizeObserver`in globalThis))return;let n=0,r=()=>{cancelAnimationFrame(n),n=requestAnimationFrame(()=>Ie(e))},i=new globalThis.ResizeObserver(r),a=()=>{i.disconnect(),i.observe(t);for(let e of t.children)i.observe(e)};a(),new globalThis.MutationObserver(()=>{a(),r()}).observe(t,{childList:!0})}function A(e){if(e.dataset.scrollHintReady===`1`)return;let t=Fe(e);t&&(e.dataset.scrollHintReady=`1`,Ie(e),t.addEventListener(`scroll`,()=>Ie(e),{passive:!0}),Re(e,t),e.querySelector(`.media-gallery-nav-prev`)?.addEventListener(`click`,()=>k(t,-1)),e.querySelector(`.media-gallery-nav-next`)?.addEventListener(`click`,()=>k(t,1)),t.addEventListener(`keydown`,e=>{if(e.target===t)switch(e.key){case`ArrowRight`:e.preventDefault(),k(t,1);break;case`ArrowLeft`:e.preventDefault(),k(t,-1);break;case`Home`:e.preventDefault(),t.scrollTo({left:0,behavior:`smooth`});break;case`End`:e.preventDefault(),t.scrollTo({left:t.scrollWidth,behavior:`smooth`})}}))}function ze(){document.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(A)}var Be=new globalThis.MutationObserver(e=>{for(let t of e)for(let e of t.addedNodes)e instanceof HTMLElement&&(e.matches(`.media-gallery-scroll-wrap`)&&A(e),e.querySelectorAll(`.media-gallery-scroll-wrap`).forEach(A))});document.readyState===`loading`?document.addEventListener(`DOMContentLoaded`,()=>{ze(),Be.observe(document.body,{childList:!0,subtree:!0})}):(ze(),Be.observe(document.body,{childList:!0,subtree:!0}));var Ve=new WeakMap,He=new WeakMap,Ue=[{trigger:`.site-header-more-btn`,popover:`.site-header-more-popover`},{trigger:`.site-header-lang-btn`,popover:`.site-header-lang-popover`}];function We(e,t,n){let r=e.querySelector(t),i=e.querySelector(n);if(!r||!i||r.dataset.moreInitialized===`true`)return;r.dataset.moreInitialized=`true`;let a=new AbortController,o=a.signal;Ve.set(r,a);function s(){i.setAttribute(`aria-hidden`,`false`),r.setAttribute(`aria-expanded`,`true`),document.dispatchEvent(new CustomEvent(`basecoat:popover`,{detail:{source:r.parentElement}}))}function c(e=!1){i.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-expanded`,`false`),e&&r.focus()}r.addEventListener(`click`,e=>{e.preventDefault(),e.stopPropagation(),r.getAttribute(`aria-expanded`)===`true`?c():s()},{signal:o}),document.addEventListener(`click`,e=>{e.target instanceof Node&&(r.parentElement?.contains(e.target)||c())},{signal:o}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&i.getAttribute(`aria-hidden`)===`false`&&c(!0)},{signal:o}),document.addEventListener(`basecoat:popover`,e=>{e.detail?.source!==r.parentElement&&c()},{signal:o})}var Ge=`jant:nav-fresh-visits`;function Ke(){let e=document.createElement(`span`);return e.className=`site-header-link-fresh`,e.setAttribute(`aria-hidden`,`true`),e.textContent=`*`,e}function qe(e){try{let t=JSON.parse(localStorage.getItem(Ge)||`{}`),n=location.pathname,r=e.querySelectorAll(`[data-fresh-at]`);for(let e of r){let r=new URL(e.href).pathname,i=parseInt(e.dataset.freshAt,10);if(r===n)t[r]=Math.floor(Date.now()/1e3);else{let n=t[r];if(!n||n<i){let t=e.querySelector(`svg`);e.insertBefore(Ke(),t)}}}localStorage.setItem(Ge,JSON.stringify(t))}catch{}}function Je(e=document){let t=e.querySelector(`.site-header-hamburger`),n=e.querySelector(`#site-nav-drawer`),r=e.querySelector(`.site-nav-drawer-backdrop`),i=n?.querySelector(`.site-nav-drawer-close`);qe(e);for(let{trigger:t,popover:n}of Ue)We(e,t,n);if(!t||!n||!r||t.dataset.drawerInitialized===`true`)return;t.dataset.drawerInitialized=`true`;let a=new AbortController,o=a.signal;He.set(t,a);function s(){n.setAttribute(`aria-hidden`,`false`),n.removeAttribute(`inert`),r.setAttribute(`aria-hidden`,`false`),t.setAttribute(`aria-expanded`,`true`),document.documentElement.classList.add(`drawer-open`);let e=n.querySelector(`.site-nav-drawer-close`)??n.querySelector(`a[href], button`);e&&e.focus()}function c(e=!0){n.setAttribute(`aria-hidden`,`true`),r.setAttribute(`aria-hidden`,`true`),t.setAttribute(`aria-expanded`,`false`),document.documentElement.classList.remove(`drawer-open`),n.addEventListener(`transitionend`,()=>{n.getAttribute(`aria-hidden`)===`true`&&n.setAttribute(`inert`,``)},{once:!0,signal:o}),e&&t.focus()}t.addEventListener(`click`,()=>{t.getAttribute(`aria-expanded`)===`true`?c():s()},{signal:o}),i?.addEventListener(`click`,()=>c(),{signal:o}),r.addEventListener(`click`,()=>c(),{signal:o}),n.addEventListener(`click`,e=>{e.target instanceof Element&&e.target.closest(`a[href]`)&&c(!1)},{signal:o}),n.addEventListener(`keydown`,e=>{e.key===`Escape`&&(e.preventDefault(),c())},{signal:o})}Je();var Ye=8;function Xe(e,t){let n=Number.parseFloat(e);return Number.isFinite(n)?n:t}function Ze(e){return Xe(getComputedStyle(e).getPropertyValue(`--site-thread-context-max-height`).trim(),240)}function Qe(e){if(e.dataset.threadContextToggleBound===`1`)return;e.dataset.threadContextToggleBound=`1`;let t=e.previousElementSibling;if(!(t instanceof HTMLElement)||t.dataset.threadContext===void 0)return;let n=e.querySelector(`.thread-context-toggle-label`),r=e.dataset.labelMore??`Show more`,i=e.dataset.labelLess??`Show less`,a=!1,o=t=>{e.setAttribute(`aria-expanded`,t?`true`:`false`),n&&(n.textContent=t?i:r)},s=()=>Array.from(t.querySelectorAll(`img`)).every(e=>e.complete),c=()=>{if(a)return;let n=Ze(t),r=t.scrollHeight>n+Ye;t.dataset.collapsed===void 0&&(t.dataset.collapsed=``),r?(e.hidden=!1,o(!1)):s()&&(e.hidden=!0)};if(c(),t.querySelectorAll(`img`).forEach(e=>{e.complete||(e.addEventListener(`load`,c,{once:!0}),e.addEventListener(`error`,c,{once:!0}))}),`ResizeObserver`in globalThis){let e=0;new globalThis.ResizeObserver(()=>{cancelAnimationFrame(e),e=requestAnimationFrame(c)}).observe(t)}let l=()=>{let e=!1,n=r=>{e||r&&r.propertyName!==`max-height`||(e=!0,t.removeEventListener(`transitionend`,n),t.dataset.collapsed===void 0&&(t.style.maxHeight=``))};t.addEventListener(`transitionend`,n),window.setTimeout(n,600)};e.addEventListener(`click`,()=>{a=!0,t.dataset.collapsed===void 0?(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,requestAnimationFrame(()=>{t.dataset.collapsed=``,t.style.maxHeight=``}),o(!1)):(t.style.maxHeight=`${t.scrollHeight}px`,t.offsetHeight,delete t.dataset.collapsed,o(!0)),l()})}function $e(e=document){e.querySelectorAll(`[data-thread-context-toggle]`).forEach(Qe)}function et(e){let t=e.closest(`.thread-group-detail`);return t?t.querySelector(`.thread-detail-item`)===e:!1}function tt(){return globalThis.location.hash===`#continue`}function nt(e=document){let t=e.querySelector(`[data-post-current]`);if(!(t instanceof HTMLElement))return;let n=tt();if(globalThis.location.hash&&!n)return;let r=n?`auto`:`smooth`,i=et(t);requestAnimationFrame(()=>{(!i||n)&&t.scrollIntoView({behavior:r,block:`start`})})}document.addEventListener(`DOMContentLoaded`,()=>{$e(document),nt(document)});var j=globalThis,M=j.ShadowRoot&&(j.ShadyCSS===void 0||j.ShadyCSS.nativeShadow)&&`adoptedStyleSheets`in Document.prototype&&`replace`in CSSStyleSheet.prototype,rt=Symbol(),it=new WeakMap,at=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==rt)throw Error(\"CSSResult is not constructable. Use `unsafeCSS` or `css` instead.\");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(M&&e===void 0){let n=t!==void 0&&t.length===1;n&&(e=it.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&it.set(t,e))}return e}toString(){return this.cssText}},ot=e=>new at(typeof e==`string`?e:e+``,void 0,rt),st=(e,t)=>{if(M)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let n of t){let t=document.createElement(`style`),r=j.litNonce;r!==void 0&&t.setAttribute(`nonce`,r),t.textContent=n.cssText,e.appendChild(t)}},ct=M?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t=``;for(let n of e.cssRules)t+=n.cssText;return ot(t)})(e):e,{is:lt,defineProperty:ut,getOwnPropertyDescriptor:dt,getOwnPropertyNames:ft,getOwnPropertySymbols:pt,getPrototypeOf:mt}=Object,N=globalThis,ht=N.trustedTypes,gt=ht?ht.emptyScript:``,_t=N.reactiveElementPolyfillSupport,P=(e,t)=>e,F={toAttribute(e,t){switch(t){case Boolean:e=e?gt:null;break;case Object:case Array:e=e==null?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=e!==null;break;case Number:n=e===null?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch{n=null}}return n}},vt=(e,t)=>!lt(e,t),yt={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:vt};Symbol.metadata??=Symbol(`metadata`),N.litPropertyMetadata??=new WeakMap;var I=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=yt){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let n=Symbol(),r=this.getPropertyDescriptor(e,n,t);r!==void 0&&ut(this.prototype,e,r)}}static getPropertyDescriptor(e,t,n){let{get:r,set:i}=dt(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:r,set(t){let a=r?.call(this);i?.call(this,t),this.requestUpdate(e,a,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??yt}static _$Ei(){if(this.hasOwnProperty(P(`elementProperties`)))return;let e=mt(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(P(`finalized`)))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(P(`properties`))){let e=this.properties,t=[...ft(e),...pt(e)];for(let n of t)this.createProperty(n,e[n])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let n=this._$Eu(e,t);n!==void 0&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let n=new Set(e.flat(1/0).reverse());for(let e of n)t.unshift(ct(e))}else e!==void 0&&t.push(ct(e));return t}static _$Eu(e,t){let n=t.attribute;return!1===n?void 0:typeof n==`string`?n:typeof e==`string`?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return st(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){let n=this.constructor.elementProperties.get(e),r=this.constructor._$Eu(e,n);if(r!==void 0&&!0===n.reflect){let i=(n.converter?.toAttribute===void 0?F:n.converter).toAttribute(t,n.type);this._$Em=e,i==null?this.removeAttribute(r):this.setAttribute(r,i),this._$Em=null}}_$AK(e,t){let n=this.constructor,r=n._$Eh.get(e);if(r!==void 0&&this._$Em!==r){let e=n.getPropertyOptions(r),i=typeof e.converter==`function`?{fromAttribute:e.converter}:e.converter?.fromAttribute===void 0?F:e.converter;this._$Em=r;let a=i.fromAttribute(t,e.type);this[r]=a??this._$Ej?.get(r)??a,this._$Em=null}}requestUpdate(e,t,n,r=!1,i){if(e!==void 0){let a=this.constructor;if(!1===r&&(i=this[e]),n??=a.getPropertyOptions(e),!((n.hasChanged??vt)(i,t)||n.useDefault&&n.reflect&&i===this._$Ej?.get(e)&&!this.hasAttribute(a._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:r,wrapped:i},a){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,a??t??this[e]),!0!==i||a!==void 0)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===r&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}let e=this.constructor.elementProperties;if(e.size>0)for(let[t,n]of e){let{wrapped:e}=n,r=this[t];!0!==e||this._$AL.has(t)||r===void 0||this.C(t,void 0,n,r)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};I.elementStyles=[],I.shadowRootOptions={mode:`open`},I[P(`elementProperties`)]=new Map,I[P(`finalized`)]=new Map,_t?.({ReactiveElement:I}),(N.reactiveElementVersions??=[]).push(`2.1.2`);var bt=globalThis,xt=e=>e,L=bt.trustedTypes,St=L?L.createPolicy(`lit-html`,{createHTML:e=>e}):void 0,Ct=`$lit$`,R=`lit$${Math.random().toFixed(9).slice(2)}$`,wt=`?`+R,Tt=`<${wt}>`,z=document,B=()=>z.createComment(``),V=e=>e===null||typeof e!=`object`&&typeof e!=`function`,Et=Array.isArray,Dt=e=>Et(e)||typeof e?.[Symbol.iterator]==`function`,Ot=`[ \n\\f\\r]`,H=/<(?:(!--|\\/[^a-zA-Z])|(\\/?[a-zA-Z][^>\\s]*)|(\\/?$))/g,kt=/-->/g,At=/>/g,U=RegExp(`>|${Ot}(?:([^\\\\s\"'>=/]+)(${Ot}*=${Ot}*(?:[^ \\t\\n\\f\\r\"'\\`<>=]|(\"|')|))|$)`,`g`),jt=/'/g,Mt=/\"/g,Nt=/^(?:script|style|textarea|title)$/i,Pt=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),W=Pt(1),G=Pt(2),K=Symbol.for(`lit-noChange`),q=Symbol.for(`lit-nothing`),Ft=new WeakMap,J=z.createTreeWalker(z,129);function It(e,t){if(!Et(e)||!e.hasOwnProperty(`raw`))throw Error(`invalid template strings array`);return St===void 0?t:St.createHTML(t)}var Lt=(e,t)=>{let n=e.length-1,r=[],i,a=t===2?`<svg>`:t===3?`<math>`:``,o=H;for(let t=0;t<n;t++){let n=e[t],s,c,l=-1,u=0;for(;u<n.length&&(o.lastIndex=u,c=o.exec(n),c!==null);)u=o.lastIndex,o===H?c[1]===`!--`?o=kt:c[1]===void 0?c[2]===void 0?c[3]!==void 0&&(o=U):(Nt.test(c[2])&&(i=RegExp(`</`+c[2],`g`)),o=U):o=At:o===U?c[0]===`>`?(o=i??H,l=-1):c[1]===void 0?l=-2:(l=o.lastIndex-c[2].length,s=c[1],o=c[3]===void 0?U:c[3]===`\"`?Mt:jt):o===Mt||o===jt?o=U:o===kt||o===At?o=H:(o=U,i=void 0);let d=o===U&&e[t+1].startsWith(`/>`)?` `:``;a+=o===H?n+Tt:l>=0?(r.push(s),n.slice(0,l)+Ct+n.slice(l)+R+d):n+R+(l===-2?t:d)}return[It(e,a+(e[n]||`<?>`)+(t===2?`</svg>`:t===3?`</math>`:``)),r]},Rt=class e{constructor({strings:t,_$litType$:n},r){let i;this.parts=[];let a=0,o=0,s=t.length-1,c=this.parts,[l,u]=Lt(t,n);if(this.el=e.createElement(l,r),J.currentNode=this.el.content,n===2||n===3){let e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;(i=J.nextNode())!==null&&c.length<s;){if(i.nodeType===1){if(i.hasAttributes())for(let e of i.getAttributeNames())if(e.endsWith(Ct)){let t=u[o++],n=i.getAttribute(e).split(R),r=/([.?@])?(.*)/.exec(t);c.push({type:1,index:a,name:r[2],strings:n,ctor:r[1]===`.`?Vt:r[1]===`?`?Ht:r[1]===`@`?Ut:X}),i.removeAttribute(e)}else e.startsWith(R)&&(c.push({type:6,index:a}),i.removeAttribute(e));if(Nt.test(i.tagName)){let e=i.textContent.split(R),t=e.length-1;if(t>0){i.textContent=L?L.emptyScript:``;for(let n=0;n<t;n++)i.append(e[n],B()),J.nextNode(),c.push({type:2,index:++a});i.append(e[t],B())}}}else if(i.nodeType===8){if(i.data===wt)c.push({type:2,index:a});else{let e=-1;for(;(e=i.data.indexOf(R,e+1))!==-1;)c.push({type:7,index:a}),e+=R.length-1}}a++}}static createElement(e,t){let n=z.createElement(`template`);return n.innerHTML=e,n}};function Y(e,t,n=e,r){if(t===K)return t;let i=r===void 0?n._$Cl:n._$Co?.[r],a=V(t)?void 0:t._$litDirective$;return i?.constructor!==a&&(i?._$AO?.(!1),a===void 0?i=void 0:(i=new a(e),i._$AT(e,n,r)),r===void 0?n._$Cl=i:(n._$Co??=[])[r]=i),i!==void 0&&(t=Y(e,i._$AS(e,t.values),i,r)),t}var zt=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:n}=this._$AD,r=(e?.creationScope??z).importNode(t,!0);J.currentNode=r;let i=J.nextNode(),a=0,o=0,s=n[0];for(;s!==void 0;){if(a===s.index){let t;s.type===2?t=new Bt(i,i.nextSibling,this,e):s.type===1?t=new s.ctor(i,s.name,s.strings,this,e):s.type===6&&(t=new Wt(i,this,e)),this._$AV.push(t),s=n[++o]}a!==s?.index&&(i=J.nextNode(),a++)}return J.currentNode=z,r}p(e){let t=0;for(let n of this._$AV)n!==void 0&&(n.strings===void 0?n._$AI(e[t]):(n._$AI(e,n,t),t+=n.strings.length-2)),t++}},Bt=class e{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,n,r){this.type=2,this._$AH=q,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=n,this.options=r,this._$Cv=r?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=Y(this,e,t),V(e)?e===q||e==null||e===``?(this._$AH!==q&&this._$AR(),this._$AH=q):e!==this._$AH&&e!==K&&this._(e):e._$litType$===void 0?e.nodeType===void 0?Dt(e)?this.k(e):this._(e):this.T(e):this.$(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==q&&V(this._$AH)?this._$AA.nextSibling.data=e:this.T(z.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:n}=e,r=typeof n==`number`?this._$AC(e):(n.el===void 0&&(n.el=Rt.createElement(It(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===r)this._$AH.p(t);else{let e=new zt(r,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=Ft.get(e.strings);return t===void 0&&Ft.set(e.strings,t=new Rt(e)),t}k(t){Et(this._$AH)||(this._$AH=[],this._$AR());let n=this._$AH,r,i=0;for(let a of t)i===n.length?n.push(r=new e(this.O(B()),this.O(B()),this,this.options)):r=n[i],r._$AI(a),i++;i<n.length&&(this._$AR(r&&r._$AB.nextSibling,i),n.length=i)}_$AR(e=this._$AA.nextSibling,t){for(this._$AP?.(!1,!0,t);e!==this._$AB;){let t=xt(e).nextSibling;xt(e).remove(),e=t}}setConnected(e){this._$AM===void 0&&(this._$Cv=e,this._$AP?.(e))}},X=class{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(e,t,n,r,i){this.type=1,this._$AH=q,this._$AN=void 0,this.element=e,this.name=t,this._$AM=r,this.options=i,n.length>2||n[0]!==``||n[1]!==``?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=q}_$AI(e,t=this,n,r){let i=this.strings,a=!1;if(i===void 0)e=Y(this,e,t,0),a=!V(e)||e!==this._$AH&&e!==K,a&&(this._$AH=e);else{let r=e,o,s;for(e=i[0],o=0;o<i.length-1;o++)s=Y(this,r[n+o],t,o),s===K&&(s=this._$AH[o]),a||=!V(s)||s!==this._$AH[o],s===q?e=q:e!==q&&(e+=(s??``)+i[o+1]),this._$AH[o]=s}a&&!r&&this.j(e)}j(e){e===q?this.element.removeAttribute(this.name):this.element.setAttribute(this.name,e??``)}},Vt=class extends X{constructor(){super(...arguments),this.type=3}j(e){this.element[this.name]=e===q?void 0:e}},Ht=class extends X{constructor(){super(...arguments),this.type=4}j(e){this.element.toggleAttribute(this.name,!!e&&e!==q)}},Ut=class extends X{constructor(e,t,n,r,i){super(e,t,n,r,i),this.type=5}_$AI(e,t=this){if((e=Y(this,e,t,0)??q)===K)return;let n=this._$AH,r=e===q&&n!==q||e.capture!==n.capture||e.once!==n.once||e.passive!==n.passive,i=e!==q&&(n===q||r);r&&this.element.removeEventListener(this.name,this,n),i&&this.element.addEventListener(this.name,this,e),this._$AH=e}handleEvent(e){typeof this._$AH==`function`?this._$AH.call(this.options?.host??this.element,e):this._$AH.handleEvent(e)}},Wt=class{constructor(e,t,n){this.element=e,this.type=6,this._$AN=void 0,this._$AM=t,this.options=n}get _$AU(){return this._$AM._$AU}_$AI(e){Y(this,e)}},Gt=bt.litHtmlPolyfillSupport;Gt?.(Rt,Bt),(bt.litHtmlVersions??=[]).push(`3.3.3`);var Kt=(e,t,n)=>{let r=n?.renderBefore??t,i=r._$litPart$;if(i===void 0){let e=n?.renderBefore??null;r._$litPart$=i=new Bt(t.insertBefore(B(),e),e,void 0,n??{})}return i._$AI(e),i},qt=globalThis,Z=class extends I{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=Kt(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return K}};Z._$litElement$=!0,Z.finalized=!0,qt.litElementHydrateSupport?.({LitElement:Z});var Jt=qt.litElementPolyfillSupport;Jt?.({LitElement:Z}),(qt.litElementVersions??=[]).push(`4.2.2`);function Q(e){if(!e.mimeType?.startsWith(`video/`)||!Number.isFinite(e.durationSeconds)||!e.durationSeconds||e.durationSeconds<=0||e.durationSeconds>15)return!1;let t=e.size;return!(typeof t==`number`&&t>12582912)}var Yt=640,Xt=8,Zt=72,Qt=704,$t=.9,en=.85;function $(e){if(!(!Number.isFinite(e)||!e||e<=0))return e}function tn(){return{width:globalThis.innerWidth||document.documentElement.clientWidth||0,height:globalThis.innerHeight||document.documentElement.clientHeight||0}}function nn(e,t){return{width:Math.max(0,e-(e<=Yt?Xt:Zt)*2),height:Math.max(0,t-32)}}function rn(e,t,n){let r=$(e?.width),i=$(e?.height);if(!r||!i)return null;let a=nn(t,n);if(a.width<=0||a.height<=0)return null;let o=Math.min(a.width/r,a.height/i);return{width:Math.max(1,Math.round(r*o)),height:Math.max(1,Math.round(i*o))}}function an(e,t,n){if(!e||e.mimeType?.startsWith(`video/`)||!$(e.width)||!$(e.height)||t<=0||n<=0)return!1;let r=$(e.width),i=$(e.height);if(!r||!i)return!1;let a=t<=Yt,o=nn(t,n),s=o.width,c=o.height;if(s<=0||c<=0)return!1;let l=r/i,u=Math.min(s,c*l);return l<$t&&u<(a?s:Math.min(s,Qt))*en}var on=class extends Z{static properties={_images:{state:!0},_currentIndex:{state:!0},_open:{state:!0},_viewportWidth:{state:!0},_viewportHeight:{state:!0},_videoCurrentTime:{state:!0},_videoDuration:{state:!0},_videoMuted:{state:!0},_videoPaused:{state:!0},_imageZoomed:{state:!0}};createRenderRoot(){return this.innerHTML=``,this}constructor(){super();let e=tn();this._images=[],this._currentIndex=0,this._open=!1,this._viewportWidth=e.width,this._viewportHeight=e.height,this._videoCurrentTime=0,this._videoDuration=0,this._videoMuted=!1,this._videoPaused=!1,this._imageZoomed=!1}connectedCallback(){super.connectedCallback(),document.addEventListener(`click`,this.#e),window.addEventListener(`resize`,this.#l),this.#u()}disconnectedCallback(){super.disconnectedCallback(),document.removeEventListener(`click`,this.#e),window.removeEventListener(`resize`,this.#l)}open(e,t){this.#u(),this.#d(),this._images=e,this._currentIndex=Math.max(0,Math.min(t,e.length-1)),this.#m(this._images[this._currentIndex]),this._imageZoomed=!1,this._open=!0,document.dispatchEvent(new CustomEvent(g,{detail:{open:!0}})),this.updateComplete.then(()=>{this.querySelector(`.media-lightbox`)?.showModal(),this.#f()})}close(){this.#d(),this.querySelector(`.media-lightbox`)?.close(),this._open=!1,document.dispatchEvent(new CustomEvent(g,{detail:{open:!1}}))}#e=e=>{let t=e.target,n=t.closest(`[data-post-media] a[data-lightbox-index]`);if(n){let t=n.closest(`[data-lightbox-group]`);if(!t)return;e.preventDefault();let r=parseInt(n.dataset.lightboxIndex??`0`,10);try{let e=JSON.parse(t.dataset.lightboxGroup??`[]`);e.length>0&&this.open(e,r)}catch{}return}let r=t.closest(`[data-post-body] img`);if(r){e.preventDefault();let t=r.closest(`[data-post-body]`);if(!t)return;let n=Array.from(t.querySelectorAll(`img`)),i=n.map(e=>({url:e.src,alt:e.alt||``,width:$(e.naturalWidth||Number(e.getAttribute(`width`))),height:$(e.naturalHeight||Number(e.getAttribute(`height`)))})),a=n.indexOf(r);i.length>0&&this.open(i,Math.max(0,a))}};#t(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex-1+this._images.length)%this._images.length)}#n(){this._images.length<=1||(this.#d(),this._imageZoomed=!1,this._currentIndex=(this._currentIndex+1)%this._images.length)}#r=e=>{let t=this._images[this._currentIndex];an(t,this._viewportWidth,this._viewportHeight)&&(e.stopPropagation(),this._imageZoomed=!this._imageZoomed)};#i=e=>{let t=e,n=e.target;if(t.key===`Escape`){e.preventDefault(),this.close();return}if(n instanceof HTMLInputElement||n instanceof HTMLButtonElement||n instanceof HTMLVideoElement)return;if(!this._images[this._currentIndex]?.mimeType?.startsWith(`video/`)){t.key===`ArrowLeft`?(e.preventDefault(),this.#t()):t.key===`ArrowRight`&&(e.preventDefault(),this.#n());return}let r=this.querySelector(`.media-lightbox-video`);r&&this.#a(t,r)};#a(e,t){let n=Number.isFinite(t.duration)&&t.duration>0?t.duration:null,r=e=>{let r=n==null?Math.max(0,e):Math.max(0,Math.min(e,n));t.currentTime=r,this._videoCurrentTime=r},i=e.key,a=i.toLowerCase();if(i===` `||a===`k`)e.preventDefault(),this.#x(t);else if(i===`ArrowLeft`)e.preventDefault(),r(t.currentTime-2);else if(i===`ArrowRight`)e.preventDefault(),r(t.currentTime+2);else if(i===`Home`)e.preventDefault(),r(0);else if(i===`End`)n!=null&&(e.preventDefault(),r(n));else if(i.length===1&&i>=`0`&&i<=`9`)n!=null&&(e.preventDefault(),r(Number(i)/10*n));else if(i===`ArrowUp`)e.preventDefault(),t.volume=Math.min(1,t.volume+.05);else if(i===`ArrowDown`)e.preventDefault(),t.volume=Math.max(0,t.volume-.05);else if(a===`m`){e.preventDefault();let n=!t.muted;t.muted=n,this._videoMuted=n}else a===`f`&&(e.preventDefault(),this.#o(t))}#o(e){let t=document,n=e;if(document.fullscreenElement??t.webkitFullscreenElement){document.exitFullscreen?document.exitFullscreen().catch(()=>{}):t.webkitExitFullscreen?.();return}e.requestFullscreen?e.requestFullscreen().catch(()=>{}):n.webkitRequestFullscreen?n.webkitRequestFullscreen():n.webkitEnterFullscreen&&n.webkitEnterFullscreen()}#s=e=>{let t=e.target;(t===e.currentTarget||t.classList.contains(`media-lightbox-content`)||t.classList.contains(`media-lightbox-stage`))&&this.close()};#c=()=>{this.#d(),this._open&&document.dispatchEvent(new CustomEvent(g,{detail:{open:!1}})),this._open=!1};#l=()=>{this.#u()};#u(){let e=tn();(e.width!==this._viewportWidth||e.height!==this._viewportHeight)&&(this._viewportWidth=e.width,this._viewportHeight=e.height)}#d(){this.querySelector(`.media-lightbox-video`)?.pause()}#f(){this.querySelector(`.media-lightbox-content`)?.focus()}#p=()=>{this.querySelector(`.media-lightbox-content`)?.focus({preventScroll:!0})};#m(e){this._videoCurrentTime=0,this._videoDuration=e?.durationSeconds&&e.durationSeconds>0?e.durationSeconds:0,this._videoMuted=!1,this._videoPaused=ue(e?.id)}#h(){let e=this._images[this._currentIndex];if(!Q(e)){this.#m(e);return}let t=this.querySelector(`.media-lightbox-video`);if(t){if(t.currentTime=0,t.muted=this._videoMuted,this._videoPaused=ue(e.id),this._videoPaused){t.pause();return}t.play().catch(()=>{this._videoPaused=!0})}}#g=e=>{let t=e.currentTarget;Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration),this._videoCurrentTime=t.currentTime,t.muted=this._videoMuted};#_=e=>{let t=e.currentTarget;this._videoCurrentTime=t.currentTime,Number.isFinite(t.duration)&&t.duration>0&&(this._videoDuration=t.duration)};#v=()=>{this._videoPaused=!1};#y=()=>{this._videoPaused=!0};#b(e){let t=this._images[this._currentIndex],n=t?.id?.trim();n&&Q(t)&&de(n,e)}#x(e){if(e.paused){this._videoPaused=!1,this.#b(!1),e.play().catch(()=>{this._videoPaused=!0,this.#b(!0)});return}e.pause(),this._videoPaused=!0,this.#b(!0)}#S=()=>{let e=this.querySelector(`.media-lightbox-video`);e&&this.#x(e)};#C=e=>{let t=e.currentTarget,n=this.querySelector(`.media-lightbox-video`),r=Number.parseFloat(t.value);!n||!Number.isFinite(r)||r<0||(n.currentTime=r,this._videoCurrentTime=r)};#w=()=>{this._videoMuted=!this._videoMuted;let e=this.querySelector(`.media-lightbox-video`);e&&(e.muted=this._videoMuted)};updated(e){if(super.updated(e),!this._open||!e.has(`_currentIndex`)&&!e.has(`_open`)&&!e.has(`_imageZoomed`))return;let t=this.querySelector(`.media-lightbox-stage`);t&&(t.scrollTop=0,t.scrollLeft=0,(e.has(`_currentIndex`)||e.has(`_open`))&&(this.#h(),this.#f()))}render(){if(!this._open)return q;let e=this._images[this._currentIndex],t=this._images.length>1,n=e?.mimeType?.startsWith(`video/`),r=Q(e),i=an(e,this._viewportWidth,this._viewportHeight),a=i&&this._imageZoomed,o=r?rn(e,this._viewportWidth,this._viewportHeight):null,s=r&&!!o&&o.height>o.width,c=o?`--media-lightbox-short-width:${o.width}px;--media-lightbox-short-height:${o.height}px;`:q,l=this._videoDuration>0?this._videoDuration:e?.durationSeconds??1,u=Math.min(this._videoCurrentTime,l),d=l>0?u/l*100:0;return W`\n <dialog\n class=${`media-lightbox${r?` media-lightbox-short`:``}`}\n @keydown=${this.#i}\n @click=${this.#s}\n @close=${this.#c}\n >\n <div class=\"media-lightbox-content\" tabindex=\"-1\">\n <button\n type=\"button\"\n class=\"media-lightbox-close\"\n @click=${()=>this.close()}\n aria-label=\"Close\"\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n </button>\n\n ${t?W`<div class=\"media-lightbox-counter\">\n ${this._currentIndex+1} / ${this._images.length}\n </div>`:q}\n <div\n class=${`media-lightbox-stage${a?` media-lightbox-stage-scroll`:``}`}\n >\n ${n?r?W`<div\n class=${`media-lightbox-short-frame${o?` media-lightbox-short-frame-contained`:``}${s?` media-lightbox-short-frame-portrait`:` media-lightbox-short-frame-landscape`}`}\n style=${c}\n >\n <div class=\"media-lightbox-short-viewport\">\n <video\n class=\"media-lightbox-video media-lightbox-video-short\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n ?autoplay=${!this._videoPaused}\n playsinline\n loop\n ?muted=${this._videoMuted}\n @click=${this.#S}\n @focus=${this.#p}\n @loadedmetadata=${this.#g}\n @timeupdate=${this.#_}\n @play=${this.#v}\n @pause=${this.#y}\n ></video>\n </div>\n <div\n class=${`media-lightbox-short-controls${s?` media-lightbox-short-controls-portrait`:``}`}\n >\n <button\n type=\"button\"\n class=\"media-lightbox-short-playback\"\n @click=${this.#S}\n aria-label=${this._videoPaused?`Play video`:`Pause video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n aria-hidden=\"true\"\n >\n ${this._videoPaused?G`<path d=\"M8 5v14l11-7z\" />`:G`<path d=\"M6 5h4v14H6zM14 5h4v14h-4z\" />`}\n </svg>\n </button>\n <input\n class=\"media-lightbox-short-progress\"\n type=\"range\"\n min=\"0\"\n max=${l}\n step=\"0.01\"\n .value=${String(u)}\n style=${`--media-progress:${d}%`}\n aria-label=\"Video progress\"\n @input=${this.#C}\n />\n <button\n type=\"button\"\n class=\"media-lightbox-short-mute\"\n @click=${this.#w}\n aria-label=${this._videoMuted?`Unmute video`:`Mute video`}\n >\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 48 48\"\n fill=\"currentColor\"\n color=\"#fff\"\n aria-hidden=\"true\"\n >\n ${this._videoMuted?G`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z\" />\n `:G`\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1V1.4c0-1.3-1.6-1.9-2.5-1L10.2 13.3H1.5z\" />\n <path d=\"M30.1 15.9c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 4.1 3.5 4.1 11.7s-4.1 11.7-4.1 11.7c-.6.6-1.6.6-2.2 0l-1.4-1.4c-.6-.6-.6-1.6 0-2.2 0 0 2.6-2 2.6-8.1s-2.6-8.1-2.6-8.1z\" />\n <path d=\"M37.2 8c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 5.7 5.1 5.7 19.6s-5.7 19.6-5.7 19.6c-.6.6-1.6.6-2.2 0L37.2 42c-.6-.6-.6-1.6 0-2.2 0 0 4.3-4.4 4.3-15.8S37.2 8 37.2 8z\" />\n `}\n </svg>\n </button>\n </div>\n </div>`:W`<video\n class=\"media-lightbox-video\"\n src=${e?.url??``}\n poster=${e?.posterUrl??``}\n controls\n autoplay\n playsinline\n @focus=${this.#p}\n ></video>`:W`<img\n class=${`media-lightbox-img${i?` media-lightbox-img-zoomable`:``}${a?` media-lightbox-img-scroll`:``}`}\n src=${e?.url??``}\n alt=${e?.alt??``}\n @click=${this.#r}\n />`}\n </div>\n ${t?W`\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-prev\"\n @click=${()=>this.#t()}\n aria-label=\"Previous\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m15 18-6-6 6-6\" />\n </svg>\n </button>\n <button\n type=\"button\"\n class=\"media-lightbox-nav media-lightbox-nav-next\"\n @click=${()=>this.#n()}\n aria-label=\"Next\"\n >\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m9 18 6-6-6-6\" />\n </svg>\n </button>\n `:q}\n </div>\n </dialog>\n `}};customElements.define(`jant-media-lightbox`,on);";
|
|
4534
5931
|
//#endregion
|
|
4535
5932
|
//#region src/services/export-theme/assets/client-site.css?raw
|
|
4536
|
-
var client_site_default = "@keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{background-color:#000;border-radius:4px;max-width:100%;max-height:100%;display:block;position:relative;overflow:hidden}.media-lightbox-short-frame-contained{width:var(--media-lightbox-short-width);height:var(--media-lightbox-short-height)}.media-lightbox-short-viewport{width:100%;height:100%;min-height:0}.media-lightbox-video-short{object-fit:contain;cursor:pointer;width:100%;max-width:none;height:100%;max-height:none;display:block}.media-lightbox-short-controls{z-index:2;box-sizing:border-box;pointer-events:none;background:linear-gradient(#0000,#000000ad);align-items:center;gap:2px;height:50px;padding:0 6px 6px;transition:opacity .18s,transform .18s;display:flex;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;flex:auto;width:0;min-width:48px;height:44px;margin:0;padding:0;display:block;position:relative}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-playback{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-playback svg{width:16px;height:16px;display:block}.media-lightbox-short-playback:hover{background-color:#ffffff24}.media-lightbox-short-playback:active{transform:scale(.92)}.media-lightbox-short-playback:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#ffffff24}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-frame{border-radius:0}}@media (hover:none),(pointer:coarse){.media-lightbox-short-frame-contained{height:calc(var(--media-lightbox-short-height) + 50px);grid-template-rows:minmax(0,1fr) 50px;display:grid}.media-lightbox-short-controls{background:#000;grid-row:2;position:relative}}@media (hover:hover) and (pointer:fine){.media-lightbox-short-controls{opacity:0;transform:translateY(4px)}.media-lightbox-short-controls>*{pointer-events:none}.media-lightbox-short-frame:hover .media-lightbox-short-controls,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls{opacity:1;transform:translateY(0)}.media-lightbox-short-frame:hover .media-lightbox-short-controls>*,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls>*{pointer-events:auto}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}\n/*$vite$:1*/";
|
|
5933
|
+
var client_site_default = "@keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{background-color:#000;border-radius:4px;max-width:100%;max-height:100%;display:block;position:relative;overflow:hidden}.media-lightbox-short-frame-contained{width:var(--media-lightbox-short-width);height:var(--media-lightbox-short-height)}.media-lightbox-short-viewport{width:100%;height:100%;min-height:0}.media-lightbox-video-short{object-fit:contain;cursor:pointer;width:100%;max-width:none;height:100%;max-height:none;display:block}.media-lightbox-short-controls{z-index:2;box-sizing:border-box;pointer-events:none;background:linear-gradient(#0000,#000000ad);align-items:center;gap:2px;height:50px;padding:0 6px 6px;transition:opacity .18s,transform .18s;display:flex;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;flex:auto;width:0;min-width:48px;height:44px;margin:0;padding:0;display:block;position:relative}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-playback{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-playback svg{width:16px;height:16px;display:block}.media-lightbox-short-playback:hover{background-color:#ffffff24}.media-lightbox-short-playback:active{transform:scale(.92)}.media-lightbox-short-playback:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#0000;border:none;border-radius:999px;flex:0 0 44px;justify-content:center;align-items:center;width:44px;height:44px;padding:0;transition:background-color .15s,transform .15s;display:inline-flex;position:relative}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#ffffff24}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-frame{border-radius:0}}@media (hover:none),(pointer:coarse){.media-lightbox-short-frame-contained{height:calc(var(--media-lightbox-short-height) + 50px);grid-template-rows:minmax(0,1fr) 50px;display:grid}.media-lightbox-short-controls{background:#000;grid-row:2;position:relative}}@media (hover:hover) and (pointer:fine){.media-lightbox-short-controls{opacity:0;transform:translateY(4px)}.media-lightbox-short-controls>*{pointer-events:none}.media-lightbox-short-frame:hover .media-lightbox-short-controls,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls{opacity:1;transform:translateY(0)}.media-lightbox-short-frame:hover .media-lightbox-short-controls>*,.media-lightbox-short-frame:focus-within .media-lightbox-short-controls>*{pointer-events:auto}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{background-color:var(--color-muted);width:100%}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}\n/*$vite$:1*/";
|
|
4537
5934
|
//#endregion
|
|
4538
5935
|
//#region src/services/export-theme/layouts/_default/baseof.html?raw
|
|
4539
5936
|
var baseof_default = "<!doctype html>\n{{- $lang := .Site.LanguageCode | default \"en\" -}}\n{{- $themeMode := .Site.Params.theme_mode | default \"auto\" -}}\n{{- $themeId := .Site.Params.theme_id -}}\n<html lang=\"{{ $lang }}\"{{ with $themeId }} data-theme=\"{{ . }}\"{{ end }}{{ if ne $themeMode \"auto\" }} data-theme-mode=\"{{ $themeMode }}\"{{ end }}>\n <head>\n {{ partial \"head.html\" . }}\n </head>\n <body>\n <div class=\"site-page\">\n {{ partial \"header.html\" . }}\n <main class=\"site-main\" id=\"main\">\n {{ block \"main\" . }}{{ end }}\n </main>\n {{ partial \"footer.html\" . }}\n </div>\n {{/* Mount the shared media lightbox web component once per page.\n The component installs a document-level click listener on connect\n and intercepts clicks on [data-post-media] a[data-lightbox-index]. */}}\n <jant-media-lightbox></jant-media-lightbox>\n </body>\n</html>\n";
|
|
@@ -4575,28 +5972,28 @@ var header_default = "{{- $jant := hugo.Data.jant -}}\n{{- $showAvatar := false
|
|
|
4575
5972
|
var footer_default = "{{- $jant := hugo.Data.jant -}}\n{{- $footerHtml := \"\" -}}\n{{- with $jant -}}{{- with .site_footer_html -}}{{- $footerHtml = . -}}{{- end -}}{{- end -}}\n{{- $showBranding := false -}}\n{{- with $jant -}}{{- if eq .show_jant_branding_on_home true -}}{{- $showBranding = true -}}{{- end -}}{{- end -}}\n{{- $navFooterItems := slice -}}\n{{- with $jant -}}{{- range .nav -}}{{- if eq .placement \"footer\" -}}{{- $navFooterItems = $navFooterItems | append . -}}{{- end -}}{{- end -}}{{- end -}}\n{{- $hasFooter := or $navFooterItems $footerHtml -}}\n{{- if $hasFooter -}}\n<footer class=\"site-footer\" role=\"contentinfo\">\n <div class=\"site-footer-inner\">\n {{- if $navFooterItems -}}\n <nav class=\"site-footer-nav\" aria-label=\"Footer\">\n <ul class=\"site-footer-nav-list\">\n {{- range $navFooterItems -}}\n {{- $isExternal := or (hasPrefix .url \"http://\") (hasPrefix .url \"https://\") -}}\n <li><a href=\"{{ .url }}\"{{ if $isExternal }} rel=\"noopener noreferrer\" target=\"_blank\"{{ end }}>{{ .label }}</a></li>\n {{- end -}}\n </ul>\n </nav>\n {{- end -}}\n {{- with $footerHtml -}}\n <div class=\"site-footer-content\">{{ . | safeHTML }}</div>\n {{- end -}}\n </div>\n</footer>\n{{- end -}}\n{{- if and $showBranding $.IsHome -}}\n<footer class=\"home-branding-credit\">\n Build with <a href=\"https://jant.me\" rel=\"noopener noreferrer\" target=\"_blank\">Jant</a>\n</footer>\n{{- end -}}\n";
|
|
4576
5973
|
//#endregion
|
|
4577
5974
|
//#region src/services/export-theme/layouts/partials/pagination.html?raw
|
|
4578
|
-
var pagination_default = "{{- $paginator := . -}}\n{{- $total := $paginator.TotalPages -}}\n{{- $current := $paginator.PageNumber -}}\n{{- if gt $total 1 -}}\n {{- /* Mirror main site's getPageNumbers: always
|
|
5975
|
+
var pagination_default = "{{- $paginator := . -}}\n{{- $total := $paginator.TotalPages -}}\n{{- $current := $paginator.PageNumber -}}\n{{- if gt $total 1 -}}\n {{- /* Mirror main site's getPageNumbers: past seven pages, always seven\n slots — 1, last, and a three-page window around the current page.\n The second and second-to-last slots hold a page or 0 (an ellipsis\n marker), so an ellipsis never stands in for a single page. */ -}}\n {{- $pages := slice -}}\n {{- if le $total 7 -}}\n {{- range seq 1 $total -}}\n {{- $pages = $pages | append . -}}\n {{- end -}}\n {{- else -}}\n {{- $start := sub $current 1 -}}\n {{- if lt $start 3 -}}{{- $start = 3 -}}{{- end -}}\n {{- if gt $start (sub $total 4) -}}{{- $start = sub $total 4 -}}{{- end -}}\n {{- $end := add $start 2 -}}\n {{- $pages = $pages | append 1 -}}\n {{- $pages = $pages | append (cond (eq $start 3) 2 0) -}}\n {{- range seq $start $end -}}\n {{- $pages = $pages | append . -}}\n {{- end -}}\n {{- $pages = $pages | append (cond (eq $end (sub $total 2)) (sub $total 1) 0) -}}\n {{- $pages = $pages | append $total -}}\n {{- end -}}\n <nav class=\"pagination\" aria-label=\"Pagination\">\n {{- if $paginator.HasPrev -}}\n <a class=\"pagination-link pagination-prev\" href=\"{{ $paginator.Prev.URL }}\" rel=\"prev\">Previous</a>\n {{- else -}}\n <span class=\"pagination-link pagination-prev is-disabled\" aria-disabled=\"true\">Previous</span>\n {{- end -}}\n {{- range $pages -}}\n {{- if eq . 0 -}}\n <span class=\"pagination-ellipsis\" aria-hidden=\"true\">...</span>\n {{- else if eq . $current -}}\n <span class=\"pagination-current\" aria-current=\"page\">{{ . }}</span>\n {{- else -}}\n {{- $href := \"\" -}}\n {{- if eq . 1 -}}\n {{- $href = ($paginator.First.URL | default \"/\") -}}\n {{- else -}}\n {{- with index $paginator.Pagers (sub . 1) -}}\n {{- $href = .URL -}}\n {{- end -}}\n {{- end -}}\n <a class=\"pagination-link\" href=\"{{ $href }}\">{{ . }}</a>\n {{- end -}}\n {{- end -}}\n {{- if $paginator.HasNext -}}\n <a class=\"pagination-link pagination-next\" href=\"{{ $paginator.Next.URL }}\" rel=\"next\">Next</a>\n {{- else -}}\n <span class=\"pagination-link pagination-next is-disabled\" aria-disabled=\"true\">Next</span>\n {{- end -}}\n </nav>\n{{- end -}}\n";
|
|
4579
5976
|
//#endregion
|
|
4580
5977
|
//#region src/services/export-theme/layouts/partials/post-card.html?raw
|
|
4581
|
-
var post_card_default = "{{- $post := . -}}\n{{- $format := $post.Params.format | default \"note\" -}}\n{{- $summaryText := $post.Params.summary_text -}}\n{{- $media := $post.Params.media | default slice -}}\n{{- $collections := $post.Params.collections | default slice -}}\n{{- $slug := $post.Params.slug | default $post.Slug -}}\n{{- $linkUrl := $post.Params.link_url -}}\n<article class=\"post-card post-card-{{ $format }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\"\n {{- if $post.Params.pinned_at }} data-post-pinned{{ end -}}\n {{- if $post.Params.featured_at }} data-post-featured{{ end -}}\n>\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-card-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if and $post.Title $linkUrl -}}\n <h2 class=\"post-card-title post-card-link-title\">\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $post.Title }}</a>\n </h2>\n {{- else if $post.Title -}}\n <h2 class=\"post-card-title post-card-link-title\"><a href=\"{{ $
|
|
5978
|
+
var post_card_default = "{{- $post := . -}}\n{{- $format := $post.Params.format | default \"note\" -}}\n{{- $summaryText := $post.Params.summary_text -}}\n{{- $media := $post.Params.media | default slice -}}\n{{- $collections := $post.Params.collections | default slice -}}\n{{- $slug := $post.Params.slug | default $post.Slug -}}\n{{- $linkUrl := $post.Params.link_url -}}\n{{- /* A reply is not rendered as a page (`build: render: never`), so its\n `.RelPermalink` is empty. The exporter registers every reply slug as an\n alias on the root, and that alias is the address a reply has. */ -}}\n{{- $permalink := $post.RelPermalink | default (printf \"/%s/\" $slug | relURL) -}}\n<article class=\"post-card post-card-{{ $format }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\"\n {{- if $post.Params.pinned_at }} data-post-pinned{{ end -}}\n {{- if $post.Params.featured_at }} data-post-featured{{ end -}}\n>\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-card-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if and $post.Title $linkUrl -}}\n <h2 class=\"post-card-title post-card-link-title\">\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $post.Title }}</a>\n </h2>\n {{- else if $post.Title -}}\n <h2 class=\"post-card-title post-card-link-title\"><a href=\"{{ $permalink }}\">{{ $post.Title }}</a></h2>\n {{- end -}}\n {{- /* Prefer Hugo's rendered HTML body — preserves paragraphs, links,\n emphasis, lists. Falls back to the plain-text `summary_text` stored\n in front matter. */ -}}\n {{- if $post.Summary -}}\n <div class=\"post-card-summary prose\">{{ $post.Summary }}</div>\n {{- else if $summaryText -}}\n <p class=\"post-card-summary\">{{ $summaryText }}</p>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"post-card-quote\">\n {{- with $post.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $post.Params.source_name $post.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $post.Params.source_name $post.Params.source_url -}}\n <a href=\"{{ $post.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $post.Params.source_name }}</a>\n {{- else if $post.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $post.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $post.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $post.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $post.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- /* Quote commentary comes ONLY from the post body (rendered via\n `.Summary`). Do NOT fall back to `summary_text` — for quote posts,\n `summary_text` in front matter is the quote text itself (see\n `getArchiveSummaryText`), which would duplicate the quote body. */ -}}\n {{- with $post.Summary -}}\n <div class=\"post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n {{- with $post.Title -}}\n <h2 class=\"post-card-title\"><a href=\"{{ $permalink }}\">{{ . }}</a></h2>\n {{- end -}}\n {{- /* Prefer Hugo's rendered HTML summary — preserves paragraphs, links,\n emphasis, lists. Falls back to the plain-text `summary_text` stored\n in front matter (used for quote bodies, OG meta, etc.). */ -}}\n {{- if $post.Summary -}}\n <div class=\"post-card-summary prose\">{{ $post.Summary }}</div>\n {{- else if $summaryText -}}\n <p class=\"post-card-summary\">{{ $summaryText }}</p>\n {{- end -}}\n {{- end -}}\n\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" $permalink) }}\n\n <footer class=\"post-card-footer post-menu-footer\" data-post-meta>\n <div class=\"post-footer-meta\">\n {{- if $post.Params.featured_at -}}\n <span class=\"post-footer-featured\" aria-label=\"Featured\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M12 3 10.1 10.1 3 12l7.1 1.9L12 21l1.9-7.1L21 12l-7.1-1.9Z\" />\n </svg>\n </span>\n {{- end -}}\n <a class=\"post-footer-link post-card-permalink\" href=\"{{ $permalink }}\">\n <time datetime=\"{{ $post.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $post.Date.Format \"Jan 2, 2006\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n {{- if $collections -}}\n <span class=\"post-collection-tags\">\n {{- range $i, $c := $collections -}}\n {{- if gt $i 0 -}}<span class=\"post-collection-sep\" aria-hidden=\"true\">, </span>{{- end -}}\n <a class=\"post-collection-tag\" href=\"{{ printf \"/%s/\" .slug | relURL }}\">\n <span class=\"post-collection-tag-text\">{{ .title }}</span>\n </a>\n {{- end -}}\n </span>\n {{- end -}}\n {{- if $post.Params.pinned_at -}}\n <span class=\"post-card-pin\" aria-label=\"Pinned\">Pinned</span>\n {{- end -}}\n </div>\n </footer>\n</article>\n";
|
|
4582
5979
|
//#endregion
|
|
4583
5980
|
//#region src/services/export-theme/layouts/partials/media-gallery.html?raw
|
|
4584
|
-
var media_gallery_default = "{{- /*\n Media gallery — unified horizontal scroll row mirroring main-site MediaGallery.tsx.\n Emits images, videos, audio cards, documents, and text attachments in the\n post's stored `media` order (already sorted by position in the exporter).\n\n Lightbox: images and videos get `[data-lightbox-index]` pointers into a\n JSON blob on `[data-post-media]`. The client-site bundle's\n `<jant-media-lightbox>` delegates clicks on those anchors.\n\n Layout: mirrors MediaGallery.tsx — one horizontal row with a computed\n `rowHeight` (driven by the first visual's aspect ratio) so mixed sizes\n align into a clean strip. Single-visual posts expand to their natural\n aspect ratio (capped at 24rem / 85% width). Non-visual attachments\n (audio/doc/text) use a 3:4 portrait card sized to the row height.\n\n Inline styles (not Tailwind utilities) because the exported theme ships\n no utility CSS — only tokens.css + main.css + theme.css + site-media.css.\n\n Context: expects `.` to be a dict with `media` (the slice) and optional\n `permalink` for future use. Caller should pass\n `(dict \"media\" .Params.media \"permalink\" .RelPermalink)`.\n*/ -}}\n\n{{- $media := .media | default slice -}}\n{{- if $media -}}\n {{- /* Collect items + build lightbox group from images/videos */ -}}\n {{- $items := slice -}}\n {{- $lightbox := slice -}}\n {{- $lbIdx := 0 -}}\n {{- $hasNonVisual := false -}}\n {{- $firstRatio := 0.0 -}}\n {{- range $media -}}\n {{- $kind := .kind | default \"file\" -}}\n {{- $item := . -}}\n {{- if or (eq $kind \"image\") (eq $kind \"video\") -}}\n {{- $lbEntry := dict \"url\" .src \"alt\" (.alt | default \"\") -}}\n {{- with .id }}{{- $lbEntry = merge $lbEntry (dict \"id\" .) -}}{{- end -}}\n {{- with .width }}{{- $lbEntry = merge $lbEntry (dict \"width\" .) -}}{{- end -}}\n {{- with .height }}{{- $lbEntry = merge $lbEntry (dict \"height\" .) -}}{{- end -}}\n {{- if eq $kind \"video\" -}}\n {{- with $item.mime_type }}{{- $lbEntry = merge $lbEntry (dict \"mimeType\" .) -}}{{- end -}}\n {{- with $item.poster }}{{- $lbEntry = merge $lbEntry (dict \"posterUrl\" .) -}}{{- end -}}\n {{- with $item.duration_seconds }}{{- $lbEntry = merge $lbEntry (dict \"durationSeconds\" .) -}}{{- end -}}\n {{- with $item.size }}{{- $lbEntry = merge $lbEntry (dict \"size\" .) -}}{{- end -}}\n {{- end -}}\n {{- $lightbox = $lightbox | append $lbEntry -}}\n {{- $items = $items | append (merge $item (dict \"_kind\" $kind \"_lbIdx\" $lbIdx)) -}}\n {{- $lbIdx = add $lbIdx 1 -}}\n {{- /* Capture first visual's aspect ratio */ -}}\n {{- if and (eq $firstRatio 0.0) $item.width $item.height (gt (float $item.width) 0.0) (gt (float $item.height) 0.0) -}}\n {{- $firstRatio = div (float $item.width) (float $item.height) -}}\n {{- end -}}\n {{- else -}}\n {{- $items = $items | append (merge $item (dict \"_kind\" $kind)) -}}\n {{- $hasNonVisual = true -}}\n {{- end -}}\n {{- end -}}\n\n {{- $count := len $items -}}\n {{- $single := eq $count 1 -}}\n {{- $first := index $items 0 -}}\n {{- $firstKind := $first._kind -}}\n {{- $singleVisual := and $single (or (eq $firstKind \"image\") (eq $firstKind \"video\")) -}}\n\n {{- /* rowHeight — mirrors MediaGallery.tsx. 4/3 fallback when no visuals. */ -}}\n {{- $rowMin := cond $hasNonVisual 220 280 -}}\n {{- $rowMax := cond $hasNonVisual 300 440 -}}\n {{- $rowHeight := cond $hasNonVisual 220 360 -}}\n {{- if and (not $singleVisual) (gt $count 1) (gt $firstRatio 0.0) -}}\n {{- $ratio := cond (lt $firstRatio 0.5) 0.5 $firstRatio -}}\n {{- $computed := math.Round (div 320.0 $ratio) -}}\n {{- $rowHeight = cond (gt $computed $rowMax) $rowMax (cond (lt $computed $rowMin) $rowMin (int $computed)) -}}\n {{- end -}}\n {{- $docCardWidth := int (math.Round (mul (float $rowHeight) 0.75)) -}}\n\n {{- /* Outer wrapper — keeps the media-gallery-scroll-wrap hooks for the\n fade-mask client script in the non-single case. */ -}}\n {{- $wrapClass := cond $singleVisual \"\" \"media-gallery-scroll-wrap\" -}}\n <div class=\"{{ $wrapClass }}\" style=\"margin-top: 0.75rem;\">\n <div data-post-media\n {{- if $lightbox }} data-lightbox-group=\"{{ $lightbox | jsonify }}\"{{- end -}}\n {{- if not $singleVisual }} tabindex=\"0\" role=\"group\" aria-label=\"Media gallery\"{{- end -}}\n style=\"display: flex; gap: 0.5rem;\n {{- if not $singleVisual }} overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none;{{- end }}\">\n {{- range $items -}}\n {{- $k := ._kind -}}\n {{- /* Per-item width for non-single visuals */ -}}\n {{- $itemRatio := 1.3333 -}}\n {{- if and .width .height (gt (float .width) 0.0) (gt (float .height) 0.0) -}}\n {{- $itemRatio = div (float .width) (float .height) -}}\n {{- end -}}\n {{- $itemWidth := int (math.Round (mul (float $rowHeight) $itemRatio)) -}}\n {{- if lt $itemWidth 160 -}}{{- $itemWidth = 160 -}}{{- end -}}\n\n {{- if eq $k \"image\" -}}\n {{- if $singleVisual -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-visual-frame\"\n style=\"display: block; width: min(100%, calc(24rem * {{ printf \"%.4f\" $itemRatio }})); max-width: 100%;\">\n <img src=\"{{ .src }}\" alt=\"{{ .alt | default \"\" }}\"\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n loading=\"lazy\" decoding=\"async\"\n class=\"media-visual\"\n style=\"display: block; width: 100%; height: auto; border-radius: 0.5rem;\n {{- if and .width .height }} aspect-ratio: {{ .width }}/{{ .height }};{{- end }}\">\n </a>\n {{- else -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-visual-frame\"\n style=\"display: block; flex-shrink: 0; width: {{ $itemWidth }}px; max-width: 85%;\">\n <img src=\"{{ .src }}\" alt=\"{{ .alt | default \"\" }}\"\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n loading=\"lazy\" decoding=\"async\"\n class=\"media-visual\"\n style=\"display: block; width: 100%; height: {{ $rowHeight }}px; object-fit: cover;\">\n </a>\n {{- end -}}\n {{- else if eq $k \"video\" -}}\n {{- $isShort := false -}}\n {{- if and .mime_type (hasPrefix .mime_type \"video/\") .duration_seconds -}}\n {{- if and (le (int .duration_seconds) 15) (or (not .size) (le (int .size) 12582912)) -}}\n {{- $isShort = true -}}\n {{- end -}}\n {{- end -}}\n {{- $videoWrapStyle := \"position: relative;\" -}}\n {{- if $singleVisual -}}\n {{- $videoWrapStyle = printf \"%s display: block; width: min(100%%, calc(24rem * %s)); max-width: 100%%;\" $videoWrapStyle (printf \"%.4f\" $itemRatio) -}}\n {{- else -}}\n {{- $videoWrapStyle = printf \"%s display: block; flex-shrink: 0; width: %dpx; max-width: 85%%;\" $videoWrapStyle $itemWidth -}}\n {{- end -}}\n {{- $videoStyle := cond $singleVisual\n (cond (and (gt (int (default 0 .width)) 0) (gt (int (default 0 .height)) 0))\n (printf \"display: block; width: 100%%; height: auto; aspect-ratio: %d/%d;\" (int .width) (int .height))\n \"display: block; width: 100%; height: auto;\")\n (printf \"display: block; width: 100%%; height: %dpx; object-fit: cover;\" $rowHeight) -}}\n {{- if $isShort -}}\n <div class=\"media-video-wrap media-video-wrap-short\" style=\"{{ $videoWrapStyle | safeCSS }}\">\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\" class=\"media-visual-frame media-video-link\" style=\"display: block; cursor: pointer;\">\n <video preload=\"none\" muted playsinline loop\n {{- with .poster }} poster=\"{{ . }}\"{{- end -}}\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n data-feed-short-video=\"\"\n data-video-src=\"{{ .src }}\"\n data-feed-video-id=\"{{ .id }}\"\n class=\"media-visual\"\n style=\"{{ $videoStyle | safeCSS }}\"></video>\n </a>\n <button type=\"button\" class=\"media-feed-video-mute\" data-feed-video-mute-toggle data-muted=\"true\" aria-label=\"Play with sound\">\n <svg class=\"media-feed-video-icon media-feed-video-icon-muted\" width=\"12\" height=\"12\" viewBox=\"0 0 48 48\" fill=\"currentColor\" aria-hidden=\"true\">\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z\"/>\n </svg>\n <svg class=\"media-feed-video-icon media-feed-video-icon-unmuted\" width=\"12\" height=\"12\" viewBox=\"0 0 48 48\" fill=\"currentColor\" aria-hidden=\"true\">\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1V1.4c0-1.3-1.6-1.9-2.5-1L10.2 13.3H1.5z\"/>\n <path d=\"M30.1 15.9c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 4.1 3.5 4.1 11.7s-4.1 11.7-4.1 11.7c-.6.6-1.6.6-2.2 0l-1.4-1.4c-.6-.6-.6-1.6 0-2.2 0 0 2.6-2 2.6-8.1s-2.6-8.1-2.6-8.1z\"/>\n <path d=\"M37.2 8c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 5.7 5.1 5.7 19.6s-5.7 19.6-5.7 19.6c-.6.6-1.6.6-2.2 0L37.2 42c-.6-.6-.6-1.6 0-2.2 0 0 4.3-4.4 4.3-15.8S37.2 8 37.2 8z\"/>\n </svg>\n </button>\n </div>\n {{- else -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-video-wrap media-visual-frame\"\n style=\"{{ $videoWrapStyle | safeCSS }}\">\n <video preload=\"none\" muted playsinline\n {{- with .poster }} poster=\"{{ . }}\"{{- end -}}\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n class=\"media-visual\"\n style=\"{{ $videoStyle | safeCSS }}\"></video>\n <div class=\"media-video-play-overlay\">\n <svg viewBox=\"0 0 24 24\" fill=\"white\"><path d=\"M8 5v14l11-7z\"/></svg>\n </div>\n </a>\n {{- end -}}\n {{- else if eq $k \"audio\" -}}\n {{- $audioName := .original_name | default (.alt | default \"Audio\") -}}\n <div class=\"media-gallery-card media-audio-card{{ if .waveform }} has-waveform{{ end }}\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <audio preload=\"none\" class=\"media-audio-el\">\n <source src=\"{{ .src }}\"{{ with .mime_type }} type=\"{{ . }}\"{{ end }}>\n </audio>\n <div class=\"media-audio-artwork\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M9 18V5l12-2v13\"/>\n <circle cx=\"6\" cy=\"18\" r=\"3\"/>\n <circle cx=\"18\" cy=\"16\" r=\"3\"/>\n </svg>\n </div>\n <div class=\"media-audio-controls\">\n <input type=\"range\" min=\"0\" max=\"1000\" value=\"0\" class=\"media-audio-range\" data-audio-range aria-label=\"Seek\">\n <canvas class=\"media-audio-waveform\" data-audio-waveform{{ with .waveform }} data-audio-peaks=\"{{ . }}\"{{ end }}></canvas>\n <div class=\"media-audio-row\">\n <div class=\"media-audio-info\">\n <div class=\"media-audio-title\" title=\"{{ $audioName }}\">{{ $audioName }}</div>\n <div class=\"media-audio-time\" data-audio-time>0:00</div>\n </div>\n <button type=\"button\" class=\"media-audio-play-btn\" data-audio-play aria-label=\"Play\">\n <svg class=\"media-audio-icon-play\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>\n <svg class=\"media-audio-icon-pause\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M6 4h4v16H6zM14 4h4v16h-4z\"/></svg>\n </button>\n </div>\n </div>\n </div>\n {{- else if eq $k \"text\" -}}\n {{- /* Static site has no text-preview modal — link direct to file. */ -}}\n {{- $label := .summary | default (.original_name | default \"Attached text\") -}}\n <a href=\"{{ .src }}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"media-gallery-card\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <div class=\"media-gallery-card-inner\">\n <div class=\"media-gallery-card-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"24\" height=\"24\" aria-hidden=\"true\">\n <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/>\n <polyline points=\"14 2 14 8 20 8\"/>\n <line x1=\"8\" y1=\"13\" x2=\"16\" y2=\"13\"/>\n <line x1=\"8\" y1=\"17\" x2=\"16\" y2=\"17\"/>\n </svg>\n </div>\n <span class=\"media-gallery-card-summary\">{{ $label }}</span>\n {{- with .chars -}}\n <span class=\"media-gallery-card-meta\">{{ . }} chars</span>\n {{- end -}}\n </div>\n </a>\n {{- else -}}\n {{- /* document / file */ -}}\n {{- $label := .original_name | default (path.Base .src) -}}\n <a href=\"{{ .src }}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"media-gallery-card\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <div class=\"media-gallery-card-inner\">\n <div class=\"media-gallery-card-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"24\" height=\"24\" aria-hidden=\"true\">\n <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/>\n <polyline points=\"14 2 14 8 20 8\"/>\n </svg>\n </div>\n <span class=\"media-gallery-card-summary\">{{ $label }}</span>\n {{- with .size -}}\n <span class=\"media-gallery-card-meta\">{{ . }} B</span>\n {{- end -}}\n </div>\n </a>\n {{- end -}}\n {{- end -}}\n </div>\n {{- if not $singleVisual -}}\n <button type=\"button\" class=\"media-gallery-nav media-gallery-nav-prev\" tabindex=\"-1\" aria-label=\"Scroll to previous media\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M15 18l-6-6 6-6\"/></svg>\n </button>\n <button type=\"button\" class=\"media-gallery-nav media-gallery-nav-next\" tabindex=\"-1\" aria-label=\"Scroll to next media\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M9 18l6-6-6-6\"/></svg>\n </button>\n {{- end -}}\n </div>\n{{- end -}}\n";
|
|
5981
|
+
var media_gallery_default = "{{- /*\n Media gallery — unified horizontal scroll row mirroring main-site MediaGallery.tsx.\n Emits images, videos, audio cards, documents, and text attachments in the\n post's stored `media` order (already sorted by position in the exporter).\n\n Lightbox: images and videos get `[data-lightbox-index]` pointers into a\n JSON blob on `[data-post-media]`. The client-site bundle's\n `<jant-media-lightbox>` delegates clicks on those anchors.\n\n Layout: mirrors MediaGallery.tsx — one horizontal row with a computed\n `rowHeight` so mixed sizes align into a clean strip. The opening visual\n leads (320px / its ratio), the widest visual is held to 520px so a landscape\n video never swallows the column, and the result is clamped to 240–384px —\n 384 being the same 24rem cap a lone visual gets, so a second attachment\n never makes the first picture grow. Non-visual attachments (audio/doc/text)\n do not enter that math; their 3:4 portrait cards are sized from its result.\n Single-visual posts skip the strip and expand to their natural aspect ratio\n (capped at 24rem / 85% width).\n\n Inline styles (not Tailwind utilities) because the exported theme ships\n no utility CSS — only tokens.css + main.css + theme.css + site-media.css.\n\n Context: expects `.` to be a dict with `media` (the slice) and optional\n `permalink` for future use. Caller should pass\n `(dict \"media\" .Params.media \"permalink\" .RelPermalink)`.\n*/ -}}\n\n{{- $media := .media | default slice -}}\n{{- if $media -}}\n {{- /* Collect items + build lightbox group from images/videos */ -}}\n {{- $items := slice -}}\n {{- $lightbox := slice -}}\n {{- $lbIdx := 0 -}}\n {{- $visualRatios := slice -}}\n {{- range $media -}}\n {{- $kind := .kind | default \"file\" -}}\n {{- $item := . -}}\n {{- if or (eq $kind \"image\") (eq $kind \"video\") -}}\n {{- $lbEntry := dict \"url\" .src \"alt\" (.alt | default \"\") -}}\n {{- with .id }}{{- $lbEntry = merge $lbEntry (dict \"id\" .) -}}{{- end -}}\n {{- with .width }}{{- $lbEntry = merge $lbEntry (dict \"width\" .) -}}{{- end -}}\n {{- with .height }}{{- $lbEntry = merge $lbEntry (dict \"height\" .) -}}{{- end -}}\n {{- if eq $kind \"video\" -}}\n {{- with $item.mime_type }}{{- $lbEntry = merge $lbEntry (dict \"mimeType\" .) -}}{{- end -}}\n {{- with $item.poster }}{{- $lbEntry = merge $lbEntry (dict \"posterUrl\" .) -}}{{- end -}}\n {{- with $item.duration_seconds }}{{- $lbEntry = merge $lbEntry (dict \"durationSeconds\" .) -}}{{- end -}}\n {{- with $item.size }}{{- $lbEntry = merge $lbEntry (dict \"size\" .) -}}{{- end -}}\n {{- end -}}\n {{- $lightbox = $lightbox | append $lbEntry -}}\n {{- $items = $items | append (merge $item (dict \"_kind\" $kind \"_lbIdx\" $lbIdx)) -}}\n {{- $lbIdx = add $lbIdx 1 -}}\n {{- /* Every visual's aspect ratio, in display order — 4:3 when unknown,\n matching getMediaAspectRatio() in MediaGallery.tsx. */ -}}\n {{- $ratio := 1.3333 -}}\n {{- if and $item.width $item.height (gt (float $item.width) 0.0) (gt (float $item.height) 0.0) -}}\n {{- $ratio = div (float $item.width) (float $item.height) -}}\n {{- end -}}\n {{- $visualRatios = $visualRatios | append $ratio -}}\n {{- else -}}\n {{- $items = $items | append (merge $item (dict \"_kind\" $kind)) -}}\n {{- end -}}\n {{- end -}}\n\n {{- $count := len $items -}}\n {{- $single := eq $count 1 -}}\n {{- $first := index $items 0 -}}\n {{- $firstKind := $first._kind -}}\n {{- $singleVisual := and $single (or (eq $firstKind \"image\") (eq $firstKind \"video\")) -}}\n\n {{- /* rowHeight — mirrors getGalleryRowHeight() in MediaGallery.tsx. */ -}}\n {{- $rowMin := 240 -}}\n {{- $rowMax := 384 -}}\n {{- $rowHeight := $rowMin -}}\n {{- if $visualRatios -}}\n {{- $widest := 0.0 -}}\n {{- range $visualRatios -}}\n {{- if gt . $widest }}{{ $widest = . }}{{ end -}}\n {{- end -}}\n {{- $byLead := div 320.0 (index $visualRatios 0) -}}\n {{- $byWidestItem := div 520.0 $widest -}}\n {{- $computed := math.Round (cond (lt $byWidestItem $byLead) $byWidestItem $byLead) -}}\n {{- $rowHeight = cond (gt $computed (float $rowMax)) $rowMax (cond (lt $computed (float $rowMin)) $rowMin (int $computed)) -}}\n {{- end -}}\n {{- $docCardWidth := int (math.Round (mul (float $rowHeight) 0.75)) -}}\n\n {{- /* Outer wrapper — keeps the media-gallery-scroll-wrap hooks for the\n fade-mask client script in the non-single case. */ -}}\n {{- $wrapClass := cond $singleVisual \"\" \"media-gallery-scroll-wrap\" -}}\n <div class=\"{{ $wrapClass }}\" style=\"margin-top: 0.75rem;\">\n <div data-post-media\n {{- if $lightbox }} data-lightbox-group=\"{{ $lightbox | jsonify }}\"{{- end -}}\n {{- if not $singleVisual }} tabindex=\"0\" role=\"group\" aria-label=\"Media gallery\"{{- end -}}\n style=\"display: flex; gap: 0.75rem;\n {{- if not $singleVisual }} overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none;{{- end }}\">\n {{- range $items -}}\n {{- $k := ._kind -}}\n {{- /* Per-item width for non-single visuals */ -}}\n {{- $itemRatio := 1.3333 -}}\n {{- if and .width .height (gt (float .width) 0.0) (gt (float .height) 0.0) -}}\n {{- $itemRatio = div (float .width) (float .height) -}}\n {{- end -}}\n {{- $itemWidth := int (math.Round (mul (float $rowHeight) $itemRatio)) -}}\n {{- if lt $itemWidth 160 -}}{{- $itemWidth = 160 -}}{{- end -}}\n\n {{- if eq $k \"image\" -}}\n {{- if $singleVisual -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-visual-frame\"\n style=\"display: block; width: min(100%, calc(24rem * {{ printf \"%.4f\" $itemRatio }})); max-width: 100%;\">\n <img src=\"{{ .src }}\" alt=\"{{ .alt | default \"\" }}\"\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n loading=\"lazy\" decoding=\"async\"\n class=\"media-visual\"\n style=\"display: block; width: 100%; height: auto; border-radius: 0.5rem;\n {{- if and .width .height }} aspect-ratio: {{ .width }}/{{ .height }};{{- end }}\">\n </a>\n {{- else -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-visual-frame\"\n style=\"display: block; flex-shrink: 0; width: {{ $itemWidth }}px; max-width: 85%;\">\n <img src=\"{{ .src }}\" alt=\"{{ .alt | default \"\" }}\"\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n loading=\"lazy\" decoding=\"async\"\n class=\"media-visual\"\n style=\"display: block; width: 100%; height: {{ $rowHeight }}px; object-fit: cover;\">\n </a>\n {{- end -}}\n {{- else if eq $k \"video\" -}}\n {{- $isShort := false -}}\n {{- if and .mime_type (hasPrefix .mime_type \"video/\") .duration_seconds -}}\n {{- if and (le (int .duration_seconds) 15) (or (not .size) (le (int .size) 12582912)) -}}\n {{- $isShort = true -}}\n {{- end -}}\n {{- end -}}\n {{- $videoWrapStyle := \"position: relative;\" -}}\n {{- if $singleVisual -}}\n {{- $videoWrapStyle = printf \"%s display: block; width: min(100%%, calc(24rem * %s)); max-width: 100%%;\" $videoWrapStyle (printf \"%.4f\" $itemRatio) -}}\n {{- else -}}\n {{- $videoWrapStyle = printf \"%s display: block; flex-shrink: 0; width: %dpx; max-width: 85%%;\" $videoWrapStyle $itemWidth -}}\n {{- end -}}\n {{- /* Fit travels with the size, as in MediaGallery.tsx, and\n site-media.css sets none: a clip in a row is cropped to the\n row's height like the pictures beside it; a lone clip is\n letterboxed in case its frames disagree with the stored size. */ -}}\n {{- $videoStyle := cond $singleVisual\n (cond (and (gt (int (default 0 .width)) 0) (gt (int (default 0 .height)) 0))\n (printf \"display: block; width: 100%%; height: auto; aspect-ratio: %d/%d; object-fit: contain;\" (int .width) (int .height))\n \"display: block; width: 100%; height: auto; object-fit: contain;\")\n (printf \"display: block; width: 100%%; height: %dpx; object-fit: cover;\" $rowHeight) -}}\n {{- if $isShort -}}\n <div class=\"media-video-wrap media-video-wrap-short\" style=\"{{ $videoWrapStyle | safeCSS }}\">\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\" class=\"media-visual-frame media-video-link\" style=\"display: block; cursor: pointer;\">\n <video preload=\"none\" muted playsinline loop\n {{- with .poster }} poster=\"{{ . }}\"{{- end -}}\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n data-feed-short-video=\"\"\n data-video-src=\"{{ .src }}\"\n data-feed-video-id=\"{{ .id }}\"\n class=\"media-visual\"\n style=\"{{ $videoStyle | safeCSS }}\"></video>\n </a>\n <button type=\"button\" class=\"media-feed-video-mute\" data-feed-video-mute-toggle data-muted=\"true\" aria-label=\"Play with sound\">\n <svg class=\"media-feed-video-icon media-feed-video-icon-muted\" width=\"12\" height=\"12\" viewBox=\"0 0 48 48\" fill=\"currentColor\" aria-hidden=\"true\">\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1v-9.8c0-.4-.2-.8-.4-1.1l-22-22c-.3-.3-.7-.4-1.1-.4h-.6zm46.8 31.4-5.5-5.5C44.9 36.6 48 31.4 48 24c0-11.4-7.2-17.4-7.2-17.4-.6-.6-1.6-.6-2.2 0L37.2 8c-.6.6-.6 1.6 0 2.2 0 0 5.7 5 5.7 13.8 0 5.4-2.1 9.3-3.8 11.6L35.5 32c1.1-1.7 2.3-4.4 2.3-8 0-6.8-4.1-10.3-4.1-10.3-.6-.6-1.6-.6-2.2 0l-1.4 1.4c-.6.6-.6 1.6 0 2.2 0 0 2.6 2 2.6 6.7 0 1.8-.4 3.2-.9 4.3L25.5 22V1.4c0-1.3-1.6-1.9-2.5-1L13.5 10 3.3-.3c-.6-.6-1.5-.6-2.1 0L-.2 1.1c-.6.6-.6 1.5 0 2.1L4 7.6l26.8 26.8 13.9 13.9c.6.6 1.5.6 2.1 0l1.4-1.4c.7-.6.7-1.6.1-2.2z\"/>\n </svg>\n <svg class=\"media-feed-video-icon media-feed-video-icon-unmuted\" width=\"12\" height=\"12\" viewBox=\"0 0 48 48\" fill=\"currentColor\" aria-hidden=\"true\">\n <path d=\"M1.5 13.3c-.8 0-1.5.7-1.5 1.5v18.4c0 .8.7 1.5 1.5 1.5h8.7l12.9 12.9c.9.9 2.5.3 2.5-1V1.4c0-1.3-1.6-1.9-2.5-1L10.2 13.3H1.5z\"/>\n <path d=\"M30.1 15.9c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 4.1 3.5 4.1 11.7s-4.1 11.7-4.1 11.7c-.6.6-1.6.6-2.2 0l-1.4-1.4c-.6-.6-.6-1.6 0-2.2 0 0 2.6-2 2.6-8.1s-2.6-8.1-2.6-8.1z\"/>\n <path d=\"M37.2 8c-.6-.6-.6-1.6 0-2.2l1.4-1.4c.6-.6 1.6-.6 2.2 0 0 0 5.7 5.1 5.7 19.6s-5.7 19.6-5.7 19.6c-.6.6-1.6.6-2.2 0L37.2 42c-.6-.6-.6-1.6 0-2.2 0 0 4.3-4.4 4.3-15.8S37.2 8 37.2 8z\"/>\n </svg>\n </button>\n </div>\n {{- else -}}\n <a href=\"{{ .src }}\" data-lightbox-index=\"{{ ._lbIdx }}\"\n class=\"media-video-wrap media-visual-frame\"\n style=\"{{ $videoWrapStyle | safeCSS }}\">\n <video preload=\"none\" muted playsinline\n {{- with .poster }} poster=\"{{ . }}\"{{- end -}}\n {{- with .width }} width=\"{{ . }}\"{{- end -}}\n {{- with .height }} height=\"{{ . }}\"{{- end }}\n class=\"media-visual\"\n style=\"{{ $videoStyle | safeCSS }}\"></video>\n <div class=\"media-video-play-overlay\">\n <svg viewBox=\"0 0 24 24\" fill=\"white\"><path d=\"M8 5v14l11-7z\"/></svg>\n </div>\n </a>\n {{- end -}}\n {{- else if eq $k \"audio\" -}}\n {{- $audioName := .original_name | default (.alt | default \"Audio\") -}}\n <div class=\"media-gallery-card media-audio-card{{ if .waveform }} has-waveform{{ end }}\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <audio preload=\"none\" class=\"media-audio-el\">\n <source src=\"{{ .src }}\"{{ with .mime_type }} type=\"{{ . }}\"{{ end }}>\n </audio>\n <div class=\"media-audio-artwork\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M9 18V5l12-2v13\"/>\n <circle cx=\"6\" cy=\"18\" r=\"3\"/>\n <circle cx=\"18\" cy=\"16\" r=\"3\"/>\n </svg>\n </div>\n <div class=\"media-audio-controls\">\n <input type=\"range\" min=\"0\" max=\"1000\" value=\"0\" class=\"media-audio-range\" data-audio-range aria-label=\"Seek\">\n <canvas class=\"media-audio-waveform\" data-audio-waveform{{ with .waveform }} data-audio-peaks=\"{{ . }}\"{{ end }}></canvas>\n <div class=\"media-audio-row\">\n <div class=\"media-audio-info\">\n <div class=\"media-audio-title\" title=\"{{ $audioName }}\">{{ $audioName }}</div>\n <div class=\"media-audio-time\" data-audio-time>0:00</div>\n </div>\n <button type=\"button\" class=\"media-audio-play-btn\" data-audio-play aria-label=\"Play\">\n <svg class=\"media-audio-icon-play\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M8 5v14l11-7z\"/></svg>\n <svg class=\"media-audio-icon-pause\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M6 4h4v16H6zM14 4h4v16h-4z\"/></svg>\n </button>\n </div>\n </div>\n </div>\n {{- else if eq $k \"text\" -}}\n {{- /* Static site has no text-preview modal — link direct to file. */ -}}\n {{- $label := .summary | default (.original_name | default \"Attached text\") -}}\n <a href=\"{{ .src }}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"media-gallery-card\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <div class=\"media-gallery-card-inner\">\n <div class=\"media-gallery-card-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"24\" height=\"24\" aria-hidden=\"true\">\n <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/>\n <polyline points=\"14 2 14 8 20 8\"/>\n <line x1=\"8\" y1=\"13\" x2=\"16\" y2=\"13\"/>\n <line x1=\"8\" y1=\"17\" x2=\"16\" y2=\"17\"/>\n </svg>\n </div>\n <span class=\"media-gallery-card-summary\">{{ $label }}</span>\n {{- with .chars -}}\n <span class=\"media-gallery-card-meta\">{{ . }} chars</span>\n {{- end -}}\n </div>\n </a>\n {{- else -}}\n {{- /* document / file */ -}}\n {{- $label := .original_name | default (path.Base .src) -}}\n <a href=\"{{ .src }}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"media-gallery-card\"\n style=\"flex-shrink: 0; width: {{ $docCardWidth }}px; height: {{ $rowHeight }}px;\">\n <div class=\"media-gallery-card-inner\">\n <div class=\"media-gallery-card-icon\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" width=\"24\" height=\"24\" aria-hidden=\"true\">\n <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/>\n <polyline points=\"14 2 14 8 20 8\"/>\n </svg>\n </div>\n <span class=\"media-gallery-card-summary\">{{ $label }}</span>\n {{- with .size -}}\n <span class=\"media-gallery-card-meta\">{{ . }} B</span>\n {{- end -}}\n </div>\n </a>\n {{- end -}}\n {{- end -}}\n </div>\n {{- if not $singleVisual -}}\n <button type=\"button\" class=\"media-gallery-nav media-gallery-nav-prev\" tabindex=\"-1\" aria-label=\"Scroll to previous media\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M15 18l-6-6 6-6\"/></svg>\n </button>\n <button type=\"button\" class=\"media-gallery-nav media-gallery-nav-next\" tabindex=\"-1\" aria-label=\"Scroll to next media\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M9 18l6-6-6-6\"/></svg>\n </button>\n {{- end -}}\n </div>\n{{- end -}}\n";
|
|
4585
5982
|
//#endregion
|
|
4586
5983
|
//#region src/services/export-theme/layouts/partials/reply.html?raw
|
|
4587
|
-
var reply_default = "{{- $reply := . -}}\n{{- $format := $reply.Params.format | default \"note\" -}}\n{{- $slug := $reply.Params.slug | default $reply.Slug -}}\n{{- $media := $reply.Params.media | default slice -}}\n{{- $linkUrl := $reply.Params.link_url -}}\n<article class=\"reply thread-item reply-{{ $format }}\" id=\"{{ $slug }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\">\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"reply-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if $reply.Title -}}\n <h3 class=\"reply-title reply-link-title\">\n {{- if $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Title }}</a>\n {{- else -}}\n {{ $reply.Title }}\n {{- end -}}\n </h3>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"reply-quote post-card-quote\">\n {{- with $reply.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $reply.Params.source_name $reply.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $reply.Params.source_name $reply.Params.source_url -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Params.source_name }}</a>\n {{- else if $reply.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $reply.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $reply.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $reply.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- else -}}\n {{- with $reply.Title -}}\n <h3 class=\"reply-title\">{{ . }}</h3>\n {{- end -}}\n {{- end -}}\n\n {{- if eq $format \"quote\" -}}\n {{- with $reply.Content -}}\n <div class=\"reply-body post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n <div class=\"reply-body prose\">\n {{ $reply.Content }}\n </div>\n {{- end -}}\n\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" $reply.RelPermalink) }}\n\n <footer class=\"reply-footer post-menu-footer\" data-post-meta>\n <div class=\"post-footer-meta\">\n <a class=\"post-footer-link reply-anchor\" href=\"#{{ $slug }}\">\n <time datetime=\"{{ $reply.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $reply.Date.Format \"Jan 2, 2006 · 15:04\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n </div>\n </footer>\n</article>\n";
|
|
5984
|
+
var reply_default = "{{- $reply := . -}}\n{{- $format := $reply.Params.format | default \"note\" -}}\n{{- $slug := $reply.Params.slug | default $reply.Slug -}}\n{{- $media := $reply.Params.media | default slice -}}\n{{- $linkUrl := $reply.Params.link_url -}}\n<article class=\"reply thread-item reply-{{ $format }}\" id=\"{{ $slug }}\" data-format=\"{{ $format }}\" data-slug=\"{{ $slug }}\">\n {{- if eq $format \"link\" -}}\n {{- if $linkUrl -}}\n {{- $domain := \"\" -}}\n {{- with urls.Parse $linkUrl -}}{{- $domain = .Host -}}{{- end -}}\n {{- if $domain -}}\n <a href=\"{{ $linkUrl }}\" class=\"reply-link-domain\" rel=\"noopener noreferrer\" target=\"_blank\">\n <svg class=\"post-card-link-domain-icon\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"2\" stroke=\"currentColor\" aria-hidden=\"true\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n </svg>\n <span>{{ $domain }}</span>\n </a>\n {{- end -}}\n {{- end -}}\n {{- if $reply.Title -}}\n <h3 class=\"reply-title reply-link-title\">\n {{- if $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Title }}</a>\n {{- else -}}\n {{ $reply.Title }}\n {{- end -}}\n </h3>\n {{- end -}}\n {{- else if eq $format \"quote\" -}}\n <blockquote class=\"reply-quote post-card-quote\">\n {{- with $reply.Params.quote_text -}}\n <span class=\"post-card-quote-mark\" aria-hidden=\"true\">\n <svg viewBox=\"0 0 96 96\" role=\"presentation\" focusable=\"false\">\n <path fill=\"currentColor\" d=\"M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z\" />\n <path fill=\"currentColor\" d=\"M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z\" />\n </svg>\n </span>\n <div class=\"post-card-quote-content\">{{ . }}</div>\n {{- end -}}\n {{- if or $reply.Params.source_name $reply.Params.source_url -}}\n <div class=\"post-card-quote-attribution\">\n {{- if and $reply.Params.source_name $reply.Params.source_url -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ $reply.Params.source_name }}</a>\n {{- else if $reply.Params.source_name -}}\n <span class=\"post-card-quote-source\">{{ $reply.Params.source_name }}</span>\n {{- else -}}\n {{- $sourceDomain := \"\" -}}\n {{- with urls.Parse $reply.Params.source_url -}}\n {{- $sourceDomain = .Host | replaceRE \"^(?:www|m|mobile)\\\\.\" \"\" -}}\n {{- end -}}\n <a href=\"{{ $reply.Params.source_url }}\" class=\"post-card-quote-source\" rel=\"noopener noreferrer\" target=\"_blank\">{{ or $sourceDomain $reply.Params.source_url }}</a>\n {{- end -}}\n </div>\n {{- end -}}\n </blockquote>\n {{- else -}}\n {{- with $reply.Title -}}\n <h3 class=\"reply-title\">{{ . }}</h3>\n {{- end -}}\n {{- end -}}\n\n {{- if eq $format \"quote\" -}}\n {{- with $reply.Content -}}\n <div class=\"reply-body post-card-quote-commentary prose\">{{ . }}</div>\n {{- end -}}\n {{- else -}}\n <div class=\"reply-body prose\">\n {{ $reply.Content }}\n </div>\n {{- end -}}\n\n {{- /* Replies are not rendered as pages, so the address is the reply's alias on the root. */ -}}\n {{ partial \"media-gallery.html\" (dict \"media\" $media \"permalink\" ($reply.RelPermalink | default (printf \"/%s/\" $slug | relURL))) }}\n\n <footer class=\"reply-footer post-menu-footer\" data-post-meta>\n <div class=\"post-footer-meta\">\n <a class=\"post-footer-link reply-anchor\" href=\"#{{ $slug }}\">\n <time datetime=\"{{ $reply.Date.Format \"2006-01-02T15:04:05Z07:00\" }}\">\n {{ $reply.Date.Format \"Jan 2, 2006 · 15:04\" }}\n </time>\n </a>\n {{- if and (eq $format \"link\") $linkUrl -}}\n <a href=\"{{ $linkUrl }}\" class=\"post-footer-external-link\" target=\"_blank\" rel=\"noopener noreferrer\" aria-label=\"Open external link\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M7 17 17 7\" />\n <path d=\"M9 7h8v8\" />\n </svg>\n </a>\n {{- end -}}\n </div>\n </footer>\n</article>\n";
|
|
4588
5985
|
//#endregion
|
|
4589
5986
|
//#region src/services/export-theme/layouts/partials/thread-preview.html?raw
|
|
4590
|
-
var thread_preview_default = "{{- /*\n Thread preview partial.\n\n Matches the main site's `ThreadPreview` component
|
|
5987
|
+
var thread_preview_default = "{{- /*\n Thread preview partial.\n\n Matches the main site's `ThreadPreview` component, and the fold both it and\n the feed take from `lib/thread-fold.ts` (`foldThreadReplies`):\n root post (full card, as \"context\")\n the first THREAD_LEADING_REPLIES (2) (full cards, as \"context\")\n \"N more posts\" gap pill ← only when the fold hides any\n the two before the newest reply (full cards, as \"context\")\n newest reply (full card, as \"hero\")\n\n The trailing window is THREAD_TRAILING_REPLIES (3) with the hero inside it.\n On a short thread the windows overlap, so each list drops whatever an\n earlier one already shows, hero first — the same order the site dedupes in.\n\n Each entry is wrapped in a `.thread-item` so the rail dot markers\n line up the same way the main site does via `.thread-item::before`.\n\n When the root has no replies, falls through to a plain post-card.\n*/ -}}\n{{- $root := . -}}\n{{- $replies := where $root.Pages \"Params.visibility\" \"public\" -}}\n{{- $replies = $replies.ByDate -}}\n{{- $replyCount := len $replies -}}\n{{- if eq $replyCount 0 -}}\n {{ partial \"post-card.html\" $root }}\n{{- else -}}\n {{- $latest := index $replies (sub $replyCount 1) -}}\n {{- $leading := slice -}}\n {{- range first 2 $replies -}}\n {{- if ne .File.UniqueID $latest.File.UniqueID -}}\n {{- $leading = $leading | append . -}}\n {{- end -}}\n {{- end -}}\n {{- $trailing := slice -}}\n {{- range last 3 $replies -}}\n {{- $reply := . -}}\n {{- $shown := eq $reply.File.UniqueID $latest.File.UniqueID -}}\n {{- range $leading -}}\n {{- if eq .File.UniqueID $reply.File.UniqueID -}}{{- $shown = true -}}{{- end -}}\n {{- end -}}\n {{- if not $shown -}}{{- $trailing = $trailing | append $reply -}}{{- end -}}\n {{- end -}}\n\n {{- /* Whatever neither window shows is the hidden middle. It is never\n negative: the three lists are disjoint and all drawn from $replies. */ -}}\n {{- $hiddenCount := sub $replyCount (add (add (len $leading) (len $trailing)) 1) -}}\n {{- /* The gap leads to the first hidden reply, as the site's does. A fold\n only hides anything once both windows are full, so that reply sits\n right after the leading window. Replies are not rendered as pages\n (`build: render: never`), so their `.RelPermalink` is empty; the\n exporter registers every reply slug as an alias on the root, and\n that is the address a reply has. */ -}}\n {{- $gapHref := $root.RelPermalink -}}\n {{- if gt $hiddenCount 0 -}}{{- $gapHref = printf \"/%s/\" (index $replies 2).Slug | relURL -}}{{- end -}}\n\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n <article class=\"thread-preview\" data-slug=\"{{ $slug }}\" data-reply-count=\"{{ $replyCount }}\">\n <div class=\"thread-preview-context\">\n <div class=\"thread-item thread-item-context thread-item-root\">\n {{ partial \"post-card.html\" $root }}\n </div>\n\n {{- range $leading -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $gapHref }}\">\n {{ $hiddenCount }} more {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n\n {{- range $trailing -}}\n <div class=\"thread-item thread-item-context\">\n {{ partial \"post-card.html\" . }}\n </div>\n {{- end -}}\n </div>\n\n <div class=\"thread-item thread-item-hero thread-preview-hero\">\n {{ partial \"post-card.html\" $latest }}\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread →\n </a>\n </div>\n </article>\n{{- end -}}\n";
|
|
4591
5988
|
//#endregion
|
|
4592
5989
|
//#region src/services/export-theme/layouts/partials/featured-thread.html?raw
|
|
4593
5990
|
var featured_thread_default = "{{- /*\n Curated Featured Thread.\n\n The Root carries a derived `featured_post_ids` projection. Render the Root,\n every selected Featured Post, and the final Post once, preserving the real\n Thread positions so omitted runs can be represented as gap links.\n*/ -}}\n{{- $root := . -}}\n{{- $featuredIds := $root.Params.featured_post_ids | default slice -}}\n{{- $posts := slice $root -}}\n{{- range $root.Pages.ByDate -}}\n {{- $posts = $posts | append . -}}\n{{- end -}}\n{{- $lastIndex := sub (len $posts) 1 -}}\n{{- $visible := slice -}}\n\n{{- range $index, $post := $posts -}}\n {{- $postId := string ($post.Params.id | default \"\") -}}\n {{- $highlighted := in $featuredIds $postId -}}\n {{- if or (eq $index 0) $highlighted (eq $index $lastIndex) -}}\n {{- $visible = $visible | append (dict\n \"post\" $post\n \"position\" $index\n \"highlighted\" $highlighted\n ) -}}\n {{- end -}}\n{{- end -}}\n\n{{- if eq (len $visible) 1 -}}\n {{- partial \"post-card.html\" $root -}}\n{{- else -}}\n {{- $slug := $root.Params.slug | default $root.Slug -}}\n {{- $previousPosition := -1 -}}\n <article class=\"thread-preview thread-preview-curated\" data-slug=\"{{ $slug }}\">\n <div class=\"thread-preview-context\">\n {{- range $entry := $visible -}}\n {{- $position := int $entry.position -}}\n {{- $hiddenCount := sub $position (add $previousPosition 1) -}}\n {{- if gt $hiddenCount 0 -}}\n <div class=\"thread-item thread-item-gap\">\n <a class=\"thread-preview-gap\" href=\"{{ $root.RelPermalink }}\">\n {{ $hiddenCount }} hidden {{ if eq $hiddenCount 1 }}post{{ else }}posts{{ end }}\n </a>\n </div>\n {{- end -}}\n <div class=\"thread-item {{ if $entry.highlighted }}thread-item-featured{{ else }}thread-item-context{{ end }}\">\n {{ partial \"post-card.html\" $entry.post }}\n </div>\n {{- $previousPosition = $position -}}\n {{- end -}}\n </div>\n <a class=\"thread-preview-thread-link\" href=\"{{ $root.RelPermalink }}\">\n View full thread →\n </a>\n </article>\n{{- end -}}\n";
|
|
4594
5991
|
//#endregion
|
|
4595
5992
|
//#region src/services/export-theme/layouts/_default/rss.xml?raw
|
|
4596
|
-
var rss_default = "{{- /*\n Atom 2005 feed template — mirrors the main site's `lib/feed.ts`.\n\n One template covers every page that opts into the custom `RSS` output\n format (home, featured, archive, and each collection section). Root-post\n sections do NOT opt in, so no `/{slug}/index.xml` gets emitted.\n\n Feed filter parity with the corresponding HTML list view:\n - home (`.Kind == \"home\"`): visibility == \"public\" (drops latest_hidden)\n - featured (`.Type == \"featured\"`): one Root per Thread whose derived\n featured projection is non-empty, ordered by selected Post publication\n - archive (`.Type == \"archive\"`): all published (includes latest_hidden)\n - collection (`.Type == \"collection\"`): Params.collections.slug match,\n ordered per `sort_order`; pinning is ignored for RSS\n\n Per-entry payload mirrors `buildSinglePostContent` + `buildFeedContent`:\n - Title: empty for quote posts, else .Title\n - Link rel=alternate: external URL for `link` format, else permalink;\n link-format posts also get a <link rel=\"related\"> back to the\n permalink\n - Content (CDATA):\n quote → <blockquote cite><p>{quote}</p></blockquote>\n + <p>— <a href=url>{source}</a></p>\n body → Hugo-rendered .Content (stripped of <script>/<style>)\n rating → <p>★★★★☆ 4/5</p>\n link → trailing <p><a href=permalink> ★ </a></p>\n replies → <hr/> + <p><small><time>{dt}</time></small></p>\n + inline title/link metadata + content\n\n Hugo's default RSS media-type override (`[outputFormats.RSS]`) makes this\n template produce application/atom+xml at /{section}/index.xml.\n*/ -}}\n{{- $page := . -}}\n{{- $limit := int (.Site.Params.rss_feed_limit | default 50) -}}\n{{- $allPosts := where .Site.Pages \"Type\" \"post\" -}}\n{{- $rootPosts := where $allPosts \"Kind\" \"section\" -}}\n\n{{- /* ------------------------------------------------------------------\n Post selection per feed kind\n ------------------------------------------------------------------ */ -}}\n{{- $posts := slice -}}\n{{- $isFeatured := eq $page.Type \"featured\" -}}\n\n{{- if eq $page.Kind \"home\" -}}\n {{- $public := where $allPosts \"Params.visibility\" \"public\" -}}\n {{- $posts = $public.ByDate.Reverse -}}\n{{- else if $isFeatured -}}\n {{- $featured := where $rootPosts \"Params.featured_sort_at\" \"ne\" nil -}}\n {{- $posts = sort $featured \"Params.featured_sort_at\" \"desc\" -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $posts = $allPosts.ByDate.Reverse -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $collectionSlug := $page.Params.slug | default $page.Slug -}}\n {{- $members := slice -}}\n {{- range $rootPosts -}}\n {{- $post := . -}}\n {{- with $post.Params.collections -}}\n {{- range . -}}\n {{- if eq .slug $collectionSlug -}}\n {{- $members = $members | append (dict \"post\" $post \"entry\" .) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $sortOrder := $page.Params.sort_order | default \"position\" -}}\n {{- $ordered := $members -}}\n {{- if $ordered -}}\n {{- if eq $sortOrder \"collected_at_desc\" -}}\n {{- $ordered = sort $ordered \"entry.collected_at\" \"desc\" -}}\n {{- else -}}\n {{- $ordered = sort $ordered \"entry.position\" \"asc\" -}}\n {{- end -}}\n {{- end -}}\n {{- range $ordered -}}{{- $posts = $posts | append .post -}}{{- end -}}\n{{- end -}}\n\n{{- if gt (len $posts) $limit -}}\n {{- $posts = first $limit $posts -}}\n{{- end -}}\n\n{{- /* ------------------------------------------------------------------\n Feed title (mirrors the main site's Atom feed titles)\n ------------------------------------------------------------------ */ -}}\n{{- $feedTitle := .Site.Title -}}\n{{- if eq $page.Kind \"home\" -}}\n {{- $feedTitle = printf \"%s - Latest posts\" .Site.Title -}}\n{{- else if $isFeatured -}}\n {{- $feedTitle = printf \"%s - Featured posts\" .Site.Title -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $feedTitle = printf \"%s - Archive\" .Site.Title -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $feedTitle = printf \"%s - %s\" .Site.Title $page.Title -}}\n{{- end -}}\n\n{{- $siteDescription := .Site.Params.description | default \"\" -}}\n{{- $siteBase := .Site.BaseURL -}}\n{{- $selfUrl := .Permalink -}}\n{{- $now := now.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n <title>{{ $feedTitle | transform.XMLEscape }}</title>\n <subtitle>{{ $siteDescription | transform.XMLEscape }}</subtitle>\n <link href=\"{{ $siteBase | transform.XMLEscape }}\" rel=\"alternate\"/>\n <link href=\"{{ $selfUrl | transform.XMLEscape }}\" rel=\"self\"/>\n <id>{{ $selfUrl | transform.XMLEscape }}</id>\n <updated>{{ $now }}</updated>\n {{- range $posts }}\n {{- $post := . -}}\n {{- $format := .Params.format | default \"note\" -}}\n {{- $permalink := .Permalink -}}\n {{- $isLink := and (eq $format \"link\") .Params.link_url -}}\n {{- $alternate := cond $isLink (string .Params.link_url) $permalink -}}\n {{- /* Atom <title>: empty for quotes (matches main site's getAtomTitle). */ -}}\n {{- $entryTitle := cond (eq $format \"quote\") \"\" (.Title | default \"\") -}}\n {{- /* <published>: from `date` frontmatter (= publishedAt).\n <updated>: prefer `last_activity_at` (thread-bumped), then `updated`\n (root edit time), else `date`. Featured membership does not rewrite\n content time or chronological ordering. */ -}}\n {{- $published := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- $updatedSrc := .Date -}}\n {{- with .Params.last_activity_at -}}{{- $updatedSrc = time . -}}\n {{- else -}}{{- with .Params.updated -}}{{- $updatedSrc = time . -}}{{- end -}}{{- end -}}\n {{- $updated := $updatedSrc.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Plain-text summary fallback chain (mirrors getFeedSummaryText). */ -}}\n {{- $summaryText := \"\" -}}\n {{- if eq $format \"quote\" -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (string (.Params.quote_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) (string (.Params.source_url | default \"\")) -}}\n {{- else -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) -}}\n {{- end -}}\n {{- if eq $summaryText \"\" -}}{{- $summaryText = printf \"Post %s\" (string (.Params.id | default \"\")) -}}{{- end -}}\n <entry>\n <title>{{ $entryTitle | transform.XMLEscape }}</title>\n <link href=\"{{ $alternate | transform.XMLEscape }}\" rel=\"alternate\"/>\n {{- if $isLink }}\n <link href=\"{{ $permalink | transform.XMLEscape }}\" rel=\"related\"/>\n {{- end }}\n <id>{{ $permalink | transform.XMLEscape }}</id>\n <published>{{ $published }}</published>\n <updated>{{ $updated }}</updated>\n <summary type=\"text\">{{ $summaryText | transform.XMLEscape }}</summary>\n <content type=\"html\"><![CDATA[\n{{- partial \"feed-post-content.xml\" (dict \"post\" $post \"permalink\" $permalink \"includeReplies\" true) -}}\n]]></content>\n </entry>\n {{- end }}\n</feed>\n";
|
|
5993
|
+
var rss_default = "{{- /*\n Atom 2005 feed template — mirrors the main site's `lib/feed.ts`.\n\n One template covers every page that opts into the custom `RSS` output\n format (home, featured, archive, and each collection section). Root-post\n sections do NOT opt in, so no `/{slug}/index.xml` gets emitted.\n\n Feed filter parity with the corresponding HTML list view:\n - home (`.Kind == \"home\"`): visibility == \"public\" (drops latest_hidden)\n - featured (`.Type == \"featured\"`): one Root per Thread whose derived\n featured projection is non-empty, ordered by selected Post publication\n - archive (`.Type == \"archive\"`): all published (includes latest_hidden)\n - collection (`.Type == \"collection\"`): Params.collections.slug match,\n ordered per `sort_order`; pinning is ignored for RSS\n\n Per-entry payload mirrors `buildSinglePostContent` + `buildFeedContent`:\n - Title: empty for quote posts, else .Title\n - Link rel=alternate: external URL for `link` format, else permalink;\n link-format posts also get a <link rel=\"related\"> back to the\n permalink\n - Content (CDATA):\n quote → <blockquote cite><p>{quote}</p></blockquote>\n + <p>— <a href=url>{source}</a></p>\n body → Hugo-rendered .Content (stripped of <script>/<style>)\n rating → <p>★★★★☆ 4/5</p>\n link → trailing <p><a href=permalink> ★ </a></p>\n replies → <hr/> + <p><small><time>{dt}</time></small></p>\n + inline title/link metadata + content\n\n Hugo's default RSS media-type override (`[outputFormats.RSS]`) makes this\n template produce application/atom+xml at /{section}/index.xml.\n*/ -}}\n{{- $page := . -}}\n{{- $limit := int (.Site.Params.rss_feed_limit | default 50) -}}\n{{- $allPosts := where .Site.Pages \"Type\" \"post\" -}}\n{{- $rootPosts := where $allPosts \"Kind\" \"section\" -}}\n\n{{- /* ------------------------------------------------------------------\n Post selection per feed kind\n ------------------------------------------------------------------ */ -}}\n{{- $posts := slice -}}\n{{- $isFeatured := eq $page.Type \"featured\" -}}\n\n{{- if eq $page.Kind \"home\" -}}\n {{- $public := where $allPosts \"Params.visibility\" \"public\" -}}\n {{- $posts = $public.ByDate.Reverse -}}\n{{- else if $isFeatured -}}\n {{- $featured := where $rootPosts \"Params.featured_sort_at\" \"ne\" nil -}}\n {{- $posts = sort $featured \"Params.featured_sort_at\" \"desc\" -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $posts = $allPosts.ByDate.Reverse -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $collectionSlug := $page.Params.slug | default $page.Slug -}}\n {{- $members := slice -}}\n {{- range $rootPosts -}}\n {{- $post := . -}}\n {{- with $post.Params.collections -}}\n {{- range . -}}\n {{- if eq .slug $collectionSlug -}}\n {{- $members = $members | append (dict \"post\" $post \"entry\" .) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $sortOrder := $page.Params.sort_order | default \"position\" -}}\n {{- $ordered := $members -}}\n {{- if $ordered -}}\n {{- if eq $sortOrder \"collected_at_desc\" -}}\n {{- $ordered = sort $ordered \"entry.collected_at\" \"desc\" -}}\n {{- else -}}\n {{- $ordered = sort $ordered \"entry.position\" \"asc\" -}}\n {{- end -}}\n {{- end -}}\n {{- range $ordered -}}{{- $posts = $posts | append .post -}}{{- end -}}\n{{- end -}}\n\n{{- if gt (len $posts) $limit -}}\n {{- $posts = first $limit $posts -}}\n{{- end -}}\n\n{{- /* ------------------------------------------------------------------\n Feed title (mirrors the main site's Atom feed titles)\n ------------------------------------------------------------------ */ -}}\n{{- $feedTitle := .Site.Title -}}\n{{- if eq $page.Kind \"home\" -}}\n {{- $feedTitle = printf \"%s - Latest posts\" .Site.Title -}}\n{{- else if $isFeatured -}}\n {{- $feedTitle = printf \"%s - Featured posts\" .Site.Title -}}\n{{- else if eq $page.Type \"archive\" -}}\n {{- $feedTitle = printf \"%s - Archive\" .Site.Title -}}\n{{- else if eq $page.Type \"collection\" -}}\n {{- $feedTitle = printf \"%s - %s\" .Site.Title $page.Title -}}\n{{- end -}}\n\n{{- $siteDescription := .Site.Params.description | default \"\" -}}\n{{- $siteBase := .Site.BaseURL -}}\n{{- $selfUrl := .Permalink -}}\n{{- $now := now.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{{- /* Namespaces ride along only when something in them is emitted, the same\n rule `lib/feed.ts` follows. */ -}}\n{{- $anyMedia := false -}}\n{{- range $posts -}}\n {{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}\n {{- range .Pages -}}{{- if .Params.media -}}{{- $anyMedia = true -}}{{- end -}}{{- end -}}\n{{- end -}}\n{{- $jantNs := cond (gt (len $posts) 0) ` xmlns:jant=\"https://jant.me/ns\"` \"\" -}}\n{{- $mediaNs := cond $anyMedia ` xmlns:media=\"http://search.yahoo.com/mrss/\"` \"\" -}}\n<feed xmlns=\"http://www.w3.org/2005/Atom\"{{ $jantNs | safeHTMLAttr }}{{ $mediaNs | safeHTMLAttr }}>\n <title>{{ $feedTitle | transform.XMLEscape }}</title>\n <subtitle>{{ $siteDescription | transform.XMLEscape }}</subtitle>\n <link href=\"{{ $siteBase | transform.XMLEscape }}\" rel=\"alternate\"/>\n <link href=\"{{ $selfUrl | transform.XMLEscape }}\" rel=\"self\"/>\n <id>{{ $selfUrl | transform.XMLEscape }}</id>\n <updated>{{ $now }}</updated>\n {{- range $posts }}\n {{- $post := . -}}\n {{- $format := .Params.format | default \"note\" -}}\n {{- $permalink := .Permalink -}}\n {{- $isLink := and (eq $format \"link\") .Params.link_url -}}\n {{- $alternate := cond $isLink (string .Params.link_url) $permalink -}}\n {{- /* Atom <title>: empty for quotes (matches main site's getAtomTitle). */ -}}\n {{- $entryTitle := cond (eq $format \"quote\") \"\" (.Title | default \"\") -}}\n {{- /* <published>: from `date` frontmatter (= publishedAt).\n <updated>: prefer `last_activity_at` (thread-bumped), then `updated`\n (root edit time), else `date`. Featured membership does not rewrite\n content time or chronological ordering. */ -}}\n {{- $published := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- $updatedSrc := .Date -}}\n {{- with .Params.last_activity_at -}}{{- $updatedSrc = time . -}}\n {{- else -}}{{- with .Params.updated -}}{{- $updatedSrc = time . -}}{{- end -}}{{- end -}}\n {{- $updated := $updatedSrc.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Plain-text summary fallback chain (mirrors getFeedSummaryText). */ -}}\n {{- $summaryText := \"\" -}}\n {{- if eq $format \"quote\" -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (string (.Params.quote_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) (string (.Params.source_url | default \"\")) -}}\n {{- else -}}\n {{- $summaryText = or (string (.Params.summary_text | default \"\")) (.Title | default \"\") (string (.Params.link_url | default \"\")) -}}\n {{- end -}}\n {{- if eq $summaryText \"\" -}}{{- $summaryText = printf \"Post %s\" (string (.Params.id | default \"\")) -}}{{- end -}}\n\n {{- /* Whether the timeline cut the text of any post the entry's card shows.\n Per post it is written into front matter by the exporter, which shares\n the boundary with the served feed; Hugo's own summary rule would cut\n somewhere else. Per entry the rule is `isEntryTruncated` in\n `lib/feed.ts`: the root plus every reply the fold keeps — the first\n THREAD_LEADING_REPLIES (2) and the last THREAD_TRAILING_REPLIES (3),\n newest included — as `lib/thread-fold.ts` defines it. A thread of five\n replies or fewer is shown whole; `first` and `last` clamp to the slice,\n so the two windows simply overlap there. */ -}}\n {{- $replies := (where $post.Pages \"Params.visibility\" \"public\").ByDate -}}\n {{- $truncated := $post.Params.truncated | default false -}}\n {{- range first 2 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n {{- range last 3 $replies -}}\n {{- if .Params.truncated -}}{{- $truncated = true -}}{{- end -}}\n {{- end -}}\n\n {{- /* Every attachment in the thread, each paired with the post that carries\n it — a text attachment's page lives under its own post. */ -}}\n {{- $entryMedia := slice -}}\n {{- range ($post.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $permalink) -}}\n {{- end -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- range (.Params.media | default slice) -}}\n {{- $entryMedia = $entryMedia | append (dict \"m\" . \"page\" $replyPermalink) -}}\n {{- end -}}\n {{- end -}}\n <entry>\n <title>{{ $entryTitle | transform.XMLEscape }}</title>\n <link href=\"{{ $alternate | transform.XMLEscape }}\" rel=\"alternate\"/>\n {{- if $isLink }}\n <link href=\"{{ $permalink | transform.XMLEscape }}\" rel=\"related\"/>\n {{- end }}\n {{- /* Images carry no enclosure: the content already shows them full size\n inside a link to the original, so one would only ask an attachment\n shelf to list the picture again. */ -}}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- if ne (string ($m.kind | default \"document\")) \"image\" }}\n <link rel=\"enclosure\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" href=\"{{ (string $m.src) | absURL | transform.XMLEscape }}\"{{ with $m.size }} length=\"{{ . }}\"{{ end }}{{ with $m.original_name }} title=\"{{ . | transform.XMLEscape }}\"{{ end }}/>\n {{- end }}\n {{- end }}\n <id>{{ $permalink | transform.XMLEscape }}</id>\n <published>{{ $published }}</published>\n <updated>{{ $updated }}</updated>\n <jant:format>{{ $format | transform.XMLEscape }}</jant:format>\n {{- if $truncated }}\n <jant:truncated/>\n {{- end }}\n {{- /* A collection is a label the author chose, which is what <category>\n is for. `jant:page` carries the URL because a single collection lives\n in the root URL namespace. */ -}}\n {{- range ($post.Params.collections | default slice) }}\n <category term=\"{{ (string .slug) | transform.XMLEscape }}\" label=\"{{ (string (.title | default .slug)) | transform.XMLEscape }}\" jant:page=\"{{ (printf \"/%s/\" (string .slug)) | absURL | transform.XMLEscape }}\"/>\n {{- end }}\n {{- range $entryMedia }}\n {{- $m := .m }}\n {{- $kind := string ($m.kind | default \"document\") }}\n {{- $medium := cond (or (eq $kind \"image\") (or (eq $kind \"video\") (eq $kind \"audio\"))) $kind \"document\" }}\n {{- $fileUrl := (string $m.src) | absURL }}\n {{- /* Where a click should land. Only emitted where it is not the file:\n a text attachment's markdown would download or dump unstyled. */ -}}\n {{- $pageUrl := $fileUrl }}\n {{- if eq $kind \"text\" }}{{ $pageUrl = printf \"%s/text/%s\" (strings.TrimSuffix \"/\" .page) (string $m.id) }}{{ end }}\n {{- $desc := string ($m.alt | default ($m.summary | default \"\")) }}\n {{- $poster := string ($m.poster | default \"\") }}\n <media:content url=\"{{ $fileUrl | transform.XMLEscape }}\" type=\"{{ (string ($m.mime_type | default \"application/octet-stream\")) | transform.XMLEscape }}\" medium=\"{{ $medium }}\"{{ with $m.size }} fileSize=\"{{ . }}\"{{ end }}{{ with $m.width }} width=\"{{ . }}\"{{ end }}{{ with $m.height }} height=\"{{ . }}\"{{ end }}{{ with $m.duration_seconds }} duration=\"{{ math.Round . }}\"{{ end }}{{ if ne $pageUrl $fileUrl }} jant:page=\"{{ $pageUrl | transform.XMLEscape }}\"{{ end }}>\n {{- with $m.original_name }}<media:title type=\"plain\">{{ . | transform.XMLEscape }}</media:title>{{ end }}\n {{- if ne $desc \"\" }}<media:description type=\"plain\">{{ $desc | transform.XMLEscape }}</media:description>{{ end }}\n {{- if ne $poster \"\" }}<media:thumbnail url=\"{{ $poster | absURL | transform.XMLEscape }}\"/>{{ end -}}\n </media:content>\n {{- end }}\n <summary type=\"text\">{{ $summaryText | transform.XMLEscape }}</summary>\n <content type=\"html\"><![CDATA[\n{{- partial \"feed-post-content.xml\" (dict \"post\" $post \"permalink\" $permalink \"includeReplies\" true) -}}\n]]></content>\n </entry>\n {{- end }}\n</feed>\n";
|
|
4597
5994
|
//#endregion
|
|
4598
5995
|
//#region src/services/export-theme/layouts/partials/feed-post-content.xml?raw
|
|
4599
|
-
var feed_post_content_default = "{{- /*\n Feed entry content builder — mirrors `buildSinglePostContent` +\n `buildFeedContent` from `packages/core/src/lib/feed.ts`.\n\n Arguments (dict):\n post — the Hugo Page for this post\n permalink — absolute permalink to the blog post (used for the\n link-format \"★\" suffix)\n includeReplies — when true, appends each public reply separated by\n <hr/> + <small><time></time></small>\n inline — when true, renders title/link metadata that top-level\n Atom entries expose through their entry fields\n\n Output is raw HTML suitable for embedding inside Atom\n <content type=\"html\"><![CDATA[...]]></content>. Since CDATA ends at the\n first `]]>`, any such sequence in rendered bodies is split using\n `replace ... \"]]>\" \"]]]]><![CDATA[>\"` — the same trick the main site uses\n in `escapeCdata`.\n*/ -}}\n{{- $post := .post -}}\n{{- $permalink := .permalink -}}\n{{- $includeReplies := .includeReplies -}}\n{{- $inline := false -}}\n{{- with .inline -}}{{- $inline = . -}}{{- end -}}\n{{- $format := $post.Params.format | default \"note\" -}}\n{{- $parts := slice -}}\n\n{{- /* --- Inline title/link metadata for thread replies --- */ -}}\n{{- if and $inline (ne $format \"quote\") -}}\n {{- $title := $post.Title | default \"\" -}}\n {{- $linkUrl := string ($post.Params.link_url | default \"\") -}}\n {{- if eq $format \"link\" -}}\n {{- if ne $linkUrl \"\" -}}\n {{- $domain := $linkUrl -}}\n {{- with urls.Parse $linkUrl -}}{{- if .Host -}}{{- $domain = replaceRE `^(www|m|mobile)\\.` \"\" .Host -}}{{- end -}}{{- end -}}\n {{- $parts = $parts | append (printf `<p><a href=%q>%s</a></p>` $linkUrl (transform.XMLEscape $domain)) -}}\n {{- end -}}\n {{- if ne $title \"\" -}}\n {{- $titleHref := $permalink -}}\n {{- if ne $linkUrl \"\" -}}{{- $titleHref = $linkUrl -}}{{- end -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $titleHref (transform.XMLEscape $title)) -}}\n {{- end -}}\n {{- else if ne $title \"\" -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $permalink (transform.XMLEscape $title)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Quote block (format=quote) --- */ -}}\n{{- if eq $format \"quote\" -}}\n {{- $quoteText := string ($post.Params.quote_text | default \"\") -}}\n {{- if ne $quoteText \"\" -}}\n {{- $sourceName := string ($post.Params.source_name | default \"\") -}}\n {{- $sourceUrl := string ($post.Params.source_url | default \"\") -}}\n {{- $cite := \"\" -}}\n {{- if ne $sourceUrl \"\" -}}{{- $cite = printf ` cite=%q` $sourceUrl -}}{{- end -}}\n {{- $block := printf \"<blockquote%s><p>%s</p></blockquote>\" $cite (transform.XMLEscape $quoteText) -}}\n {{- $parts = $parts | append $block -}}\n {{- /* Attribution line — prefer name, fall back to URL host or the\n raw URL itself (mirrors main site's extractDisplayDomain fallback\n chain in `lib/url.ts`). */ -}}\n {{- $attribution := \"\" -}}\n {{- if and (ne $sourceName \"\") (ne $sourceUrl \"\") -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $sourceName) -}}\n {{- else if ne $sourceName \"\" -}}\n {{- $attribution = transform.XMLEscape $sourceName -}}\n {{- else if ne $sourceUrl \"\" -}}\n {{- $host := $sourceUrl -}}\n {{- with urls.Parse $sourceUrl -}}{{- if .Host -}}{{- $host = .Host -}}{{- end -}}{{- end -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $host) -}}\n {{- end -}}\n {{- if ne $attribution \"\" -}}\n {{- $parts = $parts | append (printf \"<p>— %s</p>\" $attribution) -}}\n {{- end -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Rendered body HTML (with <script>/<style> stripped) --- */ -}}\n{{- $body := $post.Content -}}\n{{- if ne (string $body) \"\" -}}\n {{- $bodyStr := string $body -}}\n {{- $bodyStr = replaceRE `(?is)<script\\b[^>]*>.*?<\/script>` \"\" $bodyStr -}}\n {{- $bodyStr = replaceRE `(?is)<style\\b[^>]*>.*?</style>` \"\" $bodyStr -}}\n {{- /* Split any CDATA-terminator sequences so the content can be safely\n wrapped in <![CDATA[...]]> by the caller. */ -}}\n {{- $bodyStr = replace $bodyStr \"]]>\" \"]]]]><![CDATA[>\" -}}\n {{- if ne (trim $bodyStr \" \\t\\n\\r\") \"\" -}}\n {{- $parts = $parts | append $bodyStr -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Star rating --- */ -}}\n{{- with $post.Params.rating -}}\n {{- $r := int . -}}\n {{- if gt $r 0 -}}\n {{- $filled := strings.Repeat $r \"★\" -}}\n {{- $empty := strings.Repeat (sub 5 $r) \"☆\" -}}\n {{- $parts = $parts | append (printf \"<p>%s%s %d/5</p>\" $filled $empty $r) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Empty-body fallback (plain-text summary) --- */ -}}\n{{- if eq (len $parts) 0 -}}\n {{- $fallback := string ($post.Params.summary_text | default \"\") -}}\n {{- if eq $fallback \"\" -}}{{- $fallback = $post.Title | default (printf \"Post %s\" (string ($post.Params.id | default \"\"))) -}}{{- end -}}\n {{- $parts = $parts | append (printf \"<p>%s</p>\" (transform.XMLEscape $fallback)) -}}\n{{- end -}}\n\n{{- /* --- link-format permalink suffix (Daring Fireball ★) --- */ -}}\n{{- if and (eq $format \"link\") (ne $permalink \"\") -}}\n {{- $parts = $parts | append (printf `<p><a href=%q title=\"Permalink\"> ★ </a></p>` $permalink) -}}\n{{- end -}}\n\n{{- /* --- Thread replies --- */ -}}\n{{- if $includeReplies -}}\n {{- $replies := where $post.Pages \"Params.visibility\" \"public\" -}}\n {{- $replies = $replies.ByDate -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- $replyDatetime := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Human-readable timestamp; mirrors the main site's\n `publishedAtFormatted` in the feed — there it's locale-formatted\n by `lib/format-timestamp.ts`, but Hugo doesn't have that at\n export time, so use a simple ISO-style fallback. */ -}}\n {{- $replyLabel := .Date.Format \"Jan 2, 2006 15:04\" -}}\n {{- $parts = $parts | append \"<hr/>\" -}}\n {{- $parts = $parts | append (printf `<p><small><time datetime=%q>%s</time></small></p>` $replyDatetime (transform.XMLEscape $replyLabel)) -}}\n {{- $parts = $parts | append (partial \"feed-post-content.xml\" (dict \"post\" . \"permalink\" $replyPermalink \"includeReplies\" false \"inline\" true)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- delimit $parts \"\\n\" | safeHTML -}}\n";
|
|
5996
|
+
var feed_post_content_default = "{{- /*\n Feed entry content builder — mirrors `buildSinglePostContent` +\n `buildFeedContent` from `packages/core/src/lib/feed.ts`.\n\n Arguments (dict):\n post — the Hugo Page for this post\n permalink — absolute permalink to the blog post (used for the\n link-format \"★\" suffix)\n includeReplies — when true, appends each public reply separated by\n <hr/> + <small><time></time></small>\n inline — when true, renders title/link metadata that top-level\n Atom entries expose through their entry fields\n\n Output is raw HTML suitable for embedding inside Atom\n <content type=\"html\"><![CDATA[...]]></content>. Since CDATA ends at the\n first `]]>`, any such sequence in rendered bodies is split using\n `replace ... \"]]>\" \"]]]]><![CDATA[>\"` — the same trick the main site uses\n in `escapeCdata`.\n*/ -}}\n{{- $post := .post -}}\n{{- $permalink := .permalink -}}\n{{- $includeReplies := .includeReplies -}}\n{{- $inline := false -}}\n{{- with .inline -}}{{- $inline = . -}}{{- end -}}\n{{- $format := $post.Params.format | default \"note\" -}}\n{{- $parts := slice -}}\n\n{{- /* --- Inline title/link metadata for thread replies --- */ -}}\n{{- if and $inline (ne $format \"quote\") -}}\n {{- $title := $post.Title | default \"\" -}}\n {{- $linkUrl := string ($post.Params.link_url | default \"\") -}}\n {{- if eq $format \"link\" -}}\n {{- if ne $linkUrl \"\" -}}\n {{- $domain := $linkUrl -}}\n {{- with urls.Parse $linkUrl -}}{{- if .Host -}}{{- $domain = replaceRE `^(www|m|mobile)\\.` \"\" .Host -}}{{- end -}}{{- end -}}\n {{- $parts = $parts | append (printf `<p><a href=%q>%s</a></p>` $linkUrl (transform.XMLEscape $domain)) -}}\n {{- end -}}\n {{- if ne $title \"\" -}}\n {{- $titleHref := $permalink -}}\n {{- if ne $linkUrl \"\" -}}{{- $titleHref = $linkUrl -}}{{- end -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $titleHref (transform.XMLEscape $title)) -}}\n {{- end -}}\n {{- else if ne $title \"\" -}}\n {{- $parts = $parts | append (printf `<h2><a href=%q>%s</a></h2>` $permalink (transform.XMLEscape $title)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Quote block (format=quote) --- */ -}}\n{{- if eq $format \"quote\" -}}\n {{- $quoteText := string ($post.Params.quote_text | default \"\") -}}\n {{- if ne $quoteText \"\" -}}\n {{- $sourceName := string ($post.Params.source_name | default \"\") -}}\n {{- $sourceUrl := string ($post.Params.source_url | default \"\") -}}\n {{- $cite := \"\" -}}\n {{- if ne $sourceUrl \"\" -}}{{- $cite = printf ` cite=%q` $sourceUrl -}}{{- end -}}\n {{- /* Line breaks the author typed must survive as markup: the site\n keeps them with `white-space: pre-line`, but feed readers strip\n CSS. Blank lines split <p> blocks, single newlines become <br/>\n (mirrors `renderPlainTextHtml` in `lib/feed.ts`). */ -}}\n {{- $quoteBlocks := slice -}}\n {{- range split (replaceRE `\\r\\n?` \"\\n\" $quoteText) \"\\n\\n\" -}}\n {{- $para := trim . \" \\t\\n\" -}}\n {{- if ne $para \"\" -}}\n {{- $lines := slice -}}\n {{- range split $para \"\\n\" -}}{{- $lines = $lines | append (transform.XMLEscape .) -}}{{- end -}}\n {{- $quoteBlocks = $quoteBlocks | append (printf \"<p>%s</p>\" (delimit $lines \"<br/>\")) -}}\n {{- end -}}\n {{- end -}}\n {{- if gt (len $quoteBlocks) 0 -}}\n {{- $block := printf \"<blockquote%s>%s</blockquote>\" $cite (delimit $quoteBlocks \"\\n\") -}}\n {{- $parts = $parts | append $block -}}\n {{- end -}}\n {{- /* Attribution line — prefer name, fall back to URL host or the\n raw URL itself (mirrors main site's extractDisplayDomain fallback\n chain in `lib/url.ts`). */ -}}\n {{- $attribution := \"\" -}}\n {{- if and (ne $sourceName \"\") (ne $sourceUrl \"\") -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $sourceName) -}}\n {{- else if ne $sourceName \"\" -}}\n {{- $attribution = transform.XMLEscape $sourceName -}}\n {{- else if ne $sourceUrl \"\" -}}\n {{- $host := $sourceUrl -}}\n {{- with urls.Parse $sourceUrl -}}{{- if .Host -}}{{- $host = .Host -}}{{- end -}}{{- end -}}\n {{- $attribution = printf `<a href=%q>%s</a>` $sourceUrl (transform.XMLEscape $host) -}}\n {{- end -}}\n {{- if ne $attribution \"\" -}}\n {{- $parts = $parts | append (printf \"<p>— %s</p>\" $attribution) -}}\n {{- end -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Rendered body HTML (with <script>/<style> stripped) --- */ -}}\n{{- $body := $post.Content -}}\n{{- if ne (string $body) \"\" -}}\n {{- $bodyStr := string $body -}}\n {{- $bodyStr = replaceRE `(?is)<script\\b[^>]*>.*?<\/script>` \"\" $bodyStr -}}\n {{- $bodyStr = replaceRE `(?is)<style\\b[^>]*>.*?</style>` \"\" $bodyStr -}}\n {{- /* Split any CDATA-terminator sequences so the content can be safely\n wrapped in <![CDATA[...]]> by the caller. */ -}}\n {{- $bodyStr = replace $bodyStr \"]]>\" \"]]]]><![CDATA[>\" -}}\n {{- if ne (trim $bodyStr \" \\t\\n\\r\") \"\" -}}\n {{- $parts = $parts | append $bodyStr -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Attachments --- */ -}}\n{{- /* One container per post, marked `data-post-media` — the attribute the\n site puts on its gallery strip, so a consumer styles the strip instead of\n reassembling it from the Media RSS elements. In a thread this is also the\n only surface that says which post an attachment belongs to. Mirrors\n `renderMediaItem` in `packages/core/src/lib/feed.ts`. */ -}}\n{{- $mediaList := $post.Params.media | default slice -}}\n{{- if gt (len $mediaList) 0 -}}\n {{- $items := slice -}}\n {{- range $mediaList -}}\n {{- $kind := string (.kind | default \"document\") -}}\n {{- $mime := string (.mime_type | default \"application/octet-stream\") -}}\n {{- /* `trim` wraps the index rather than piping into it: a Go template\n pipe binds to the last argument, so `index … 0 | trim \" \"` would trim\n the index instead of the result. */ -}}\n {{- $mimeLabel := trim (index (split $mime \";\") 0) \" \" -}}\n {{- $src := (string .src) | absURL -}}\n {{- $name := string (.original_name | default \"\") -}}\n {{- $alt := string (.alt | default \"\") -}}\n {{- /* Duration and size, in the shapes `formatFeedDuration` and\n `formatFeedBytes` produce. */ -}}\n {{- $metaParts := slice -}}\n {{- with .duration_seconds -}}\n {{- $total := int (math.Round .) -}}\n {{- if gt $total 0 -}}\n {{- $metaParts = $metaParts | append (printf \"%d:%02d\" (div $total 60) (mod $total 60)) -}}\n {{- end -}}\n {{- end -}}\n {{- with .size -}}\n {{- $b := int . -}}\n {{- if gt $b 0 -}}\n {{- if lt $b 1024 -}}\n {{- $metaParts = $metaParts | append (printf \"%d B\" $b) -}}\n {{- else if lt $b 1048576 -}}\n {{- $metaParts = $metaParts | append (printf \"%d KB\" (int (math.Round (div (float $b) 1024.0)))) -}}\n {{- else -}}\n {{- $metaParts = $metaParts | append (printf \"%.1f MB\" (div (float $b) 1048576.0)) -}}\n {{- end -}}\n {{- end -}}\n {{- end -}}\n {{- $meta := delimit $metaParts \" · \" -}}\n {{- $metaSuffix := \"\" -}}\n {{- if ne $meta \"\" -}}{{- $metaSuffix = printf \" (%s)\" (transform.XMLEscape $meta) -}}{{- end -}}\n {{- $dims := \"\" -}}\n {{- if and .width .height -}}{{- $dims = printf ` width=\"%d\" height=\"%d\"` (int .width) (int .height) -}}{{- end -}}\n\n {{- if eq $kind \"image\" -}}\n {{- $caption := \"\" -}}\n {{- if ne $alt \"\" -}}{{- $caption = printf \"<figcaption>%s</figcaption>\" (transform.XMLEscape $alt) -}}{{- end -}}\n {{- /* `%q` is Go string quoting, not attribute escaping: a `\"` or `\\`\n in the alt would leave the attribute malformed. URLs go through\n `%q` because they never carry either; author text goes through\n `transform.XMLEscape`, the same as the caption beside it. */ -}}\n {{- $items = $items | append (printf `<figure><a href=%q><img src=%q alt=\"%s\"%s/></a>%s</figure>` $src $src (transform.XMLEscape $alt) $dims $caption) -}}\n {{- else if eq $kind \"video\" -}}\n {{- /* A poster is written only when there is a real still; the pipeline\n leaves nothing usable otherwise, and `<img src=\"clip.mp4\">` was the\n bug that taught us. The link sits outside the <video> so it survives\n a sanitizer that drops the player with its children, and\n `preload=\"none\"` keeps a reader from pulling the whole file. */ -}}\n {{- $posterAttr := \"\" -}}\n {{- with .poster -}}{{- $posterAttr = printf ` poster=%q` ((string .) | absURL) -}}{{- end -}}\n {{- $altSuffix := \"\" -}}\n {{- if ne $alt \"\" -}}{{- $altSuffix = printf \": %s\" (transform.XMLEscape $alt) -}}{{- end -}}\n {{- $items = $items | append (printf `<figure><video controls preload=\"none\"%s%s><source src=%q type=%q/></video><figcaption><a href=%q>▶ Watch video</a>%s%s</figcaption></figure>` $posterAttr $dims $src $mimeLabel $src $metaSuffix $altSuffix) -}}\n {{- else if eq $kind \"text\" -}}\n {{- /* Markdown a browser would download or dump unstyled, so the link\n goes to the page that renders it. */ -}}\n {{- $href := printf \"%s/text/%s\" (strings.TrimSuffix \"/\" $permalink) (string .id) -}}\n {{- $label := printf \"📎 [%s] %s\" (transform.XMLEscape $mimeLabel) (transform.XMLEscape (or $name \"Attached text\")) -}}\n {{- $textMeta := $metaSuffix -}}\n {{- with .chars -}}\n {{- if gt (int .) 0 -}}{{- $textMeta = printf \" (%d chars)\" (int .) -}}{{- end -}}\n {{- end -}}\n {{- $summarySuffix := \"\" -}}\n {{- with .summary -}}\n {{- if ne (string .) \"\" -}}{{- $summarySuffix = printf \": %s\" (transform.XMLEscape (string .)) -}}{{- end -}}\n {{- end -}}\n {{- $items = $items | append (printf `<p><a href=%q>%s</a>%s%s</p>` $href $label $textMeta $summarySuffix) -}}\n {{- else -}}\n {{- $fallbackName := cond (eq $kind \"audio\") \"Audio\" \"Attachment\" -}}\n {{- $label := printf \"📎 [%s] %s\" (transform.XMLEscape $mimeLabel) (transform.XMLEscape (or $name $fallbackName)) -}}\n {{- $items = $items | append (printf `<p><a href=%q>%s</a>%s</p>` $src $label $metaSuffix) -}}\n {{- end -}}\n {{- end -}}\n {{- $parts = $parts | append (printf \"<div data-post-media>\\n%s\\n</div>\" (delimit $items \"\\n\")) -}}\n{{- end -}}\n\n{{- /* --- Star rating --- */ -}}\n{{- with $post.Params.rating -}}\n {{- $r := int . -}}\n {{- if gt $r 0 -}}\n {{- $filled := strings.Repeat $r \"★\" -}}\n {{- $empty := strings.Repeat (sub 5 $r) \"☆\" -}}\n {{- $parts = $parts | append (printf \"<p>%s%s %d/5</p>\" $filled $empty $r) -}}\n {{- end -}}\n{{- end -}}\n\n{{- /* --- Empty-body fallback (plain-text summary) --- */ -}}\n{{- if eq (len $parts) 0 -}}\n {{- $fallback := string ($post.Params.summary_text | default \"\") -}}\n {{- if eq $fallback \"\" -}}{{- $fallback = $post.Title | default (printf \"Post %s\" (string ($post.Params.id | default \"\"))) -}}{{- end -}}\n {{- $parts = $parts | append (printf \"<p>%s</p>\" (transform.XMLEscape $fallback)) -}}\n{{- end -}}\n\n{{- /* --- link-format permalink suffix (Daring Fireball ★) --- */ -}}\n{{- if and (eq $format \"link\") (ne $permalink \"\") -}}\n {{- $parts = $parts | append (printf `<p><a href=%q title=\"Permalink\"> ★ </a></p>` $permalink) -}}\n{{- end -}}\n\n{{- /* --- Thread replies --- */ -}}\n{{- if $includeReplies -}}\n {{- $replies := where $post.Pages \"Params.visibility\" \"public\" -}}\n {{- $replies = $replies.ByDate -}}\n {{- range $replies -}}\n {{- $replyPermalink := .Permalink -}}\n {{- $replyDatetime := .Date.UTC.Format \"2006-01-02T15:04:05Z\" -}}\n {{- /* Human-readable timestamp; mirrors the main site's\n `publishedAtFormatted` in the feed — there it's locale-formatted\n by `lib/format-timestamp.ts`, but Hugo doesn't have that at\n export time, so use a simple ISO-style fallback. */ -}}\n {{- $replyLabel := .Date.Format \"Jan 2, 2006 15:04\" -}}\n {{- $parts = $parts | append \"<hr/>\" -}}\n {{- $parts = $parts | append (printf `<p><small><time datetime=%q>%s</time></small></p>` $replyDatetime (transform.XMLEscape $replyLabel)) -}}\n {{- $parts = $parts | append (partial \"feed-post-content.xml\" (dict \"post\" . \"permalink\" $replyPermalink \"includeReplies\" false \"inline\" true)) -}}\n {{- end -}}\n{{- end -}}\n\n{{- delimit $parts \"\\n\" | safeHTML -}}\n";
|
|
4600
5997
|
//#endregion
|
|
4601
5998
|
//#region src/services/export.ts
|
|
4602
5999
|
/**
|
|
@@ -4637,7 +6034,7 @@ function buildDefaultAppleTouchAsset() {
|
|
|
4637
6034
|
function createExportService(services, siteConfig, deps = {}) {
|
|
4638
6035
|
return {
|
|
4639
6036
|
async generateHugoFiles() {
|
|
4640
|
-
const collectionDirectoryDataPromise = typeof services.collections.listDirectoryData === "function" ? services.collections.listDirectoryData() : Promise.resolve(null);
|
|
6037
|
+
const collectionDirectoryDataPromise = typeof services.collections.listDirectoryData === "function" ? services.collections.listDirectoryData({ isAuthenticated: false }) : Promise.resolve(null);
|
|
4641
6038
|
const [allPosts, allCollections, collectionDirectoryData] = await Promise.all([
|
|
4642
6039
|
services.posts.list({
|
|
4643
6040
|
excludeReplies: false,
|
|
@@ -4662,8 +6059,13 @@ function createExportService(services, siteConfig, deps = {}) {
|
|
|
4662
6059
|
for (const collection of allCollections) collectionTitleMap.set(collection.id, collection.title);
|
|
4663
6060
|
const iconAssets = await buildSiteIconAssets(siteConfig, deps.storage);
|
|
4664
6061
|
const collectionMetrics = buildExportedCollectionMetrics(allCollections, allPosts, collectionsByRoot);
|
|
4665
|
-
const exportedCollectionDirectoryItems = buildExportedCollectionDirectoryItems(collectionDirectoryData?.items
|
|
4666
|
-
|
|
6062
|
+
const exportedCollectionDirectoryItems = buildExportedCollectionDirectoryItems(collectionDirectoryData?.items ? collectionDirectoryData.items.filter((item) => item.type !== "smart_collection").map((item) => ({
|
|
6063
|
+
type: item.type,
|
|
6064
|
+
label: item.label,
|
|
6065
|
+
url: item.url,
|
|
6066
|
+
description: item.description,
|
|
6067
|
+
collection: item.collection
|
|
6068
|
+
})) : allCollections.map((collection) => ({
|
|
4667
6069
|
type: "collection",
|
|
4668
6070
|
collection
|
|
4669
6071
|
})), collectionSlugMap, collectionMetrics);
|
|
@@ -4708,10 +6110,15 @@ function createExportService(services, siteConfig, deps = {}) {
|
|
|
4708
6110
|
const usedSlugs = /* @__PURE__ */ new Set();
|
|
4709
6111
|
for (const s of slugMap.values()) usedSlugs.add(s);
|
|
4710
6112
|
for (const s of collectionSlugMap.values()) usedSlugs.add(s);
|
|
4711
|
-
|
|
6113
|
+
const hasFeaturedSection = !usedSlugs.has("featured");
|
|
6114
|
+
if (hasFeaturedSection) exportFiles.push({
|
|
4712
6115
|
path: "content/featured/_index.md",
|
|
4713
6116
|
content: await buildFeaturedSection(siteConfig.rssFeedsEnabled)
|
|
4714
6117
|
});
|
|
6118
|
+
if (siteConfig.rssFeedsEnabled) exportFiles.push({
|
|
6119
|
+
path: "static/_redirects",
|
|
6120
|
+
content: buildFeedRedirects(siteConfig.mainRssFeed, hasFeaturedSection)
|
|
6121
|
+
});
|
|
4715
6122
|
exportFiles.push({
|
|
4716
6123
|
path: "data/jant.toml",
|
|
4717
6124
|
content: buildJantDataToml(siteConfig, iconAssets, exportedCollectionDirectoryItems)
|
|
@@ -4838,7 +6245,7 @@ function createExportService(services, siteConfig, deps = {}) {
|
|
|
4838
6245
|
});
|
|
4839
6246
|
exportFiles.push({
|
|
4840
6247
|
path: "README.md",
|
|
4841
|
-
content: buildReadme(siteConfig
|
|
6248
|
+
content: buildReadme(siteConfig, hasFeaturedSection)
|
|
4842
6249
|
});
|
|
4843
6250
|
exportFiles.push({
|
|
4844
6251
|
path: ".gitignore",
|
|
@@ -4913,6 +6320,20 @@ function buildExportedCollectionEntriesForThread(threadId, collectionEntriesByTh
|
|
|
4913
6320
|
}
|
|
4914
6321
|
return resolved;
|
|
4915
6322
|
}
|
|
6323
|
+
/**
|
|
6324
|
+
* Whether the site's timeline cuts this post's text short.
|
|
6325
|
+
*
|
|
6326
|
+
* Shares the boundary with the served feed rather than approximating it: Hugo
|
|
6327
|
+
* has its own summary rule and would cut somewhere else, so the answer is
|
|
6328
|
+
* computed here and carried as front matter.
|
|
6329
|
+
*
|
|
6330
|
+
* @param post - The post being exported
|
|
6331
|
+
* @returns true when the timeline shows less than the post's page
|
|
6332
|
+
* @example
|
|
6333
|
+
* isPostTruncated(longArticle); // true
|
|
6334
|
+
*/ function isPostTruncated(post) {
|
|
6335
|
+
return extractTimelineSummary(post.body, !!post.title, { namespace: post.id })?.hasMore === true;
|
|
6336
|
+
}
|
|
4916
6337
|
function collectionEntriesToRefs(entries) {
|
|
4917
6338
|
return entries.map((entry) => ({
|
|
4918
6339
|
slug: entry.slug,
|
|
@@ -5028,7 +6449,10 @@ function extOfStorageKey(key) {
|
|
|
5028
6449
|
format: root.format,
|
|
5029
6450
|
status: root.status,
|
|
5030
6451
|
visibility: root.visibility,
|
|
6452
|
+
language: root.language ?? void 0,
|
|
6453
|
+
translation_group: root.translationGroupId ?? void 0,
|
|
5031
6454
|
summary_text: getArchiveSummaryText(root) ?? void 0,
|
|
6455
|
+
truncated: isPostTruncated(root) || void 0,
|
|
5032
6456
|
link_url: root.format === "link" && root.url ? root.url : void 0,
|
|
5033
6457
|
source_name: root.format === "quote" && root.title ? root.title : void 0,
|
|
5034
6458
|
source_url: root.format === "quote" && root.url ? root.url : void 0,
|
|
@@ -5081,6 +6505,7 @@ function extOfStorageKey(key) {
|
|
|
5081
6505
|
status: reply.status,
|
|
5082
6506
|
visibility: reply.visibility,
|
|
5083
6507
|
summary_text: getArchiveSummaryText(reply) ?? void 0,
|
|
6508
|
+
truncated: isPostTruncated(reply) || void 0,
|
|
5084
6509
|
link_url: reply.format === "link" && reply.url ? reply.url : void 0,
|
|
5085
6510
|
source_name: reply.format === "quote" && reply.title ? reply.title : void 0,
|
|
5086
6511
|
source_url: reply.format === "quote" && reply.url ? reply.url : void 0,
|
|
@@ -5208,11 +6633,13 @@ function formatCollectionActivityIso(timestamp) {
|
|
|
5208
6633
|
for (const item of items) if (item.type === "divider") {
|
|
5209
6634
|
seenDivider = true;
|
|
5210
6635
|
groupSizes.push(0);
|
|
5211
|
-
} else if (isContentItem(item))
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
6636
|
+
} else if (isContentItem(item)) {
|
|
6637
|
+
if (seenDivider) {
|
|
6638
|
+
const lastGroupIndex = groupSizes.length - 1;
|
|
6639
|
+
const lastGroupSize = groupSizes[lastGroupIndex];
|
|
6640
|
+
if (lastGroupSize !== void 0) groupSizes[lastGroupIndex] = lastGroupSize + 1;
|
|
6641
|
+
} else ungroupedCount += 1;
|
|
6642
|
+
}
|
|
5216
6643
|
const hasGroups = groupSizes.length > 0;
|
|
5217
6644
|
const maxGroupIndex = Math.max(0, groupSizes.length - 1);
|
|
5218
6645
|
const groupWidth = hasGroups ? Math.max(1, maxGroupIndex.toString(36).length) : 0;
|
|
@@ -5298,13 +6725,20 @@ function buildExportedCollectionMetrics(collections, posts, collectionsByThread)
|
|
|
5298
6725
|
}
|
|
5299
6726
|
return metrics;
|
|
5300
6727
|
}
|
|
5301
|
-
|
|
6728
|
+
/**
|
|
6729
|
+
* Label to bake into the static site.
|
|
6730
|
+
*
|
|
6731
|
+
* Mirrors `getNavItemDisplayLabel`: a stored label is the author's own words
|
|
6732
|
+
* and wins, an empty one means the item follows what it points at. The export
|
|
6733
|
+
* has no i18n runtime, so built-in destinations fall back to their English
|
|
6734
|
+
* default rather than a translated one.
|
|
6735
|
+
*/ function resolveNavItemLabel(item) {
|
|
5302
6736
|
if (item.label) return item.label;
|
|
5303
6737
|
if (item.systemKey) {
|
|
5304
6738
|
const definition = SYSTEM_NAV_KEYS[item.systemKey];
|
|
5305
6739
|
if (definition) return definition.defaultLabel;
|
|
5306
6740
|
}
|
|
5307
|
-
return item.label;
|
|
6741
|
+
return item.targetTitle?.trim() || item.label;
|
|
5308
6742
|
}
|
|
5309
6743
|
/**
|
|
5310
6744
|
* Resolve a nav item's final href for the Hugo export.
|
|
@@ -5322,7 +6756,7 @@ function resolveNavItemLabel(item) {
|
|
|
5322
6756
|
if (item.systemKey === "featured") return "/featured/";
|
|
5323
6757
|
if (item.systemKey === "collections") return "/collections/";
|
|
5324
6758
|
if (item.systemKey === "archive") return "/archive/";
|
|
5325
|
-
if (item.systemKey === "rss") return mainRssFeed === "featured" ? "/featured/index.xml" : "/index.xml";
|
|
6759
|
+
if (item.systemKey === "rss" || item.systemKey === "subscribe") return mainRssFeed === "featured" ? "/featured/index.xml" : "/index.xml";
|
|
5326
6760
|
return item.url;
|
|
5327
6761
|
}
|
|
5328
6762
|
/** Escape a string for use inside a TOML double-quoted value. */ function escapeTomlString(value) {
|
|
@@ -5367,6 +6801,7 @@ function buildHugoToml(config) {
|
|
|
5367
6801
|
"[params]",
|
|
5368
6802
|
` description = "${escapeTomlString(config.siteDescription)}"`,
|
|
5369
6803
|
` main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
|
|
6804
|
+
` archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
|
|
5370
6805
|
` public_api_enabled = ${config.publicApiEnabled}`,
|
|
5371
6806
|
` rss_feeds_enabled = ${config.rssFeedsEnabled}`,
|
|
5372
6807
|
` show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
|
|
@@ -5393,7 +6828,10 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
|
|
|
5393
6828
|
`site_name = "${escapeTomlString(config.siteName)}"`,
|
|
5394
6829
|
`site_description = "${escapeTomlString(config.siteDescription)}"`,
|
|
5395
6830
|
`site_language = "${escapeTomlString(config.siteLanguage)}"`,
|
|
6831
|
+
`multilingual_enabled = ${config.multilingualEnabled}`,
|
|
6832
|
+
`additional_languages = "${escapeTomlString(config.additionalLanguages.join(","))}"`,
|
|
5396
6833
|
`main_rss_feed = "${escapeTomlString(config.mainRssFeed)}"`,
|
|
6834
|
+
`archive_default_layout = "${escapeTomlString(config.archiveDefaultLayout)}"`,
|
|
5397
6835
|
`public_api_enabled = ${config.publicApiEnabled}`,
|
|
5398
6836
|
`rss_feeds_enabled = ${config.rssFeedsEnabled}`,
|
|
5399
6837
|
`show_jant_branding_on_home = ${config.showJantBrandingOnHome}`,
|
|
@@ -5418,7 +6856,7 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
|
|
|
5418
6856
|
if (config.siteFooter) parts.push(`site_footer_markdown = "${escapeTomlString(config.siteFooter)}"`);
|
|
5419
6857
|
for (const item of config.navItems) {
|
|
5420
6858
|
if (item.systemKey === "settings") continue;
|
|
5421
|
-
if (!config.rssFeedsEnabled && item.type === "system" && item.systemKey
|
|
6859
|
+
if (!config.rssFeedsEnabled && item.type === "system" && isFeedNavKey(item.systemKey)) continue;
|
|
5422
6860
|
parts.push("");
|
|
5423
6861
|
parts.push("[[nav]]");
|
|
5424
6862
|
parts.push(`type = "${escapeTomlString(item.type)}"`);
|
|
@@ -5450,6 +6888,72 @@ function buildJantDataToml(config, iconAssets, directoryItems) {
|
|
|
5450
6888
|
}
|
|
5451
6889
|
return `${parts.join("\n")}\n`;
|
|
5452
6890
|
}
|
|
6891
|
+
/**
|
|
6892
|
+
* Build the `static/_redirects` file that keeps existing feed subscribers
|
|
6893
|
+
* working after the site moves to this export.
|
|
6894
|
+
*
|
|
6895
|
+
* Jant serves its feeds under `/feed` (`/feed`, `/latest/feed`,
|
|
6896
|
+
* `/featured/feed`, `/archive/feed`, `/{collection}/feed`); Hugo serves the
|
|
6897
|
+
* same documents as `index.xml` inside each section. The two namespaces do
|
|
6898
|
+
* not overlap, so every subscriber's saved address 404s the moment the
|
|
6899
|
+
* exported site goes up.
|
|
6900
|
+
*
|
|
6901
|
+
* Hugo's `aliases:` mechanism cannot cover this the way it covers post URLs:
|
|
6902
|
+
* an alias page is a meta-refresh plus a script, and feed readers fetch and
|
|
6903
|
+
* parse XML without running either. Only an HTTP redirect reaches them, which
|
|
6904
|
+
* is host configuration rather than Hugo output — so the export emits the one
|
|
6905
|
+
* format two common static hosts read verbatim (Cloudflare Pages and Netlify).
|
|
6906
|
+
* Hosts that do not read `_redirects` ignore the file; the README carries the
|
|
6907
|
+
* rule for them to translate.
|
|
6908
|
+
*
|
|
6909
|
+
* @param mainRssFeed - The site's main feed setting, "latest" or "featured".
|
|
6910
|
+
* @param hasFeaturedSection - Whether the export emitted a featured section.
|
|
6911
|
+
* It is skipped when a post or collection already owns the `featured` slug,
|
|
6912
|
+
* and pointing a redirect at a feed that was never built would turn a live
|
|
6913
|
+
* subscription into a 404 rather than leaving it merely stale.
|
|
6914
|
+
* @returns The `_redirects` file body.
|
|
6915
|
+
* @example
|
|
6916
|
+
* buildFeedRedirects("latest", true); // "# ...\n/feed /index.xml 301\n..."
|
|
6917
|
+
*/ function buildFeedRedirects(mainRssFeed, hasFeaturedSection) {
|
|
6918
|
+
const LATEST = "/index.xml";
|
|
6919
|
+
const FEATURED = "/featured/index.xml";
|
|
6920
|
+
const main = mainRssFeed === "featured" && hasFeaturedSection ? FEATURED : LATEST;
|
|
6921
|
+
const featured = hasFeaturedSection ? FEATURED : LATEST;
|
|
6922
|
+
const rules = [
|
|
6923
|
+
["/feed", main],
|
|
6924
|
+
["/latest/feed", LATEST],
|
|
6925
|
+
["/featured/feed", featured],
|
|
6926
|
+
["/archive/feed", "/archive/index.xml"]
|
|
6927
|
+
];
|
|
6928
|
+
const legacy = [
|
|
6929
|
+
["/feed/latest", LATEST],
|
|
6930
|
+
["/feed/all", LATEST],
|
|
6931
|
+
["/feed/featured", featured],
|
|
6932
|
+
["/feed/atom.xml", main],
|
|
6933
|
+
["/feed/latest/atom.xml", LATEST],
|
|
6934
|
+
["/feed/all/atom.xml", LATEST],
|
|
6935
|
+
["/feed/featured/atom.xml", featured]
|
|
6936
|
+
];
|
|
6937
|
+
const width = Math.max(...[...rules, ...legacy].map(([from]) => from.length), 11);
|
|
6938
|
+
const line = ([from, to]) => `${from.padEnd(width)} ${to} 301`;
|
|
6939
|
+
return `# Feed addresses moved in this export.
|
|
6940
|
+
#
|
|
6941
|
+
# Jant served its feeds under /feed; Hugo serves them as index.xml inside each
|
|
6942
|
+
# section. Cloudflare Pages and Netlify read this file and issue the redirects
|
|
6943
|
+
# below, so readers who subscribed to the old addresses keep receiving posts.
|
|
6944
|
+
# Other hosts ignore this file — see README.md for the same rules to translate
|
|
6945
|
+
# into your host's configuration.
|
|
6946
|
+
|
|
6947
|
+
${rules.map(line).join("\n")}
|
|
6948
|
+
|
|
6949
|
+
# Aliases the live site answered with a 308. They would die at the same moment.
|
|
6950
|
+
${legacy.map(line).join("\n")}
|
|
6951
|
+
|
|
6952
|
+
# Collection feeds, by slug. Collections are the only root-level sections that
|
|
6953
|
+
# emit Atom, so any other slug 404s here exactly as it did on the live site.
|
|
6954
|
+
${line(["/:slug/feed", "/:slug/index.xml"])}
|
|
6955
|
+
`;
|
|
6956
|
+
}
|
|
5453
6957
|
function buildGitignore() {
|
|
5454
6958
|
return `# Hugo build output
|
|
5455
6959
|
public/
|
|
@@ -5466,7 +6970,49 @@ Thumbs.db
|
|
|
5466
6970
|
*.swp
|
|
5467
6971
|
`;
|
|
5468
6972
|
}
|
|
5469
|
-
|
|
6973
|
+
/**
|
|
6974
|
+
* Render a two-column Markdown table with padded cells.
|
|
6975
|
+
*
|
|
6976
|
+
* The values differ per site, so building the rows by hand would leave the
|
|
6977
|
+
* committed README ragged for some exports and aligned for others.
|
|
6978
|
+
*
|
|
6979
|
+
* @param headers - The two column headers.
|
|
6980
|
+
* @param rows - Cell pairs; each cell is wrapped in backticks.
|
|
6981
|
+
* @returns The table as Markdown, without a trailing newline.
|
|
6982
|
+
* @example
|
|
6983
|
+
* renderMarkdownTable(["Jant", "This export"], [["/feed", "/index.xml"]]);
|
|
6984
|
+
*/ function renderMarkdownTable(headers, rows) {
|
|
6985
|
+
const cells = rows.map(([from, to]) => [`\`${from}\``, `\`${to}\``]);
|
|
6986
|
+
const widths = [Math.max(headers[0].length, ...cells.map((r) => r[0].length)), Math.max(headers[1].length, ...cells.map((r) => r[1].length))];
|
|
6987
|
+
const line = (a, b) => `| ${a.padEnd(widths[0])} | ${b.padEnd(widths[1])} |`;
|
|
6988
|
+
return [
|
|
6989
|
+
line(headers[0], headers[1]),
|
|
6990
|
+
`| ${"-".repeat(widths[0])} | ${"-".repeat(widths[1])} |`,
|
|
6991
|
+
...cells.map((r) => line(r[0], r[1]))
|
|
6992
|
+
].join("\n");
|
|
6993
|
+
}
|
|
6994
|
+
/**
|
|
6995
|
+
* Build the export's README.
|
|
6996
|
+
*
|
|
6997
|
+
* @param config - The exported site's configuration.
|
|
6998
|
+
* @param hasFeaturedSection - Whether a featured section was emitted, which
|
|
6999
|
+
* decides where the feed redirects point.
|
|
7000
|
+
* @returns Markdown for `README.md` at the root of the export.
|
|
7001
|
+
* @example
|
|
7002
|
+
* buildReadme(config, true); // "# My Site — Hugo Export\n..."
|
|
7003
|
+
*/ function buildReadme(config, hasFeaturedSection) {
|
|
7004
|
+
const siteName = config.siteName;
|
|
7005
|
+
const featuredFeed = hasFeaturedSection ? "/featured/index.xml" : "/index.xml";
|
|
7006
|
+
const mainFeed = config.mainRssFeed === "featured" ? featuredFeed : "/index.xml";
|
|
7007
|
+
const staticListing = config.rssFeedsEnabled ? "\n _redirects — Feed redirects (see Feeds above)" : "";
|
|
7008
|
+
const feedNote = config.rssFeedsEnabled ? "\n- Feed addresses are the exception: they move to `index.xml` and stay reachable only through `static/_redirects`. See [Feeds](#feeds)." : "";
|
|
7009
|
+
const feedTable = renderMarkdownTable(["Jant", "This export"], [
|
|
7010
|
+
["/feed", mainFeed],
|
|
7011
|
+
["/latest/feed", "/index.xml"],
|
|
7012
|
+
["/featured/feed", featuredFeed],
|
|
7013
|
+
["/archive/feed", "/archive/index.xml"],
|
|
7014
|
+
["/{collection}/feed", "/{collection}/index.xml"]
|
|
7015
|
+
]);
|
|
5470
7016
|
return `# ${siteName} — Hugo Export
|
|
5471
7017
|
|
|
5472
7018
|
This is a static site exported from [Jant](https://github.com/jant-me/jant), ready to build with [Hugo](https://gohugo.io/).
|
|
@@ -5511,7 +7057,19 @@ hugo --minify
|
|
|
5511
7057
|
|
|
5512
7058
|
The output goes to the \`public/\` directory. Upload it to any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, etc.).
|
|
5513
7059
|
|
|
5514
|
-
|
|
7060
|
+
${config.rssFeedsEnabled ? `## Feeds
|
|
7061
|
+
|
|
7062
|
+
The feed addresses changed. Jant served them under \`/feed\`; Hugo serves them as \`index.xml\` inside each section:
|
|
7063
|
+
|
|
7064
|
+
${feedTable}
|
|
7065
|
+
|
|
7066
|
+
A reader who is already subscribed holds one of the old addresses, and a feed reader that gets a 404 stops delivering posts. \`static/_redirects\` maps every old address to its new one with a 301. Cloudflare Pages and Netlify read that file as published; on any other host, translate its rules into that host's redirect configuration before you point the domain here.
|
|
7067
|
+
|
|
7068
|
+
Hugo's \`aliases:\` cannot cover this. An alias page redirects with a meta refresh and a script, and feed readers fetch XML without running either — only an HTTP redirect reaches them.
|
|
7069
|
+
|
|
7070
|
+
The **Subscribe** entry in the site navigation points at \`${mainFeed}\`. The exported site has no \`/subscribe\` page; that page belongs to the Jant runtime.
|
|
7071
|
+
|
|
7072
|
+
` : ""}## Project structure
|
|
5515
7073
|
|
|
5516
7074
|
\`\`\`
|
|
5517
7075
|
hugo.toml — Site configuration (baseURL, title, theme, params)
|
|
@@ -5527,7 +7085,7 @@ content/
|
|
|
5527
7085
|
data/
|
|
5528
7086
|
jant.toml — Nav items, branding, display preferences, ordered collections directory
|
|
5529
7087
|
themes/jant/ — Bundled Hugo theme (overrideable via layouts/ at the site root)
|
|
5530
|
-
static/ — Copy files here to add them to the published site
|
|
7088
|
+
static/ — Copy files here to add them to the published site${staticListing}
|
|
5531
7089
|
\`\`\`
|
|
5532
7090
|
|
|
5533
7091
|
## Customizing
|
|
@@ -5553,10 +7111,599 @@ Safe to re-run; files already on disk are reused. Anything that fails to downloa
|
|
|
5553
7111
|
## Notes
|
|
5554
7112
|
|
|
5555
7113
|
- Each thread is a Hugo branch bundle. Replies live as nested leaf bundles with \`build.render = "never"\` so they do not produce standalone URLs; they render inside the thread page.
|
|
5556
|
-
- \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor
|
|
7114
|
+
- \`/{reply-slug}/\` URLs are preserved via \`aliases:\` on the root post, so old links still land on the right thread anchor.${feedNote}
|
|
5557
7115
|
- Media is emitted under \`static/media/{id}.ext\` and referenced from a flat \`media:\` array on each post. When a storage provider has a configured public URL (R2/S3/local proxy), the exporter links to the provider URL instead of re-bundling the bytes.
|
|
5558
7116
|
- Posts with \`draft: true\` in front matter are only built when you pass \`--buildDrafts\` to \`hugo\` / \`hugo serve\`.
|
|
5559
7117
|
`;
|
|
5560
7118
|
}
|
|
5561
7119
|
//#endregion
|
|
5562
|
-
|
|
7120
|
+
//#region src/lib/github-api.ts
|
|
7121
|
+
var github_api_exports = /* @__PURE__ */ __exportAll({
|
|
7122
|
+
GitHubApiError: () => GitHubApiError,
|
|
7123
|
+
createGitHubClient: () => createGitHubClient,
|
|
7124
|
+
parseRepoSlug: () => parseRepoSlug
|
|
7125
|
+
});
|
|
7126
|
+
/**
|
|
7127
|
+
* GitHub REST API client for content sync.
|
|
7128
|
+
*
|
|
7129
|
+
* Pure fetch-based — no npm dependencies. Works in both Cloudflare Workers
|
|
7130
|
+
* and Node 18+.
|
|
7131
|
+
*/ var API_BASE = "https://api.github.com";
|
|
7132
|
+
var COMMON_HEADERS = {
|
|
7133
|
+
Accept: "application/vnd.github+json",
|
|
7134
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
7135
|
+
"User-Agent": "jant-github-sync"
|
|
7136
|
+
};
|
|
7137
|
+
var GitHubApiError = class extends Error {
|
|
7138
|
+
status;
|
|
7139
|
+
body;
|
|
7140
|
+
url;
|
|
7141
|
+
constructor(status, body, url) {
|
|
7142
|
+
super(`GitHub API ${status}: ${body} (${url})`), this.status = status, this.body = body, this.url = url;
|
|
7143
|
+
this.name = "GitHubApiError";
|
|
7144
|
+
}
|
|
7145
|
+
};
|
|
7146
|
+
/**
|
|
7147
|
+
* Create a GitHub API client.
|
|
7148
|
+
*
|
|
7149
|
+
* Accepts either a static token (PAT) or a provider function that returns a
|
|
7150
|
+
* fresh token on each request (used for short-lived GitHub App installation
|
|
7151
|
+
* tokens which need to be refreshed before expiry — the provider handles
|
|
7152
|
+
* caching internally).
|
|
7153
|
+
*/ function createGitHubClient(auth) {
|
|
7154
|
+
const getToken = typeof auth === "string" ? async () => auth : auth;
|
|
7155
|
+
async function request(method, path, body) {
|
|
7156
|
+
const url = `${API_BASE}${path}`;
|
|
7157
|
+
const token = await getToken();
|
|
7158
|
+
const res = await fetch(url, {
|
|
7159
|
+
method,
|
|
7160
|
+
headers: {
|
|
7161
|
+
...COMMON_HEADERS,
|
|
7162
|
+
Authorization: `Bearer ${token}`,
|
|
7163
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
7164
|
+
},
|
|
7165
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
7166
|
+
});
|
|
7167
|
+
if (!res.ok) {
|
|
7168
|
+
const text = await res.text();
|
|
7169
|
+
throw new GitHubApiError(res.status, text, url);
|
|
7170
|
+
}
|
|
7171
|
+
if (res.status === 204) return void 0;
|
|
7172
|
+
return await res.json();
|
|
7173
|
+
}
|
|
7174
|
+
async function requestOrNull(method, path) {
|
|
7175
|
+
const url = `${API_BASE}${path}`;
|
|
7176
|
+
const token = await getToken();
|
|
7177
|
+
const res = await fetch(url, {
|
|
7178
|
+
method,
|
|
7179
|
+
headers: {
|
|
7180
|
+
...COMMON_HEADERS,
|
|
7181
|
+
Authorization: `Bearer ${token}`
|
|
7182
|
+
}
|
|
7183
|
+
});
|
|
7184
|
+
if (res.status === 404) return null;
|
|
7185
|
+
if (!res.ok) {
|
|
7186
|
+
const text = await res.text();
|
|
7187
|
+
throw new GitHubApiError(res.status, text, url);
|
|
7188
|
+
}
|
|
7189
|
+
return await res.json();
|
|
7190
|
+
}
|
|
7191
|
+
return {
|
|
7192
|
+
async getRepo(owner, repo) {
|
|
7193
|
+
return request("GET", `/repos/${owner}/${repo}`);
|
|
7194
|
+
},
|
|
7195
|
+
async getRef(owner, repo, ref) {
|
|
7196
|
+
return { sha: (await request("GET", `/repos/${owner}/${repo}/git/ref/${ref}`)).object.sha };
|
|
7197
|
+
},
|
|
7198
|
+
async getCommit(owner, repo, sha) {
|
|
7199
|
+
const data = await request("GET", `/repos/${owner}/${repo}/git/commits/${sha}`);
|
|
7200
|
+
return {
|
|
7201
|
+
sha: data.sha,
|
|
7202
|
+
treeSha: data.tree.sha
|
|
7203
|
+
};
|
|
7204
|
+
},
|
|
7205
|
+
async updateRef(owner, repo, ref, sha) {
|
|
7206
|
+
await request("PATCH", `/repos/${owner}/${repo}/git/refs/${ref}`, {
|
|
7207
|
+
sha,
|
|
7208
|
+
force: false
|
|
7209
|
+
});
|
|
7210
|
+
},
|
|
7211
|
+
async createRef(owner, repo, ref, sha) {
|
|
7212
|
+
await request("POST", `/repos/${owner}/${repo}/git/refs`, {
|
|
7213
|
+
ref: `refs/${ref}`,
|
|
7214
|
+
sha
|
|
7215
|
+
});
|
|
7216
|
+
},
|
|
7217
|
+
async getFileContent(owner, repo, path, ref) {
|
|
7218
|
+
return requestOrNull("GET", `/repos/${owner}/${repo}/contents/${path}${ref ? `?ref=${encodeURIComponent(ref)}` : ""}`);
|
|
7219
|
+
},
|
|
7220
|
+
async createOrUpdateFile(owner, repo, path, opts) {
|
|
7221
|
+
const body = {
|
|
7222
|
+
message: opts.message,
|
|
7223
|
+
content: btoa(unescape(encodeURIComponent(opts.content)))
|
|
7224
|
+
};
|
|
7225
|
+
if (opts.sha) body.sha = opts.sha;
|
|
7226
|
+
if (opts.branch) body.branch = opts.branch;
|
|
7227
|
+
return { sha: (await request("PUT", `/repos/${owner}/${repo}/contents/${path}`, body)).content.sha };
|
|
7228
|
+
},
|
|
7229
|
+
async deleteFile(owner, repo, path, opts) {
|
|
7230
|
+
await request("DELETE", `/repos/${owner}/${repo}/contents/${path}`, {
|
|
7231
|
+
message: opts.message,
|
|
7232
|
+
sha: opts.sha,
|
|
7233
|
+
...opts.branch ? { branch: opts.branch } : {}
|
|
7234
|
+
});
|
|
7235
|
+
},
|
|
7236
|
+
async createBlob(owner, repo, content, encoding) {
|
|
7237
|
+
return request("POST", `/repos/${owner}/${repo}/git/blobs`, {
|
|
7238
|
+
content,
|
|
7239
|
+
encoding
|
|
7240
|
+
});
|
|
7241
|
+
},
|
|
7242
|
+
async createTree(owner, repo, items, baseTree) {
|
|
7243
|
+
return request("POST", `/repos/${owner}/${repo}/git/trees`, {
|
|
7244
|
+
tree: items,
|
|
7245
|
+
...baseTree ? { base_tree: baseTree } : {}
|
|
7246
|
+
});
|
|
7247
|
+
},
|
|
7248
|
+
async getTree(owner, repo, sha, opts) {
|
|
7249
|
+
return request("GET", `/repos/${owner}/${repo}/git/trees/${sha}${opts?.recursive ? "?recursive=1" : ""}`);
|
|
7250
|
+
},
|
|
7251
|
+
async createCommit(owner, repo, opts) {
|
|
7252
|
+
return request("POST", `/repos/${owner}/${repo}/git/commits`, {
|
|
7253
|
+
message: opts.message,
|
|
7254
|
+
tree: opts.tree,
|
|
7255
|
+
parents: opts.parents
|
|
7256
|
+
});
|
|
7257
|
+
},
|
|
7258
|
+
async createWebhook(owner, repo, opts) {
|
|
7259
|
+
return request("POST", `/repos/${owner}/${repo}/hooks`, {
|
|
7260
|
+
name: "web",
|
|
7261
|
+
active: true,
|
|
7262
|
+
events: opts.events,
|
|
7263
|
+
config: {
|
|
7264
|
+
url: opts.url,
|
|
7265
|
+
secret: opts.secret,
|
|
7266
|
+
content_type: "json",
|
|
7267
|
+
insecure_ssl: "0"
|
|
7268
|
+
}
|
|
7269
|
+
});
|
|
7270
|
+
},
|
|
7271
|
+
async listWebhooks(owner, repo) {
|
|
7272
|
+
return request("GET", `/repos/${owner}/${repo}/hooks`);
|
|
7273
|
+
},
|
|
7274
|
+
async deleteWebhook(owner, repo, hookId) {
|
|
7275
|
+
await request("DELETE", `/repos/${owner}/${repo}/hooks/${hookId}`);
|
|
7276
|
+
}
|
|
7277
|
+
};
|
|
7278
|
+
}
|
|
7279
|
+
/**
|
|
7280
|
+
* Parse an `owner/repo` string into its parts.
|
|
7281
|
+
*
|
|
7282
|
+
* @returns `{ owner, repo }` or `null` if the format is invalid.
|
|
7283
|
+
*/ function parseRepoSlug(slug) {
|
|
7284
|
+
const match = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/.exec(slug.trim());
|
|
7285
|
+
if (!match) return null;
|
|
7286
|
+
const [, owner, repo] = match;
|
|
7287
|
+
if (!owner || !repo) return null;
|
|
7288
|
+
return {
|
|
7289
|
+
owner,
|
|
7290
|
+
repo
|
|
7291
|
+
};
|
|
7292
|
+
}
|
|
7293
|
+
//#endregion
|
|
7294
|
+
//#region src/services/github-sync.ts
|
|
7295
|
+
/**
|
|
7296
|
+
* GitHub Sync Service
|
|
7297
|
+
*
|
|
7298
|
+
* Handles bidirectional content synchronization between Jant and a GitHub repo.
|
|
7299
|
+
* Posts are serialized as Hugo-format Markdown bundles (reusing the export
|
|
7300
|
+
* format): each post is a branch bundle at `content/{slug}/_index.md` with
|
|
7301
|
+
* reply leaves at `content/{root-slug}/{reply-slug}/index.md`.
|
|
7302
|
+
*
|
|
7303
|
+
* Push (Jant → GitHub):
|
|
7304
|
+
* - Always full sync: regenerate Jant-managed files in a single atomic commit
|
|
7305
|
+
* - Uses base_tree so untracked files in the repo (READMEs, CI, etc.) are preserved
|
|
7306
|
+
* - Debounced: multiple rapid changes collapse into one sync
|
|
7307
|
+
*
|
|
7308
|
+
* Pull (GitHub → Jant):
|
|
7309
|
+
* - Webhook-triggered: match modified files to existing posts by slug and update
|
|
7310
|
+
* - Unknown files are skipped; new posts cannot be created from GitHub
|
|
7311
|
+
* - File deletions are intentionally ignored to avoid catastrophic data loss
|
|
7312
|
+
* (e.g. user deletes the repo → site wiped). Deletes must go through Jant's UI.
|
|
7313
|
+
*
|
|
7314
|
+
* Anti-loop: all commits from Jant include `[jant-sync]` in the message.
|
|
7315
|
+
* Incoming webhooks with this marker are skipped.
|
|
7316
|
+
*/ var github_sync_exports = /* @__PURE__ */ __exportAll({
|
|
7317
|
+
JANT_MANAGED_GLOBS: () => JANT_MANAGED_GLOBS,
|
|
7318
|
+
JANT_SYNC_MARKER_PATH: () => JANT_SYNC_MARKER_PATH,
|
|
7319
|
+
JANT_SYNC_MARKER_SCHEMA_VERSION: () => 3,
|
|
7320
|
+
SYNC_COMMIT_MARKER: () => SYNC_COMMIT_MARKER,
|
|
7321
|
+
classifyRepoForSync: () => classifyRepoForSync,
|
|
7322
|
+
computeManagedDeletions: () => computeManagedDeletions,
|
|
7323
|
+
createGitHubSyncService: () => createGitHubSyncService,
|
|
7324
|
+
isManagedPath: () => isManagedPath,
|
|
7325
|
+
pathMatchesManagedGlob: () => pathMatchesManagedGlob
|
|
7326
|
+
});
|
|
7327
|
+
/** Marker included in commit messages to prevent webhook loops. */ var SYNC_COMMIT_MARKER = "[jant-sync]";
|
|
7328
|
+
/**
|
|
7329
|
+
* Path of the ownership marker file written at the repo root.
|
|
7330
|
+
*
|
|
7331
|
+
* Presence of this file (with a matching `site_id`) identifies the repo
|
|
7332
|
+
* as actively managed by a Jant site. Used to distinguish three states
|
|
7333
|
+
* during connect: empty repo, Jant-owned repo, foreign repo with existing
|
|
7334
|
+
* content. Also serves as the initialization file for empty repos so we
|
|
7335
|
+
* don't need a separate throwaway placeholder.
|
|
7336
|
+
*/ var JANT_SYNC_MARKER_PATH = ".jant-sync";
|
|
7337
|
+
/**
|
|
7338
|
+
* Hard list of paths Jant fully owns and always overwrites on push.
|
|
7339
|
+
* Anything outside this set is user territory and preserved via base_tree.
|
|
7340
|
+
* Files inside this set that Jant no longer generates are deleted on the
|
|
7341
|
+
* next push (see `computeManagedDeletions`).
|
|
7342
|
+
*
|
|
7343
|
+
* - `content/**` — posts, collections, sections (rendered by Hugo)
|
|
7344
|
+
* - `data/jant.toml` — nav, branding, collections directory (the rest of
|
|
7345
|
+
* `data/` is user territory so Hugo's `data/menu.toml` convention, etc.,
|
|
7346
|
+
* can be used freely)
|
|
7347
|
+
* - `themes/jant/**` — the packaged Jant theme (layouts + static assets)
|
|
7348
|
+
* - `hugo.toml` — site config, including `theme = "jant"`
|
|
7349
|
+
* - `.gitignore`, `README.md` — scaffolded once, then kept in sync
|
|
7350
|
+
* - `.jant-sync` — ownership marker; written by this service, not by export
|
|
7351
|
+
*
|
|
7352
|
+
* The list is also stored in the marker itself (`managed_globs`) so future
|
|
7353
|
+
* schema bumps can diff the old and new sets to decide what needs cleanup.
|
|
7354
|
+
*/ var JANT_MANAGED_GLOBS = [
|
|
7355
|
+
"content/**",
|
|
7356
|
+
"data/jant.toml",
|
|
7357
|
+
"themes/jant/**",
|
|
7358
|
+
"hugo.toml",
|
|
7359
|
+
".gitignore",
|
|
7360
|
+
"README.md",
|
|
7361
|
+
".jant-sync"
|
|
7362
|
+
];
|
|
7363
|
+
/**
|
|
7364
|
+
* Match a repo-relative path against a single glob from
|
|
7365
|
+
* `JANT_MANAGED_GLOBS`. Only two forms are supported because those are
|
|
7366
|
+
* the only two shapes the constant uses:
|
|
7367
|
+
*
|
|
7368
|
+
* - Exact path (`"hugo.toml"`, `"data/jant.toml"`)
|
|
7369
|
+
* - Directory prefix + `/**` (`"content/**"`, `"themes/jant/**"`)
|
|
7370
|
+
*
|
|
7371
|
+
* Exported for testing.
|
|
7372
|
+
*/ function pathMatchesManagedGlob(path, glob) {
|
|
7373
|
+
if (glob.endsWith("/**")) {
|
|
7374
|
+
const prefix = glob.slice(0, -3);
|
|
7375
|
+
return path === prefix || path.startsWith(prefix + "/");
|
|
7376
|
+
}
|
|
7377
|
+
return path === glob;
|
|
7378
|
+
}
|
|
7379
|
+
/**
|
|
7380
|
+
* True when `path` falls inside one of Jant's managed globs.
|
|
7381
|
+
*
|
|
7382
|
+
* Exported for testing.
|
|
7383
|
+
*/ function isManagedPath(path) {
|
|
7384
|
+
return JANT_MANAGED_GLOBS.some((g) => pathMatchesManagedGlob(path, g));
|
|
7385
|
+
}
|
|
7386
|
+
/**
|
|
7387
|
+
* Compute the tree items that should null-out files on the remote HEAD
|
|
7388
|
+
* which Jant claims ownership of (matches `JANT_MANAGED_GLOBS`) but is
|
|
7389
|
+
* not writing in the current push (not in `writtenPaths`).
|
|
7390
|
+
*
|
|
7391
|
+
* Returns a list of `{ sha: null }` tree entries suitable for appending
|
|
7392
|
+
* to the payload of `createTree`. Exported for testing.
|
|
7393
|
+
*/ function computeManagedDeletions(headTreeItems, writtenPaths) {
|
|
7394
|
+
const items = [];
|
|
7395
|
+
for (const item of headTreeItems) {
|
|
7396
|
+
if (item.type !== "blob") continue;
|
|
7397
|
+
if (!isManagedPath(item.path)) continue;
|
|
7398
|
+
if (writtenPaths.has(item.path)) continue;
|
|
7399
|
+
items.push({
|
|
7400
|
+
path: item.path,
|
|
7401
|
+
mode: "100644",
|
|
7402
|
+
type: "blob",
|
|
7403
|
+
sha: null
|
|
7404
|
+
});
|
|
7405
|
+
}
|
|
7406
|
+
return items;
|
|
7407
|
+
}
|
|
7408
|
+
function parseMarker(text) {
|
|
7409
|
+
try {
|
|
7410
|
+
const parsed = JSON.parse(text);
|
|
7411
|
+
if (typeof parsed.site_id === "string" && typeof parsed.site_host === "string" && typeof parsed.created_at === "number" && typeof parsed.schema_version === "number") return parsed;
|
|
7412
|
+
} catch {}
|
|
7413
|
+
return null;
|
|
7414
|
+
}
|
|
7415
|
+
function decodeMarkerContent(file) {
|
|
7416
|
+
if (file.encoding === "base64") try {
|
|
7417
|
+
const cleaned = file.content.replace(/\s+/g, "");
|
|
7418
|
+
const binary = atob(cleaned);
|
|
7419
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
7420
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
7421
|
+
} catch {
|
|
7422
|
+
return "";
|
|
7423
|
+
}
|
|
7424
|
+
return file.content;
|
|
7425
|
+
}
|
|
7426
|
+
function formatMarker(marker) {
|
|
7427
|
+
return `${JSON.stringify(marker, null, 2)}\n`;
|
|
7428
|
+
}
|
|
7429
|
+
function safeHost(siteUrl) {
|
|
7430
|
+
try {
|
|
7431
|
+
return new URL(siteUrl).host;
|
|
7432
|
+
} catch {
|
|
7433
|
+
return "";
|
|
7434
|
+
}
|
|
7435
|
+
}
|
|
7436
|
+
/**
|
|
7437
|
+
* Classify a repository to decide how to proceed with a sync connection.
|
|
7438
|
+
*
|
|
7439
|
+
* - `empty`: repo has no commits on the default branch (or repo is brand new).
|
|
7440
|
+
* - `owned`: `.jant-sync` marker present with matching `site_id`.
|
|
7441
|
+
* - `owned-by-other-site`: `.jant-sync` present but `site_id` differs —
|
|
7442
|
+
* another Jant site already backs up here, blocking the connect.
|
|
7443
|
+
* - `foreign`: non-empty repo without a marker — requires explicit
|
|
7444
|
+
* user confirmation before connect.
|
|
7445
|
+
*/ async function classifyRepoForSync(client, owner, repo, siteId) {
|
|
7446
|
+
const defaultBranch = (await client.getRepo(owner, repo)).default_branch;
|
|
7447
|
+
try {
|
|
7448
|
+
await client.getRef(owner, repo, `heads/${defaultBranch}`);
|
|
7449
|
+
} catch {
|
|
7450
|
+
return { kind: "empty" };
|
|
7451
|
+
}
|
|
7452
|
+
const markerFile = await client.getFileContent(owner, repo, JANT_SYNC_MARKER_PATH);
|
|
7453
|
+
if (!markerFile) return {
|
|
7454
|
+
kind: "foreign",
|
|
7455
|
+
defaultBranch
|
|
7456
|
+
};
|
|
7457
|
+
const marker = parseMarker(decodeMarkerContent(markerFile));
|
|
7458
|
+
if (!marker) return {
|
|
7459
|
+
kind: "foreign",
|
|
7460
|
+
defaultBranch
|
|
7461
|
+
};
|
|
7462
|
+
if (marker.site_id === siteId) return {
|
|
7463
|
+
kind: "owned",
|
|
7464
|
+
marker
|
|
7465
|
+
};
|
|
7466
|
+
return {
|
|
7467
|
+
kind: "owned-by-other-site",
|
|
7468
|
+
marker
|
|
7469
|
+
};
|
|
7470
|
+
}
|
|
7471
|
+
function createGitHubSyncService(services, siteId, siteConfig, deps = {}) {
|
|
7472
|
+
/**
|
|
7473
|
+
* Build the ownership marker for this push. Preserves `created_at`
|
|
7474
|
+
* from an existing marker (when readable) so the timestamp reflects
|
|
7475
|
+
* when this repo was first bound to this site, not the latest push.
|
|
7476
|
+
*/ function buildMarker(existingContent, now) {
|
|
7477
|
+
const existing = existingContent ? parseMarker(existingContent) : null;
|
|
7478
|
+
const preservedCreatedAt = existing && existing.site_id === siteId ? existing.created_at : now;
|
|
7479
|
+
return {
|
|
7480
|
+
schema_version: 3,
|
|
7481
|
+
site_id: siteId,
|
|
7482
|
+
site_host: safeHost(siteConfig.siteUrl),
|
|
7483
|
+
created_at: preservedCreatedAt,
|
|
7484
|
+
managed_globs: [...JANT_MANAGED_GLOBS]
|
|
7485
|
+
};
|
|
7486
|
+
}
|
|
7487
|
+
async function loadConfig() {
|
|
7488
|
+
const [repo, enabled, authModeRaw] = await Promise.all([
|
|
7489
|
+
services.settings.get("GITHUB_SYNC_REPO"),
|
|
7490
|
+
services.settings.get("GITHUB_SYNC_ENABLED"),
|
|
7491
|
+
services.settings.get("GITHUB_SYNC_AUTH_MODE")
|
|
7492
|
+
]);
|
|
7493
|
+
if (!repo || enabled !== "true") return null;
|
|
7494
|
+
const authMode = authModeRaw === "app" ? "app" : "pat";
|
|
7495
|
+
const [token, installationId, webhookId, webhookSecret, lastPushSha] = await Promise.all([
|
|
7496
|
+
services.settings.get("GITHUB_SYNC_TOKEN"),
|
|
7497
|
+
services.settings.get("GITHUB_SYNC_APP_INSTALLATION_ID"),
|
|
7498
|
+
services.settings.get("GITHUB_SYNC_WEBHOOK_ID"),
|
|
7499
|
+
services.settings.get("GITHUB_SYNC_WEBHOOK_SECRET"),
|
|
7500
|
+
services.settings.get("GITHUB_SYNC_LAST_PUSH_SHA")
|
|
7501
|
+
]);
|
|
7502
|
+
if (authMode === "pat" && !token) return null;
|
|
7503
|
+
if (authMode === "app" && !installationId) return null;
|
|
7504
|
+
return {
|
|
7505
|
+
authMode,
|
|
7506
|
+
token: token ?? void 0,
|
|
7507
|
+
installationId: installationId ?? void 0,
|
|
7508
|
+
repo,
|
|
7509
|
+
enabled: true,
|
|
7510
|
+
webhookId: webhookId ?? void 0,
|
|
7511
|
+
webhookSecret: webhookSecret ?? void 0,
|
|
7512
|
+
lastPushSha: lastPushSha ?? void 0
|
|
7513
|
+
};
|
|
7514
|
+
}
|
|
7515
|
+
function createClient(config) {
|
|
7516
|
+
const parsed = parseRepoSlug(config.repo);
|
|
7517
|
+
if (!parsed) throw new Error(`Invalid repo slug: ${config.repo}`);
|
|
7518
|
+
let client;
|
|
7519
|
+
if (config.authMode === "app") {
|
|
7520
|
+
if (!deps.githubApp) throw new Error("GitHub App is not configured on this deployment. Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_SLUG to use App auth.");
|
|
7521
|
+
if (!config.installationId) throw new Error("GitHub App installation id is missing.");
|
|
7522
|
+
const app = deps.githubApp;
|
|
7523
|
+
const installationId = config.installationId;
|
|
7524
|
+
client = createGitHubClient(() => getInstallationToken(app, installationId));
|
|
7525
|
+
} else {
|
|
7526
|
+
if (!config.token) throw new Error("GitHub sync PAT is missing.");
|
|
7527
|
+
client = createGitHubClient(config.token);
|
|
7528
|
+
}
|
|
7529
|
+
return {
|
|
7530
|
+
client,
|
|
7531
|
+
owner: parsed.owner,
|
|
7532
|
+
repo: parsed.repo
|
|
7533
|
+
};
|
|
7534
|
+
}
|
|
7535
|
+
/**
|
|
7536
|
+
* Get the HEAD SHA, initializing an empty repo if needed.
|
|
7537
|
+
* GitHub's Git Trees API requires at least one commit to exist.
|
|
7538
|
+
*/ async function getOrInitHead(client, owner, repo, defaultBranch, seedMarker) {
|
|
7539
|
+
try {
|
|
7540
|
+
return { sha: (await client.getRef(owner, repo, `heads/${defaultBranch}`)).sha };
|
|
7541
|
+
} catch {
|
|
7542
|
+
await client.createOrUpdateFile(owner, repo, JANT_SYNC_MARKER_PATH, {
|
|
7543
|
+
content: formatMarker(seedMarker),
|
|
7544
|
+
message: `Initialize Jant sync ${SYNC_COMMIT_MARKER}`
|
|
7545
|
+
});
|
|
7546
|
+
return { sha: (await client.getRef(owner, repo, `heads/${defaultBranch}`)).sha };
|
|
7547
|
+
}
|
|
7548
|
+
}
|
|
7549
|
+
return {
|
|
7550
|
+
getConfig: loadConfig,
|
|
7551
|
+
async pushFullSync() {
|
|
7552
|
+
const config = await loadConfig();
|
|
7553
|
+
if (!config) throw new Error("GitHub Sync is not configured");
|
|
7554
|
+
const { client, owner, repo } = createClient(config);
|
|
7555
|
+
const exportFiles = await createExportService(services, siteConfig, {
|
|
7556
|
+
storage: deps.storage,
|
|
7557
|
+
bundleMedia: false
|
|
7558
|
+
}).generateHugoFiles();
|
|
7559
|
+
const defaultBranch = (await client.getRepo(owner, repo)).default_branch;
|
|
7560
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
7561
|
+
const existingMarkerBeforeInit = await client.getFileContent(owner, repo, JANT_SYNC_MARKER_PATH).catch(() => null);
|
|
7562
|
+
const marker = buildMarker(existingMarkerBeforeInit ? decodeMarkerContent(existingMarkerBeforeInit) : null, now);
|
|
7563
|
+
const { sha: headSha } = await getOrInitHead(client, owner, repo, defaultBranch, marker);
|
|
7564
|
+
const treeItems = [{
|
|
7565
|
+
path: JANT_SYNC_MARKER_PATH,
|
|
7566
|
+
mode: "100644",
|
|
7567
|
+
type: "blob",
|
|
7568
|
+
content: formatMarker(marker)
|
|
7569
|
+
}];
|
|
7570
|
+
for (const file of exportFiles) if (typeof file.content === "string") treeItems.push({
|
|
7571
|
+
path: file.path,
|
|
7572
|
+
mode: "100644",
|
|
7573
|
+
type: "blob",
|
|
7574
|
+
content: file.content
|
|
7575
|
+
});
|
|
7576
|
+
else {
|
|
7577
|
+
const blob = await client.createBlob(owner, repo, uint8ArrayToBase64(file.content), "base64");
|
|
7578
|
+
treeItems.push({
|
|
7579
|
+
path: file.path,
|
|
7580
|
+
mode: "100644",
|
|
7581
|
+
type: "blob",
|
|
7582
|
+
sha: blob.sha
|
|
7583
|
+
});
|
|
7584
|
+
}
|
|
7585
|
+
const headCommit = await client.getCommit(owner, repo, headSha);
|
|
7586
|
+
const headTree = await client.getTree(owner, repo, headCommit.treeSha, { recursive: true });
|
|
7587
|
+
if (headTree.truncated) throw new Error("GitHub tree exceeds API limits (>100k entries or >7MB); incremental deletion cannot run safely against this repo.");
|
|
7588
|
+
const writtenPaths = new Set(treeItems.map((item) => item.path));
|
|
7589
|
+
treeItems.push(...computeManagedDeletions(headTree.tree, writtenPaths));
|
|
7590
|
+
const tree = await client.createTree(owner, repo, treeItems, headCommit.treeSha);
|
|
7591
|
+
const commit = await client.createCommit(owner, repo, {
|
|
7592
|
+
message: `Sync site ${SYNC_COMMIT_MARKER}`,
|
|
7593
|
+
tree: tree.sha,
|
|
7594
|
+
parents: [headSha]
|
|
7595
|
+
});
|
|
7596
|
+
await client.updateRef(owner, repo, `heads/${defaultBranch}`, commit.sha);
|
|
7597
|
+
await services.settings.set("GITHUB_SYNC_LAST_PUSH_SHA", commit.sha);
|
|
7598
|
+
await services.settings.set("GITHUB_SYNC_LAST_PUSH_AT", String(Math.floor(Date.now() / 1e3)));
|
|
7599
|
+
return { commitSha: commit.sha };
|
|
7600
|
+
},
|
|
7601
|
+
async handleWebhookPush(payload) {
|
|
7602
|
+
if (payload.commits.some((c) => c.message.includes("[jant-sync]")) && payload.commits.length === 1) return;
|
|
7603
|
+
const modified = /* @__PURE__ */ new Set();
|
|
7604
|
+
for (const commit of payload.commits) {
|
|
7605
|
+
if (commit.message.includes("[jant-sync]")) continue;
|
|
7606
|
+
for (const file of [...commit.modified, ...commit.added]) if (classifyBundlePath(file)) modified.add(file);
|
|
7607
|
+
}
|
|
7608
|
+
if (modified.size === 0) return;
|
|
7609
|
+
const config = await loadConfig();
|
|
7610
|
+
if (!config) return;
|
|
7611
|
+
const { client, owner, repo } = createClient(config);
|
|
7612
|
+
for (const filePath of modified) {
|
|
7613
|
+
const classification = classifyBundlePath(filePath);
|
|
7614
|
+
if (!classification) continue;
|
|
7615
|
+
const fileContent = await client.getFileContent(owner, repo, filePath, payload.after);
|
|
7616
|
+
if (!fileContent) continue;
|
|
7617
|
+
const { frontMatter, body } = await parseFrontMatter(decodeBase64Content(fileContent.content));
|
|
7618
|
+
const slug = typeof frontMatter.slug === "string" && frontMatter.slug.trim() ? frontMatter.slug.trim() : classification.slug;
|
|
7619
|
+
if (!slug) continue;
|
|
7620
|
+
const pathRecord = await services.paths.getByPath(slug);
|
|
7621
|
+
if (!pathRecord?.postId) continue;
|
|
7622
|
+
const existingPost = await services.posts.getById(pathRecord.postId);
|
|
7623
|
+
if (!existingPost) continue;
|
|
7624
|
+
if (classification.kind === "reply") {
|
|
7625
|
+
const rootPath = await services.paths.getByPath(classification.rootSlug);
|
|
7626
|
+
if (!rootPath?.postId) continue;
|
|
7627
|
+
if (existingPost.threadId !== rootPath.postId) continue;
|
|
7628
|
+
}
|
|
7629
|
+
const trimmedBody = body.trim();
|
|
7630
|
+
const tiptapBody = trimmedBody ? markdownToTiptapJson(trimmedBody) : null;
|
|
7631
|
+
const updateData = {};
|
|
7632
|
+
if (tiptapBody !== null) updateData.body = tiptapBody;
|
|
7633
|
+
if (frontMatter.title !== void 0) updateData.title = frontMatter.title;
|
|
7634
|
+
if (frontMatter.link_url !== void 0) updateData.url = frontMatter.link_url;
|
|
7635
|
+
if (frontMatter.source_name !== void 0) updateData.sourceName = frontMatter.source_name;
|
|
7636
|
+
if (frontMatter.source_url !== void 0) updateData.sourceUrl = frontMatter.source_url;
|
|
7637
|
+
if (frontMatter.quote_text !== void 0) updateData.quoteText = frontMatter.quote_text;
|
|
7638
|
+
if (frontMatter.rating !== void 0) updateData.rating = frontMatter.rating;
|
|
7639
|
+
if (Object.keys(updateData).length > 0) await services.posts.update(existingPost.id, updateData);
|
|
7640
|
+
}
|
|
7641
|
+
},
|
|
7642
|
+
async setupWebhook(callbackUrl) {
|
|
7643
|
+
const config = await loadConfig();
|
|
7644
|
+
if (!config) throw new Error("GitHub Sync is not configured");
|
|
7645
|
+
const { client, owner, repo } = createClient(config);
|
|
7646
|
+
const secretBytes = /* @__PURE__ */ new Uint8Array(32);
|
|
7647
|
+
crypto.getRandomValues(secretBytes);
|
|
7648
|
+
const secret = Array.from(secretBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
7649
|
+
const webhook = await client.createWebhook(owner, repo, {
|
|
7650
|
+
url: callbackUrl,
|
|
7651
|
+
secret,
|
|
7652
|
+
events: ["push"]
|
|
7653
|
+
});
|
|
7654
|
+
await services.settings.set("GITHUB_SYNC_WEBHOOK_SECRET", secret);
|
|
7655
|
+
await services.settings.set("GITHUB_SYNC_WEBHOOK_ID", String(webhook.id));
|
|
7656
|
+
return { webhookId: webhook.id };
|
|
7657
|
+
},
|
|
7658
|
+
async teardownWebhook() {
|
|
7659
|
+
const config = await loadConfig();
|
|
7660
|
+
if (!config) return;
|
|
7661
|
+
if (config.webhookId) try {
|
|
7662
|
+
const { client, owner, repo } = createClient(config);
|
|
7663
|
+
await client.deleteWebhook(owner, repo, parseInt(config.webhookId));
|
|
7664
|
+
} catch {}
|
|
7665
|
+
await services.settings.set("GITHUB_SYNC_ENABLED", "false");
|
|
7666
|
+
await services.settings.set("GITHUB_SYNC_TOKEN", "");
|
|
7667
|
+
await services.settings.set("GITHUB_SYNC_REPO", "");
|
|
7668
|
+
await services.settings.set("GITHUB_SYNC_WEBHOOK_SECRET", "");
|
|
7669
|
+
await services.settings.set("GITHUB_SYNC_WEBHOOK_ID", "");
|
|
7670
|
+
await services.settings.set("GITHUB_SYNC_LAST_PUSH_SHA", "");
|
|
7671
|
+
await services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
|
|
7672
|
+
await services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
|
|
7673
|
+
}
|
|
7674
|
+
};
|
|
7675
|
+
}
|
|
7676
|
+
function decodeBase64Content(content) {
|
|
7677
|
+
const cleaned = content.replace(/\n/g, "");
|
|
7678
|
+
return decodeURIComponent(escape(atob(cleaned)));
|
|
7679
|
+
}
|
|
7680
|
+
function classifyBundlePath(path) {
|
|
7681
|
+
if (!path.startsWith("content/")) return null;
|
|
7682
|
+
const segments = path.slice(8).split("/");
|
|
7683
|
+
if (segments.length === 2 && segments[1] === "_index.md") {
|
|
7684
|
+
const slug = segments[0];
|
|
7685
|
+
if (!slug) return null;
|
|
7686
|
+
return {
|
|
7687
|
+
kind: "root",
|
|
7688
|
+
slug
|
|
7689
|
+
};
|
|
7690
|
+
}
|
|
7691
|
+
if (segments.length === 3 && segments[2] === "index.md") {
|
|
7692
|
+
const rootSlug = segments[0];
|
|
7693
|
+
const slug = segments[1];
|
|
7694
|
+
if (!rootSlug || !slug) return null;
|
|
7695
|
+
return {
|
|
7696
|
+
kind: "reply",
|
|
7697
|
+
rootSlug,
|
|
7698
|
+
slug
|
|
7699
|
+
};
|
|
7700
|
+
}
|
|
7701
|
+
return null;
|
|
7702
|
+
}
|
|
7703
|
+
function uint8ArrayToBase64(bytes) {
|
|
7704
|
+
let binary = "";
|
|
7705
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
7706
|
+
return btoa(binary);
|
|
7707
|
+
}
|
|
7708
|
+
//#endregion
|
|
7709
|
+
export { getHostedControlPlaneBaseUrl as $, HOME_BRANDING_LINK_LABEL as $t, upgradeLegacyFootnotes as A, toAbsoluteSiteUrl as An, MAX_SITE_DESCRIPTION_LENGTH as At, env_exports as B, SMART_COLLECTION_SORT_ORDERS as Bt, extractSummary as C, isFullUrl as Cn, EARLIEST_FILTERABLE_YEAR as Ct, renderTiptapDocumentAroundBoundary as D, sanitizeUrl as Dn, MAX_COLLECTION_DESCRIPTION_LENGTH as Dt, renderTiptapDocument as E, normalizeSiteUrl as En, LATEST_FILTERABLE_YEAR as Et, formatYearMonth as F, url_exports as Fn, PATH_KINDS as Ft, getConfiguredStorageDriver as G, SYSTEM_NAV_KEY_VALUES as Gt, getConfiguredSingleSiteOrigin as H, STATUSES as Ht, formatYearMonthLabel as I, escapeHtml as In, PUBLIC_ARCHIVE_VISIBILITIES as It, getDiscoverDefault as J, VISIBILITIES as Jt, getCorsOrigins as K, TEXT_ATTACHMENT_CONTENT_FORMATS as Kt, now as L, __commonJSMin as Ln, SITE_DOMAIN_KINDS as Lt, formatRelativeAge as M, toPublicHref as Mn, MEDIA_KINDS as Mt, formatRelativeTime as N, toPublicPath as Nn, NAV_ITEM_PLACEMENTS as Nt, renderTiptapJson as O, stripSitePathPrefix as On, MAX_MEDIA_ATTACHMENTS as Ot, formatTime as P, toSameSitePath as Pn, NAV_ITEM_TYPES as Pt, getGitHubAppConfig as Q, getPublicUrlForProvider as Qt, time_exports as R, __exportAll as Rn, SITE_MEMBER_ROLES as Rt, extractBodyText as S, getSitePathPrefix$1 as Sn, DEFAULT_NAVIGATION_PROFILE as St, extractTimelineSummary as T, normalizePath as Tn, GITHUB_APP_ACCOUNT_TYPES as Tt, getConfiguredSingleSitePathPrefix as U, STORAGE_DRIVERS as Ut, getAuthSecret as V, SORT_ORDERS as Vt, getConfiguredSingleSiteUrl as W, SYSTEM_NAV_KEYS as Wt, getDiscoverPingUrl as X, getImageUrl as Xt, getDiscoverDirectoryBaseUrl as Y, isFeedNavKey as Yt, getEnvString as Z, getMediaUrl as Zt, markdownToTiptapJson as _, base64ToUint8Array as _n, ARCHIVE_VISIBILITIES as _t, github_api_exports as a, getDefaultJantFaviconIcoBytes as an, getInternalAdminToken as at, toPlainText as b, extractDomain as bn, COLLECTION_SORT_ORDERS as bt, export_exports as c, getJantIconFilename as cn, getSiteResolutionMode as ct, buildInstallUrl as d, getJantLogoFills as dn, shouldTrustProxy as dt, HOME_BRANDING_PREFIX as en, getHostedControlPlaneDomainCheckSecret as et, getInstallation as f, getJantLogoHref as fn, shouldUseSecureCookies as ft, tiptapJsonToMarkdown as g, arrayBufferToBase64 as gn, ARCHIVE_LAYOUTS as gt, searchInstallationRepos as h, JANT_LOGO_VIEW_BOX as hn, THEME_MODES as ht, createGitHubClient as i, getDefaultJantAppleTouchIconBytes as in, getHostedControlPlaneSsoSecret as it, formatDate as j, toInternalPath as jn, MAX_SITE_FOOTER_LENGTH as jt, trimTiptapBody as k, toAbsoluteAssetUrl as kn, MAX_PINNED_POSTS as kt, buildRootActivityExpr as l, getJantIconHref as ln, getTelegramBotPool as lt, listInstallationReposPage as m, JANT_LOGO_PATH_DATA as mn, CONFIG_FIELDS as mt, createGitHubSyncService as n, JANT_HOME_URL as nn, getHostedControlPlaneInternalToken as nt, parseRepoSlug as o, getJantBrandPackHref as on, getLocalStoragePath as ot, github_app_exports as p, getJantPositiveLogoPngHref as pn, coalesceDisplayText as pt, getDevApiToken as q, UPLOAD_SESSION_STATES as qt, github_sync_exports as r, JANT_POSITIVE_LOGO_PNG_FILENAME as rn, getHostedControlPlaneProviderLabel as rt, createExportService as s, getJantBundledAsset as sn, getPort as st, SYNC_COMMIT_MARKER as t, JANT_BRAND_PACK_FILENAME as tn, getHostedControlPlaneInternalBaseUrl as tt, rootActivityColumns as u, getJantLogoFilename as un, getTelegramWebhookSecret as ut, markdown_exports as v, buildSiteUrl as vn, COLLECTION_DIRECTORY_ENTRY_TYPES as vt, extractSummaryHtml as w, isSafeInternalRedirect as wn, FORMATS as wt, NOTE_SUMMARY_MAX_CHARS as x, getSiteOrigin$1 as xn, CONTENT_DISPOSITIONS as xt, render as y, extractDisplayDomain as yn, COLLECTION_FRESHNESS_WINDOW_SECONDS as yt, toISOString as z, __toESM as zn, SITE_STATUSES as zt };
|