@byline/core 4.2.0 → 4.4.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/@types/admin-types.d.ts +26 -9
- package/dist/@types/admin-types.js +4 -2
- package/dist/@types/collection-types.d.ts +9 -3
- package/dist/config/attach-hooks.js +19 -24
- package/dist/config/routes.test.node.js +1 -7
- package/dist/config/validate-admin-configs.d.ts +8 -5
- package/dist/config/validate-admin-configs.js +76 -45
- package/dist/config/validate-admin-configs.test.node.js +93 -3
- package/dist/config/validate-collections.js +63 -25
- package/dist/config/validate-collections.test.node.js +63 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/patches/apply-patches.js +20 -29
- package/dist/patches/patch.test.node.js +49 -1
- package/dist/paths/index.d.ts +16 -0
- package/dist/paths/index.js +15 -0
- package/dist/paths/parse-path.d.ts +44 -0
- package/dist/paths/parse-path.js +133 -0
- package/dist/paths/path-dialects.test.node.d.ts +8 -0
- package/dist/paths/path-dialects.test.node.js +267 -0
- package/dist/paths/path-types.d.ts +86 -0
- package/dist/paths/path-types.js +8 -0
- package/dist/paths/paths.test.node.d.ts +8 -0
- package/dist/paths/paths.test.node.js +318 -0
- package/dist/paths/resolve-path.d.ts +47 -0
- package/dist/paths/resolve-path.js +116 -0
- package/dist/schemas/zod/builder.js +26 -2
- package/dist/schemas/zod/builder.test.node.js +72 -0
- package/dist/services/assign-counter-values.js +9 -3
- package/dist/storage/collection-fingerprint.test.node.js +1 -4
- package/package.json +6 -6
|
@@ -510,6 +510,68 @@ describe('validateCollections', () => {
|
|
|
510
510
|
},
|
|
511
511
|
],
|
|
512
512
|
};
|
|
513
|
-
|
|
513
|
+
// The path names the block type, so the message identifies which block's
|
|
514
|
+
// `file` declaration is at fault rather than just `content.file`.
|
|
515
|
+
expect(() => validateCollections([collection])).toThrow(/content\.attachmentsBlock\.file.*must not contain duplicate slashes/s);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// search config field resolution
|
|
520
|
+
//
|
|
521
|
+
// `buildSearchDocument` resolves each name among the collection's top-level
|
|
522
|
+
// fields and silently skips what it cannot resolve, so an unrecognised name
|
|
523
|
+
// used to produce a collection that indexed less than its author intended,
|
|
524
|
+
// with no signal at boot or at index time.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
describe('validateCollections — search config fields', () => {
|
|
527
|
+
const searchable = (search) => ({
|
|
528
|
+
...baseCollection,
|
|
529
|
+
search,
|
|
530
|
+
fields: [
|
|
531
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
532
|
+
{ name: 'summary', label: 'Summary', type: 'text' },
|
|
533
|
+
{ name: 'featured', label: 'Featured', type: 'checkbox' },
|
|
534
|
+
{ name: 'topics', label: 'Topics', type: 'relation', targetCollection: 'topics' },
|
|
535
|
+
{ name: 'format', label: 'Format', type: 'select', options: [] },
|
|
536
|
+
{
|
|
537
|
+
name: 'files',
|
|
538
|
+
label: 'Files',
|
|
539
|
+
type: 'array',
|
|
540
|
+
fields: [
|
|
541
|
+
{
|
|
542
|
+
name: 'filesGroup',
|
|
543
|
+
type: 'group',
|
|
544
|
+
fields: [{ name: 'caption', label: 'Caption', type: 'text' }],
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
it('accepts top-level names, including a container in body', () => {
|
|
551
|
+
expect(() => validateCollections([
|
|
552
|
+
searchable({
|
|
553
|
+
body: [{ field: 'title', boost: 2 }, 'summary', 'files'],
|
|
554
|
+
facets: ['topics'],
|
|
555
|
+
filters: ['featured'],
|
|
556
|
+
}),
|
|
557
|
+
])).not.toThrow();
|
|
558
|
+
});
|
|
559
|
+
it('rejects a body name that does not exist', () => {
|
|
560
|
+
expect(() => validateCollections([searchable({ body: ['titel'] })])).toThrow(/no top-level field with that name exists/);
|
|
561
|
+
});
|
|
562
|
+
it('rejects a dotted path and explains what to name instead', () => {
|
|
563
|
+
// Accepting it would be worse than rejecting: the indexer would resolve
|
|
564
|
+
// nothing and silently index an empty body.
|
|
565
|
+
expect(() => validateCollections([searchable({ body: ['files.filesGroup.caption'] })])).toThrow(/top-level fields only.*name the top-level container/s);
|
|
566
|
+
});
|
|
567
|
+
it('rejects a facet that is not a relation', () => {
|
|
568
|
+
expect(() => validateCollections([searchable({ facets: ['format'] })])).toThrow(/Facets are controlled-vocabulary references/);
|
|
569
|
+
});
|
|
570
|
+
it('rejects a filter naming a container field', () => {
|
|
571
|
+
expect(() => validateCollections([searchable({ filters: ['files'] })])).toThrow(/Filters project a single scalar value/);
|
|
572
|
+
});
|
|
573
|
+
it('checks facets and filters too, not only body', () => {
|
|
574
|
+
expect(() => validateCollections([searchable({ facets: ['nope'] })])).toThrow(/no top-level field with that name exists/);
|
|
575
|
+
expect(() => validateCollections([searchable({ filters: ['nope'] })])).toThrow(/no top-level field with that name exists/);
|
|
514
576
|
});
|
|
515
577
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
|
|
|
12
12
|
export { type BylineLogger, getLogger } from './lib/logger.js';
|
|
13
13
|
export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
|
|
14
14
|
export * from './patches/index.js';
|
|
15
|
+
export * from './paths/index.js';
|
|
15
16
|
export { mergePredicates, type ParseContext, type ParsedSort, type ParsedWhere, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
16
17
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
17
18
|
export * from './services/index.js';
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './l
|
|
|
28
28
|
export { getLogger } from './lib/logger.js';
|
|
29
29
|
export { AsyncRegistry, Registry } from './lib/registry.js';
|
|
30
30
|
export * from './patches/index.js';
|
|
31
|
+
export * from './paths/index.js';
|
|
31
32
|
export { mergePredicates, parsePredicateFilters, parseSort, parseWhere, } from './query/parse-where.js';
|
|
32
33
|
export { getCollectionSchemasForPath } from './schemas/zod/cache.js';
|
|
33
34
|
export * from './services/index.js';
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Core implementation of the patch engine
|
|
2
2
|
import { isBlocksField, isStructureField } from '../@types/field-types.js';
|
|
3
|
-
|
|
3
|
+
import { parseInstancePath } from '../paths/parse-path.js';
|
|
4
|
+
// Instance-path grammar — see `@byline/core` `paths/`. Patch paths address
|
|
5
|
+
// items, so they carry selectors and no block type (the item holds `_type`).
|
|
6
|
+
//
|
|
7
|
+
// Historical shapes, unchanged:
|
|
4
8
|
// - "foo.bar" -> [{field: 'foo'}, {field: 'bar'}]
|
|
5
9
|
// - "reviews[0].rating" -> [{field: 'reviews'}, {index: 0}, {field: 'rating'}]
|
|
6
10
|
// - "reviews[id=abc].rating" -> [{field: 'reviews'}, {id: 'abc'}, {field: 'rating'}]
|
|
@@ -8,33 +12,19 @@ import { isBlocksField, isStructureField } from '../@types/field-types.js';
|
|
|
8
12
|
export function parsePatchPath(path) {
|
|
9
13
|
if (!path)
|
|
10
14
|
return [];
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
while (true) {
|
|
25
|
-
match = bracketRegex.exec(part);
|
|
26
|
-
if (match === null)
|
|
27
|
-
break;
|
|
28
|
-
const token = match[1];
|
|
29
|
-
if (/^\d+$/.test(token)) {
|
|
30
|
-
segments.push({ kind: 'index', index: Number.parseInt(token, 10) });
|
|
31
|
-
}
|
|
32
|
-
else if (token.startsWith('id=')) {
|
|
33
|
-
segments.push({ kind: 'id', id: token.slice(3) });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return segments;
|
|
15
|
+
const parsed = parseInstancePath(String(path));
|
|
16
|
+
// An unparseable path yields no segments, so callers reject the patch
|
|
17
|
+
// rather than applying it somewhere else. The previous hand-rolled parser
|
|
18
|
+
// was lenient — `"a["` produced `[field: 'a']`, and the patch was then
|
|
19
|
+
// applied at `a`, a path the client never addressed. Silent misapplication
|
|
20
|
+
// is a worse failure than a rejected patch.
|
|
21
|
+
if (!parsed.ok)
|
|
22
|
+
return [];
|
|
23
|
+
return parsed.segments.map((segment) => segment.kind === 'field'
|
|
24
|
+
? // The grammar names this `name`; the patch resolver has always called
|
|
25
|
+
// it `key`. Renamed at the boundary rather than churning consumers.
|
|
26
|
+
{ kind: 'field', key: segment.name }
|
|
27
|
+
: segment);
|
|
38
28
|
}
|
|
39
29
|
// Very small, best-effort resolver that walks the CollectionDefinition to find
|
|
40
30
|
// the field targeted by a patch path. This is intentionally conservative and
|
|
@@ -187,7 +177,8 @@ function ensurePath(root, segments) {
|
|
|
187
177
|
function applyFieldPatch(doc, patch) {
|
|
188
178
|
const segments = parsePatchPath(patch.path);
|
|
189
179
|
if (segments.length === 0) {
|
|
190
|
-
|
|
180
|
+
// Covers both an empty path and one that did not parse.
|
|
181
|
+
throw new Error(`Empty or unparseable path for field patch: ${JSON.stringify(patch.path ?? '')}`);
|
|
191
182
|
}
|
|
192
183
|
if (patch.kind === 'field.set') {
|
|
193
184
|
const { parent, key } = ensurePath(doc, segments);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { applyPatches } from './apply-patches.js';
|
|
2
|
+
import { applyPatches, parsePatchPath } from './apply-patches.js';
|
|
3
3
|
const DocsDefinition = {
|
|
4
4
|
path: 'docs',
|
|
5
5
|
labels: {
|
|
@@ -270,3 +270,51 @@ describe('applyPatches', () => {
|
|
|
270
270
|
expect(updated.content[0]?.constrainedWidth).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Patch paths are instance paths
|
|
275
|
+
//
|
|
276
|
+
// `parsePatchPath` delegates to the shared grammar's `parseInstancePath`
|
|
277
|
+
// (`@byline/core` `paths/`). The two agree on every well-formed path; they
|
|
278
|
+
// differ only in that a malformed one is now rejected rather than partially
|
|
279
|
+
// parsed.
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
describe('parsePatchPath — shared instance-path grammar', () => {
|
|
282
|
+
it('parses positional and id selectors', () => {
|
|
283
|
+
expect(parsePatchPath('content[0].gallery[1].alt')).toEqual([
|
|
284
|
+
{ kind: 'field', key: 'content' },
|
|
285
|
+
{ kind: 'index', index: 0 },
|
|
286
|
+
{ kind: 'field', key: 'gallery' },
|
|
287
|
+
{ kind: 'index', index: 1 },
|
|
288
|
+
{ kind: 'field', key: 'alt' },
|
|
289
|
+
]);
|
|
290
|
+
expect(parsePatchPath('content[id=abc].alt')).toEqual([
|
|
291
|
+
{ kind: 'field', key: 'content' },
|
|
292
|
+
{ kind: 'id', id: 'abc' },
|
|
293
|
+
{ kind: 'field', key: 'alt' },
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
it('carries no block-type segment — the addressed item holds its own `_type`', () => {
|
|
297
|
+
expect(parsePatchPath('content[0].alt')).not.toContainEqual({
|
|
298
|
+
kind: 'field',
|
|
299
|
+
key: 'photoBlock',
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
it('rejects a malformed path instead of parsing it partially', () => {
|
|
303
|
+
// Previously `'a['` yielded `[{ kind: 'field', key: 'a' }]`, so a patch
|
|
304
|
+
// was applied at `a` — a path the client never addressed. Returning no
|
|
305
|
+
// segments makes callers reject the patch instead.
|
|
306
|
+
for (const malformed of ['a[', 'a[]', 'a[x]', 'a]b', 'a..b']) {
|
|
307
|
+
expect(parsePatchPath(malformed)).toEqual([]);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
it('surfaces a malformed path as a patch error rather than a silent write', () => {
|
|
311
|
+
const original = { title: 'original' };
|
|
312
|
+
const { doc, errors } = applyPatches(DocsDefinition, original, [
|
|
313
|
+
{ kind: 'field.set', path: 'title[', value: 'injected' },
|
|
314
|
+
]);
|
|
315
|
+
expect(errors).toHaveLength(1);
|
|
316
|
+
expect(errors[0]?.message).toMatch(/unparseable path/i);
|
|
317
|
+
// The truncated path `title` is never written.
|
|
318
|
+
expect(doc.title).toBe('original');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Field path grammar — one AST, two serialisations.
|
|
10
|
+
*
|
|
11
|
+
* See `path-types.ts` for the instance/declaration model, and
|
|
12
|
+
* `docs/04-collections/01-fields.md` for the author-facing account.
|
|
13
|
+
*/
|
|
14
|
+
export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
|
|
15
|
+
export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
|
|
16
|
+
export type { PathParseFailure, PathParseResult, PathResolution, PathSegment, ResolveOptions, } from './path-types.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Field path grammar — one AST, two serialisations.
|
|
10
|
+
*
|
|
11
|
+
* See `path-types.ts` for the instance/declaration model, and
|
|
12
|
+
* `docs/04-collections/01-fields.md` for the author-facing account.
|
|
13
|
+
*/
|
|
14
|
+
export { formatDeclarationPath, formatInstancePath, parseDeclarationPath, parseInstancePath, toDeclarationSegments, } from './parse-path.js';
|
|
15
|
+
export { resolveDeclarationPath, walkFieldDeclarations } from './resolve-path.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { PathParseResult, PathSegment } from './path-types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Parse a declaration path: dotted, index-free, block types written as plain
|
|
11
|
+
* segments (`content.photoBlock.gallery.alt`).
|
|
12
|
+
*
|
|
13
|
+
* Every segment comes back as `kind: 'field'`. Item indices are a parse
|
|
14
|
+
* error rather than something silently dropped — a caller writing
|
|
15
|
+
* `files[0].caption` where a declaration is expected has made a category
|
|
16
|
+
* mistake, and saying so is more useful than quietly addressing every item.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseDeclarationPath(path: string): PathParseResult;
|
|
19
|
+
/**
|
|
20
|
+
* Parse an instance path: dotted field names with bracket item selectors,
|
|
21
|
+
* either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
|
|
22
|
+
*
|
|
23
|
+
* Blocks carry no discriminator here — the addressed item resolves its own
|
|
24
|
+
* `_type` — so a block hop is just the blocks field name followed by an
|
|
25
|
+
* item selector.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseInstancePath(path: string): PathParseResult;
|
|
28
|
+
/**
|
|
29
|
+
* Serialise segments as a declaration path. Index and id segments are
|
|
30
|
+
* dropped, so this doubles as the instance → declaration projection when
|
|
31
|
+
* applied to instance segments.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatDeclarationPath(segments: readonly PathSegment[]): string;
|
|
34
|
+
/** Serialise segments as an instance path, rendering selectors in brackets. */
|
|
35
|
+
export declare function formatInstancePath(segments: readonly PathSegment[]): string;
|
|
36
|
+
/**
|
|
37
|
+
* Drop the item selectors from a path, leaving the declaration it addresses.
|
|
38
|
+
*
|
|
39
|
+
* This is the projection several call sites hand-roll with a regex today.
|
|
40
|
+
* Doing it over segments rather than raw text is what makes it safe: a field
|
|
41
|
+
* legitimately named `0` is a `field` segment and survives, where a
|
|
42
|
+
* string-level `/^\d+$/` filter would silently delete it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function toDeclarationSegments(segments: readonly PathSegment[]): readonly PathSegment[];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Parsing and serialising field paths.
|
|
10
|
+
//
|
|
11
|
+
// Parsing is schema-unaware: it recovers structure from text and nothing
|
|
12
|
+
// more. In particular it cannot tell a block type from a field name, since
|
|
13
|
+
// both are bare identifiers — `resolveDeclarationPath` settles that against
|
|
14
|
+
// a field set. Callers that already hold the schema (path *producers*) should
|
|
15
|
+
// build segments directly and use the formatters here.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const INDEX_TOKEN = /^\[(\d+)\]$/;
|
|
18
|
+
const ID_TOKEN = /^\[id=(.+)\]$/;
|
|
19
|
+
/**
|
|
20
|
+
* Parse a declaration path: dotted, index-free, block types written as plain
|
|
21
|
+
* segments (`content.photoBlock.gallery.alt`).
|
|
22
|
+
*
|
|
23
|
+
* Every segment comes back as `kind: 'field'`. Item indices are a parse
|
|
24
|
+
* error rather than something silently dropped — a caller writing
|
|
25
|
+
* `files[0].caption` where a declaration is expected has made a category
|
|
26
|
+
* mistake, and saying so is more useful than quietly addressing every item.
|
|
27
|
+
*/
|
|
28
|
+
export function parseDeclarationPath(path) {
|
|
29
|
+
if (path.trim() === '')
|
|
30
|
+
return { ok: false, reason: 'empty' };
|
|
31
|
+
if (path.includes('[') || path.includes(']'))
|
|
32
|
+
return { ok: false, reason: 'index' };
|
|
33
|
+
const segments = [];
|
|
34
|
+
for (const part of path.split('.')) {
|
|
35
|
+
if (part === '')
|
|
36
|
+
return { ok: false, reason: 'emptySegment' };
|
|
37
|
+
segments.push({ kind: 'field', name: part });
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, segments };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse an instance path: dotted field names with bracket item selectors,
|
|
43
|
+
* either positional (`gallery[1]`) or by stable id (`gallery[id=abc]`).
|
|
44
|
+
*
|
|
45
|
+
* Blocks carry no discriminator here — the addressed item resolves its own
|
|
46
|
+
* `_type` — so a block hop is just the blocks field name followed by an
|
|
47
|
+
* item selector.
|
|
48
|
+
*/
|
|
49
|
+
export function parseInstancePath(path) {
|
|
50
|
+
if (path.trim() === '')
|
|
51
|
+
return { ok: false, reason: 'empty' };
|
|
52
|
+
const segments = [];
|
|
53
|
+
for (const part of path.split('.')) {
|
|
54
|
+
if (part === '')
|
|
55
|
+
return { ok: false, reason: 'emptySegment' };
|
|
56
|
+
const bracket = part.indexOf('[');
|
|
57
|
+
if (bracket === -1) {
|
|
58
|
+
if (part.includes(']'))
|
|
59
|
+
return { ok: false, reason: 'malformed' };
|
|
60
|
+
segments.push({ kind: 'field', name: part });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const name = part.slice(0, bracket);
|
|
64
|
+
if (name === '')
|
|
65
|
+
return { ok: false, reason: 'emptySegment' };
|
|
66
|
+
segments.push({ kind: 'field', name });
|
|
67
|
+
// One segment may carry several selectors (`items[0][1]` is not produced
|
|
68
|
+
// today, but parsing it costs nothing and beats silently truncating).
|
|
69
|
+
let rest = part.slice(bracket);
|
|
70
|
+
while (rest !== '') {
|
|
71
|
+
const close = rest.indexOf(']');
|
|
72
|
+
if (close === -1)
|
|
73
|
+
return { ok: false, reason: 'malformed' };
|
|
74
|
+
const token = rest.slice(0, close + 1);
|
|
75
|
+
rest = rest.slice(close + 1);
|
|
76
|
+
const indexMatch = INDEX_TOKEN.exec(token);
|
|
77
|
+
if (indexMatch?.[1] != null) {
|
|
78
|
+
segments.push({ kind: 'index', index: Number.parseInt(indexMatch[1], 10) });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const idMatch = ID_TOKEN.exec(token);
|
|
82
|
+
if (idMatch?.[1] != null) {
|
|
83
|
+
segments.push({ kind: 'id', id: idMatch[1] });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
return { ok: false, reason: 'malformed' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, segments };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Serialise segments as a declaration path. Index and id segments are
|
|
93
|
+
* dropped, so this doubles as the instance → declaration projection when
|
|
94
|
+
* applied to instance segments.
|
|
95
|
+
*/
|
|
96
|
+
export function formatDeclarationPath(segments) {
|
|
97
|
+
return segments
|
|
98
|
+
.filter((segment) => segment.kind === 'field' || segment.kind === 'blockType')
|
|
99
|
+
.map((segment) => (segment.kind === 'blockType' ? segment.blockType : segment.name))
|
|
100
|
+
.join('.');
|
|
101
|
+
}
|
|
102
|
+
/** Serialise segments as an instance path, rendering selectors in brackets. */
|
|
103
|
+
export function formatInstancePath(segments) {
|
|
104
|
+
let out = '';
|
|
105
|
+
for (const segment of segments) {
|
|
106
|
+
switch (segment.kind) {
|
|
107
|
+
case 'field':
|
|
108
|
+
out += out === '' ? segment.name : `.${segment.name}`;
|
|
109
|
+
break;
|
|
110
|
+
case 'blockType':
|
|
111
|
+
// Instance paths carry no discriminator; the item knows its own type.
|
|
112
|
+
break;
|
|
113
|
+
case 'index':
|
|
114
|
+
out += `[${segment.index}]`;
|
|
115
|
+
break;
|
|
116
|
+
case 'id':
|
|
117
|
+
out += `[id=${segment.id}]`;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Drop the item selectors from a path, leaving the declaration it addresses.
|
|
125
|
+
*
|
|
126
|
+
* This is the projection several call sites hand-roll with a regex today.
|
|
127
|
+
* Doing it over segments rather than raw text is what makes it safe: a field
|
|
128
|
+
* legitimately named `0` is a `field` segment and survives, where a
|
|
129
|
+
* string-level `/^\d+$/` filter would silently delete it.
|
|
130
|
+
*/
|
|
131
|
+
export function toDeclarationSegments(segments) {
|
|
132
|
+
return segments.filter((segment) => segment.kind === 'field' || segment.kind === 'blockType');
|
|
133
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|