@lowdefy/helpers 6.0.0 → 7.0.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.
@@ -0,0 +1,27 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ const MOUNT_EVENT_NAMES = [
16
+ 'onInit',
17
+ 'onInitAsync',
18
+ 'onMount',
19
+ 'onMountAsync'
20
+ ];
21
+ // Mount-class events fire by themselves when a page or block opens, so no DOM
22
+ // interaction ever causes one. The pairing rule and the journey compiler both
23
+ // need to tell them apart, and must agree on the list.
24
+ function isMountEventName({ eventName }) {
25
+ return MOUNT_EVENT_NAMES.includes(eventName);
26
+ }
27
+ export default isMountEventName;
@@ -0,0 +1,21 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ const TRACE_ID_PATTERN = /^\d{8}T\d{6}Z-[a-z0-9]{6}$/;
16
+ // A trace id names a session or run file on disk, so this check is also the
17
+ // path-traversal guard for anything that builds a path from one.
18
+ function isTraceId(value) {
19
+ return typeof value === 'string' && TRACE_ID_PATTERN.test(value);
20
+ }
21
+ export default isTraceId;
@@ -0,0 +1,103 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The rules a journey target is resolved by. The journey runner resolves targets with them, and
16
+ // the engine's describe functions read the same rules in reverse, so the two cannot drift apart.
17
+ // A radio or checkbox inside a <label> is reached through the label: the label carries the
18
+ // option's text and is what a person clicks, while the input may have no size at all (antd
19
+ // Segmented hides it at zero width and height).
20
+ const radioOptionRule = {
21
+ selector: 'label',
22
+ has: 'input[type="radio"]'
23
+ };
24
+ const checkboxOptionRule = {
25
+ selector: 'label',
26
+ has: 'input[type="checkbox"]'
27
+ };
28
+ function selectorOf({ selector, has, not = [] }) {
29
+ const hasPart = has ? `:has(${has})` : '';
30
+ return `${selector}${hasPart}${not.map((item)=>`:not(${item})`).join('')}`;
31
+ }
32
+ const radioOption = selectorOf(radioOptionRule);
33
+ const checkboxOption = selectorOf(checkboxOptionRule);
34
+ // ARIA roles that make an element an interactive control.
35
+ const interactiveRoles = [
36
+ 'button',
37
+ 'switch',
38
+ 'checkbox',
39
+ 'radio',
40
+ 'tab',
41
+ 'menuitem'
42
+ ];
43
+ // The controls a click target resolves to. Each rule is a simple selector, a descendant it must
44
+ // have and selectors it must not match, so the rules serve both as one selector string
45
+ // (Playwright, browsers) and as a matcher (isInteractiveControl) for DOMs whose selector engine
46
+ // gets :has() and complex :not() wrong, such as jsdom.
47
+ const interactiveControlRules = [
48
+ {
49
+ selector: 'button'
50
+ },
51
+ {
52
+ selector: '[role="button"]'
53
+ },
54
+ {
55
+ selector: 'a[href]'
56
+ },
57
+ radioOptionRule,
58
+ checkboxOptionRule,
59
+ {
60
+ selector: 'input:not([type="hidden"])',
61
+ not: [
62
+ 'label input[type="radio"]',
63
+ 'label input[type="checkbox"]'
64
+ ]
65
+ },
66
+ {
67
+ selector: 'textarea'
68
+ },
69
+ {
70
+ selector: 'select'
71
+ },
72
+ ...interactiveRoles.filter((role)=>role !== 'button').map((role)=>({
73
+ selector: `[role="${role}"]`
74
+ }))
75
+ ];
76
+ // A block that renders into a portal (Modal, Drawer) puts this class on the portal's root, so a
77
+ // journey target scoped to the block reaches the portal content, which is outside the block's
78
+ // #bl- wrapper. The class is a CSS selector-safe form of the block id.
79
+ function portalClass(blockId) {
80
+ return `ldf-portal-${String(blockId).replace(/[^\w-]/g, '_')}`;
81
+ }
82
+ const journeyTargetSelectors = {
83
+ // Every block renders a wrapper with id `bl-<blockId>`.
84
+ blockWrapperPrefix: 'bl-',
85
+ // ag-grid's displayed row index and a cell's column id.
86
+ cellAttribute: 'col-id',
87
+ checkboxOption,
88
+ // The options of a dropdown, the elements a `select` step clicks.
89
+ dropdownOption: '.ant-select-item-option, [role="option"]',
90
+ interactiveControl: interactiveControlRules.map(selectorOf).join(', '),
91
+ interactiveControlRules,
92
+ interactiveRoles,
93
+ // Portal layers, front-most first: an open dropdown menu covers a dialog, a dialog covers the
94
+ // page.
95
+ layers: [
96
+ '[role="menu"]',
97
+ '[role="dialog"]'
98
+ ],
99
+ portalClass,
100
+ radioOption,
101
+ rowAttribute: 'row-index'
102
+ };
103
+ export default journeyTargetSelectors;
@@ -0,0 +1,25 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import stampHash from './stampHash.js';
16
+ // The stamp hash of a list whose order carries no meaning (the organization ids
17
+ // a person belongs to): sorted first, so the same set always hashes the same,
18
+ // and JSON-encoded, so no separator inside an entry can make two lists read as
19
+ // one.
20
+ function listHash({ values }) {
21
+ return stampHash(JSON.stringify([
22
+ ...values
23
+ ].sort()));
24
+ }
25
+ export default listHash;
@@ -0,0 +1,51 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // type.isObject also accepts class instances, whose internals a copy would expose.
16
+ function isPlainObject(value) {
17
+ if (value === null || typeof value !== 'object') return false;
18
+ const proto = Object.getPrototypeOf(value);
19
+ return proto === Object.prototype || proto === null;
20
+ }
21
+ function mapNode(value, key, visit, copies) {
22
+ const visited = visit(value, key);
23
+ if (visited !== value) return visited;
24
+ if (!Array.isArray(value) && !isPlainObject(value)) return value;
25
+ // Reusing the copy of an object already seen keeps shared references and cycles
26
+ // intact instead of recursing forever.
27
+ if (copies.has(value)) return copies.get(value);
28
+ if (Array.isArray(value)) {
29
+ const mappedArray = [];
30
+ copies.set(value, mappedArray);
31
+ value.forEach((item)=>{
32
+ mappedArray.push(mapNode(item, undefined, visit, copies));
33
+ });
34
+ return mappedArray;
35
+ }
36
+ const mappedObject = {};
37
+ copies.set(value, mappedObject);
38
+ Object.entries(value).forEach(([entryKey, item])=>{
39
+ mappedObject[entryKey] = mapNode(item, entryKey, visit, copies);
40
+ });
41
+ return mappedObject;
42
+ }
43
+ // Copies the plain objects and arrays in a value, calling visit(value, key) on every
44
+ // node first - key is the object key, undefined for the root and array items. A
45
+ // different return replaces the node; the same value is kept, and descended into
46
+ // when it is a plain object or array. Class instances, Dates and Errors are kept
47
+ // by reference. The input is never mutated.
48
+ function mapPlainValues(value, visit) {
49
+ return mapNode(value, undefined, visit, new Map());
50
+ }
51
+ export default mapPlainValues;
@@ -0,0 +1,30 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { snakeCase } from 'change-case';
16
+ import type from './type.js';
17
+ // Shallow snake_cases every own top-level key of the resolved caller - the
18
+ // object mirror of the adapter's schema-wide fieldName derive. Values are
19
+ // copied by reference: the attributes and profile bags (and the roles array)
20
+ // ride through whole, so their inner keys - app/module-owned, not platform
21
+ // surface - are never transformed.
22
+ function normalizeCaller(user) {
23
+ if (type.isNone(user)) return user;
24
+ const normalized = {};
25
+ for (const [key, value] of Object.entries(user)){
26
+ normalized[snakeCase(key)] = value;
27
+ }
28
+ return normalized;
29
+ }
30
+ export default normalizeCaller;
@@ -0,0 +1,36 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import buildPagePath from './buildPagePath.js';
16
+ import parsePathPattern from './parsePathPattern.js';
17
+ import type from './type.js';
18
+ // The key a page instance's context and input are stored under. A page with placeholders has one
19
+ // instance per set of decoded values, keyed by the path the builder writes for them, so every
20
+ // spelling of the same values in an arriving URL gives one key. "#" cannot appear in a page id and
21
+ // the builder encodes it in values, so no instance key equals another page's key.
22
+ function pageInstanceKey({ pageId, path, pathParams }) {
23
+ if (type.isUndefined(path)) {
24
+ return `page:${pageId}`;
25
+ }
26
+ const hasPlaceholders = parsePathPattern(path).some((segment)=>type.isString(segment.name));
27
+ if (!hasPlaceholders) {
28
+ return `page:${pageId}`;
29
+ }
30
+ return `page:${pageId}#${buildPagePath({
31
+ pageId,
32
+ path,
33
+ pathParams
34
+ })}`;
35
+ }
36
+ export default pageInstanceKey;
@@ -0,0 +1,102 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import isMountEventName from './isMountEventName.js';
16
+ import type from './type.js';
17
+ // Absorbs event-loop and render delay between the DOM interaction and the
18
+ // moment the engine starts the event's actions, without reaching back to an
19
+ // unrelated earlier click.
20
+ const PAIRING_SLACK_MS = 1000;
21
+ function canBeCaused({ event }) {
22
+ return event.scope !== 'app' && !isMountEventName({
23
+ eventName: event.eventName
24
+ });
25
+ }
26
+ // An interaction can have caused an event when the event's block encloses the
27
+ // interaction's target (the handler is on the clicked block or an ancestor of
28
+ // it) and the event started no later than its debounce plus the slack after
29
+ // the interaction.
30
+ function couldCause({ event, interaction }) {
31
+ if (!interaction.blockIds.includes(event.blockId)) return false;
32
+ const delay = event.startTimestamp - interaction.t;
33
+ const windowMs = (event.debounceMs ?? 0) + PAIRING_SLACK_MS;
34
+ return delay >= 0 && delay <= windowMs;
35
+ }
36
+ function findCause({ event, interactions }) {
37
+ let cause;
38
+ interactions.forEach((interaction)=>{
39
+ if (!couldCause({
40
+ event,
41
+ interaction
42
+ })) return;
43
+ if (type.isUndefined(cause) || interaction.t >= cause.t) {
44
+ cause = interaction;
45
+ }
46
+ });
47
+ return cause;
48
+ }
49
+ // Decides which DOM interaction caused which engine event, so a trace record
50
+ // means the same thing whichever source wrote it: the dev recorder runs this in
51
+ // the browser, the production pull in Node.
52
+ //
53
+ // - interactions: [{ id, t, blockIds }], `t` epoch ms, `blockIds` innermost first.
54
+ // - events: [{ id, blockId, eventName, startTimestamp, debounceMs, scope }].
55
+ //
56
+ // Mount-class and app events are never paired. Any other event pairs with the
57
+ // latest interaction that could have caused it; an event with none is
58
+ // programmatic and goes to `unpaired`. Within a pair, events on the innermost
59
+ // block come first, then the earliest, so the record builder takes the first
60
+ // as `event` and the rest as `also`.
61
+ function pairTraceEvents({ interactions, events }) {
62
+ const byInteraction = new Map();
63
+ const unpaired = [];
64
+ events.forEach((event, order)=>{
65
+ const cause = canBeCaused({
66
+ event
67
+ }) ? findCause({
68
+ event,
69
+ interactions
70
+ }) : undefined;
71
+ if (type.isUndefined(cause)) {
72
+ unpaired.push(event.id);
73
+ return;
74
+ }
75
+ if (!byInteraction.has(cause)) byInteraction.set(cause, []);
76
+ byInteraction.get(cause).push({
77
+ depth: cause.blockIds.indexOf(event.blockId),
78
+ event,
79
+ order
80
+ });
81
+ });
82
+ const pairs = interactions.filter((interaction)=>byInteraction.has(interaction)).map((interaction)=>{
83
+ const paired = [
84
+ ...byInteraction.get(interaction)
85
+ ].sort((a, b)=>{
86
+ if (a.depth !== b.depth) return a.depth - b.depth;
87
+ if (a.event.startTimestamp !== b.event.startTimestamp) {
88
+ return a.event.startTimestamp - b.event.startTimestamp;
89
+ }
90
+ return a.order - b.order;
91
+ });
92
+ return {
93
+ interactionId: interaction.id,
94
+ eventIds: paired.map((entry)=>entry.event.id)
95
+ };
96
+ });
97
+ return {
98
+ pairs,
99
+ unpaired
100
+ };
101
+ }
102
+ export default pairTraceEvents;
@@ -0,0 +1,46 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ const FIELDS = [
17
+ 'session',
18
+ 'roles',
19
+ 'memberships'
20
+ ];
21
+ // Reads an x-lowdefy-caller header (see callerStamp) into
22
+ // { caller, session, roles, memberships }. A field the header does not carry is
23
+ // null; an anonymous caller is { caller: '' } with every field null. No header
24
+ // is null: the response was not answered by a route that resolved a caller (it
25
+ // failed before, or came from a proxy), so it claims no caller.
26
+ function parseCallerStamp(header) {
27
+ if (type.isNone(header)) {
28
+ return null;
29
+ }
30
+ const [caller, ...fields] = header.split(';').map((part)=>part.trim());
31
+ const stamp = {
32
+ caller,
33
+ session: null,
34
+ roles: null,
35
+ memberships: null
36
+ };
37
+ fields.forEach((field)=>{
38
+ const separator = field.indexOf('=');
39
+ const name = field.slice(0, separator);
40
+ if (separator > 0 && FIELDS.includes(name)) {
41
+ stamp[name] = field.slice(separator + 1);
42
+ }
43
+ });
44
+ return stamp;
45
+ }
46
+ export default parseCallerStamp;
@@ -0,0 +1,58 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import isChainHeaderEnd from './isChainHeaderEnd.js';
16
+ import readChainValue from './readChainValue.js';
17
+ function parseHeader(header) {
18
+ const [tag, ...classes] = header.split('.');
19
+ return {
20
+ tag: tag.toLowerCase(),
21
+ classes: classes.filter((name)=>name !== '')
22
+ };
23
+ }
24
+ // Splits a posthog-js `$elements_chain` into entries, target first. Each entry is
25
+ // `tag.class1.class2:` followed by sorted `key="value"` pairs, and entries are joined by `;`.
26
+ // Total: a malformed chain gives the entries read before the fault.
27
+ function parseElementsChain(chain) {
28
+ const entries = [];
29
+ let index = 0;
30
+ while(index < chain.length){
31
+ let headerEnd = index;
32
+ while(headerEnd < chain.length && chain[headerEnd] !== ';' && !isChainHeaderEnd(chain, headerEnd)){
33
+ headerEnd += 1;
34
+ }
35
+ const entry = {
36
+ ...parseHeader(chain.slice(index, headerEnd)),
37
+ attributes: {}
38
+ };
39
+ entries.push(entry);
40
+ if (chain[headerEnd] !== ':') {
41
+ index = headerEnd + 1;
42
+ continue;
43
+ }
44
+ index = headerEnd + 1;
45
+ while(index < chain.length && chain[index] !== ';'){
46
+ const keyEnd = chain.indexOf('="', index);
47
+ if (keyEnd === -1) {
48
+ return entries;
49
+ }
50
+ const { value, end } = readChainValue(chain, keyEnd + 2);
51
+ entry.attributes[chain.slice(index, keyEnd)] = value;
52
+ index = end;
53
+ }
54
+ index += 1;
55
+ }
56
+ return entries;
57
+ }
58
+ export default parseElementsChain;
@@ -0,0 +1,23 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The page path a URL shows: its pathname without the basePath and the outer slashes. It is the
16
+ // page id only for pages without a `path` pattern. The app root gives null.
17
+ function parsePageId(url, basePath = '') {
18
+ const pathname = new URL(url, 'http://localhost').pathname;
19
+ const stripped = basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
20
+ const pageId = stripped.replace(/^\//, '').replace(/\/$/, '');
21
+ return pageId === '' ? null : pageId;
22
+ }
23
+ export default parsePageId;
@@ -0,0 +1,88 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ // The page id character set without "/", which separates segments.
17
+ const fixedSegmentPattern = /^[A-Za-z0-9\-_:]+$/;
18
+ const placeholderNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ function parsePlaceholder({ path, segment }) {
20
+ const name = segment.slice(1, -1);
21
+ if (name.startsWith('...')) {
22
+ throw new Error(`Page path "${path}" has a catch-all placeholder "${segment}". Catch-all placeholders are not supported.`);
23
+ }
24
+ if (name.startsWith('?') || name.endsWith('?')) {
25
+ throw new Error(`Page path "${path}" has an optional placeholder "${segment}". Optional placeholders are not supported.`);
26
+ }
27
+ if (!placeholderNamePattern.test(name)) {
28
+ throw new Error(`Page path "${path}" has an invalid placeholder "${segment}". Placeholder names must start with a letter or "_" and only contain A-Z, a-z, 0-9 and "_".`);
29
+ }
30
+ return {
31
+ name
32
+ };
33
+ }
34
+ function parseSegment({ path, segment }) {
35
+ if (segment === '') {
36
+ throw new Error(`Page path "${path}" has an empty segment.`);
37
+ }
38
+ if (segment.startsWith('{') && segment.endsWith('}')) {
39
+ return parsePlaceholder({
40
+ path,
41
+ segment
42
+ });
43
+ }
44
+ if (segment.includes('{') || segment.includes('}')) {
45
+ throw new Error(`Page path "${path}" has a placeholder inside segment "${segment}". A placeholder must be a whole segment, like "{id}".`);
46
+ }
47
+ if (!fixedSegmentPattern.test(segment)) {
48
+ throw new Error(`Page path "${path}" segment "${segment}" contains invalid characters. Fixed segments must only contain A-Z, a-z, 0-9, "-", "_" and ":".`);
49
+ }
50
+ return {
51
+ fixed: segment
52
+ };
53
+ }
54
+ function parseSegments(path) {
55
+ if (path.startsWith('/') || path.endsWith('/')) {
56
+ throw new Error(`Page path "${path}" must not start or end with "/".`);
57
+ }
58
+ const names = new Set();
59
+ return path.split('/').map((segment)=>{
60
+ const parsed = parseSegment({
61
+ path,
62
+ segment
63
+ });
64
+ if (type.isString(parsed.name)) {
65
+ if (names.has(parsed.name)) {
66
+ throw new Error(`Page path "${path}" uses placeholder "${segment}" more than once.`);
67
+ }
68
+ names.add(parsed.name);
69
+ }
70
+ return Object.freeze(parsed);
71
+ });
72
+ }
73
+ // Patterns come from build config, so the cache holds at most one entry per page path.
74
+ const cache = new Map();
75
+ // Splits a page path pattern into segments, each { fixed } or { name }. The result is shared
76
+ // between callers and frozen.
77
+ function parsePathPattern(path) {
78
+ if (!type.isString(path)) {
79
+ throw new Error(`Page path must be a string. Received ${JSON.stringify(path)}.`);
80
+ }
81
+ if (cache.has(path)) {
82
+ return cache.get(path);
83
+ }
84
+ const segments = Object.freeze(parseSegments(path));
85
+ cache.set(path, segments);
86
+ return segments;
87
+ }
88
+ export default parsePathPattern;
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import type from './type.js';
16
+ // An ag-grid `row-index` as a journey target row. Pinned rows (`t-0`, `b-0`) are not zero-based
17
+ // integers, so they give null.
18
+ function parseRowIndex(value) {
19
+ if (!type.isString(value) || !/^\d+$/.test(value)) {
20
+ return null;
21
+ }
22
+ return Number(value);
23
+ }
24
+ export default parseRowIndex;