emdash 0.7.0 → 0.9.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/dist/{adapters-Di31kZ28.d.mts → adapters-DoNJiveC.d.mts} +1 -1
- package/dist/{adapters-Di31kZ28.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
- package/dist/{apply-5uslYdUu.mjs → apply-BzltprvY.mjs} +90 -139
- package/dist/apply-BzltprvY.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +194 -17
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +6 -7
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +34 -57
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +17 -12
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +9 -6
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +301 -165
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +34 -10
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
- package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
- package/dist/{byline-C4OVd8b3.mjs → byline-BSaNL1w7.mjs} +5 -5
- package/dist/byline-BSaNL1w7.mjs.map +1 -0
- package/dist/bylines-CvJ3PYz2.mjs +113 -0
- package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
- package/dist/cache-C6N_hhN7.mjs +65 -0
- package/dist/cache-C6N_hhN7.mjs.map +1 -0
- package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
- package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
- package/dist/cli/index.mjs +229 -31
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +3 -3
- package/dist/client/index.mjs.map +1 -1
- package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
- package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
- package/dist/{content-D7J5y73J.mjs → content-8lOYF0pr.mjs} +43 -28
- package/dist/content-8lOYF0pr.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +2 -2
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/libsql.d.mts.map +1 -1
- package/dist/db/libsql.mjs +7 -2
- package/dist/db/libsql.mjs.map +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/db/sqlite.d.mts.map +1 -1
- package/dist/db/sqlite.mjs +8 -3
- package/dist/db/sqlite.mjs.map +1 -1
- package/dist/{db-errors-D0UT85nC.mjs → db-errors-WRezodiz.mjs} +1 -1
- package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
- package/dist/{default-CME5YdZ3.mjs → default-D8ksjWhO.mjs} +1 -1
- package/dist/{default-CME5YdZ3.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
- package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
- package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
- package/dist/{error-CiYn9yDu.mjs → error-D_-tqP-I.mjs} +1 -1
- package/dist/error-D_-tqP-I.mjs.map +1 -0
- package/dist/{index-De6_Xv3v.d.mts → index-BFRaVcD6.d.mts} +243 -40
- package/dist/index-BFRaVcD6.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +29 -25
- package/dist/{load-CBcmDIot.mjs → load-DDqMMvZL.mjs} +2 -2
- package/dist/{load-CBcmDIot.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
- package/dist/{loader-DeiBJEMe.mjs → loader-CKLbBnhK.mjs} +32 -10
- package/dist/loader-CKLbBnhK.mjs.map +1 -0
- package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DqWNC3lM.mjs} +45 -3
- package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/media/local-runtime.mjs +3 -3
- package/dist/{media-DqHVh136.mjs → media-BW32b4gi.mjs} +4 -7
- package/dist/media-BW32b4gi.mjs.map +1 -0
- package/dist/{mode-CpNnGkPz.mjs → mode-ier8jbBk.mjs} +1 -1
- package/dist/mode-ier8jbBk.mjs.map +1 -0
- package/dist/options-BVp3UsTS.mjs +117 -0
- package/dist/options-BVp3UsTS.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/{placeholder-tzpqGWII.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
- package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
- package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
- package/dist/placeholder-CIJejMlK.mjs.map +1 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
- package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
- package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
- package/dist/public-url-DByxYjUw.mjs +51 -0
- package/dist/public-url-DByxYjUw.mjs.map +1 -0
- package/dist/{query-g4Ug-9j9.mjs → query-Cg9ZKRQ0.mjs} +114 -16
- package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
- package/dist/{redirect-CN0Rt9Ob.mjs → redirect-BhUBKRc1.mjs} +13 -8
- package/dist/redirect-BhUBKRc1.mjs.map +1 -0
- package/dist/{registry-Ci3WxVAr.mjs → registry-Dw70ChxB.mjs} +69 -11
- package/dist/registry-Dw70ChxB.mjs.map +1 -0
- package/dist/{request-cache-DiR961CV.mjs → request-cache-B-bmkipQ.mjs} +1 -1
- package/dist/request-cache-B-bmkipQ.mjs.map +1 -0
- package/dist/runner-Bnoj7vjK.d.mts +44 -0
- package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
- package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
- package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +4 -4
- package/dist/{search-B0effn3j.mjs → search-dOGEccMa.mjs} +341 -152
- package/dist/search-dOGEccMa.mjs.map +1 -0
- package/dist/secrets-CW3reAnU.mjs +314 -0
- package/dist/secrets-CW3reAnU.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +15 -14
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +4 -4
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{taxonomies-K2z0Uhnj.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
- package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
- package/dist/{tokens-BFPFx3CA.mjs → tokens-D7zMmWi2.mjs} +2 -2
- package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
- package/dist/{transport-BykRfpyy.mjs → transport-BeMCmin1.mjs} +6 -5
- package/dist/{transport-BykRfpyy.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts → transport-DNEfeMaU.d.mts} +1 -1
- package/dist/{transport-H4Iwx7tC.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
- package/dist/types-4fVtCIm0.mjs +68 -0
- package/dist/types-4fVtCIm0.mjs.map +1 -0
- package/dist/{types-CnZYHyLW.d.mts → types-BSyXeCFW.d.mts} +24 -2
- package/dist/{types-CnZYHyLW.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
- package/dist/{types-DgrIP0tF.d.mts → types-BuBIptGk.d.mts} +80 -106
- package/dist/types-BuBIptGk.d.mts.map +1 -0
- package/dist/{types-BH2L167P.mjs → types-CDbKp7ND.mjs} +1 -1
- package/dist/{types-BH2L167P.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
- package/dist/{types-DDS4MxsT.mjs → types-CIOg5AR8.mjs} +1 -1
- package/dist/{types-DDS4MxsT.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
- package/dist/{types-6CUZRrZP.d.mts → types-CJsYGpco.d.mts} +24 -2
- package/dist/{types-6CUZRrZP.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
- package/dist/types-CRxNbK-Z.mjs +68 -0
- package/dist/types-CRxNbK-Z.mjs.map +1 -0
- package/dist/{types-C2v0c34j.d.mts → types-CrtWgIvl.d.mts} +1 -1
- package/dist/{types-C2v0c34j.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
- package/dist/{types-CFWjXmus.d.mts → types-M78DQ1lx.d.mts} +1 -1
- package/dist/{types-CFWjXmus.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
- package/dist/{validate-CqsNItbt.mjs → validate-Baqf0slj.mjs} +3 -3
- package/dist/{validate-CqsNItbt.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
- package/dist/{validate-kM8Pjuf7.d.mts → validate-BfQh_C_y.d.mts} +4 -4
- package/dist/{validate-kM8Pjuf7.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
- package/dist/validation-BfEI7tNe.mjs +144 -0
- package/dist/validation-BfEI7tNe.mjs.map +1 -0
- package/dist/version-DoxrVdYf.mjs +7 -0
- package/dist/{version-BnTKdfam.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
- package/dist/zod-generator-CC0xNe_K.mjs +132 -0
- package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
- package/locals.d.ts +1 -6
- package/package.json +21 -7
- package/src/api/auth-storage.ts +37 -0
- package/src/api/error.ts +6 -0
- package/src/api/errors.ts +8 -0
- package/src/api/handlers/comments.ts +19 -4
- package/src/api/handlers/content.ts +151 -4
- package/src/api/handlers/device-flow.ts +5 -0
- package/src/api/handlers/index.ts +2 -0
- package/src/api/handlers/marketplace.ts +11 -4
- package/src/api/handlers/media.ts +8 -1
- package/src/api/handlers/menus.ts +160 -21
- package/src/api/handlers/oauth-authorization.ts +72 -33
- package/src/api/handlers/redirects.ts +16 -3
- package/src/api/handlers/revision.ts +23 -14
- package/src/api/handlers/sections.ts +8 -1
- package/src/api/handlers/taxonomies.ts +131 -22
- package/src/api/handlers/validation.ts +212 -0
- package/src/api/openapi/document.ts +4 -1
- package/src/api/public-url.ts +54 -5
- package/src/api/route-utils.ts +14 -0
- package/src/api/schemas/comments.ts +2 -2
- package/src/api/schemas/common.ts +1 -1
- package/src/api/schemas/content.ts +17 -0
- package/src/api/schemas/sections.ts +3 -3
- package/src/api/schemas/setup.ts +8 -0
- package/src/api/schemas/users.ts +1 -1
- package/src/api/schemas/widgets.ts +12 -10
- package/src/api/setup-complete.ts +40 -0
- package/src/api/types.ts +5 -1
- package/src/astro/integration/index.ts +30 -2
- package/src/astro/integration/routes.ts +28 -0
- package/src/astro/integration/runtime.ts +49 -1
- package/src/astro/integration/virtual-modules.ts +73 -2
- package/src/astro/integration/vite-config.ts +49 -13
- package/src/astro/middleware/auth.ts +34 -6
- package/src/astro/middleware/redirect.ts +29 -16
- package/src/astro/middleware/request-context.ts +15 -5
- package/src/astro/middleware.ts +41 -10
- package/src/astro/routes/PluginRegistry.tsx +10 -1
- package/src/astro/routes/api/auth/invite/complete.ts +6 -1
- package/src/astro/routes/api/auth/mode.ts +57 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
- package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
- package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
- package/src/astro/routes/api/auth/signup/complete.ts +6 -1
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
- package/src/astro/routes/api/content/[collection]/index.ts +1 -9
- package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
- package/src/astro/routes/api/import/wordpress/media.ts +2 -7
- package/src/astro/routes/api/import/wordpress/prepare.ts +9 -0
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
- package/src/astro/routes/api/manifest.ts +62 -45
- package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
- package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
- package/src/astro/routes/api/openapi.json.ts +27 -10
- package/src/astro/routes/api/redirects/404s/index.ts +10 -4
- package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
- package/src/astro/routes/api/redirects/[id].ts +10 -4
- package/src/astro/routes/api/redirects/index.ts +7 -3
- package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
- package/src/astro/routes/api/schema/collections/index.ts +1 -1
- package/src/astro/routes/api/search/index.ts +10 -2
- package/src/astro/routes/api/sections/[slug].ts +10 -4
- package/src/astro/routes/api/sections/index.ts +7 -3
- package/src/astro/routes/api/settings/email.ts +4 -9
- package/src/astro/routes/api/setup/admin-verify.ts +6 -1
- package/src/astro/routes/api/setup/admin.ts +8 -2
- package/src/astro/routes/api/setup/index.ts +2 -2
- package/src/astro/routes/api/setup/status.ts +3 -1
- package/src/astro/routes/api/snapshot.ts +44 -18
- package/src/astro/routes/api/taxonomies/index.ts +0 -1
- package/src/astro/routes/api/themes/preview.ts +11 -5
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
- package/src/astro/routes/api/widget-areas/[name].ts +4 -1
- package/src/astro/routes/api/widget-areas/index.ts +4 -1
- package/src/astro/types.ts +32 -3
- package/src/auth/allowed-origins.ts +168 -0
- package/src/auth/mode.ts +15 -3
- package/src/auth/passkey-config.ts +35 -13
- package/src/auth/providers/github-admin.tsx +29 -0
- package/src/auth/providers/github.ts +31 -0
- package/src/auth/providers/google-admin.tsx +44 -0
- package/src/auth/providers/google.ts +31 -0
- package/src/auth/types.ts +114 -4
- package/src/bylines/index.ts +37 -88
- package/src/cli/commands/auth.ts +28 -6
- package/src/cli/commands/bundle-utils.ts +11 -2
- package/src/cli/commands/bundle.ts +31 -9
- package/src/cli/commands/content.ts +13 -0
- package/src/cli/commands/login.ts +8 -1
- package/src/cli/commands/publish.ts +24 -0
- package/src/cli/commands/secrets.ts +183 -0
- package/src/cli/credentials.ts +1 -1
- package/src/cli/index.ts +5 -1
- package/src/client/index.ts +4 -4
- package/src/client/transport.ts +17 -7
- package/src/components/Break.astro +2 -2
- package/src/components/EmDashHead.astro +18 -13
- package/src/components/EmDashImage.astro +7 -6
- package/src/components/Embed.astro +1 -1
- package/src/components/Gallery.astro +6 -4
- package/src/components/Image.astro +9 -4
- package/src/components/InlinePortableTextEditor.tsx +106 -19
- package/src/components/LiveSearch.astro +5 -14
- package/src/config/secrets.ts +528 -0
- package/src/database/dialect-helpers.ts +50 -0
- package/src/database/migrations/034_published_at_index.ts +1 -1
- package/src/database/migrations/035_bounded_404_log.ts +56 -39
- package/src/database/migrations/runner.ts +156 -23
- package/src/database/repositories/audit.ts +6 -8
- package/src/database/repositories/byline.ts +6 -8
- package/src/database/repositories/comment.ts +12 -16
- package/src/database/repositories/content.ts +76 -52
- package/src/database/repositories/index.ts +1 -1
- package/src/database/repositories/media.ts +10 -13
- package/src/database/repositories/plugin-storage.ts +4 -6
- package/src/database/repositories/redirect.ts +26 -19
- package/src/database/repositories/taxonomy.ts +40 -3
- package/src/database/repositories/types.ts +57 -8
- package/src/database/repositories/user.ts +6 -8
- package/src/db/libsql.ts +1 -3
- package/src/db/sqlite.ts +2 -5
- package/src/emdash-runtime.ts +388 -247
- package/src/index.ts +14 -1
- package/src/loader.ts +30 -6
- package/src/mcp/server.ts +781 -141
- package/src/media/normalize.ts +1 -1
- package/src/media/url.ts +78 -0
- package/src/page/site-identity.ts +58 -0
- package/src/plugins/adapt-sandbox-entry.ts +22 -10
- package/src/plugins/context.ts +13 -10
- package/src/plugins/define-plugin.ts +40 -12
- package/src/plugins/email-console.ts +10 -3
- package/src/plugins/hooks.ts +34 -19
- package/src/plugins/index.ts +9 -0
- package/src/plugins/manifest-schema.ts +49 -2
- package/src/plugins/types.ts +174 -13
- package/src/preview/urls.ts +23 -3
- package/src/query.ts +149 -6
- package/src/redirects/cache.ts +38 -18
- package/src/request-cache.ts +3 -0
- package/src/schema/registry.ts +97 -5
- package/src/schema/zod-generator.ts +27 -5
- package/src/search/fts-manager.ts +0 -2
- package/src/search/query.ts +111 -26
- package/src/search/types.ts +8 -1
- package/src/sections/index.ts +7 -9
- package/src/seed/apply.ts +2 -0
- package/src/settings/index.ts +80 -6
- package/src/settings/types.ts +23 -1
- package/src/storage/s3.ts +12 -6
- package/src/taxonomies/index.ts +11 -1
- package/src/virtual-modules.d.ts +21 -1
- package/src/widgets/index.ts +1 -1
- package/dist/apply-5uslYdUu.mjs.map +0 -1
- package/dist/byline-C4OVd8b3.mjs.map +0 -1
- package/dist/bylines-hPTW79hw.mjs +0 -157
- package/dist/bylines-hPTW79hw.mjs.map +0 -1
- package/dist/cache-BkKBuIvS.mjs +0 -56
- package/dist/cache-BkKBuIvS.mjs.map +0 -1
- package/dist/chunk-ClPoSABd.mjs +0 -21
- package/dist/content-D7J5y73J.mjs.map +0 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
- package/dist/error-CiYn9yDu.mjs.map +0 -1
- package/dist/index-De6_Xv3v.d.mts.map +0 -1
- package/dist/loader-DeiBJEMe.mjs.map +0 -1
- package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
- package/dist/media-DqHVh136.mjs.map +0 -1
- package/dist/mode-CpNnGkPz.mjs.map +0 -1
- package/dist/placeholder-C-fk5hYI.mjs.map +0 -1
- package/dist/query-g4Ug-9j9.mjs.map +0 -1
- package/dist/redirect-CN0Rt9Ob.mjs.map +0 -1
- package/dist/registry-Ci3WxVAr.mjs.map +0 -1
- package/dist/request-cache-DiR961CV.mjs.map +0 -1
- package/dist/runner-BR2xKwhn.d.mts +0 -34
- package/dist/runner-BR2xKwhn.d.mts.map +0 -1
- package/dist/search-B0effn3j.mjs.map +0 -1
- package/dist/taxonomies-K2z0Uhnj.mjs.map +0 -1
- package/dist/types-CMMN0pNg.mjs +0 -31
- package/dist/types-CMMN0pNg.mjs.map +0 -1
- package/dist/types-DgrIP0tF.d.mts.map +0 -1
- package/dist/version-BnTKdfam.mjs +0 -7
package/src/schema/registry.ts
CHANGED
|
@@ -8,9 +8,11 @@ import { withTransaction } from "../database/transaction.js";
|
|
|
8
8
|
import type { CollectionTable, Database, FieldTable } from "../database/types.js";
|
|
9
9
|
import { validateIdentifier } from "../database/validate.js";
|
|
10
10
|
import { FTSManager } from "../search/fts-manager.js";
|
|
11
|
+
import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
|
|
11
12
|
import {
|
|
12
13
|
type Collection,
|
|
13
14
|
type CollectionSource,
|
|
15
|
+
type CollectionSupport,
|
|
14
16
|
type ColumnType,
|
|
15
17
|
type Field,
|
|
16
18
|
type CreateCollectionInput,
|
|
@@ -49,6 +51,34 @@ function isColumnType(value: string): value is ColumnType {
|
|
|
49
51
|
return COLUMN_TYPES.has(value);
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
const VALID_COLLECTION_SUPPORTS: ReadonlySet<string> = new Set<CollectionSupport>([
|
|
55
|
+
"drafts",
|
|
56
|
+
"revisions",
|
|
57
|
+
"preview",
|
|
58
|
+
"scheduling",
|
|
59
|
+
"search",
|
|
60
|
+
"seo",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function isCollectionSupport(value: unknown): value is CollectionSupport {
|
|
64
|
+
return typeof value === "string" && VALID_COLLECTION_SUPPORTS.has(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse a collection's `supports` column (stored as a JSON array of
|
|
69
|
+
* CollectionSupport keys). Unknown/invalid entries are filtered out so the
|
|
70
|
+
* runtime value matches the declared `CollectionSupport[]` type.
|
|
71
|
+
*
|
|
72
|
+
* Throws on malformed JSON so corruption surfaces loudly; returns an empty
|
|
73
|
+
* array only for explicitly null/empty values or non-array JSON.
|
|
74
|
+
*/
|
|
75
|
+
function parseSupports(raw: string | null | undefined): CollectionSupport[] {
|
|
76
|
+
if (!raw) return [];
|
|
77
|
+
const parsed: unknown = JSON.parse(raw);
|
|
78
|
+
if (!Array.isArray(parsed)) return [];
|
|
79
|
+
return parsed.filter(isCollectionSupport);
|
|
80
|
+
}
|
|
81
|
+
|
|
52
82
|
/**
|
|
53
83
|
* Error thrown when a schema operation fails
|
|
54
84
|
*/
|
|
@@ -114,6 +144,61 @@ export class SchemaRegistry {
|
|
|
114
144
|
return { ...collection, fields };
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
/**
|
|
148
|
+
* List every collection together with its fields in O(1) query shapes
|
|
149
|
+
* — one for collections, then one batched query for the fields of every
|
|
150
|
+
* returned collection — instead of the N+1 pattern of `listCollections`
|
|
151
|
+
* + per-collection `listFields`. The fields query is chunked at
|
|
152
|
+
* `SQL_BATCH_SIZE` to stay under D1's bound-parameter limit, so on
|
|
153
|
+
* sites with more than `SQL_BATCH_SIZE` collections the field fetch
|
|
154
|
+
* becomes `ceil(collectionCount / SQL_BATCH_SIZE)` queries — still
|
|
155
|
+
* a constant factor, not N+1. Typical sites have well under
|
|
156
|
+
* `SQL_BATCH_SIZE` collections, so this is two queries in practice.
|
|
157
|
+
*
|
|
158
|
+
* Used by the manifest build, which previously paid N+1 round-trips on
|
|
159
|
+
* every admin request. Each round-trip costs ~80–150ms against the D1
|
|
160
|
+
* primary on a busy link, so a 10-collection site spent ~1 s rebuilding
|
|
161
|
+
* a manifest that is now built fresh per admin request (no cache).
|
|
162
|
+
*/
|
|
163
|
+
async listCollectionsWithFields(): Promise<CollectionWithFields[]> {
|
|
164
|
+
const collectionRows = await this.db
|
|
165
|
+
.selectFrom("_emdash_collections")
|
|
166
|
+
.selectAll()
|
|
167
|
+
.orderBy("slug", "asc")
|
|
168
|
+
.execute();
|
|
169
|
+
|
|
170
|
+
if (collectionRows.length === 0) return [];
|
|
171
|
+
|
|
172
|
+
const fieldsByCollection = new Map<string, Field[]>();
|
|
173
|
+
// Chunk to stay under D1's bound-parameter limit. Typical sites have
|
|
174
|
+
// well under SQL_BATCH_SIZE collections, so this is a single query
|
|
175
|
+
// in practice; on larger sites it becomes a small constant number
|
|
176
|
+
// of queries, never N+1.
|
|
177
|
+
for (const idChunk of chunks(
|
|
178
|
+
collectionRows.map((c) => c.id),
|
|
179
|
+
SQL_BATCH_SIZE,
|
|
180
|
+
)) {
|
|
181
|
+
const fieldRows = await this.db
|
|
182
|
+
.selectFrom("_emdash_fields")
|
|
183
|
+
.where("collection_id", "in", idChunk)
|
|
184
|
+
.selectAll()
|
|
185
|
+
.orderBy("collection_id", "asc")
|
|
186
|
+
.orderBy("sort_order", "asc")
|
|
187
|
+
.orderBy("created_at", "asc")
|
|
188
|
+
.execute();
|
|
189
|
+
for (const row of fieldRows) {
|
|
190
|
+
const list = fieldsByCollection.get(row.collection_id) ?? [];
|
|
191
|
+
list.push(this.mapFieldRow(row));
|
|
192
|
+
fieldsByCollection.set(row.collection_id, list);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return collectionRows.map((c) => ({
|
|
197
|
+
...this.mapCollectionRow(c),
|
|
198
|
+
fields: fieldsByCollection.get(c.id) ?? [],
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
117
202
|
/**
|
|
118
203
|
* Create a new collection
|
|
119
204
|
*/
|
|
@@ -132,11 +217,18 @@ export class SchemaRegistry {
|
|
|
132
217
|
|
|
133
218
|
const id = ulid();
|
|
134
219
|
|
|
220
|
+
// Default `supports` to drafts + revisions when the caller didn't
|
|
221
|
+
// specify it. Explicit empty array (`[]`) is preserved as an opt-out
|
|
222
|
+
// — only `undefined` triggers the default. This is the canonical
|
|
223
|
+
// default for new collections; the MCP and admin UI layers used to
|
|
224
|
+
// duplicate this default but now defer to the registry.
|
|
225
|
+
const supports = input.supports ?? ["drafts", "revisions"];
|
|
226
|
+
|
|
135
227
|
// Insert collection record and create content table in a transaction
|
|
136
228
|
// so a failure in table creation doesn't leave an orphaned row.
|
|
137
229
|
// Uses withTransaction for D1 compatibility (no transaction support).
|
|
138
230
|
// Derive hasSeo from supports array if not explicitly set
|
|
139
|
-
const hasSeo = input.hasSeo ??
|
|
231
|
+
const hasSeo = input.hasSeo ?? supports.includes("seo") ?? false;
|
|
140
232
|
|
|
141
233
|
await withTransaction(this.db, async (trx) => {
|
|
142
234
|
await trx
|
|
@@ -148,7 +240,7 @@ export class SchemaRegistry {
|
|
|
148
240
|
label_singular: input.labelSingular ?? null,
|
|
149
241
|
description: input.description ?? null,
|
|
150
242
|
icon: input.icon ?? null,
|
|
151
|
-
supports:
|
|
243
|
+
supports: JSON.stringify(supports),
|
|
152
244
|
source: input.source ?? "manual",
|
|
153
245
|
has_seo: hasSeo ? 1 : 0,
|
|
154
246
|
comments_enabled: input.commentsEnabled ? 1 : 0,
|
|
@@ -243,7 +335,7 @@ export class SchemaRegistry {
|
|
|
243
335
|
// Sync FTS state when the supports array changes (e.g. search toggled on/off)
|
|
244
336
|
if (input.supports !== undefined) {
|
|
245
337
|
const hadSearch = existing.supports.includes("search");
|
|
246
|
-
const hasSearch = (
|
|
338
|
+
const hasSearch = parseSupports(row.supports).includes("search");
|
|
247
339
|
if (hadSearch !== hasSearch) {
|
|
248
340
|
await this.syncSearchState(slug, trx);
|
|
249
341
|
}
|
|
@@ -525,7 +617,7 @@ export class SchemaRegistry {
|
|
|
525
617
|
.executeTakeFirst();
|
|
526
618
|
if (!row) return;
|
|
527
619
|
|
|
528
|
-
const wantsSearch = (
|
|
620
|
+
const wantsSearch = parseSupports(row.supports).includes("search");
|
|
529
621
|
const searchableFields = await ftsManager.getSearchableFields(collectionSlug);
|
|
530
622
|
const config = await ftsManager.getSearchConfig(collectionSlug);
|
|
531
623
|
const ftsActive = config?.enabled === true;
|
|
@@ -881,7 +973,7 @@ export class SchemaRegistry {
|
|
|
881
973
|
labelSingular: row.label_singular ?? undefined,
|
|
882
974
|
description: row.description ?? undefined,
|
|
883
975
|
icon: row.icon ?? undefined,
|
|
884
|
-
supports:
|
|
976
|
+
supports: parseSupports(row.supports),
|
|
885
977
|
source: row.source && isCollectionSource(row.source) ? row.source : undefined,
|
|
886
978
|
hasSeo: row.has_seo === 1,
|
|
887
979
|
urlPattern: row.url_pattern ?? undefined,
|
|
@@ -35,9 +35,16 @@ export function generateFieldSchema(field: Field): ZodTypeAny {
|
|
|
35
35
|
schema = applyValidation(schema, field);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Apply required/optional
|
|
38
|
+
// Apply required/optional. Non-required fields use `.nullish()` rather
|
|
39
|
+
// than `.optional()` because the underlying SQLite columns are nullable
|
|
40
|
+
// (see `SchemaRegistry.addFieldColumn` -- non-required fields are added
|
|
41
|
+
// without `NOT NULL`). The admin re-sends what it loaded from the
|
|
42
|
+
// server on autosave, so any field that's actually `null` in the DB
|
|
43
|
+
// must round-trip cleanly through the validator. `.optional()` only
|
|
44
|
+
// accepts `undefined`; `.nullish()` accepts both `undefined` and
|
|
45
|
+
// `null`. (#867 — autosave failures on seeded entries.)
|
|
39
46
|
if (!field.required) {
|
|
40
|
-
schema = schema.
|
|
47
|
+
schema = schema.nullish();
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
// Apply default value
|
|
@@ -68,7 +75,15 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
|
|
|
68
75
|
return z.number().int();
|
|
69
76
|
|
|
70
77
|
case "boolean":
|
|
71
|
-
|
|
78
|
+
// Boolean fields map to `INTEGER` columns (`FIELD_TYPE_TO_COLUMN`
|
|
79
|
+
// in `schema/types.ts`) and `serializeValue` in
|
|
80
|
+
// `database/repositories/content.ts` writes booleans as 0/1.
|
|
81
|
+
// `deserializeValue` never converts them back, so reads return
|
|
82
|
+
// numbers. Coerce the stored 0/1 shape here so a GET → POST
|
|
83
|
+
// round-trip on a boolean field passes validation. Other inputs
|
|
84
|
+
// (strings, other numbers) fall through to `z.boolean()` and
|
|
85
|
+
// produce its standard rejection.
|
|
86
|
+
return z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean());
|
|
72
87
|
|
|
73
88
|
case "datetime":
|
|
74
89
|
return z.string().datetime().or(z.string().date());
|
|
@@ -92,12 +107,19 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny {
|
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
case "portableText":
|
|
95
|
-
// Portable Text is an array of blocks
|
|
110
|
+
// Portable Text is an array of blocks. We require `_type` because
|
|
111
|
+
// renderers dispatch on it, but `_key` is intentionally optional:
|
|
112
|
+
// it's a UI-layer concern that the editor regenerates on every
|
|
113
|
+
// change (see `PortableTextEditor`), and the rest of this schema
|
|
114
|
+
// uses `.passthrough()` for everything below the top level. Making
|
|
115
|
+
// `_key` strictly required here was an accidentally tight invariant
|
|
116
|
+
// that rejected any seed/import data not authored against the
|
|
117
|
+
// editor (#867 — autosave failures on seeded template content).
|
|
96
118
|
return z.array(
|
|
97
119
|
z
|
|
98
120
|
.object({
|
|
99
121
|
_type: z.string(),
|
|
100
|
-
_key: z.string(),
|
|
122
|
+
_key: z.string().optional(),
|
|
101
123
|
})
|
|
102
124
|
.passthrough(),
|
|
103
125
|
);
|
|
@@ -418,8 +418,6 @@ export class FTSManager {
|
|
|
418
418
|
console.warn(
|
|
419
419
|
`FTS index for "${collectionSlug}" has ${ftsRows} rows but content table has ${contentRows}. Rebuilding.`,
|
|
420
420
|
);
|
|
421
|
-
const fields = await this.getSearchableFields(collectionSlug);
|
|
422
|
-
const config = await this.getSearchConfig(collectionSlug);
|
|
423
421
|
if (fields.length > 0) {
|
|
424
422
|
await this.rebuildIndex(collectionSlug, fields, config?.weights);
|
|
425
423
|
}
|
package/src/search/query.ts
CHANGED
|
@@ -26,6 +26,23 @@ const WHITESPACE_SPLIT_PATTERN = /\s+/;
|
|
|
26
26
|
const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
|
|
27
27
|
const DOUBLE_QUOTE_PATTERN = /"/g;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
|
|
31
|
+
* error fingerprints rather than a broad "fts5" / "syntax error" filter
|
|
32
|
+
* (which would also swallow internal table-corruption errors). The two
|
|
33
|
+
* fingerprints we care about are:
|
|
34
|
+
*
|
|
35
|
+
* - "fts5: syntax error near …" — unbalanced quotes, stray operators,
|
|
36
|
+
* other malformed user input
|
|
37
|
+
* - "unknown special query: …" — bare special tokens like `^*` that
|
|
38
|
+
* parse but don't resolve to a real FTS5 directive
|
|
39
|
+
*/
|
|
40
|
+
function isFts5SyntaxError(error: unknown): boolean {
|
|
41
|
+
if (!(error instanceof Error)) return false;
|
|
42
|
+
const message = error.message.toLowerCase();
|
|
43
|
+
return message.includes("fts5: syntax error") || message.includes("unknown special query");
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
/**
|
|
30
47
|
* Search across multiple collections
|
|
31
48
|
*
|
|
@@ -198,14 +215,16 @@ async function searchSingleCollection(
|
|
|
198
215
|
const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;
|
|
199
216
|
|
|
200
217
|
// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
218
|
+
let results;
|
|
219
|
+
try {
|
|
220
|
+
results = await sql<{
|
|
221
|
+
id: string;
|
|
222
|
+
slug: string | null;
|
|
223
|
+
locale: string;
|
|
224
|
+
title: string | null;
|
|
225
|
+
snippet: string | null;
|
|
226
|
+
score: number;
|
|
227
|
+
}>`
|
|
209
228
|
SELECT
|
|
210
229
|
c.id,
|
|
211
230
|
c.slug,
|
|
@@ -222,6 +241,20 @@ async function searchSingleCollection(
|
|
|
222
241
|
ORDER BY score
|
|
223
242
|
LIMIT ${limit}
|
|
224
243
|
`.execute(db);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// FTS5 returns syntax errors for queries with unbalanced quotes,
|
|
246
|
+
// stray operators, or other malformed input. Treat these as
|
|
247
|
+
// "no matches" so the user gets an empty result rather than an
|
|
248
|
+
// internals-leaking error. Other errors (table missing, IO) still
|
|
249
|
+
// propagate. Intentionally not logged: any anonymous client can
|
|
250
|
+
// trigger this path, and the underlying error message embeds the
|
|
251
|
+
// raw query, so logging would be both noisy and a log-injection
|
|
252
|
+
// vector.
|
|
253
|
+
if (isFts5SyntaxError(error)) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
225
258
|
|
|
226
259
|
return results.rows.map((row) => ({
|
|
227
260
|
collection,
|
|
@@ -229,11 +262,51 @@ async function searchSingleCollection(
|
|
|
229
262
|
slug: row.slug,
|
|
230
263
|
locale: row.locale,
|
|
231
264
|
title: row.title ?? undefined,
|
|
232
|
-
snippet
|
|
265
|
+
// SQLite's snippet() returns NULL when the targeted column is
|
|
266
|
+
// NULL for that row — even if the row matched via a different
|
|
267
|
+
// searchable column. Skip sanitization in that case so we don't
|
|
268
|
+
// throw on `null.replace`. The SearchResult.snippet field is
|
|
269
|
+
// already optional, so omitting it is the documented contract.
|
|
270
|
+
snippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),
|
|
233
271
|
score: Math.abs(row.score), // bm25 returns negative scores
|
|
234
272
|
}));
|
|
235
273
|
}
|
|
236
274
|
|
|
275
|
+
// Module-scope regexes so the engine doesn't recompile per call —
|
|
276
|
+
// snippet sanitization runs on every search result.
|
|
277
|
+
const SNIPPET_AMP_RE = /&/g;
|
|
278
|
+
const SNIPPET_LT_RE = /</g;
|
|
279
|
+
const SNIPPET_GT_RE = />/g;
|
|
280
|
+
const SNIPPET_QUOT_RE = /"/g;
|
|
281
|
+
const SNIPPET_APOS_RE = /'/g;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
|
|
285
|
+
*
|
|
286
|
+
* SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
|
|
287
|
+
* markers around matched terms but does not escape the surrounding
|
|
288
|
+
* source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
|
|
289
|
+
* `'` would render as broken markup, and a `<script>` literal in a
|
|
290
|
+
* title (or any other indexed field) would execute when displayed.
|
|
291
|
+
*
|
|
292
|
+
* The fix: HTML-escape the whole string, which turns the markers into
|
|
293
|
+
* `<mark>` / `</mark>`. Then restore those two patterns to
|
|
294
|
+
* their original tag form. The result is "the indexed text with all
|
|
295
|
+
* HTML metacharacters escaped, plus a small set of literal `<mark>`
|
|
296
|
+
* highlight tags around matched terms" — which matches the API's
|
|
297
|
+
* documented contract.
|
|
298
|
+
*/
|
|
299
|
+
function sanitizeSnippet(snippet: string): string {
|
|
300
|
+
return snippet
|
|
301
|
+
.replace(SNIPPET_AMP_RE, "&")
|
|
302
|
+
.replace(SNIPPET_LT_RE, "<")
|
|
303
|
+
.replace(SNIPPET_GT_RE, ">")
|
|
304
|
+
.replace(SNIPPET_QUOT_RE, """)
|
|
305
|
+
.replace(SNIPPET_APOS_RE, "'")
|
|
306
|
+
.replaceAll("<mark>", "<mark>")
|
|
307
|
+
.replaceAll("</mark>", "</mark>");
|
|
308
|
+
}
|
|
309
|
+
|
|
237
310
|
/**
|
|
238
311
|
* Get search suggestions for autocomplete
|
|
239
312
|
*
|
|
@@ -282,23 +355,35 @@ export async function getSuggestions(
|
|
|
282
355
|
continue;
|
|
283
356
|
}
|
|
284
357
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
358
|
+
let results;
|
|
359
|
+
try {
|
|
360
|
+
results = await sql<{
|
|
361
|
+
id: string;
|
|
362
|
+
title: string;
|
|
363
|
+
}>`
|
|
364
|
+
SELECT
|
|
365
|
+
c.id,
|
|
366
|
+
c.title
|
|
367
|
+
FROM "${sql.raw(ftsTable)}" f
|
|
368
|
+
JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
|
|
369
|
+
WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
|
|
370
|
+
AND c.status = 'published'
|
|
371
|
+
AND c.deleted_at IS NULL
|
|
372
|
+
AND c.title IS NOT NULL
|
|
373
|
+
${locale ? sql`AND c.locale = ${locale}` : sql``}
|
|
374
|
+
ORDER BY bm25("${sql.raw(ftsTable)}")
|
|
375
|
+
LIMIT ${limit}
|
|
376
|
+
`.execute(db);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
// Same swallow as searchSingleCollection: malformed prefix
|
|
379
|
+
// queries should yield no suggestions, not surface DB errors.
|
|
380
|
+
// Intentionally not logged (anonymous-triggerable, echoes
|
|
381
|
+
// user input -- see searchSingleCollection for rationale).
|
|
382
|
+
if (isFts5SyntaxError(error)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
302
387
|
|
|
303
388
|
for (const row of results.rows) {
|
|
304
389
|
suggestions.push({
|
package/src/search/types.ts
CHANGED
|
@@ -58,7 +58,14 @@ export interface SearchResult {
|
|
|
58
58
|
locale: string;
|
|
59
59
|
/** Entry title (if available) */
|
|
60
60
|
title?: string;
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Highlighted snippet showing match context.
|
|
63
|
+
*
|
|
64
|
+
* Sanitized server-side to be safe for `set:html` / `innerHTML`:
|
|
65
|
+
* all HTML metacharacters in the source text are escaped, and
|
|
66
|
+
* matched terms are wrapped in literal `<mark>...</mark>` tags
|
|
67
|
+
* (the only HTML the snippet is allowed to contain).
|
|
68
|
+
*/
|
|
62
69
|
snippet?: string;
|
|
63
70
|
/** Relevance score (higher = more relevant) */
|
|
64
71
|
score: number;
|
package/src/sections/index.ts
CHANGED
|
@@ -137,17 +137,15 @@ export async function getSectionsWithDb(
|
|
|
137
137
|
// Order by title ASC, id ASC for stable cursor pagination
|
|
138
138
|
query = query.orderBy("title", "asc").orderBy("id", "asc");
|
|
139
139
|
|
|
140
|
-
// Cursor-based pagination
|
|
140
|
+
// Cursor-based pagination — throws on invalid cursor.
|
|
141
141
|
if (options.cursor) {
|
|
142
142
|
const decoded = decodeCursor(options.cursor);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
eb.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
);
|
|
150
|
-
}
|
|
143
|
+
query = query.where((eb) =>
|
|
144
|
+
eb.or([
|
|
145
|
+
eb("title", ">", decoded.orderValue),
|
|
146
|
+
eb.and([eb("title", "=", decoded.orderValue), eb("id", ">", decoded.id)]),
|
|
147
|
+
]),
|
|
148
|
+
);
|
|
151
149
|
}
|
|
152
150
|
|
|
153
151
|
query = query.limit(limit + 1);
|
package/src/seed/apply.ts
CHANGED
|
@@ -927,6 +927,8 @@ async function applyWidget(
|
|
|
927
927
|
sort_order: sortOrder,
|
|
928
928
|
type: widget.type,
|
|
929
929
|
title: widget.title ?? null,
|
|
930
|
+
// `widget.content` is Portable Text for content-type widgets;
|
|
931
|
+
// for other widget kinds it's null.
|
|
930
932
|
content: widget.content ? JSON.stringify(widget.content) : null,
|
|
931
933
|
menu_name: widget.menuName ?? null,
|
|
932
934
|
component_id: widget.componentId ?? null,
|
package/src/settings/index.ts
CHANGED
|
@@ -18,6 +18,53 @@ import type { SiteSettings, SiteSettingKey, MediaReference } from "./types.js";
|
|
|
18
18
|
/** Prefix for site settings in the options table */
|
|
19
19
|
const SETTINGS_PREFIX = "site:";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Worker-isolate cache for the resolved `site:*` settings.
|
|
23
|
+
*
|
|
24
|
+
* Site settings (title, logo, SEO defaults) change rarely but are read on
|
|
25
|
+
* every public request. Caching across the isolate's lifetime drops the
|
|
26
|
+
* `options WHERE name LIKE 'site:%'` prefix scan from once-per-request to
|
|
27
|
+
* once-per-isolate. Cross-isolate staleness is bounded by isolate lifetime
|
|
28
|
+
* (workerd typically recycles within minutes); acceptable for chrome.
|
|
29
|
+
*
|
|
30
|
+
* Stored on globalThis with a Symbol.for key so Vite SSR chunk duplication
|
|
31
|
+
* doesn't produce two independent caches (same pattern as request-context.ts).
|
|
32
|
+
*
|
|
33
|
+
* Invalidation: every `site:*` write bumps `version`. Reads compare the
|
|
34
|
+
* cached promise's version against the current version and refetch on
|
|
35
|
+
* mismatch. Caching the promise (not the resolved value) lets concurrent
|
|
36
|
+
* cold-isolate readers share the in-flight query.
|
|
37
|
+
*/
|
|
38
|
+
interface SiteSettingsHolder {
|
|
39
|
+
version: number;
|
|
40
|
+
cached: Promise<Partial<SiteSettings>> | null;
|
|
41
|
+
cachedVersion: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SITE_SETTINGS_CACHE_KEY = Symbol.for("emdash:site-settings");
|
|
45
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
46
|
+
const holder: SiteSettingsHolder =
|
|
47
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern (see request-context.ts)
|
|
48
|
+
(g[SITE_SETTINGS_CACHE_KEY] as SiteSettingsHolder | undefined) ??
|
|
49
|
+
(() => {
|
|
50
|
+
const h: SiteSettingsHolder = { version: 0, cached: null, cachedVersion: -1 };
|
|
51
|
+
g[SITE_SETTINGS_CACHE_KEY] = h;
|
|
52
|
+
return h;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bump the isolate-wide site-settings cache version, forcing the next
|
|
57
|
+
* `getSiteSettings()` to re-query the database.
|
|
58
|
+
*
|
|
59
|
+
* Called from every `site:*` write path. Other isolates still serve their
|
|
60
|
+
* own cached copy until they expire — staleness bounded by isolate lifetime.
|
|
61
|
+
*/
|
|
62
|
+
export function invalidateSiteSettingsCache(): void {
|
|
63
|
+
holder.version++;
|
|
64
|
+
holder.cached = null;
|
|
65
|
+
holder.cachedVersion = -1;
|
|
66
|
+
}
|
|
67
|
+
|
|
21
68
|
/**
|
|
22
69
|
* Type guard for MediaReference values
|
|
23
70
|
*/
|
|
@@ -26,13 +73,18 @@ function isMediaReference(value: unknown): value is MediaReference {
|
|
|
26
73
|
}
|
|
27
74
|
|
|
28
75
|
/**
|
|
29
|
-
* Resolve a media reference to include the full URL
|
|
76
|
+
* Resolve a media reference to include the full URL plus content metadata.
|
|
77
|
+
*
|
|
78
|
+
* Pulls `mimeType` and intrinsic dimensions from the media row so callers
|
|
79
|
+
* can emit correct head tags (e.g. `<link rel="icon" type="image/svg+xml">`,
|
|
80
|
+
* which Chromium requires when the URL has no `.svg` extension) without
|
|
81
|
+
* a second round-trip to the media table.
|
|
30
82
|
*/
|
|
31
83
|
async function resolveMediaReference(
|
|
32
84
|
mediaRef: MediaReference | undefined,
|
|
33
85
|
db: Kysely<Database>,
|
|
34
86
|
_storage: Storage | null,
|
|
35
|
-
): Promise<
|
|
87
|
+
): Promise<MediaReference | undefined> {
|
|
36
88
|
if (!mediaRef?.mediaId) {
|
|
37
89
|
return mediaRef;
|
|
38
90
|
}
|
|
@@ -46,6 +98,9 @@ async function resolveMediaReference(
|
|
|
46
98
|
return {
|
|
47
99
|
...mediaRef,
|
|
48
100
|
url: `/_emdash/api/media/file/${media.storageKey}`,
|
|
101
|
+
contentType: media.mimeType,
|
|
102
|
+
...(media.width !== null ? { width: media.width } : {}),
|
|
103
|
+
...(media.height !== null ? { height: media.height } : {}),
|
|
49
104
|
};
|
|
50
105
|
}
|
|
51
106
|
} catch {
|
|
@@ -142,9 +197,24 @@ export async function getSiteSettingWithDb<K extends SiteSettingKey>(
|
|
|
142
197
|
* ```
|
|
143
198
|
*/
|
|
144
199
|
export function getSiteSettings(): Promise<Partial<SiteSettings>> {
|
|
145
|
-
return requestCached("siteSettings",
|
|
146
|
-
const
|
|
147
|
-
|
|
200
|
+
return requestCached("siteSettings", () => {
|
|
201
|
+
const versionAtCall = holder.version;
|
|
202
|
+
if (holder.cached && holder.cachedVersion === versionAtCall) {
|
|
203
|
+
return holder.cached;
|
|
204
|
+
}
|
|
205
|
+
const fetchPromise = (async () => {
|
|
206
|
+
const db = await getDb();
|
|
207
|
+
return getSiteSettingsWithDb(db);
|
|
208
|
+
})().catch((error) => {
|
|
209
|
+
if (holder.cached === fetchPromise) {
|
|
210
|
+
holder.cached = null;
|
|
211
|
+
holder.cachedVersion = -1;
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
});
|
|
215
|
+
holder.cached = fetchPromise;
|
|
216
|
+
holder.cachedVersion = versionAtCall;
|
|
217
|
+
return fetchPromise;
|
|
148
218
|
});
|
|
149
219
|
}
|
|
150
220
|
|
|
@@ -218,7 +288,11 @@ export async function setSiteSettings(
|
|
|
218
288
|
}
|
|
219
289
|
}
|
|
220
290
|
|
|
221
|
-
|
|
291
|
+
try {
|
|
292
|
+
await options.setMany(updates);
|
|
293
|
+
} finally {
|
|
294
|
+
invalidateSiteSettingsCache();
|
|
295
|
+
}
|
|
222
296
|
}
|
|
223
297
|
|
|
224
298
|
/**
|
package/src/settings/types.ts
CHANGED
|
@@ -4,10 +4,32 @@
|
|
|
4
4
|
* Global configuration for the site (title, logo, social links, etc.)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Media reference for logo/favicon.
|
|
9
|
+
*
|
|
10
|
+
* Stored shape is just `{ mediaId, alt? }`. The remaining fields are
|
|
11
|
+
* populated by `resolveMediaReference` on read so templates can emit
|
|
12
|
+
* correct head tags without a second round-trip to the media table.
|
|
13
|
+
*
|
|
14
|
+
* The Zod schemas at the REST/MCP boundary (`mediaReference`) define
|
|
15
|
+
* only `mediaId` and `alt` and rely on default strip-mode parsing to
|
|
16
|
+
* discard the resolved fields if a client posts them back. If you
|
|
17
|
+
* ever switch those schemas to `passthrough`, you must also strip the
|
|
18
|
+
* resolved fields explicitly in `setSiteSettings`, or stored options
|
|
19
|
+
* will accumulate stale `url` / `contentType` / `width` / `height`
|
|
20
|
+
* snapshots.
|
|
21
|
+
*/
|
|
8
22
|
export interface MediaReference {
|
|
9
23
|
mediaId: string;
|
|
10
24
|
alt?: string;
|
|
25
|
+
/** Resolved URL. Populated by `resolveMediaReference`; absent on raw stored values. */
|
|
26
|
+
url?: string;
|
|
27
|
+
/** Stored MIME type (e.g. `image/svg+xml`). Populated alongside `url`. */
|
|
28
|
+
contentType?: string;
|
|
29
|
+
/** Pixel width if known. Populated alongside `url`. */
|
|
30
|
+
width?: number;
|
|
31
|
+
/** Pixel height if known. Populated alongside `url`. */
|
|
32
|
+
height?: number;
|
|
11
33
|
}
|
|
12
34
|
|
|
13
35
|
/** Site-level SEO settings */
|
package/src/storage/s3.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
S3Client,
|
|
10
|
+
type S3ClientConfig,
|
|
10
11
|
PutObjectCommand,
|
|
11
12
|
GetObjectCommand,
|
|
12
13
|
DeleteObjectCommand,
|
|
@@ -131,9 +132,14 @@ export class S3Storage implements Storage {
|
|
|
131
132
|
this.publicUrl = config.publicUrl;
|
|
132
133
|
this.endpoint = config.endpoint;
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
// S3ClientConfig types `credentials` as required, but the SDK accepts
|
|
136
|
+
// omitted credentials at runtime (falls back to the provider chain).
|
|
137
|
+
/* eslint-disable typescript-eslint(no-unsafe-type-assertion) -- upstream @aws-sdk/client-s3 overstates required fields */
|
|
138
|
+
const clientConfig = {
|
|
135
139
|
endpoint: config.endpoint,
|
|
136
140
|
region: config.region || "auto",
|
|
141
|
+
// Required for R2 and some S3-compatible services
|
|
142
|
+
forcePathStyle: true,
|
|
137
143
|
...(config.accessKeyId && config.secretAccessKey
|
|
138
144
|
? {
|
|
139
145
|
credentials: {
|
|
@@ -142,9 +148,9 @@ export class S3Storage implements Storage {
|
|
|
142
148
|
},
|
|
143
149
|
}
|
|
144
150
|
: {}),
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
} as S3ClientConfig;
|
|
152
|
+
/* eslint-enable typescript-eslint(no-unsafe-type-assertion) */
|
|
153
|
+
this.client = new S3Client(clientConfig);
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
async upload(options: {
|
|
@@ -317,8 +323,8 @@ export class S3Storage implements Storage {
|
|
|
317
323
|
if (this.publicUrl) {
|
|
318
324
|
return `${this.publicUrl.replace(TRAILING_SLASH_PATTERN, "")}/${key}`;
|
|
319
325
|
}
|
|
320
|
-
//
|
|
321
|
-
return
|
|
326
|
+
// No public URL configured; defer to the /_emdash/api/media/file route.
|
|
327
|
+
return `/_emdash/api/media/file/${key}`;
|
|
322
328
|
}
|
|
323
329
|
}
|
|
324
330
|
|