@pilotiq/pilotiq 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +209 -0
- package/dist/Resource.d.ts +39 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +30 -0
- package/dist/Resource.js.map +1 -1
- package/dist/pageData/navigation.d.ts +17 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +14 -0
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/react/AppShell.d.ts +5 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +1 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/FieldFocusReporterRegistry.d.ts +29 -0
- package/dist/react/FieldFocusReporterRegistry.d.ts.map +1 -0
- package/dist/react/FieldFocusReporterRegistry.js +14 -0
- package/dist/react/FieldFocusReporterRegistry.js.map +1 -0
- package/dist/react/FieldPresenceRegistry.d.ts +38 -0
- package/dist/react/FieldPresenceRegistry.d.ts.map +1 -0
- package/dist/react/FieldPresenceRegistry.js +14 -0
- package/dist/react/FieldPresenceRegistry.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +17 -0
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +44 -3
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RecordWrapperGate.d.ts +19 -6
- package/dist/react/RecordWrapperGate.d.ts.map +1 -1
- package/dist/react/RecordWrapperGate.js +18 -8
- package/dist/react/RecordWrapperGate.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +27 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +105 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +10 -0
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +179 -0
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/textDelta.d.ts +44 -0
- package/dist/react/fields/textDelta.d.ts.map +1 -0
- package/dist/react/fields/textDelta.js +80 -0
- package/dist/react/fields/textDelta.js.map +1 -0
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/parseRecordEditUrl.d.ts +33 -9
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
- package/dist/react/parseRecordEditUrl.js +40 -2
- package/dist/react/parseRecordEditUrl.js.map +1 -1
- package/package.json +1 -1
- package/src/Resource.test.ts +44 -0
- package/src/Resource.ts +58 -0
- package/src/pageData/navigation.ts +32 -1
- package/src/pageData.test.ts +36 -0
- package/src/react/AppShell.tsx +6 -1
- package/src/react/FieldFocusReporterRegistry.ts +37 -0
- package/src/react/FieldPresenceRegistry.ts +46 -0
- package/src/react/FormCollabBindingRegistry.ts +63 -1
- package/src/react/FormStateContext.tsx +62 -3
- package/src/react/RecordWrapperGate.tsx +26 -8
- package/src/react/fields/FieldShell.tsx +39 -2
- package/src/react/fields/MarkdownInput.tsx +100 -3
- package/src/react/fields/TextLikeInput.tsx +203 -1
- package/src/react/fields/textDelta.test.ts +141 -0
- package/src/react/fields/textDelta.ts +86 -0
- package/src/react/index.ts +20 -1
- package/src/react/parseRecordEditUrl.test.ts +48 -1
- package/src/react/parseRecordEditUrl.ts +52 -13
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { computeDelta, preserveCursor } from './textDelta.js'
|
|
5
|
+
|
|
6
|
+
describe('computeDelta — string-diff to TextDelta', () => {
|
|
7
|
+
it('returns null for identical strings', () => {
|
|
8
|
+
assert.equal(computeDelta('hello', 'hello'), null)
|
|
9
|
+
assert.equal(computeDelta('', ''), null)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('emits insert when text is appended', () => {
|
|
13
|
+
assert.deepEqual(
|
|
14
|
+
computeDelta('hello', 'hello!'),
|
|
15
|
+
{ kind: 'insert', index: 5, text: '!' },
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('emits insert when text is prepended', () => {
|
|
20
|
+
assert.deepEqual(
|
|
21
|
+
computeDelta('world', 'hello world'),
|
|
22
|
+
{ kind: 'insert', index: 0, text: 'hello ' },
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('emits insert when text is spliced mid-string', () => {
|
|
27
|
+
// Inserting an 'l' to make 'helo' → 'hello'. The longest common
|
|
28
|
+
// prefix is 'hel' (3 chars — before[2]='l' and after[2]='l' both
|
|
29
|
+
// match), so the insertion lands at index 3. Either interpretation
|
|
30
|
+
// (index 2 or index 3) produces the same CRDT result; the diff
|
|
31
|
+
// picks the rightmost feasible point deterministically.
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
computeDelta('helo', 'hello'),
|
|
34
|
+
{ kind: 'insert', index: 3, text: 'l' },
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('emits delete when a trailing run is removed', () => {
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
computeDelta('hello!', 'hello'),
|
|
41
|
+
{ kind: 'delete', index: 5, length: 1 },
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('emits delete when a leading run is removed', () => {
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
computeDelta('hello world', 'world'),
|
|
48
|
+
{ kind: 'delete', index: 0, length: 6 },
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('emits delete when a mid-string run is removed', () => {
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
computeDelta('hello', 'hlo'),
|
|
55
|
+
{ kind: 'delete', index: 1, length: 2 },
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('emits replace when a mid-string selection is swapped', () => {
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
computeDelta('hello world', 'hello pilot'),
|
|
62
|
+
{ kind: 'replace', from: 6, to: 11, text: 'pilot' },
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('emits replace when the whole string is swapped', () => {
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
computeDelta('foo', 'bar'),
|
|
69
|
+
{ kind: 'replace', from: 0, to: 3, text: 'bar' },
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('emits insert when growing from empty', () => {
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
computeDelta('', 'a'),
|
|
76
|
+
{ kind: 'insert', index: 0, text: 'a' },
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('emits delete when shrinking to empty', () => {
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
computeDelta('abc', ''),
|
|
83
|
+
{ kind: 'delete', index: 0, length: 3 },
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('handles repeated-char shrink without prefix/suffix overlap', () => {
|
|
88
|
+
// 'aaa' → 'aa' — the prefix walk could greedily eat all 2 chars from
|
|
89
|
+
// the after side; the suffix cap must stop suffix at 2 so beforeMid
|
|
90
|
+
// is 'a' (length 1) instead of '' (length 0, identity).
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
computeDelta('aaa', 'aa'),
|
|
93
|
+
{ kind: 'delete', index: 2, length: 1 },
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('preserveCursor — anchor across remote edits', () => {
|
|
99
|
+
it('returns input cursor when strings are identical', () => {
|
|
100
|
+
assert.equal(preserveCursor('hello', 'hello', 3), 3)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('leaves cursor untouched when edit lands AFTER cursor', () => {
|
|
104
|
+
// Cursor at index 2 ('he|llo'); remote appends ' world'. Edit prefix
|
|
105
|
+
// length is 5, cursor 2 ≤ prefix → no shift.
|
|
106
|
+
assert.equal(preserveCursor('hello', 'hello world', 2), 2)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('shifts cursor when edit lands BEFORE cursor', () => {
|
|
110
|
+
// Cursor at 5 ('hello|'); remote prepends 'XX '. The common prefix
|
|
111
|
+
// is empty, so cursor > prefix → shift by (8 − 5) = 3, landing at
|
|
112
|
+
// 8 (the end of the new string, same logical position as before).
|
|
113
|
+
assert.equal(preserveCursor('hello', 'XX hello', 5), 8)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('lands at end-of-string for non-contiguous edits (heuristic limit)', () => {
|
|
117
|
+
// Both-sides insertion ('hello' → 'X hello world') flattens into a
|
|
118
|
+
// single full-string `replace` at the diff layer because the prefix
|
|
119
|
+
// and suffix walks find no common ground. Cursor lands at the end
|
|
120
|
+
// of the new string — imperfect for this case but harmless. A
|
|
121
|
+
// future v2 using Yjs `RelativePosition` would land it at 7
|
|
122
|
+
// (just after the original 'hello' substring).
|
|
123
|
+
assert.equal(preserveCursor('hello', 'X hello world', 5), 13)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('clamps cursor when remote deletes around the cursor', () => {
|
|
127
|
+
// Cursor at 5 ('hello|world'); remote deletes 'hello'. Prefix is 0,
|
|
128
|
+
// delta is -5 → shifted to 0.
|
|
129
|
+
assert.equal(preserveCursor('helloworld', 'world', 5), 0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('never returns a negative cursor', () => {
|
|
133
|
+
assert.equal(preserveCursor('abcdef', '', 3), 0)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('never returns a cursor past the new length', () => {
|
|
137
|
+
// Defensive — caller might pass a stale cursor longer than the new
|
|
138
|
+
// string. Clamp to new bounds.
|
|
139
|
+
assert.equal(preserveCursor('hello', 'hi', 10), 2)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { TextDelta } from '../FormCollabBindingRegistry.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase F.6 — derive a single character-level edit op from two strings.
|
|
5
|
+
*
|
|
6
|
+
* Strategy: find the longest common prefix and suffix between `before`
|
|
7
|
+
* and `after`; whatever's left in the middle is the changed region.
|
|
8
|
+
*
|
|
9
|
+
* - middle-after empty + middle-before non-empty → `delete`
|
|
10
|
+
* - middle-before empty + middle-after non-empty → `insert`
|
|
11
|
+
* - both non-empty → `replace`
|
|
12
|
+
* - both empty (identical strings) → `null`
|
|
13
|
+
*
|
|
14
|
+
* This correctly handles the common edit shapes: single-key insert,
|
|
15
|
+
* single-key backspace, multi-char paste replacing a selection, IME
|
|
16
|
+
* commits, accent-key composition. It does NOT preserve user intent
|
|
17
|
+
* when the same character appears at multiple positions and the edit
|
|
18
|
+
* could be attributed to either occurrence — Yjs's per-character
|
|
19
|
+
* identity makes that distinction lossy at the string-diff layer
|
|
20
|
+
* (the `Y.Text` itself maintains item identity internally). For v1
|
|
21
|
+
* we accept the ambiguity; the CRDT semantics still converge.
|
|
22
|
+
*/
|
|
23
|
+
export function computeDelta(before: string, after: string): TextDelta | null {
|
|
24
|
+
if (before === after) return null
|
|
25
|
+
|
|
26
|
+
let prefix = 0
|
|
27
|
+
const minLen = Math.min(before.length, after.length)
|
|
28
|
+
while (prefix < minLen && before[prefix] === after[prefix]) prefix++
|
|
29
|
+
|
|
30
|
+
// Walk back from each end, capped so suffix can't overlap the prefix
|
|
31
|
+
// on either side. Without the cap, identical strings of repeated
|
|
32
|
+
// chars (e.g. 'aaa' → 'aa') would consume the same byte from both
|
|
33
|
+
// directions and produce an empty middle on both sides.
|
|
34
|
+
let suffix = 0
|
|
35
|
+
const maxSuffix = Math.min(before.length - prefix, after.length - prefix)
|
|
36
|
+
while (
|
|
37
|
+
suffix < maxSuffix &&
|
|
38
|
+
before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
|
|
39
|
+
) {
|
|
40
|
+
suffix++
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const beforeMid = before.slice(prefix, before.length - suffix)
|
|
44
|
+
const afterMid = after.slice(prefix, after.length - suffix)
|
|
45
|
+
|
|
46
|
+
if (beforeMid.length === 0 && afterMid.length > 0) {
|
|
47
|
+
return { kind: 'insert', index: prefix, text: afterMid }
|
|
48
|
+
}
|
|
49
|
+
if (afterMid.length === 0 && beforeMid.length > 0) {
|
|
50
|
+
return { kind: 'delete', index: prefix, length: beforeMid.length }
|
|
51
|
+
}
|
|
52
|
+
return { kind: 'replace', from: prefix, to: prefix + beforeMid.length, text: afterMid }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Phase F.6 — best-effort cursor anchor across a remote-applied edit.
|
|
57
|
+
*
|
|
58
|
+
* - Edit landed AFTER cursor (cursor inside the common prefix) → keep
|
|
59
|
+
* cursor where it is.
|
|
60
|
+
* - Edit landed BEFORE cursor → shift
|
|
61
|
+
* cursor by `after.length − before.length` so its character-offset
|
|
62
|
+
* into the post-edit string matches its pre-edit anchor.
|
|
63
|
+
* - Edit landed OVERLAPPING the cursor → cursor
|
|
64
|
+
* ends up at the boundary between the changed region and the
|
|
65
|
+
* unchanged suffix (which `Math.max(0, cursor + delta)` produces
|
|
66
|
+
* naturally and clamps to the new bounds).
|
|
67
|
+
*
|
|
68
|
+
* This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
|
|
69
|
+
* at the exact same insertion point can still see a one-character cursor
|
|
70
|
+
* twitch on the remote-mirror side; v2 (in-input remote carets) would
|
|
71
|
+
* upgrade to relative positions if/when a consumer asks. Native input
|
|
72
|
+
* cursors are clamped to `[0, after.length]` by every browser, so this
|
|
73
|
+
* function does the same to avoid `setSelectionRange` throwing.
|
|
74
|
+
*/
|
|
75
|
+
export function preserveCursor(before: string, after: string, cursor: number): number {
|
|
76
|
+
if (before === after) return cursor
|
|
77
|
+
|
|
78
|
+
let prefix = 0
|
|
79
|
+
const minLen = Math.min(before.length, after.length)
|
|
80
|
+
while (prefix < minLen && before[prefix] === after[prefix]) prefix++
|
|
81
|
+
|
|
82
|
+
if (cursor <= prefix) return Math.min(cursor, after.length)
|
|
83
|
+
|
|
84
|
+
const delta = after.length - before.length
|
|
85
|
+
return Math.max(0, Math.min(after.length, cursor + delta))
|
|
86
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -49,7 +49,20 @@ export {
|
|
|
49
49
|
type FormCollabBinding,
|
|
50
50
|
type FormCollabBindingFactory,
|
|
51
51
|
type FormCollabBindingFactoryArgs,
|
|
52
|
+
type TextBinding,
|
|
53
|
+
type TextDelta,
|
|
52
54
|
} from './FormCollabBindingRegistry.js'
|
|
55
|
+
export {
|
|
56
|
+
registerFieldPresenceComponent,
|
|
57
|
+
getFieldPresenceComponent,
|
|
58
|
+
type FieldPresenceProps,
|
|
59
|
+
} from './FieldPresenceRegistry.js'
|
|
60
|
+
export {
|
|
61
|
+
registerFieldFocusReporter,
|
|
62
|
+
getFieldFocusReporter,
|
|
63
|
+
type FieldFocusReporter,
|
|
64
|
+
type FieldFocusEvent,
|
|
65
|
+
} from './FieldFocusReporterRegistry.js'
|
|
53
66
|
export {
|
|
54
67
|
registerRecordWrapper,
|
|
55
68
|
getRecordWrapper,
|
|
@@ -59,7 +72,13 @@ export {
|
|
|
59
72
|
RecordWrapperGate,
|
|
60
73
|
type RecordWrapperGateProps,
|
|
61
74
|
} from './RecordWrapperGate.js'
|
|
62
|
-
export {
|
|
75
|
+
export {
|
|
76
|
+
parseRecordPageUrl,
|
|
77
|
+
parseRecordEditUrl,
|
|
78
|
+
type RecordPageIdentity,
|
|
79
|
+
type RecordPageRole,
|
|
80
|
+
type RecordEditIdentity,
|
|
81
|
+
} from './parseRecordEditUrl.js'
|
|
63
82
|
export {
|
|
64
83
|
registerWidgetRenderer,
|
|
65
84
|
getWidgetRenderer,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { parseRecordEditUrl } from './parseRecordEditUrl.js'
|
|
3
|
+
import { parseRecordEditUrl, parseRecordPageUrl } from './parseRecordEditUrl.js'
|
|
4
4
|
|
|
5
5
|
test('parseRecordEditUrl: bare resource edit', () => {
|
|
6
6
|
assert.deepEqual(
|
|
@@ -73,3 +73,50 @@ test('parseRecordEditUrl: slug-only edit (no record id) returns null', () => {
|
|
|
73
73
|
// slice. Defensive: reject when slugParts is empty.
|
|
74
74
|
assert.equal(parseRecordEditUrl('/admin/edit', '/admin'), null)
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
// ─── parseRecordPageUrl (role-aware) ─────────────────────────
|
|
78
|
+
|
|
79
|
+
test('parseRecordPageUrl: edit URL returns role=edit', () => {
|
|
80
|
+
assert.deepEqual(
|
|
81
|
+
parseRecordPageUrl('/admin/articles/123/edit', '/admin'),
|
|
82
|
+
{ resourceSlug: 'articles', recordId: '123', role: 'edit' },
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('parseRecordPageUrl: view URL returns role=view', () => {
|
|
87
|
+
assert.deepEqual(
|
|
88
|
+
parseRecordPageUrl('/admin/articles/123/view', '/admin'),
|
|
89
|
+
{ resourceSlug: 'articles', recordId: '123', role: 'view' },
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('parseRecordPageUrl: cluster-prefixed view URL', () => {
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
parseRecordPageUrl('/admin/blog/articles/123/view', '/admin'),
|
|
96
|
+
{ resourceSlug: 'blog/articles', recordId: '123', role: 'view' },
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('parseRecordPageUrl: terminal token other than edit|view returns null', () => {
|
|
101
|
+
// 'delete' / 'restore' / 'force-delete' are POST handlers, not pages.
|
|
102
|
+
assert.equal(parseRecordPageUrl('/admin/articles/123/delete', '/admin'), null)
|
|
103
|
+
assert.equal(parseRecordPageUrl('/admin/articles/123/restore', '/admin'), null)
|
|
104
|
+
// Custom record sub-pages also fall through here — they have their own
|
|
105
|
+
// gate path (not record-bound for collab purposes in v1).
|
|
106
|
+
assert.equal(parseRecordPageUrl('/admin/articles/123/history', '/admin'), null)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('parseRecordPageUrl: view URL in nested-relation form', () => {
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
parseRecordPageUrl('/admin/articles/123/comments/456/view', '/admin'),
|
|
112
|
+
{ resourceSlug: 'articles/123/comments', recordId: '456', role: 'view' },
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ─── Legacy alias: parseRecordEditUrl filters view URLs ──────
|
|
117
|
+
|
|
118
|
+
test('parseRecordEditUrl: view URL returns null (back-compat: edit-only)', () => {
|
|
119
|
+
// A consumer still calling the legacy `parseRecordEditUrl` should
|
|
120
|
+
// continue to see view URLs filtered out — only edit URLs round-trip.
|
|
121
|
+
assert.equal(parseRecordEditUrl('/admin/articles/123/view', '/admin'), null)
|
|
122
|
+
})
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* URL → record-page identity parser. Used by `RecordWrapperGate` (and any
|
|
3
|
+
* plugin that wants to reason about record-bound URLs) to decide whether
|
|
4
|
+
* the current page is a record-edit or record-view route.
|
|
4
5
|
*
|
|
5
6
|
* A URL matches when:
|
|
6
7
|
* 1. it starts with the panel's `basePath`
|
|
7
|
-
* 2. after stripping the prefix it ends with `/edit`
|
|
8
|
+
* 2. after stripping the prefix it ends with `/edit` or `/view`
|
|
8
9
|
* 3. there are at least three remaining segments (resource slug,
|
|
9
|
-
* record id,
|
|
10
|
+
* record id, terminal token)
|
|
10
11
|
*
|
|
11
12
|
* The `resourceSlug` is the slash-joined chain of every segment up to
|
|
12
13
|
* the record id — this gives clustered resources (`${base}/blog/articles/123/edit`)
|
|
@@ -14,22 +15,40 @@
|
|
|
14
15
|
* distinct slugs so two URLs that target different records always
|
|
15
16
|
* produce different room names downstream.
|
|
16
17
|
*
|
|
17
|
-
* `/admin/articles/123/edit` → {
|
|
18
|
-
* `/admin/
|
|
19
|
-
* `/admin/articles/123/
|
|
20
|
-
* `/admin/articles/123/comments`
|
|
21
|
-
* `/admin/articles/123/comments
|
|
18
|
+
* `/admin/articles/123/edit` → { slug: 'articles', id: '123', role: 'edit' }
|
|
19
|
+
* `/admin/articles/123/view` → { slug: 'articles', id: '123', role: 'view' }
|
|
20
|
+
* `/admin/blog/articles/123/edit` → { slug: 'blog/articles', id: '123', role: 'edit' }
|
|
21
|
+
* `/admin/articles/123/comments/456/edit` → { slug: 'articles/123/comments', id: '456', role: 'edit' }
|
|
22
|
+
* `/admin/articles/123/comments` → null (no trailing /edit or /view)
|
|
23
|
+
* `/admin/articles/123/comments/create` → null (terminal token isn't edit|view)
|
|
22
24
|
* `/site/articles/123/edit` → null (basePath mismatch when basePath='/admin')
|
|
23
25
|
*/
|
|
24
|
-
|
|
26
|
+
|
|
27
|
+
/** Page roles `parseRecordPageUrl` recognizes. `'edit'` and `'view'`
|
|
28
|
+
* are the two record-scoped page roles where collab and other
|
|
29
|
+
* record-bound plugins want to mount their per-record wrapper. */
|
|
30
|
+
export type RecordPageRole = 'edit' | 'view'
|
|
31
|
+
|
|
32
|
+
export interface RecordPageIdentity {
|
|
25
33
|
resourceSlug: string
|
|
26
34
|
recordId: string
|
|
35
|
+
/** Which terminal URL segment matched — `'edit'` for the writable edit
|
|
36
|
+
* page, `'view'` for the read-only view page. Lets the gate decide per
|
|
37
|
+
* resource whether collab activates on this role (defaults to `'edit'`
|
|
38
|
+
* only; resources opt into `'view'` for presence-only experiences). */
|
|
39
|
+
role: RecordPageRole
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
const RECOGNIZED_ROLES: ReadonlyArray<RecordPageRole> = ['edit', 'view']
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a pilotiq URL into a `{ slug, id, role }` identity. Returns
|
|
46
|
+
* `null` for any URL that isn't a record-edit or record-view page.
|
|
47
|
+
*/
|
|
48
|
+
export function parseRecordPageUrl(
|
|
30
49
|
currentPath: string,
|
|
31
50
|
basePath: string,
|
|
32
|
-
):
|
|
51
|
+
): RecordPageIdentity | null {
|
|
33
52
|
if (!currentPath) return null
|
|
34
53
|
// Normalise — trailing slashes on the URL or trailing slashes on
|
|
35
54
|
// basePath would otherwise reject perfectly valid matches.
|
|
@@ -42,7 +61,8 @@ export function parseRecordEditUrl(
|
|
|
42
61
|
const parts = tail.split('/').filter(Boolean)
|
|
43
62
|
|
|
44
63
|
if (parts.length < 3) return null
|
|
45
|
-
|
|
64
|
+
const terminal = parts[parts.length - 1]!
|
|
65
|
+
if (!RECOGNIZED_ROLES.includes(terminal as RecordPageRole)) return null
|
|
46
66
|
|
|
47
67
|
const recordId = parts[parts.length - 2]!
|
|
48
68
|
const slugParts = parts.slice(0, parts.length - 2)
|
|
@@ -51,5 +71,24 @@ export function parseRecordEditUrl(
|
|
|
51
71
|
return {
|
|
52
72
|
resourceSlug: slugParts.join('/'),
|
|
53
73
|
recordId,
|
|
74
|
+
role: terminal as RecordPageRole,
|
|
54
75
|
}
|
|
55
76
|
}
|
|
77
|
+
|
|
78
|
+
/** Legacy alias: parse a URL into an edit-only identity. Returns `null`
|
|
79
|
+
* for view URLs (and any non-edit URL). Kept for back-compat with the
|
|
80
|
+
* pre-`parseRecordPageUrl` public API; new code should call
|
|
81
|
+
* `parseRecordPageUrl` and branch on `role`. */
|
|
82
|
+
export interface RecordEditIdentity {
|
|
83
|
+
resourceSlug: string
|
|
84
|
+
recordId: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseRecordEditUrl(
|
|
88
|
+
currentPath: string,
|
|
89
|
+
basePath: string,
|
|
90
|
+
): RecordEditIdentity | null {
|
|
91
|
+
const identity = parseRecordPageUrl(currentPath, basePath)
|
|
92
|
+
if (!identity || identity.role !== 'edit') return null
|
|
93
|
+
return { resourceSlug: identity.resourceSlug, recordId: identity.recordId }
|
|
94
|
+
}
|