@uniweb/core 0.7.0 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,8 +30,8 @@
30
30
  "jest": "^29.7.0"
31
31
  },
32
32
  "dependencies": {
33
- "@uniweb/theming": "0.1.3",
34
- "@uniweb/semantic-parser": "1.1.9"
33
+ "@uniweb/semantic-parser": "1.1.9",
34
+ "@uniweb/theming": "0.1.3"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
@@ -148,18 +148,31 @@ export default class EntityStore {
148
148
 
149
149
  /**
150
150
  * Auto-inject `detail:` on collection refs whose collection has
151
- * `deferred:` declared. The build emits per-record files at
152
- * `/data/<name>/<slug>.json` for those collections; this populates
153
- * the detail-fetch URL so the existing dynamic-route singular flow
154
- * uses the per-record file (with deferred fields) instead of the
155
- * matched-item-from-cascade-collection (without).
151
+ * `deferred:` declared. The detail pattern points at the per-record
152
+ * source so the existing dynamic-route singular flow fetches a record
153
+ * with all fields (including the deferred ones) instead of the
154
+ * matched-item-from-the-cascade-collection (without).
155
+ *
156
+ * Two patterns:
157
+ *
158
+ * - Markdown-backed collections (the build emits per-record files
159
+ * at `/data/<name>/<slug>.json`): the auto-injected pattern is
160
+ * that path. `isLocalPath` resolution downstream gives the
161
+ * fetch a `path:` shape.
162
+ *
163
+ * - API-backed collections (the source is a remote URL; the build
164
+ * emits no per-record files): the author declares a `detailUrl:`
165
+ * on the collection — e.g., `/api/articles/{slug}` — and the
166
+ * auto-injected pattern uses it. `isLocalPath` resolution
167
+ * downstream gives the fetch a `url:` shape (because the
168
+ * collection itself has `url:`, not `path:`).
156
169
  *
157
170
  * Conventions:
158
- * - Per-record files are keyed by `item.slug`. The injected pattern
159
- * uses the `{slug}` placeholder; substitution works when the
160
- * dynamic route's paramName is 'slug' (the documented convention).
161
- * Routes using other param names need an explicit author-written
162
- * `detail:` value.
171
+ * - Per-record sources are keyed by `item.slug`. The injected
172
+ * pattern uses the `{slug}` placeholder; substitution works when
173
+ * the dynamic route's paramName is 'slug' (the documented
174
+ * convention). Routes with other param names need an explicit
175
+ * author-written `detail:` value.
163
176
  * - Author-supplied `cfg.detail` always wins. This helper only fills
164
177
  * in the default for collections that have declared deferred fields.
165
178
  * - Per-record files are not currently localized; sites needing
@@ -173,7 +186,10 @@ export default class EntityStore {
173
186
  if (!collConfig || typeof collConfig !== 'object') return cfg
174
187
  const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
175
188
  if (!deferred || deferred.length === 0) return cfg
176
- return { ...cfg, detail: `/data/${schema}/{slug}.json` }
189
+ const pattern = typeof collConfig.detailUrl === 'string'
190
+ ? collConfig.detailUrl
191
+ : `/data/${schema}/{slug}.json`
192
+ return { ...cfg, detail: pattern }
177
193
  }
178
194
 
179
195
  /**
package/src/index.js CHANGED
@@ -22,6 +22,7 @@ export { default as ObservableState } from './observable-state.js'
22
22
  export { default as singularize } from './singularize.js'
23
23
  export { substitutePlaceholders } from './substitute-placeholders.js'
24
24
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
25
+ export { resolveStyle as resolveRequestStyle, listStyleNames as listRequestStyleNames } from './request-styles/index.js'
25
26
 
26
27
  /**
27
28
  * The singleton Uniweb instance.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * flat-query — plain URL query-string style.
3
+ *
4
+ * GET-only. Operators become bare query params:
5
+ *
6
+ * ?limit=10
7
+ * ?sort=-date (single-key, "-" prefix for desc)
8
+ * ?sort=-date,title (multi-key, comma-separated)
9
+ * ?dept=biology&tenured=true (where, flat AND of equalities only)
10
+ *
11
+ * The `where:` operator is pushed **only** when the predicate is a flat
12
+ * AND of equalities on top-level fields. Nested operator objects
13
+ * (`{ age: { gte: 18 } }`), composition (`and` / `or` / `not`), and
14
+ * dotted field paths disqualify pushdown — the wire format can't
15
+ * express them. In those cases flat-query silently skips `where`, and
16
+ * the default fetcher applies it as a runtime fallback after the
17
+ * response arrives. This is the documented trade-off for the style's
18
+ * simplicity: flat-query fits simple REST APIs; richer queries either
19
+ * evaluate client-side or pick a different style.
20
+ *
21
+ * POST requests encode nothing — flat-query is a URL-params shape and
22
+ * has no meaningful POST representation.
23
+ *
24
+ * Use case: public REST APIs like `?dept=biology&limit=10&sort=-date`.
25
+ * For GraphQL or body-shaped backends, use json-body. For Strapi v4,
26
+ * use the strapi style.
27
+ */
28
+
29
+ export const flatQuery = {
30
+ name: 'flat-query',
31
+ canPush: new Set(['where', 'limit', 'sort']),
32
+ defaultEnvelope: null,
33
+
34
+ encode(request, { method, pushCandidates, rename }) {
35
+ const pushed = new Set()
36
+ const queryParams = []
37
+
38
+ if (method !== 'GET') {
39
+ // POST has no flat-query representation; silent no-op.
40
+ return { queryParams, bodyMerge: null, pushed }
41
+ }
42
+
43
+ // where — only flat AND of equalities.
44
+ if (pushCandidates.has('where') && request.where !== undefined) {
45
+ const pairs = encodeFlatEqualities(request.where)
46
+ if (pairs !== null) {
47
+ for (const [k, v] of pairs) queryParams.push([applyRename(k, rename), v])
48
+ pushed.add('where')
49
+ }
50
+ }
51
+
52
+ if (pushCandidates.has('limit') && request.limit !== undefined) {
53
+ queryParams.push([wireName('limit', 'limit', rename), String(request.limit)])
54
+ pushed.add('limit')
55
+ }
56
+
57
+ if (pushCandidates.has('sort') && request.sort !== undefined) {
58
+ const encoded = encodeSort(request.sort)
59
+ if (encoded !== null) {
60
+ queryParams.push([wireName('sort', 'sort', rename), encoded])
61
+ pushed.add('sort')
62
+ }
63
+ }
64
+
65
+ return { queryParams, bodyMerge: null, pushed }
66
+ },
67
+ }
68
+
69
+ /**
70
+ * Encode a where-object as flat [field, value] pairs. Returns null when
71
+ * the predicate doesn't fit the flat-AND-equalities shape — caller
72
+ * falls back to runtime evaluation.
73
+ */
74
+ function encodeFlatEqualities(where) {
75
+ if (!where || typeof where !== 'object' || Array.isArray(where)) return null
76
+ const out = []
77
+ for (const [key, raw] of Object.entries(where)) {
78
+ // Composition and operator shorthand disqualify the whole predicate.
79
+ if (key === 'and' || key === 'or' || key === 'not') return null
80
+ if (key.includes('.')) return null // dotted path — flat-query can't express nesting
81
+
82
+ // Bare primitive → implicit equality.
83
+ if (isPrimitive(raw)) {
84
+ out.push([key, stringifyPrimitive(raw)])
85
+ continue
86
+ }
87
+
88
+ // { eq: primitive } → equality. Anything else disqualifies.
89
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
90
+ const opKeys = Object.keys(raw)
91
+ if (opKeys.length === 1 && opKeys[0] === 'eq' && isPrimitive(raw.eq)) {
92
+ out.push([key, stringifyPrimitive(raw.eq)])
93
+ continue
94
+ }
95
+ }
96
+
97
+ return null
98
+ }
99
+ return out
100
+ }
101
+
102
+ /**
103
+ * Encode `sort: 'date desc'` → `-date`; `sort: 'date desc, title asc'` →
104
+ * `-date,title`. Returns null on malformed input (caller falls back).
105
+ */
106
+ function encodeSort(sortExpr) {
107
+ if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
108
+ const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
109
+ if (parts.length === 0) return null
110
+ const encoded = []
111
+ for (const part of parts) {
112
+ const [field, dirRaw] = part.split(/\s+/)
113
+ if (!field) return null
114
+ const desc = (dirRaw || 'asc').toLowerCase() === 'desc'
115
+ encoded.push(desc ? `-${field}` : field)
116
+ }
117
+ return encoded.join(',')
118
+ }
119
+
120
+ function isPrimitive(value) {
121
+ if (value === null) return true
122
+ const t = typeof value
123
+ return t === 'string' || t === 'number' || t === 'boolean'
124
+ }
125
+
126
+ function stringifyPrimitive(value) {
127
+ if (value === null) return ''
128
+ return String(value)
129
+ }
130
+
131
+ function wireName(operator, defaultWire, rename) {
132
+ if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
133
+ return rename[operator]
134
+ }
135
+ return defaultWire
136
+ }
137
+
138
+ // `rename` on flat-query's where pushdown doesn't apply to individual
139
+ // field names — authors who need `dept → department` should write
140
+ // their where-objects with the backend's field name directly. rename
141
+ // remains available for `limit` and `sort` wire names.
142
+ function applyRename(fieldName, _rename) {
143
+ return fieldName
144
+ }
145
+
146
+ export default flatQuery
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Request-style registry.
3
+ *
4
+ * A "style" describes how the default fetcher reshapes a normalized
5
+ * request into wire format — which operators become URL params, which
6
+ * go into a body, what envelope the response carries. Sites pick a
7
+ * style on `site.yml fetcher.request.style`; when unset, the ambient
8
+ * default is `json-body`.
9
+ *
10
+ * Styles are shipped by the framework. There is no `registerStyle()`
11
+ * API for foundations or sites — custom wire shapes are expressed via
12
+ * foundation-level named transports. Styles are specifically for
13
+ * reshapings of the framework's own fetcher behavior.
14
+ *
15
+ * Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
16
+ */
17
+
18
+ import { jsonBody } from './json-body.js'
19
+ import { flatQuery } from './flat-query.js'
20
+ import { strapi } from './strapi.js'
21
+
22
+ const STYLES = new Map([
23
+ [jsonBody.name, jsonBody],
24
+ [flatQuery.name, flatQuery],
25
+ [strapi.name, strapi],
26
+ ])
27
+
28
+ /**
29
+ * Resolve a style by name. Returns the requested style if registered,
30
+ * otherwise the ambient default (`json-body`). Unknown names trigger a
31
+ * one-time dev warning; production silently falls back.
32
+ *
33
+ * @param {string|undefined|null} name
34
+ * @param {{ dev?: boolean }} [options]
35
+ * @returns {Object} A style module.
36
+ */
37
+ export function resolveStyle(name, { dev = false } = {}) {
38
+ if (!name) return jsonBody
39
+ const style = STYLES.get(name)
40
+ if (style) return style
41
+ if (dev && !warnedUnknownStyles.has(name)) {
42
+ warnedUnknownStyles.add(name)
43
+ console.warn(
44
+ `[default-fetcher] unknown request style "${name}"; falling back to "json-body". ` +
45
+ `Known styles: ${[...STYLES.keys()].join(', ')}.`,
46
+ )
47
+ }
48
+ return jsonBody
49
+ }
50
+
51
+ const warnedUnknownStyles = new Set()
52
+
53
+ /**
54
+ * Sentinel list of names registered at the registry level. Used by tests
55
+ * and by dev-mode diagnostics; not part of the site-facing contract.
56
+ */
57
+ export function listStyleNames() {
58
+ return [...STYLES.keys()]
59
+ }
60
+
61
+ export { jsonBody, flatQuery, strapi }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * json-body — the framework's general-purpose request style.
3
+ *
4
+ * Ambient default when the site doesn't pick a style. Speaks the
5
+ * framework's own conventions:
6
+ *
7
+ * GET — operators travel as URL params prefixed with underscore:
8
+ * ?_where=<JSON.stringify(predicate)>
9
+ * ?_limit=N
10
+ * ?_sort=field:dir (comma-separated for multi-key)
11
+ * The leading underscore avoids collision with backend-
12
+ * specific query params the author may have included in `url:`.
13
+ *
14
+ * POST — operators merge as top-level keys into an object body,
15
+ * alongside any author-supplied body. Content-Type defaults
16
+ * to `application/json` unless the site set a different one.
17
+ * String POST bodies (rare; typically GraphQL-only) don't
18
+ * receive operator merge — the string is sent as-is.
19
+ *
20
+ * Operator name renames are applied from the `rename:` map passed in
21
+ * context. Shallow substitutions only — `rename: { limit: pageSize }`
22
+ * swaps the wire name `_limit` → `pageSize` on GET, or the body key
23
+ * `limit` → `pageSize` on POST. The operator identity (what `limit`
24
+ * means in the query) does not change.
25
+ *
26
+ * Internal module. Accessed by `default-fetcher.js` via the registry;
27
+ * never imported directly from outside `@uniweb/runtime`.
28
+ */
29
+
30
+ export const jsonBody = {
31
+ name: 'json-body',
32
+
33
+ // Which operators this style knows how to push. The effective push set
34
+ // is the intersection of this and the site's `supports:` list.
35
+ canPush: new Set(['where', 'limit', 'sort']),
36
+
37
+ // Default response envelope. null = no wrapper (a plain JSON payload).
38
+ defaultEnvelope: null,
39
+
40
+ /**
41
+ * Encode a request against the json-body conventions.
42
+ *
43
+ * @param {Object} request - The normalized fetch request.
44
+ * @param {Object} ctx
45
+ * @param {'GET'|'POST'} ctx.method - The method the fetcher chose.
46
+ * @param {Set<string>} ctx.pushCandidates - Operators present on the
47
+ * request AND listed in the site's `supports:`. Style may choose to
48
+ * push all, some, or none of these based on what it knows how to
49
+ * express on the wire.
50
+ * @param {Object|null} ctx.rename - Optional { operator → wireName } map.
51
+ * @returns {{
52
+ * queryParams: Array<[string, string]>,
53
+ * bodyMerge: Object|null,
54
+ * pushed: Set<string>,
55
+ * }}
56
+ * queryParams — pairs appended to the URL's query string.
57
+ * bodyMerge — object merged into the POST body, or null.
58
+ * pushed — the operators that actually rode on the wire.
59
+ * Feeds the fetcher's cache-key derivation and
60
+ * runtime-fallback skip.
61
+ */
62
+ encode(request, { method, pushCandidates, rename }) {
63
+ const pushed = new Set()
64
+ const queryParams = []
65
+ let bodyMerge = null
66
+
67
+ if (method === 'GET') {
68
+ if (pushCandidates.has('where') && request.where !== undefined) {
69
+ queryParams.push([
70
+ wireName('where', '_where', rename),
71
+ JSON.stringify(request.where),
72
+ ])
73
+ pushed.add('where')
74
+ }
75
+ if (pushCandidates.has('limit') && request.limit !== undefined) {
76
+ queryParams.push([
77
+ wireName('limit', '_limit', rename),
78
+ String(request.limit),
79
+ ])
80
+ pushed.add('limit')
81
+ }
82
+ if (pushCandidates.has('sort') && request.sort !== undefined) {
83
+ queryParams.push([
84
+ wireName('sort', '_sort', rename),
85
+ String(request.sort),
86
+ ])
87
+ pushed.add('sort')
88
+ }
89
+ } else if (method === 'POST') {
90
+ const merged = {}
91
+ if (pushCandidates.has('where') && request.where !== undefined) {
92
+ merged[wireName('where', 'where', rename)] = request.where
93
+ pushed.add('where')
94
+ }
95
+ if (pushCandidates.has('limit') && request.limit !== undefined) {
96
+ merged[wireName('limit', 'limit', rename)] = request.limit
97
+ pushed.add('limit')
98
+ }
99
+ if (pushCandidates.has('sort') && request.sort !== undefined) {
100
+ merged[wireName('sort', 'sort', rename)] = request.sort
101
+ pushed.add('sort')
102
+ }
103
+ if (Object.keys(merged).length > 0) bodyMerge = merged
104
+ }
105
+
106
+ return { queryParams, bodyMerge, pushed }
107
+ },
108
+ }
109
+
110
+ function wireName(operator, defaultWire, rename) {
111
+ if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
112
+ return rename[operator]
113
+ }
114
+ return defaultWire
115
+ }
116
+
117
+ export default jsonBody
@@ -0,0 +1,232 @@
1
+ /**
2
+ * strapi — Strapi v4 REST API query style.
3
+ *
4
+ * GET-only. Encodes a full where-object into Strapi's bracket-notation
5
+ * filters:
6
+ *
7
+ * where: { dept: 'biology' }
8
+ * → filters[dept][$eq]=biology
9
+ *
10
+ * where: { age: { gte: 18 } }
11
+ * → filters[age][$gte]=18
12
+ *
13
+ * where: { 'tenure.start': { gte: 2015 } }
14
+ * → filters[tenure][start][$gte]=2015
15
+ *
16
+ * where: { or: [{ a: 1 }, { b: 2 }] }
17
+ * → filters[$or][0][a][$eq]=1 & filters[$or][1][b][$eq]=2
18
+ *
19
+ * where: { not: { dept: 'emeritus' } }
20
+ * → filters[$not][dept][$eq]=emeritus
21
+ *
22
+ * limit: 10
23
+ * → pagination[limit]=10
24
+ *
25
+ * sort: 'date desc'
26
+ * → sort=date:desc
27
+ *
28
+ * sort: 'date desc, title asc'
29
+ * → sort[0]=date:desc & sort[1]=title:asc
30
+ *
31
+ * Response envelope defaults to `{ collection: 'data', item: 'data' }` —
32
+ * Strapi v4 wraps every response in `{ data, meta }`. Sites can override
33
+ * via `envelope:` at the site or per-fetch level.
34
+ *
35
+ * Operators mapped (where-object → Strapi `$op`):
36
+ * eq, ne, gt, gte, lt, lte, in, nin → notIn, like → containsi,
37
+ * exists → notNull, and → $and, or → $or, not → $not.
38
+ *
39
+ * For operators the where-object supports but Strapi doesn't have a
40
+ * clean equivalent for, the style leaves them untouched (the default
41
+ * fetcher applies them as a runtime fallback).
42
+ */
43
+
44
+ export const strapi = {
45
+ name: 'strapi',
46
+ canPush: new Set(['where', 'limit', 'sort']),
47
+ defaultEnvelope: { collection: 'data', item: 'data' },
48
+
49
+ encode(request, { method, pushCandidates, rename }) {
50
+ const pushed = new Set()
51
+ const queryParams = []
52
+
53
+ if (method !== 'GET') {
54
+ // Strapi v4 REST is GET-only for reads.
55
+ return { queryParams, bodyMerge: null, pushed }
56
+ }
57
+
58
+ if (pushCandidates.has('where') && request.where !== undefined) {
59
+ const filterKey = wireName('where', 'filters', rename)
60
+ const pairs = encodeStrapiFilters(request.where, filterKey)
61
+ if (pairs !== null) {
62
+ for (const pair of pairs) queryParams.push(pair)
63
+ pushed.add('where')
64
+ }
65
+ }
66
+
67
+ if (pushCandidates.has('limit') && request.limit !== undefined) {
68
+ const limitKey = wireName('limit', 'pagination[limit]', rename)
69
+ queryParams.push([limitKey, String(request.limit)])
70
+ pushed.add('limit')
71
+ }
72
+
73
+ if (pushCandidates.has('sort') && request.sort !== undefined) {
74
+ const sortPairs = encodeStrapiSort(request.sort, wireName('sort', 'sort', rename))
75
+ if (sortPairs !== null) {
76
+ for (const pair of sortPairs) queryParams.push(pair)
77
+ pushed.add('sort')
78
+ }
79
+ }
80
+
81
+ return { queryParams, bodyMerge: null, pushed }
82
+ },
83
+ }
84
+
85
+ // ─── where encoding ────────────────────────────────────────────────────────
86
+
87
+ // Values are the Strapi operator *with* bracket delimiters, so they
88
+ // concatenate cleanly into a bracketed path like `filters[field][$eq]`.
89
+ const OPERATOR_MAP = {
90
+ eq: '[$eq]',
91
+ ne: '[$ne]',
92
+ gt: '[$gt]',
93
+ gte: '[$gte]',
94
+ lt: '[$lt]',
95
+ lte: '[$lte]',
96
+ in: '[$in]',
97
+ nin: '[$notIn]',
98
+ like: '[$containsi]', // case-insensitive substring — closest Strapi analog
99
+ exists: '[$notNull]', // overridden below for exists:false → [$null]
100
+ }
101
+
102
+ /**
103
+ * Walk a where-object and emit [paramName, paramValue] pairs encoding
104
+ * Strapi's bracket syntax. Returns null if any branch is unencodable
105
+ * (caller falls back to runtime evaluation for the whole predicate).
106
+ */
107
+ function encodeStrapiFilters(where, rootKey) {
108
+ const out = []
109
+ try {
110
+ walkPredicate(where, [rootKey], out)
111
+ } catch (err) {
112
+ if (err && err.unencodable) return null
113
+ throw err
114
+ }
115
+ return out.length > 0 ? out : null
116
+ }
117
+
118
+ function walkPredicate(node, path, out) {
119
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
120
+ throwUnencodable()
121
+ }
122
+
123
+ // Top-level each key is either composition ($and/$or/$not) or a field.
124
+ for (const [key, value] of Object.entries(node)) {
125
+ if (key === 'and' || key === 'or') {
126
+ if (!Array.isArray(value)) throwUnencodable()
127
+ value.forEach((sub, i) => {
128
+ walkPredicate(sub, [...path, `[$${key}]`, `[${i}]`], out)
129
+ })
130
+ } else if (key === 'not') {
131
+ walkPredicate(value, [...path, '[$not]'], out)
132
+ } else {
133
+ emitFieldPredicate(key, value, path, out)
134
+ }
135
+ }
136
+ }
137
+
138
+ function emitFieldPredicate(field, value, path, out) {
139
+ // Dotted paths become nested bracket segments: `tenure.start` →
140
+ // `[tenure][start]`.
141
+ const fieldSegments = field.split('.').map((seg) => `[${seg}]`)
142
+ const fieldPath = [...path, ...fieldSegments]
143
+
144
+ // Bare primitive → implicit eq.
145
+ if (isPrimitive(value)) {
146
+ out.push([joinPath(fieldPath) + '[$eq]', stringifyPrimitive(value)])
147
+ return
148
+ }
149
+
150
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
151
+ for (const [opName, opValue] of Object.entries(value)) {
152
+ const strapiOp = OPERATOR_MAP[opName]
153
+ if (!strapiOp) throwUnencodable()
154
+
155
+ if (opName === 'in' || opName === 'nin') {
156
+ if (!Array.isArray(opValue)) throwUnencodable()
157
+ opValue.forEach((item, i) => {
158
+ if (!isPrimitive(item)) throwUnencodable()
159
+ out.push([joinPath(fieldPath) + strapiOp + `[${i}]`, stringifyPrimitive(item)])
160
+ })
161
+ continue
162
+ }
163
+
164
+ if (opName === 'exists') {
165
+ // { exists: true } → field IS NOT NULL → [field][$notNull]=true
166
+ // { exists: false } → field IS NULL → [field][$null]=true
167
+ const wantsPresent = !!opValue
168
+ out.push([joinPath(fieldPath) + (wantsPresent ? '[$notNull]' : '[$null]'), 'true'])
169
+ continue
170
+ }
171
+
172
+ if (!isPrimitive(opValue)) throwUnencodable()
173
+ out.push([joinPath(fieldPath) + strapiOp, stringifyPrimitive(opValue)])
174
+ }
175
+ return
176
+ }
177
+
178
+ throwUnencodable()
179
+ }
180
+
181
+ function joinPath(segments) {
182
+ return segments.join('')
183
+ }
184
+
185
+ function throwUnencodable() {
186
+ const err = new Error('unencodable predicate')
187
+ err.unencodable = true
188
+ throw err
189
+ }
190
+
191
+ // ─── sort encoding ─────────────────────────────────────────────────────────
192
+
193
+ function encodeStrapiSort(sortExpr, baseKey) {
194
+ if (typeof sortExpr !== 'string' || sortExpr.length === 0) return null
195
+ const parts = sortExpr.split(',').map((s) => s.trim()).filter(Boolean)
196
+ if (parts.length === 0) return null
197
+
198
+ const encoded = []
199
+ for (const part of parts) {
200
+ const [field, dirRaw] = part.split(/\s+/)
201
+ if (!field) return null
202
+ const dir = (dirRaw || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
203
+ encoded.push(`${field}:${dir}`)
204
+ }
205
+
206
+ if (encoded.length === 1) {
207
+ return [[baseKey, encoded[0]]]
208
+ }
209
+ return encoded.map((v, i) => [`${baseKey}[${i}]`, v])
210
+ }
211
+
212
+ // ─── helpers ───────────────────────────────────────────────────────────────
213
+
214
+ function isPrimitive(value) {
215
+ if (value === null) return true
216
+ const t = typeof value
217
+ return t === 'string' || t === 'number' || t === 'boolean'
218
+ }
219
+
220
+ function stringifyPrimitive(value) {
221
+ if (value === null) return ''
222
+ return String(value)
223
+ }
224
+
225
+ function wireName(operator, defaultWire, rename) {
226
+ if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
227
+ return rename[operator]
228
+ }
229
+ return defaultWire
230
+ }
231
+
232
+ export default strapi