convex-cms 0.0.2 → 0.0.3
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/admin-dist/nitro.json +15 -0
- package/admin-dist/public/assets/CmsEmptyState-CRswfTzk.js +5 -0
- package/admin-dist/public/assets/CmsPageHeader-CirpXndm.js +1 -0
- package/admin-dist/public/assets/CmsStatusBadge-CbEUpQu-.js +1 -0
- package/admin-dist/public/assets/CmsToolbar-BI2nZOXp.js +1 -0
- package/admin-dist/public/assets/ContentEntryEditor-CBeCyK_m.js +4 -0
- package/admin-dist/public/assets/ErrorState-BIVaWmom.js +1 -0
- package/admin-dist/public/assets/TaxonomyFilter-ChaY6Y_x.js +1 -0
- package/admin-dist/public/assets/_contentTypeId-DQ8k_Rvw.js +1 -0
- package/admin-dist/public/assets/_entryId-CKU_glsK.js +1 -0
- package/admin-dist/public/assets/alert-BXjTqrwQ.js +1 -0
- package/admin-dist/public/assets/badge-hvUOzpVZ.js +1 -0
- package/admin-dist/public/assets/circle-check-big-CF_pR17r.js +1 -0
- package/admin-dist/public/assets/command-DU82cJlt.js +1 -0
- package/admin-dist/public/assets/content-_LXl3pp7.js +1 -0
- package/admin-dist/public/assets/content-types-KjxaXGxY.js +2 -0
- package/admin-dist/public/assets/globals-CS6BZ0zp.css +1 -0
- package/admin-dist/public/assets/index-DNGIZHL-.js +1 -0
- package/admin-dist/public/assets/label-KNtpL71g.js +1 -0
- package/admin-dist/public/assets/link-2-Bw2aI4V4.js +1 -0
- package/admin-dist/public/assets/list-sYepHjt_.js +1 -0
- package/admin-dist/public/assets/main-CKj5yfEi.js +97 -0
- package/admin-dist/public/assets/media-Bkrkffm7.js +1 -0
- package/admin-dist/public/assets/new._contentTypeId-C3LstjNs.js +1 -0
- package/admin-dist/public/assets/plus-DUn8v_Xf.js +1 -0
- package/admin-dist/public/assets/rotate-ccw-DJEoHcRI.js +1 -0
- package/admin-dist/public/assets/scroll-area-DfIlT0in.js +1 -0
- package/admin-dist/public/assets/search-MuAUDJKR.js +1 -0
- package/admin-dist/public/assets/select-BD29IXCI.js +1 -0
- package/admin-dist/public/assets/settings-DmMyn_6A.js +1 -0
- package/admin-dist/public/assets/switch-h3Rrnl5i.js +1 -0
- package/admin-dist/public/assets/tabs-imc8h-Dp.js +1 -0
- package/admin-dist/public/assets/taxonomies-dAsrT65H.js +1 -0
- package/admin-dist/public/assets/textarea-BTy7nwzR.js +1 -0
- package/admin-dist/public/assets/trash-SAWKZZHv.js +1 -0
- package/admin-dist/public/assets/triangle-alert-E52Vfeuh.js +1 -0
- package/admin-dist/public/assets/useBreadcrumbLabel-BECBMCzM.js +1 -0
- package/admin-dist/public/assets/usePermissions-Basjs9BT.js +1 -0
- package/admin-dist/public/favicon.ico +0 -0
- package/admin-dist/server/_chunks/_libs/@date-fns/tz.mjs +217 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/core.mjs +719 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/dom.mjs +622 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/react-dom.mjs +292 -0
- package/admin-dist/server/_chunks/_libs/@floating-ui/utils.mjs +320 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/number.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/primitive.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-arrow.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-avatar.mjs +119 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-checkbox.mjs +270 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-collection.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-compose-refs.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-context.mjs +137 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dialog.mjs +325 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-direction.mjs +9 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dismissable-layer.mjs +210 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-dropdown-menu.mjs +253 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-guards.mjs +29 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-focus-scope.mjs +206 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-id.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-label.mjs +23 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-menu.mjs +812 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popover.mjs +300 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-popper.mjs +286 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-portal.mjs +16 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-presence.mjs +128 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-primitive.mjs +141 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-roving-focus.mjs +224 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-scroll-area.mjs +721 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-select.mjs +1163 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-separator.mjs +28 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-slot.mjs +601 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-switch.mjs +152 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-tabs.mjs +189 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-callback-ref.mjs +11 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-controllable-state.mjs +69 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-effect-event.mjs +1 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-escape-keydown.mjs +17 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-is-hydrated.mjs +15 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-layout-effect.mjs +6 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-previous.mjs +14 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-use-size.mjs +39 -0
- package/admin-dist/server/_chunks/_libs/@radix-ui/react-visually-hidden.mjs +33 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/history.mjs +409 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-router.mjs +1711 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/react-store.mjs +56 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/router-core.mjs +4829 -0
- package/admin-dist/server/_chunks/_libs/@tanstack/store.mjs +134 -0
- package/admin-dist/server/_chunks/_libs/react-dom.mjs +10781 -0
- package/admin-dist/server/_chunks/_libs/react.mjs +513 -0
- package/admin-dist/server/_libs/aria-hidden.mjs +122 -0
- package/admin-dist/server/_libs/class-variance-authority.mjs +44 -0
- package/admin-dist/server/_libs/clsx.mjs +16 -0
- package/admin-dist/server/_libs/cmdk.mjs +315 -0
- package/admin-dist/server/_libs/convex.mjs +4841 -0
- package/admin-dist/server/_libs/cookie-es.mjs +58 -0
- package/admin-dist/server/_libs/croner.mjs +1 -0
- package/admin-dist/server/_libs/crossws.mjs +1 -0
- package/admin-dist/server/_libs/date-fns.mjs +1716 -0
- package/admin-dist/server/_libs/detect-node-es.mjs +1 -0
- package/admin-dist/server/_libs/get-nonce.mjs +9 -0
- package/admin-dist/server/_libs/h3-v2.mjs +277 -0
- package/admin-dist/server/_libs/h3.mjs +401 -0
- package/admin-dist/server/_libs/hookable.mjs +1 -0
- package/admin-dist/server/_libs/isbot.mjs +20 -0
- package/admin-dist/server/_libs/lucide-react.mjs +850 -0
- package/admin-dist/server/_libs/ohash.mjs +1 -0
- package/admin-dist/server/_libs/react-day-picker.mjs +2201 -0
- package/admin-dist/server/_libs/react-remove-scroll-bar.mjs +82 -0
- package/admin-dist/server/_libs/react-remove-scroll.mjs +328 -0
- package/admin-dist/server/_libs/react-style-singleton.mjs +69 -0
- package/admin-dist/server/_libs/rou3.mjs +8 -0
- package/admin-dist/server/_libs/seroval-plugins.mjs +58 -0
- package/admin-dist/server/_libs/seroval.mjs +1765 -0
- package/admin-dist/server/_libs/srvx.mjs +719 -0
- package/admin-dist/server/_libs/tailwind-merge.mjs +3010 -0
- package/admin-dist/server/_libs/tiny-invariant.mjs +12 -0
- package/admin-dist/server/_libs/tiny-warning.mjs +5 -0
- package/admin-dist/server/_libs/tslib.mjs +39 -0
- package/admin-dist/server/_libs/ufo.mjs +54 -0
- package/admin-dist/server/_libs/unctx.mjs +1 -0
- package/admin-dist/server/_libs/unstorage.mjs +1 -0
- package/admin-dist/server/_libs/use-callback-ref.mjs +66 -0
- package/admin-dist/server/_libs/use-sidecar.mjs +106 -0
- package/admin-dist/server/_libs/use-sync-external-store.mjs +139 -0
- package/admin-dist/server/_libs/zod.mjs +4223 -0
- package/admin-dist/server/_ssr/CmsEmptyState-DU7-7-mV.mjs +290 -0
- package/admin-dist/server/_ssr/CmsPageHeader-CseW0AHm.mjs +24 -0
- package/admin-dist/server/_ssr/CmsStatusBadge-B_pi4KCp.mjs +127 -0
- package/admin-dist/server/_ssr/CmsToolbar-X75ex6ek.mjs +49 -0
- package/admin-dist/server/_ssr/ContentEntryEditor-CepusRsA.mjs +3720 -0
- package/admin-dist/server/_ssr/ErrorState-cI-bKLez.mjs +89 -0
- package/admin-dist/server/_ssr/TaxonomyFilter-Bwrq0-cz.mjs +188 -0
- package/admin-dist/server/_ssr/_contentTypeId-BqYKEcLr.mjs +379 -0
- package/admin-dist/server/_ssr/_entryId-CRfnqeDf.mjs +161 -0
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-BwDlABVk.mjs +4 -0
- package/admin-dist/server/_ssr/alert-CVt45UUP.mjs +92 -0
- package/admin-dist/server/_ssr/badge-6BsP37vG.mjs +125 -0
- package/admin-dist/server/_ssr/command-fy8epIKf.mjs +128 -0
- package/admin-dist/server/_ssr/config.server-D7JHDcDv.mjs +117 -0
- package/admin-dist/server/_ssr/content-B5RhL7uW.mjs +532 -0
- package/admin-dist/server/_ssr/content-types-BIOqCQYN.mjs +1166 -0
- package/admin-dist/server/_ssr/index-DHSHDPt1.mjs +193 -0
- package/admin-dist/server/_ssr/index.mjs +1275 -0
- package/admin-dist/server/_ssr/label-C8Dko1j7.mjs +22 -0
- package/admin-dist/server/_ssr/media-CSx3XttC.mjs +1832 -0
- package/admin-dist/server/_ssr/new._contentTypeId-DzanEZQM.mjs +144 -0
- package/admin-dist/server/_ssr/router-DDWcF-kt.mjs +1556 -0
- package/admin-dist/server/_ssr/scroll-area-bjPYwhXN.mjs +59 -0
- package/admin-dist/server/_ssr/select-BUhDDf4T.mjs +142 -0
- package/admin-dist/server/_ssr/settings-DAsxnw2q.mjs +348 -0
- package/admin-dist/server/_ssr/start-HYkvq4Ni.mjs +4 -0
- package/admin-dist/server/_ssr/switch-BgyRtQ1Z.mjs +31 -0
- package/admin-dist/server/_ssr/tabs-DzMdRB1A.mjs +628 -0
- package/admin-dist/server/_ssr/taxonomies-C8j8g5Q5.mjs +915 -0
- package/admin-dist/server/_ssr/textarea-9jNeYJSc.mjs +18 -0
- package/admin-dist/server/_ssr/trash-DYMxwhZB.mjs +291 -0
- package/admin-dist/server/_ssr/useBreadcrumbLabel-FNSAr2Ha.mjs +16 -0
- package/admin-dist/server/_ssr/usePermissions-BJGGahrJ.mjs +68 -0
- package/admin-dist/server/favicon.ico +0 -0
- package/admin-dist/server/index.mjs +627 -0
- package/dist/cli/index.js +0 -0
- package/dist/client/admin-config.d.ts +0 -1
- package/dist/client/admin-config.d.ts.map +1 -1
- package/dist/client/admin-config.js +0 -1
- package/dist/client/admin-config.js.map +1 -1
- package/dist/client/adminApi.d.ts.map +1 -1
- package/dist/client/agentTools.d.ts +1237 -135
- package/dist/client/agentTools.d.ts.map +1 -1
- package/dist/client/agentTools.js +33 -9
- package/dist/client/agentTools.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +9 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/mediaAssets.d.ts +35 -0
- package/dist/component/mediaAssets.d.ts.map +1 -1
- package/dist/component/mediaAssets.js +81 -0
- package/dist/component/mediaAssets.js.map +1 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +2 -1
- package/dist/test.js.map +1 -1
- package/package.json +9 -5
- package/dist/component/auditLog.d.ts +0 -410
- package/dist/component/auditLog.d.ts.map +0 -1
- package/dist/component/auditLog.js +0 -607
- package/dist/component/auditLog.js.map +0 -1
- package/dist/component/types.d.ts +0 -4
- package/dist/component/types.d.ts.map +0 -1
- package/dist/component/types.js +0 -2
- package/dist/component/types.js.map +0 -1
- package/src/cli/commands/admin.ts +0 -104
- package/src/cli/index.ts +0 -21
- package/src/cli/utils/detectConvexUrl.ts +0 -54
- package/src/cli/utils/openBrowser.ts +0 -16
- package/src/client/admin-config.ts +0 -138
- package/src/client/adminApi.ts +0 -942
- package/src/client/agentTools.ts +0 -1311
- package/src/client/argTypes.ts +0 -316
- package/src/client/field-types.ts +0 -187
- package/src/client/index.ts +0 -1301
- package/src/client/queryBuilder.ts +0 -1100
- package/src/client/schema/codegen.ts +0 -500
- package/src/client/schema/defineContentType.ts +0 -501
- package/src/client/schema/index.ts +0 -169
- package/src/client/schema/schemaDrift.ts +0 -574
- package/src/client/schema/typedClient.ts +0 -688
- package/src/client/schema/types.ts +0 -666
- package/src/client/types.ts +0 -723
- package/src/client/workflows.ts +0 -141
- package/src/client/wrapper.ts +0 -4304
- package/src/component/_generated/api.ts +0 -140
- package/src/component/_generated/component.ts +0 -5029
- package/src/component/_generated/dataModel.ts +0 -60
- package/src/component/_generated/server.ts +0 -156
- package/src/component/authorization.ts +0 -647
- package/src/component/authorizationHooks.ts +0 -668
- package/src/component/bulkOperations.ts +0 -687
- package/src/component/contentEntries.ts +0 -1976
- package/src/component/contentEntryMutations.ts +0 -1223
- package/src/component/contentEntryValidation.ts +0 -707
- package/src/component/contentLock.ts +0 -550
- package/src/component/contentTypeMigration.ts +0 -1064
- package/src/component/contentTypeMutations.ts +0 -969
- package/src/component/contentTypes.ts +0 -346
- package/src/component/convex.config.ts +0 -44
- package/src/component/documentTypes.ts +0 -240
- package/src/component/eventEmitter.ts +0 -485
- package/src/component/exportImport.ts +0 -1169
- package/src/component/index.ts +0 -491
- package/src/component/lib/deepReferenceResolver.ts +0 -999
- package/src/component/lib/errors.ts +0 -816
- package/src/component/lib/index.ts +0 -145
- package/src/component/lib/mediaReferenceResolver.ts +0 -495
- package/src/component/lib/metadataExtractor.ts +0 -792
- package/src/component/lib/mutationAuth.ts +0 -199
- package/src/component/lib/queries.ts +0 -79
- package/src/component/lib/ragContentChunker.ts +0 -1371
- package/src/component/lib/referenceResolver.ts +0 -430
- package/src/component/lib/slugGenerator.ts +0 -262
- package/src/component/lib/slugUniqueness.ts +0 -333
- package/src/component/lib/softDelete.ts +0 -44
- package/src/component/localeFallbackChain.ts +0 -673
- package/src/component/localeFields.ts +0 -896
- package/src/component/mediaAssetMutations.ts +0 -725
- package/src/component/mediaAssets.ts +0 -932
- package/src/component/mediaFolderMutations.ts +0 -1046
- package/src/component/mediaUploadMutations.ts +0 -224
- package/src/component/mediaVariantMutations.ts +0 -900
- package/src/component/mediaVariants.ts +0 -793
- package/src/component/ragContentIndexer.ts +0 -1067
- package/src/component/rateLimitHooks.ts +0 -572
- package/src/component/roles.ts +0 -1360
- package/src/component/scheduledPublish.ts +0 -358
- package/src/component/schema.ts +0 -617
- package/src/component/taxonomies.ts +0 -949
- package/src/component/taxonomyMutations.ts +0 -1210
- package/src/component/trash.ts +0 -724
- package/src/component/userContext.ts +0 -898
- package/src/component/validation.ts +0 -1388
- package/src/component/validators.ts +0 -949
- package/src/component/versionMutations.ts +0 -392
- package/src/component/webhookTrigger.ts +0 -1922
- package/src/react/index.ts +0 -898
- package/src/test.ts +0 -1580
|
@@ -1,430 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reference Resolution Utilities
|
|
3
|
-
*
|
|
4
|
-
* Provides functions for resolving and populating content references.
|
|
5
|
-
* These utilities help with fetching referenced content entries and
|
|
6
|
-
* validating reference constraints.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Id } from "../_generated/dataModel.js";
|
|
10
|
-
import { isDeleted } from "./softDelete.js";
|
|
11
|
-
import { QueryCtx } from "../_generated/server.js";
|
|
12
|
-
|
|
13
|
-
// =============================================================================
|
|
14
|
-
// Types
|
|
15
|
-
// =============================================================================
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* A single reference value (content entry ID as string)
|
|
19
|
-
*/
|
|
20
|
-
export type SingleReference = string;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Multiple reference values (array of content entry IDs)
|
|
24
|
-
*/
|
|
25
|
-
export type MultipleReferences = string[];
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Reference field value - either single or multiple based on field configuration
|
|
29
|
-
*/
|
|
30
|
-
export type ReferenceValue = SingleReference | MultipleReferences;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* A resolved reference with the full content entry data
|
|
34
|
-
*/
|
|
35
|
-
export interface ResolvedReference {
|
|
36
|
-
/** The content entry ID */
|
|
37
|
-
id: string;
|
|
38
|
-
/** The content type name */
|
|
39
|
-
contentTypeName: string;
|
|
40
|
-
/** The content type display name */
|
|
41
|
-
contentTypeDisplayName: string;
|
|
42
|
-
/** The entry's slug */
|
|
43
|
-
slug: string;
|
|
44
|
-
/** The entry's status (supports custom workflow states) */
|
|
45
|
-
status: string;
|
|
46
|
-
/** The entry's data (field values) */
|
|
47
|
-
data: Record<string, unknown>;
|
|
48
|
-
/** Whether the entry exists and is not deleted */
|
|
49
|
-
exists: boolean;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Options for resolving references
|
|
54
|
-
*/
|
|
55
|
-
export interface ResolveOptions {
|
|
56
|
-
/** Include soft-deleted entries (default: false) */
|
|
57
|
-
includeDeleted?: boolean;
|
|
58
|
-
/** Only return published entries (default: false) */
|
|
59
|
-
publishedOnly?: boolean;
|
|
60
|
-
/** Specific fields to include from the entry data (default: all) */
|
|
61
|
-
fields?: string[];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Result of a reference resolution operation
|
|
66
|
-
*/
|
|
67
|
-
export interface ResolveResult {
|
|
68
|
-
/** Successfully resolved references */
|
|
69
|
-
resolved: ResolvedReference[];
|
|
70
|
-
/** IDs that could not be resolved (not found or deleted) */
|
|
71
|
-
unresolved: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// =============================================================================
|
|
75
|
-
// Core Resolution Functions
|
|
76
|
-
// =============================================================================
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Resolve a single reference to its full content entry.
|
|
80
|
-
*
|
|
81
|
-
* @param ctx - Convex query context
|
|
82
|
-
* @param referenceId - The content entry ID to resolve
|
|
83
|
-
* @param options - Resolution options
|
|
84
|
-
* @returns The resolved reference or null if not found
|
|
85
|
-
*
|
|
86
|
-
* @example
|
|
87
|
-
* ```typescript
|
|
88
|
-
* // In a query handler:
|
|
89
|
-
* const author = await resolveReference(ctx, entry.data.authorId);
|
|
90
|
-
* if (author) {
|
|
91
|
-
* console.log("Author:", author.data.name);
|
|
92
|
-
* }
|
|
93
|
-
* ```
|
|
94
|
-
*/
|
|
95
|
-
export async function resolveReference(
|
|
96
|
-
ctx: QueryCtx,
|
|
97
|
-
referenceId: string,
|
|
98
|
-
options: ResolveOptions = {},
|
|
99
|
-
): Promise<ResolvedReference | null> {
|
|
100
|
-
const { includeDeleted = false, publishedOnly = false } = options;
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
// Get the content entry
|
|
104
|
-
const entry = await ctx.db.get(referenceId as Id<"contentEntries">);
|
|
105
|
-
|
|
106
|
-
if (!entry) {
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Check soft-delete status
|
|
111
|
-
if (!includeDeleted && isDeleted(entry)) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Check published status
|
|
116
|
-
if (publishedOnly && entry.status !== "published") {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Get the content type for this entry
|
|
121
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
122
|
-
|
|
123
|
-
if (!contentType || isDeleted(contentType)) {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Filter fields if specified
|
|
128
|
-
let data = entry.data as Record<string, unknown>;
|
|
129
|
-
if (options.fields && options.fields.length > 0) {
|
|
130
|
-
const filteredData: Record<string, unknown> = {};
|
|
131
|
-
for (const field of options.fields) {
|
|
132
|
-
if (field in data) {
|
|
133
|
-
filteredData[field] = data[field];
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
data = filteredData;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
id: referenceId,
|
|
141
|
-
contentTypeName: contentType.name,
|
|
142
|
-
contentTypeDisplayName: contentType.displayName,
|
|
143
|
-
slug: entry.slug,
|
|
144
|
-
status: entry.status,
|
|
145
|
-
data,
|
|
146
|
-
exists: true,
|
|
147
|
-
};
|
|
148
|
-
} catch {
|
|
149
|
-
// Invalid ID format or other error
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Resolve multiple references to their full content entries.
|
|
156
|
-
*
|
|
157
|
-
* @param ctx - Convex query context
|
|
158
|
-
* @param referenceIds - Array of content entry IDs to resolve
|
|
159
|
-
* @param options - Resolution options
|
|
160
|
-
* @returns Result with resolved references and unresolved IDs
|
|
161
|
-
*
|
|
162
|
-
* @example
|
|
163
|
-
* ```typescript
|
|
164
|
-
* // In a query handler:
|
|
165
|
-
* const result = await resolveReferences(ctx, entry.data.relatedPostIds, {
|
|
166
|
-
* publishedOnly: true,
|
|
167
|
-
* });
|
|
168
|
-
*
|
|
169
|
-
* console.log("Found:", result.resolved.length);
|
|
170
|
-
* console.log("Missing:", result.unresolved);
|
|
171
|
-
* ```
|
|
172
|
-
*/
|
|
173
|
-
export async function resolveReferences(
|
|
174
|
-
ctx: QueryCtx,
|
|
175
|
-
referenceIds: string[],
|
|
176
|
-
options: ResolveOptions = {},
|
|
177
|
-
): Promise<ResolveResult> {
|
|
178
|
-
const resolved: ResolvedReference[] = [];
|
|
179
|
-
const unresolved: string[] = [];
|
|
180
|
-
|
|
181
|
-
// Resolve each reference in parallel for efficiency
|
|
182
|
-
const promises = referenceIds.map(async (id) => {
|
|
183
|
-
const result = await resolveReference(ctx, id, options);
|
|
184
|
-
return { id, result };
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const results = await Promise.all(promises);
|
|
188
|
-
|
|
189
|
-
for (const { id, result } of results) {
|
|
190
|
-
if (result) {
|
|
191
|
-
resolved.push(result);
|
|
192
|
-
} else {
|
|
193
|
-
unresolved.push(id);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return { resolved, unresolved };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Check if a reference ID points to a valid, existing content entry.
|
|
202
|
-
*
|
|
203
|
-
* @param ctx - Convex query context
|
|
204
|
-
* @param referenceId - The content entry ID to check
|
|
205
|
-
* @param allowedContentTypes - Optional array of allowed content type names
|
|
206
|
-
* @returns Object with validity status and optional error message
|
|
207
|
-
*
|
|
208
|
-
* @example
|
|
209
|
-
* ```typescript
|
|
210
|
-
* // Validate a reference before saving:
|
|
211
|
-
* const check = await isValidReference(ctx, authorId, ["user"]);
|
|
212
|
-
* if (!check.valid) {
|
|
213
|
-
* throw new Error(check.error);
|
|
214
|
-
* }
|
|
215
|
-
* ```
|
|
216
|
-
*/
|
|
217
|
-
export async function isValidReference(
|
|
218
|
-
ctx: QueryCtx,
|
|
219
|
-
referenceId: string,
|
|
220
|
-
allowedContentTypes?: string[],
|
|
221
|
-
): Promise<{ valid: boolean; error?: string }> {
|
|
222
|
-
try {
|
|
223
|
-
// Get the content entry
|
|
224
|
-
const entry = await ctx.db.get(referenceId as Id<"contentEntries">);
|
|
225
|
-
|
|
226
|
-
if (!entry) {
|
|
227
|
-
return { valid: false, error: `Content entry not found: ${referenceId}` };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Check soft-delete status
|
|
231
|
-
if (isDeleted(entry)) {
|
|
232
|
-
return {
|
|
233
|
-
valid: false,
|
|
234
|
-
error: `Content entry has been deleted: ${referenceId}`,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// If content type constraints specified, check them
|
|
239
|
-
if (allowedContentTypes && allowedContentTypes.length > 0) {
|
|
240
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
241
|
-
|
|
242
|
-
if (!contentType) {
|
|
243
|
-
return {
|
|
244
|
-
valid: false,
|
|
245
|
-
error: `Content type not found for entry: ${referenceId}`,
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!allowedContentTypes.includes(contentType.name)) {
|
|
250
|
-
return {
|
|
251
|
-
valid: false,
|
|
252
|
-
error: `Expected content type: ${allowedContentTypes.join(
|
|
253
|
-
" or ",
|
|
254
|
-
)}. Got: ${contentType.name}`,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return { valid: true };
|
|
260
|
-
} catch {
|
|
261
|
-
return {
|
|
262
|
-
valid: false,
|
|
263
|
-
error: `Invalid reference ID format: ${referenceId}`,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Validate all references in a content entry's data.
|
|
270
|
-
*
|
|
271
|
-
* Iterates through all reference fields and validates that each reference
|
|
272
|
-
* points to a valid, existing content entry of the allowed type.
|
|
273
|
-
*
|
|
274
|
-
* @param ctx - Convex query context
|
|
275
|
-
* @param data - The content entry data containing reference fields
|
|
276
|
-
* @param fields - Array of field definitions (to identify reference fields)
|
|
277
|
-
* @returns Object with overall validity and array of errors
|
|
278
|
-
*
|
|
279
|
-
* @example
|
|
280
|
-
* ```typescript
|
|
281
|
-
* // Validate all references before creating/updating an entry:
|
|
282
|
-
* const validation = await validateAllReferences(ctx, data, contentType.fields);
|
|
283
|
-
* if (!validation.valid) {
|
|
284
|
-
* throw new Error(validation.errors.join(", "));
|
|
285
|
-
* }
|
|
286
|
-
* ```
|
|
287
|
-
*/
|
|
288
|
-
export async function validateAllReferences(
|
|
289
|
-
ctx: QueryCtx,
|
|
290
|
-
data: Record<string, unknown>,
|
|
291
|
-
fields: Array<{
|
|
292
|
-
name: string;
|
|
293
|
-
type: string;
|
|
294
|
-
options?: {
|
|
295
|
-
allowedContentTypes?: string[];
|
|
296
|
-
multiple?: boolean;
|
|
297
|
-
};
|
|
298
|
-
}>,
|
|
299
|
-
): Promise<{ valid: boolean; errors: string[] }> {
|
|
300
|
-
const errors: string[] = [];
|
|
301
|
-
|
|
302
|
-
// Find all reference fields
|
|
303
|
-
const referenceFields = fields.filter((f) => f.type === "reference");
|
|
304
|
-
|
|
305
|
-
for (const field of referenceFields) {
|
|
306
|
-
const value = data[field.name];
|
|
307
|
-
|
|
308
|
-
if (value === null || value === undefined) {
|
|
309
|
-
continue; // Skip empty values (required validation is separate)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const allowedTypes = field.options?.allowedContentTypes;
|
|
313
|
-
const multiple = field.options?.multiple ?? false;
|
|
314
|
-
|
|
315
|
-
if (multiple) {
|
|
316
|
-
// Validate array of references
|
|
317
|
-
if (!Array.isArray(value)) {
|
|
318
|
-
errors.push(`${field.name}: Expected array of references`);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
for (const refId of value) {
|
|
323
|
-
if (typeof refId !== "string") {
|
|
324
|
-
errors.push(`${field.name}: Invalid reference ID type`);
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const check = await isValidReference(ctx, refId, allowedTypes);
|
|
329
|
-
if (!check.valid) {
|
|
330
|
-
errors.push(`${field.name}: ${check.error}`);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
} else {
|
|
334
|
-
// Validate single reference
|
|
335
|
-
if (typeof value !== "string") {
|
|
336
|
-
errors.push(`${field.name}: Expected string reference ID`);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const check = await isValidReference(ctx, value, allowedTypes);
|
|
341
|
-
if (!check.valid) {
|
|
342
|
-
errors.push(`${field.name}: ${check.error}`);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return {
|
|
348
|
-
valid: errors.length === 0,
|
|
349
|
-
errors,
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// =============================================================================
|
|
354
|
-
// Utility Functions
|
|
355
|
-
// =============================================================================
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Extract all reference IDs from a content entry's data.
|
|
359
|
-
*
|
|
360
|
-
* @param data - The content entry data
|
|
361
|
-
* @param fields - Array of field definitions
|
|
362
|
-
* @returns Array of all reference IDs found in the data
|
|
363
|
-
*/
|
|
364
|
-
export function extractReferenceIds(
|
|
365
|
-
data: Record<string, unknown>,
|
|
366
|
-
fields: Array<{
|
|
367
|
-
name: string;
|
|
368
|
-
type: string;
|
|
369
|
-
options?: { multiple?: boolean };
|
|
370
|
-
}>,
|
|
371
|
-
): string[] {
|
|
372
|
-
const ids: string[] = [];
|
|
373
|
-
|
|
374
|
-
const referenceFields = fields.filter((f) => f.type === "reference");
|
|
375
|
-
|
|
376
|
-
for (const field of referenceFields) {
|
|
377
|
-
const value = data[field.name];
|
|
378
|
-
|
|
379
|
-
if (value === null || value === undefined) {
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const multiple = field.options?.multiple ?? false;
|
|
384
|
-
|
|
385
|
-
if (multiple && Array.isArray(value)) {
|
|
386
|
-
for (const id of value) {
|
|
387
|
-
if (typeof id === "string") {
|
|
388
|
-
ids.push(id);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
} else if (typeof value === "string") {
|
|
392
|
-
ids.push(value);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return ids;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Get the content type name for a content entry ID.
|
|
401
|
-
*
|
|
402
|
-
* This is a helper function for the `validateReferenceContentType` function
|
|
403
|
-
* in the validation module.
|
|
404
|
-
*
|
|
405
|
-
* @param ctx - Convex query context
|
|
406
|
-
* @param entryId - The content entry ID
|
|
407
|
-
* @returns The content type name or null if not found
|
|
408
|
-
*/
|
|
409
|
-
export async function getContentTypeName(
|
|
410
|
-
ctx: QueryCtx,
|
|
411
|
-
entryId: string,
|
|
412
|
-
): Promise<string | null> {
|
|
413
|
-
try {
|
|
414
|
-
const entry = await ctx.db.get(entryId as Id<"contentEntries">);
|
|
415
|
-
|
|
416
|
-
if (!entry || isDeleted(entry)) {
|
|
417
|
-
return null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const contentType = await ctx.db.get(entry.contentTypeId);
|
|
421
|
-
|
|
422
|
-
if (!contentType || isDeleted(contentType)) {
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
return contentType.name;
|
|
427
|
-
} catch {
|
|
428
|
-
return null;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Slug Generator Utility
|
|
3
|
-
*
|
|
4
|
-
* Generates URL-friendly slugs from content titles.
|
|
5
|
-
* Handles special characters, unicode, and ensures slug format consistency.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Options for slug generation
|
|
10
|
-
*/
|
|
11
|
-
export interface SlugOptions {
|
|
12
|
-
/** Maximum length of the generated slug (default: 100) */
|
|
13
|
-
maxLength?: number;
|
|
14
|
-
/** Separator character to use (default: '-') */
|
|
15
|
-
separator?: string;
|
|
16
|
-
/** Whether to lowercase the slug (default: true) */
|
|
17
|
-
lowercase?: boolean;
|
|
18
|
-
/** Custom replacements for specific characters */
|
|
19
|
-
customReplacements?: Record<string, string>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Default character replacements for common special characters and unicode
|
|
24
|
-
*/
|
|
25
|
-
const DEFAULT_REPLACEMENTS: Record<string, string> = {
|
|
26
|
-
// German
|
|
27
|
-
ä: "ae",
|
|
28
|
-
ö: "oe",
|
|
29
|
-
ü: "ue",
|
|
30
|
-
ß: "ss",
|
|
31
|
-
Ä: "ae",
|
|
32
|
-
Ö: "oe",
|
|
33
|
-
Ü: "ue",
|
|
34
|
-
// French
|
|
35
|
-
à: "a",
|
|
36
|
-
â: "a",
|
|
37
|
-
ç: "c",
|
|
38
|
-
é: "e",
|
|
39
|
-
è: "e",
|
|
40
|
-
ê: "e",
|
|
41
|
-
ë: "e",
|
|
42
|
-
î: "i",
|
|
43
|
-
ï: "i",
|
|
44
|
-
ô: "o",
|
|
45
|
-
ù: "u",
|
|
46
|
-
û: "u",
|
|
47
|
-
ÿ: "y",
|
|
48
|
-
œ: "oe",
|
|
49
|
-
æ: "ae",
|
|
50
|
-
// Spanish
|
|
51
|
-
ñ: "n",
|
|
52
|
-
Ñ: "n",
|
|
53
|
-
// Polish
|
|
54
|
-
ą: "a",
|
|
55
|
-
Ą: "a",
|
|
56
|
-
ć: "c",
|
|
57
|
-
Ć: "c",
|
|
58
|
-
ę: "e",
|
|
59
|
-
Ę: "e",
|
|
60
|
-
ł: "l",
|
|
61
|
-
Ł: "l",
|
|
62
|
-
ń: "n",
|
|
63
|
-
Ń: "n",
|
|
64
|
-
ó: "o",
|
|
65
|
-
Ó: "o",
|
|
66
|
-
ś: "s",
|
|
67
|
-
Ś: "s",
|
|
68
|
-
ź: "z",
|
|
69
|
-
Ź: "z",
|
|
70
|
-
ż: "z",
|
|
71
|
-
Ż: "z",
|
|
72
|
-
// Nordic
|
|
73
|
-
å: "a",
|
|
74
|
-
Å: "a",
|
|
75
|
-
ø: "o",
|
|
76
|
-
Ø: "o",
|
|
77
|
-
// Other common
|
|
78
|
-
ð: "d",
|
|
79
|
-
þ: "th",
|
|
80
|
-
// Symbols
|
|
81
|
-
"&": "and",
|
|
82
|
-
"@": "at",
|
|
83
|
-
"#": "hash",
|
|
84
|
-
"%": "percent",
|
|
85
|
-
"+": "plus",
|
|
86
|
-
"=": "equals",
|
|
87
|
-
// Punctuation to remove (replaced with empty string to prevent separator)
|
|
88
|
-
"'": "",
|
|
89
|
-
"\u2018": "", // left single quote '
|
|
90
|
-
"\u2019": "", // right single quote '
|
|
91
|
-
"\u201C": "", // left double quote "
|
|
92
|
-
"\u201D": "", // right double quote "
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Generates a URL-friendly slug from a given title string.
|
|
97
|
-
*
|
|
98
|
-
* @param title - The input string to convert to a slug
|
|
99
|
-
* @param options - Optional configuration for slug generation
|
|
100
|
-
* @returns A URL-friendly slug string
|
|
101
|
-
*
|
|
102
|
-
* @example
|
|
103
|
-
* ```typescript
|
|
104
|
-
* generateSlug("Hello World!") // "hello-world"
|
|
105
|
-
* generateSlug("Café & Restaurant") // "cafe-and-restaurant"
|
|
106
|
-
* generateSlug("日本語タイトル") // "ri-ben-yu-taitoru"
|
|
107
|
-
* generateSlug(" Multiple Spaces ") // "multiple-spaces"
|
|
108
|
-
* ```
|
|
109
|
-
*/
|
|
110
|
-
export function generateSlug(title: string, options: SlugOptions = {}): string {
|
|
111
|
-
const {
|
|
112
|
-
maxLength = 100,
|
|
113
|
-
separator = "-",
|
|
114
|
-
lowercase = true,
|
|
115
|
-
customReplacements = {},
|
|
116
|
-
} = options;
|
|
117
|
-
|
|
118
|
-
if (!title || typeof title !== "string") {
|
|
119
|
-
return "";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Merge custom replacements with defaults (custom takes precedence)
|
|
123
|
-
const replacements = { ...DEFAULT_REPLACEMENTS, ...customReplacements };
|
|
124
|
-
|
|
125
|
-
let slug = title.trim();
|
|
126
|
-
|
|
127
|
-
// Apply character replacements
|
|
128
|
-
for (const [char, replacement] of Object.entries(replacements)) {
|
|
129
|
-
slug = slug.split(char).join(replacement);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Normalize unicode to decomposed form, then remove combining diacritical marks
|
|
133
|
-
slug = slug.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
134
|
-
|
|
135
|
-
// Convert to lowercase if requested
|
|
136
|
-
if (lowercase) {
|
|
137
|
-
slug = slug.toLowerCase();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Replace any non-alphanumeric characters (except separator) with separator
|
|
141
|
-
// This regex keeps letters (including unicode letters after normalization), numbers
|
|
142
|
-
const separatorRegex = new RegExp(`[^a-z0-9${escapeRegex(separator)}]`, "gi");
|
|
143
|
-
slug = slug.replace(separatorRegex, separator);
|
|
144
|
-
|
|
145
|
-
// Collapse multiple consecutive separators into one
|
|
146
|
-
const multipleSeparatorRegex = new RegExp(`${escapeRegex(separator)}+`, "g");
|
|
147
|
-
slug = slug.replace(multipleSeparatorRegex, separator);
|
|
148
|
-
|
|
149
|
-
// Remove leading and trailing separators
|
|
150
|
-
const trimSeparatorRegex = new RegExp(
|
|
151
|
-
`^${escapeRegex(separator)}|${escapeRegex(separator)}$`,
|
|
152
|
-
"g"
|
|
153
|
-
);
|
|
154
|
-
slug = slug.replace(trimSeparatorRegex, "");
|
|
155
|
-
|
|
156
|
-
// Truncate to max length, but don't cut words in the middle
|
|
157
|
-
if (slug.length > maxLength) {
|
|
158
|
-
slug = truncateSlug(slug, maxLength, separator);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return slug;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Escapes special regex characters in a string
|
|
166
|
-
*/
|
|
167
|
-
function escapeRegex(str: string): string {
|
|
168
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Truncates a slug to a maximum length without cutting words
|
|
173
|
-
*/
|
|
174
|
-
function truncateSlug(
|
|
175
|
-
slug: string,
|
|
176
|
-
maxLength: number,
|
|
177
|
-
separator: string
|
|
178
|
-
): string {
|
|
179
|
-
if (slug.length <= maxLength) {
|
|
180
|
-
return slug;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Find the last separator before the max length
|
|
184
|
-
const truncated = slug.substring(0, maxLength);
|
|
185
|
-
const lastSeparatorIndex = truncated.lastIndexOf(separator);
|
|
186
|
-
|
|
187
|
-
if (lastSeparatorIndex > 0) {
|
|
188
|
-
return truncated.substring(0, lastSeparatorIndex);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// If no separator found, just truncate at maxLength
|
|
192
|
-
return truncated;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Validates if a string is a valid slug format
|
|
197
|
-
*
|
|
198
|
-
* @param slug - The string to validate
|
|
199
|
-
* @param separator - The separator character (default: '-')
|
|
200
|
-
* @returns True if the string is a valid slug
|
|
201
|
-
*
|
|
202
|
-
* @example
|
|
203
|
-
* ```typescript
|
|
204
|
-
* isValidSlug("hello-world") // true
|
|
205
|
-
* isValidSlug("Hello World") // false
|
|
206
|
-
* isValidSlug("hello--world") // false
|
|
207
|
-
* isValidSlug("-hello-world") // false
|
|
208
|
-
* ```
|
|
209
|
-
*/
|
|
210
|
-
export function isValidSlug(slug: string, separator: string = "-"): boolean {
|
|
211
|
-
if (!slug || typeof slug !== "string") {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Must be lowercase alphanumeric with single separators
|
|
216
|
-
const validSlugRegex = new RegExp(
|
|
217
|
-
`^[a-z0-9]+(?:${escapeRegex(separator)}[a-z0-9]+)*$`
|
|
218
|
-
);
|
|
219
|
-
|
|
220
|
-
return validSlugRegex.test(slug);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Generates a unique slug by appending a numeric suffix if needed.
|
|
225
|
-
* This is a helper that can be used with a uniqueness check function.
|
|
226
|
-
*
|
|
227
|
-
* @param baseSlug - The base slug to make unique
|
|
228
|
-
* @param isUnique - Async function that checks if a slug is unique
|
|
229
|
-
* @param maxAttempts - Maximum number of suffix attempts (default: 100)
|
|
230
|
-
* @returns A unique slug
|
|
231
|
-
*
|
|
232
|
-
* @example
|
|
233
|
-
* ```typescript
|
|
234
|
-
* const checkUnique = async (slug: string) => {
|
|
235
|
-
* return !(await db.query("entries").withSlug(slug).first());
|
|
236
|
-
* };
|
|
237
|
-
* const uniqueSlug = await generateUniqueSlug("hello-world", checkUnique);
|
|
238
|
-
* // Returns "hello-world" if unique, or "hello-world-1", "hello-world-2", etc.
|
|
239
|
-
* ```
|
|
240
|
-
*/
|
|
241
|
-
export async function generateUniqueSlug(
|
|
242
|
-
baseSlug: string,
|
|
243
|
-
isUnique: (slug: string) => Promise<boolean>,
|
|
244
|
-
maxAttempts: number = 100
|
|
245
|
-
): Promise<string> {
|
|
246
|
-
// Check if base slug is already unique
|
|
247
|
-
if (await isUnique(baseSlug)) {
|
|
248
|
-
return baseSlug;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Try appending numeric suffixes
|
|
252
|
-
for (let i = 1; i <= maxAttempts; i++) {
|
|
253
|
-
const candidateSlug = `${baseSlug}-${i}`;
|
|
254
|
-
if (await isUnique(candidateSlug)) {
|
|
255
|
-
return candidateSlug;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Fallback: append timestamp if all numeric suffixes are taken
|
|
260
|
-
const timestamp = Date.now().toString(36);
|
|
261
|
-
return `${baseSlug}-${timestamp}`;
|
|
262
|
-
}
|