@proveanything/smartlinks 1.6.5 → 1.6.7
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/docs/API_SUMMARY.md +1 -1
- package/dist/docs/deep-link-discovery.md +195 -223
- package/docs/API_SUMMARY.md +1 -1
- package/docs/deep-link-discovery.md +195 -223
- package/package.json +1 -1
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -7,20 +7,18 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
9
9
|
- [Overview](#overview)
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
10
|
+
- [Two Sources of Truth](#two-sources-of-truth)
|
|
11
|
+
- [Static Links — App Manifest](#static-links--app-manifest)
|
|
12
|
+
- [Dynamic Links — App Config](#dynamic-links--app-config)
|
|
12
13
|
- [Schema: DeepLinkEntry](#schema-deeplinkentry)
|
|
13
|
-
- [
|
|
14
|
-
- [Data-Driven (Dynamic)](#1-data-driven-entries-dynamic)
|
|
15
|
-
- [Route-Based (Static)](#2-route-based-entries-static)
|
|
16
|
-
- [Mixed](#3-mixed)
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
17
15
|
- [URL Resolution](#url-resolution)
|
|
18
|
-
- [Syncing
|
|
16
|
+
- [Syncing Dynamic Links](#syncing-dynamic-links)
|
|
19
17
|
- [Consumer Patterns](#consumer-patterns)
|
|
18
|
+
- [Merging Both Sources](#merging-both-sources)
|
|
20
19
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
21
20
|
- [AI Orchestration](#ai-orchestration)
|
|
22
21
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
23
|
-
- [Manifest Declaration](#manifest-declaration)
|
|
24
22
|
- [TypeScript Types](#typescript-types)
|
|
25
23
|
- [Rules & Best Practices](#rules--best-practices)
|
|
26
24
|
|
|
@@ -28,113 +26,112 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
28
26
|
|
|
29
27
|
## Overview
|
|
30
28
|
|
|
31
|
-
SmartLinks apps
|
|
29
|
+
SmartLinks apps expose **deep-linkable states** — specific views, pages, or configurations that the portal, AI orchestrators, or other apps can navigate to directly without loading the app first.
|
|
30
|
+
|
|
31
|
+
Deep-linkable states come from **two sources** depending on their nature:
|
|
32
|
+
|
|
33
|
+
| Source | What goes here | When it changes |
|
|
34
|
+
|--------|---------------|-----------------|
|
|
35
|
+
| **App manifest** (`app.admin.json`) | Fixed routes built into the app | Only when the app itself is updated |
|
|
36
|
+
| **App config** (`appConfig.linkable`) | Content-driven entries that vary by collection | When admins create, remove, or rename content |
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
Consumers merge both sources to get the full set of navigable states for an app.
|
|
34
39
|
|
|
35
40
|
```text
|
|
36
|
-
|
|
37
|
-
│
|
|
38
|
-
│
|
|
39
|
-
│
|
|
40
|
-
│
|
|
41
|
-
│
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
42
|
+
│ App Definition (build time) │
|
|
43
|
+
│ app.admin.json → "linkable": [ │
|
|
44
|
+
│ { "title": "Gallery", "path": "/gallery" }, │
|
|
45
|
+
│ { "title": "Settings", "path": "/settings" } │
|
|
46
|
+
│ ] │
|
|
47
|
+
└──────────────────────────────────────┬──────────────────────────────────┘
|
|
48
|
+
│ static (always present)
|
|
49
|
+
▼
|
|
50
|
+
┌────────────────────────┐
|
|
51
|
+
│ merged linkable[] │◄──── dynamic (per collection)
|
|
52
|
+
└────────────────────────┘
|
|
53
|
+
▲
|
|
54
|
+
┌──────────────────────────────────────┴──────────────────────────────────┐
|
|
55
|
+
│ App Config (runtime, per collection) │
|
|
56
|
+
│ appConfig.linkable = [ │
|
|
57
|
+
│ { "title": "About Us", "params": { "pageId": "about-us" } }, │
|
|
58
|
+
│ { "title": "FAQ", "params": { "pageId": "faq" } } │
|
|
59
|
+
│ ] │
|
|
60
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
### Key Benefits
|
|
55
64
|
|
|
56
65
|
- ✅ **Discoverable** — consumers don't need to know the app's internal routing
|
|
57
|
-
- ✅ **
|
|
58
|
-
- ✅ **Dynamic** —
|
|
66
|
+
- ✅ **No first-run initialisation** — static routes are available from the moment the app is installed
|
|
67
|
+
- ✅ **Dynamic** — content-driven entries update automatically when data changes
|
|
59
68
|
- ✅ **AI-friendly** — machine-readable titles make states naturally addressable by LLMs
|
|
60
|
-
- ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API
|
|
69
|
+
- ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API and the app manifest
|
|
61
70
|
|
|
62
71
|
---
|
|
63
72
|
|
|
64
|
-
##
|
|
73
|
+
## Two Sources of Truth
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
### Static Links — App Manifest
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
import { appConfiguration } from '@proveanything/smartlinks';
|
|
77
|
+
Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.admin.json` as a top-level `linkable` array:
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
]
|
|
81
|
-
},
|
|
82
|
-
admin: true
|
|
83
|
-
});
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"linkable": [
|
|
82
|
+
{ "title": "Gallery", "path": "/gallery" },
|
|
83
|
+
{ "title": "Settings", "path": "/settings" },
|
|
84
|
+
{ "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
|
|
85
|
+
],
|
|
86
|
+
"setup": { }
|
|
87
|
+
}
|
|
84
88
|
```
|
|
85
89
|
|
|
86
|
-
**
|
|
90
|
+
**Use this for:**
|
|
91
|
+
- Named sections or tabs that are always present in the app
|
|
92
|
+
- Admin-accessible pages that exist at fixed routes
|
|
93
|
+
- Any navigable state whose existence doesn't depend on content
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
92
|
-
const linkable = config?.linkable ?? [];
|
|
93
|
-
|
|
94
|
-
// linkable = [
|
|
95
|
-
// { title: 'Gallery', path: '/gallery' },
|
|
96
|
-
// { title: 'About Us', params: { pageId: 'about-us' } },
|
|
97
|
-
// { title: 'FAQ', params: { pageId: 'faq' } },
|
|
98
|
-
// ]
|
|
99
|
-
```
|
|
95
|
+
**Why manifest, not config?**
|
|
96
|
+
These routes are part of the app's *definition*, not its *runtime state*. Putting them in the manifest means they're immediately available the moment the app is installed — no first-run initialisation, no sync step, no risk of them being missing if the app has never been launched.
|
|
100
97
|
|
|
101
98
|
---
|
|
102
99
|
|
|
103
|
-
|
|
100
|
+
### Dynamic Links — App Config
|
|
104
101
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
### Storage Location
|
|
108
|
-
|
|
109
|
-
The `linkable` key is **reserved** at the top level of `appConfig` for this purpose. Do not use it for anything else.
|
|
102
|
+
Dynamic links are entries **generated from content** — CMS pages, product guides, FAQ items, or anything else that admins create and manage. These are stored at `appConfig.linkable` and updated whenever the content changes:
|
|
110
103
|
|
|
111
104
|
```typescript
|
|
112
|
-
|
|
105
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
106
|
+
|
|
113
107
|
await appConfiguration.setConfig({
|
|
114
108
|
collectionId,
|
|
115
109
|
appId,
|
|
116
110
|
config: {
|
|
117
111
|
// ... other app config ...
|
|
118
|
-
linkable: [
|
|
112
|
+
linkable: [
|
|
113
|
+
{ title: 'About Us', params: { pageId: 'about-us' } },
|
|
114
|
+
{ title: 'Care Guide', params: { pageId: 'care-guide' } },
|
|
115
|
+
{ title: 'FAQ', params: { pageId: 'faq' } },
|
|
116
|
+
]
|
|
119
117
|
},
|
|
120
118
|
admin: true
|
|
121
119
|
});
|
|
122
|
-
|
|
123
|
-
// Reading
|
|
124
|
-
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
125
|
-
const linkable: DeepLinkEntry[] = config?.linkable ?? [];
|
|
126
120
|
```
|
|
127
121
|
|
|
128
|
-
|
|
122
|
+
**Use this for:**
|
|
123
|
+
- Pages or content entries created by admins that should be directly navigable
|
|
124
|
+
- Any set of linkable states that varies between collections or changes over time
|
|
125
|
+
|
|
126
|
+
The `linkable` key is **reserved** at the top level of `appConfig` for this purpose.
|
|
129
127
|
|
|
130
|
-
|
|
131
|
-
- **Reading** can be done publicly — portals and consumers typically read the registry without admin credentials.
|
|
128
|
+
> **`appConfig.linkable` is for dynamic content only.** Static routes belong in the manifest. Don't write fixed routes to `appConfig` on first install just to make them discoverable — that's the problem this split is designed to avoid.
|
|
132
129
|
|
|
133
130
|
---
|
|
134
131
|
|
|
135
132
|
## Schema: DeepLinkEntry
|
|
136
133
|
|
|
137
|
-
|
|
134
|
+
Both sources use the same entry shape:
|
|
138
135
|
|
|
139
136
|
```typescript
|
|
140
137
|
interface DeepLinkEntry {
|
|
@@ -161,65 +158,63 @@ interface DeepLinkEntry {
|
|
|
161
158
|
}
|
|
162
159
|
```
|
|
163
160
|
|
|
164
|
-
### Field Notes
|
|
165
|
-
|
|
166
161
|
| Field | Required | Notes |
|
|
167
162
|
|-------|----------|-------|
|
|
168
163
|
| `title` | ✅ | Displayed in menus and offered to AI agents. Should be concise and human-readable. |
|
|
169
164
|
| `path` | ❌ | Hash route within the app. Defaults to `/` if omitted. |
|
|
170
165
|
| `params` | ❌ | App-specific query params only. Platform params are injected automatically. |
|
|
171
166
|
|
|
172
|
-
> **Tip:**
|
|
167
|
+
> **Tip:** Different entries can share the same `path` if they differ by `params` — for example, two entries pointing at `/settings` with different `tab` params.
|
|
173
168
|
|
|
174
169
|
---
|
|
175
170
|
|
|
176
|
-
##
|
|
177
|
-
|
|
178
|
-
### 1. Data-Driven Entries (Dynamic)
|
|
171
|
+
## Quick Start
|
|
179
172
|
|
|
180
|
-
|
|
173
|
+
**An app with both static routes and dynamic content pages:**
|
|
181
174
|
|
|
175
|
+
`app.admin.json` — declare static routes once, at build time:
|
|
182
176
|
```json
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
]
|
|
177
|
+
{
|
|
178
|
+
"linkable": [
|
|
179
|
+
{ "title": "Gallery", "path": "/gallery" },
|
|
180
|
+
{ "title": "Settings", "path": "/settings" }
|
|
181
|
+
]
|
|
182
|
+
}
|
|
188
183
|
```
|
|
189
184
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
App code — sync dynamic content entries whenever pages change:
|
|
186
|
+
```typescript
|
|
187
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
193
188
|
|
|
194
|
-
|
|
189
|
+
async function syncDynamicLinks(collectionId: string, appId: string): Promise<void> {
|
|
190
|
+
// Always fetch fresh data — never rely on cache
|
|
191
|
+
const pages = await myApi.getPublishedPages({ forceRefresh: true });
|
|
192
|
+
const entries = pages
|
|
193
|
+
.filter(p => p.deepLinkable)
|
|
194
|
+
.map(p => ({ title: p.title, params: { pageId: p.slug } }));
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
const config = await appConfiguration.getConfig({ collectionId, appId, admin: true }) ?? {};
|
|
197
|
+
await appConfiguration.setConfig({
|
|
198
|
+
collectionId, appId,
|
|
199
|
+
config: { ...config, linkable: entries },
|
|
200
|
+
admin: true,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
202
203
|
```
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
### 3. Mixed
|
|
205
|
+
Consumer — merge both sources:
|
|
206
|
+
```typescript
|
|
207
|
+
// staticLinks come from the app's manifest (loaded by the platform when the app was registered)
|
|
208
|
+
const staticLinks: DeepLinkEntry[] = appManifest?.linkable ?? [];
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
// dynamicLinks come from appConfig (per-collection, fetched at runtime)
|
|
211
|
+
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
212
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
211
213
|
|
|
212
|
-
|
|
213
|
-
[
|
|
214
|
-
{ "title": "Gallery", "path": "/gallery" },
|
|
215
|
-
{ "title": "About Us", "params": { "pageId": "about-us" } },
|
|
216
|
-
{ "title": "FAQ", "params": { "pageId": "faq" } },
|
|
217
|
-
{ "title": "Settings", "path": "/settings" }
|
|
218
|
-
]
|
|
214
|
+
// Full set — static (structural) first, then dynamic (content)
|
|
215
|
+
const allLinks = [...staticLinks, ...dynamicLinks];
|
|
219
216
|
```
|
|
220
217
|
|
|
221
|
-
**Ordering:** Entries are displayed in array order. Place the most commonly navigated states first.
|
|
222
|
-
|
|
223
218
|
---
|
|
224
219
|
|
|
225
220
|
## URL Resolution
|
|
@@ -256,16 +251,17 @@ The platform automatically injects the following — **never include these in `p
|
|
|
256
251
|
|
|
257
252
|
---
|
|
258
253
|
|
|
259
|
-
## Syncing
|
|
254
|
+
## Syncing Dynamic Links
|
|
255
|
+
|
|
256
|
+
This section only applies to **dynamic links stored in `appConfig.linkable`**. Static links declared in the manifest require no syncing.
|
|
260
257
|
|
|
261
258
|
### When to Sync
|
|
262
259
|
|
|
263
|
-
Apps **MUST** update
|
|
260
|
+
Apps **MUST** update `appConfig.linkable` whenever the set of dynamic navigable states changes:
|
|
264
261
|
|
|
265
262
|
- A page is created, deleted, published, or unpublished
|
|
266
263
|
- A page's `deepLinkable` flag is toggled
|
|
267
264
|
- A page title changes
|
|
268
|
-
- App routes are added or removed
|
|
269
265
|
|
|
270
266
|
### How to Sync
|
|
271
267
|
|
|
@@ -275,9 +271,9 @@ The sync pattern is a **full replace** — read current config, overwrite `linka
|
|
|
275
271
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
276
272
|
|
|
277
273
|
async function syncLinkable(collectionId: string, appId: string): Promise<void> {
|
|
278
|
-
// 1. Fetch the current set of
|
|
279
|
-
// Always fetch fresh — never rely on a local cache
|
|
280
|
-
const entries = await
|
|
274
|
+
// 1. Fetch the current set of dynamic entries from your data source.
|
|
275
|
+
// Always fetch fresh — never rely on a local cache.
|
|
276
|
+
const entries = await fetchDynamicLinkEntries(); // app-specific
|
|
281
277
|
|
|
282
278
|
// 2. Read the existing appConfig to preserve other keys
|
|
283
279
|
let config: Record<string, unknown> = {};
|
|
@@ -291,7 +287,7 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
|
|
|
291
287
|
// Config may not exist yet on first run — that's fine
|
|
292
288
|
}
|
|
293
289
|
|
|
294
|
-
// 3.
|
|
290
|
+
// 3. Overwrite only the linkable key and save
|
|
295
291
|
await appConfiguration.setConfig({
|
|
296
292
|
collectionId,
|
|
297
293
|
appId,
|
|
@@ -301,42 +297,13 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
|
|
|
301
297
|
}
|
|
302
298
|
```
|
|
303
299
|
|
|
304
|
-
### What `fetchLinkableEntries` looks like
|
|
305
|
-
|
|
306
|
-
This function is app-specific. A typical CMS app might look like:
|
|
307
|
-
|
|
308
|
-
```typescript
|
|
309
|
-
async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
|
|
310
|
-
// Fetch your pages/content from the server (not from cache)
|
|
311
|
-
const pages = await myApi.getPublishedPages({ forceRefresh: true });
|
|
312
|
-
|
|
313
|
-
return pages
|
|
314
|
-
.filter(page => page.deepLinkable)
|
|
315
|
-
.map(page => ({
|
|
316
|
-
title: page.title,
|
|
317
|
-
params: { pageId: page.slug },
|
|
318
|
-
}));
|
|
319
|
-
}
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
A route-based app simply returns a static array:
|
|
323
|
-
|
|
324
|
-
```typescript
|
|
325
|
-
async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
|
|
326
|
-
return [
|
|
327
|
-
{ title: 'Gallery', path: '/gallery' },
|
|
328
|
-
{ title: 'Settings', path: '/settings' },
|
|
329
|
-
];
|
|
330
|
-
}
|
|
331
|
-
```
|
|
332
|
-
|
|
333
300
|
### Idempotency
|
|
334
301
|
|
|
335
302
|
The sync is always a full replace of the `linkable` array. Running it multiple times with the same data produces the same result — no duplicates, no orphans.
|
|
336
303
|
|
|
337
|
-
### Clearing
|
|
304
|
+
### Clearing Dynamic Links
|
|
338
305
|
|
|
339
|
-
To remove all
|
|
306
|
+
To remove all dynamic entries (e.g., all content was unpublished):
|
|
340
307
|
|
|
341
308
|
```typescript
|
|
342
309
|
await appConfiguration.setConfig({
|
|
@@ -347,13 +314,48 @@ await appConfiguration.setConfig({
|
|
|
347
314
|
});
|
|
348
315
|
```
|
|
349
316
|
|
|
317
|
+
Note that clearing `appConfig.linkable` only removes dynamic entries. Static links declared in the manifest are unaffected.
|
|
318
|
+
|
|
350
319
|
---
|
|
351
320
|
|
|
352
321
|
## Consumer Patterns
|
|
353
322
|
|
|
323
|
+
### Merging Both Sources
|
|
324
|
+
|
|
325
|
+
Consumers must always merge static (manifest) and dynamic (appConfig) entries. The platform provides the manifest when the app is registered; the consumer fetches the config at runtime:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
329
|
+
|
|
330
|
+
interface ResolvedLink extends DeepLinkEntry {
|
|
331
|
+
appId: string;
|
|
332
|
+
source: 'manifest' | 'config';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function getAppLinks(
|
|
336
|
+
collectionId: string,
|
|
337
|
+
appId: string,
|
|
338
|
+
appManifest: { linkable?: DeepLinkEntry[] }
|
|
339
|
+
): Promise<ResolvedLink[]> {
|
|
340
|
+
// Static entries from the manifest — always present, no network call needed
|
|
341
|
+
const staticLinks = (appManifest.linkable ?? []).map(e => ({
|
|
342
|
+
...e, appId, source: 'manifest' as const
|
|
343
|
+
}));
|
|
344
|
+
|
|
345
|
+
// Dynamic entries from appConfig — vary per collection
|
|
346
|
+
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
347
|
+
const dynamicLinks = (config?.linkable ?? []).map(e => ({
|
|
348
|
+
...e, appId, source: 'config' as const
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
// Static first (fixed structure), then dynamic (content entries)
|
|
352
|
+
return [...staticLinks, ...dynamicLinks];
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
354
356
|
### Portal Navigation Menu
|
|
355
357
|
|
|
356
|
-
A portal shell
|
|
358
|
+
A portal shell aggregates links from all installed apps to build a unified navigation menu:
|
|
357
359
|
|
|
358
360
|
```typescript
|
|
359
361
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
@@ -365,15 +367,22 @@ interface NavLink {
|
|
|
365
367
|
params?: Record<string, string>;
|
|
366
368
|
}
|
|
367
369
|
|
|
368
|
-
async function buildNavMenu(
|
|
370
|
+
async function buildNavMenu(
|
|
371
|
+
collectionId: string,
|
|
372
|
+
apps: Array<{ appId: string; manifest: { linkable?: DeepLinkEntry[] } }>
|
|
373
|
+
): Promise<NavLink[]> {
|
|
369
374
|
const links: NavLink[] = [];
|
|
370
375
|
|
|
371
|
-
for (const appId of
|
|
376
|
+
for (const { appId, manifest } of apps) {
|
|
377
|
+
// Static links from manifest
|
|
378
|
+
const staticLinks = manifest.linkable ?? [];
|
|
379
|
+
|
|
380
|
+
// Dynamic links from appConfig
|
|
372
381
|
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
373
|
-
const
|
|
382
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
374
383
|
|
|
375
384
|
links.push(
|
|
376
|
-
...
|
|
385
|
+
...[...staticLinks, ...dynamicLinks].map(entry => ({ ...entry, appId }))
|
|
377
386
|
);
|
|
378
387
|
}
|
|
379
388
|
|
|
@@ -383,19 +392,22 @@ async function buildNavMenu(collectionId: string, appIds: string[]): Promise<Nav
|
|
|
383
392
|
|
|
384
393
|
### AI Orchestration
|
|
385
394
|
|
|
386
|
-
An AI agent
|
|
395
|
+
An AI agent discovers all navigable states and offers them to the user:
|
|
387
396
|
|
|
388
397
|
```typescript
|
|
389
398
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
390
399
|
|
|
391
|
-
async function getNavigationOptions(
|
|
400
|
+
async function getNavigationOptions(
|
|
401
|
+
collectionId: string,
|
|
402
|
+
appId: string,
|
|
403
|
+
appManifest: { linkable?: DeepLinkEntry[] }
|
|
404
|
+
): Promise<string[]> {
|
|
405
|
+
const staticLinks = appManifest.linkable ?? [];
|
|
392
406
|
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
393
|
-
const
|
|
407
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
394
408
|
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
// Which would you prefer?"
|
|
398
|
-
return linkable.map(entry => entry.title);
|
|
409
|
+
// "I can navigate you to: Gallery, Settings, About Us, or FAQ."
|
|
410
|
+
return [...staticLinks, ...dynamicLinks].map(entry => entry.title);
|
|
399
411
|
}
|
|
400
412
|
```
|
|
401
413
|
|
|
@@ -403,16 +415,15 @@ The `title` field is intentionally human-readable so it can be injected directly
|
|
|
403
415
|
|
|
404
416
|
### Cross-App Navigation
|
|
405
417
|
|
|
406
|
-
An app
|
|
418
|
+
An app constructs a `NavigationRequest` using the target app's merged links:
|
|
407
419
|
|
|
408
420
|
```typescript
|
|
409
|
-
//
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
});
|
|
421
|
+
// Search static links first, then dynamic
|
|
422
|
+
const staticEntry = targetManifest?.linkable?.find(e => e.title === 'Gallery');
|
|
423
|
+
const config = await appConfiguration.getConfig({ collectionId, appId: 'target-app-id' });
|
|
424
|
+
const dynamicEntry = config?.linkable?.find(e => e.title === 'About Us');
|
|
414
425
|
|
|
415
|
-
const entry =
|
|
426
|
+
const entry = staticEntry ?? dynamicEntry;
|
|
416
427
|
|
|
417
428
|
if (entry) {
|
|
418
429
|
onNavigate({
|
|
@@ -425,51 +436,12 @@ if (entry) {
|
|
|
425
436
|
|
|
426
437
|
---
|
|
427
438
|
|
|
428
|
-
## Manifest Declaration
|
|
429
|
-
|
|
430
|
-
Apps that support deep linking **SHOULD** declare the `linkable` key in their `app.admin.json` config schema. This signals to platform tooling that the key is auto-managed and prevents accidental manual edits:
|
|
431
|
-
|
|
432
|
-
```json
|
|
433
|
-
{
|
|
434
|
-
"setup": {
|
|
435
|
-
"configSchema": {
|
|
436
|
-
"properties": {
|
|
437
|
-
"linkable": {
|
|
438
|
-
"type": "array",
|
|
439
|
-
"description": "Auto-managed deep link registry. Do not edit manually.",
|
|
440
|
-
"readOnly": true,
|
|
441
|
-
"items": {
|
|
442
|
-
"type": "object",
|
|
443
|
-
"required": ["title"],
|
|
444
|
-
"properties": {
|
|
445
|
-
"title": { "type": "string", "description": "Human-readable label" },
|
|
446
|
-
"path": { "type": "string", "description": "Hash route path, e.g. '/gallery'" },
|
|
447
|
-
"params": {
|
|
448
|
-
"type": "object",
|
|
449
|
-
"description": "App-specific query params. Do not include platform context params.",
|
|
450
|
-
"additionalProperties": { "type": "string" }
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
> **Note:** The `readOnly: true` flag is a hint to the admin UI to hide or disable this field in manual config editors.
|
|
462
|
-
|
|
463
|
-
---
|
|
464
|
-
|
|
465
439
|
## TypeScript Types
|
|
466
440
|
|
|
467
|
-
Copy these types into your app, or import them if they become part of the SDK's public API:
|
|
468
|
-
|
|
469
441
|
```typescript
|
|
470
442
|
/**
|
|
471
443
|
* A single navigable state exposed by a SmartLinks app.
|
|
472
|
-
*
|
|
444
|
+
* Used in both app.admin.json `linkable` (static) and appConfig `linkable` (dynamic).
|
|
473
445
|
*/
|
|
474
446
|
export interface DeepLinkEntry {
|
|
475
447
|
/** Human-readable label shown in menus and offered to AI agents */
|
|
@@ -487,10 +459,7 @@ export interface DeepLinkEntry {
|
|
|
487
459
|
params?: Record<string, string>;
|
|
488
460
|
}
|
|
489
461
|
|
|
490
|
-
/**
|
|
491
|
-
* The shape of the `linkable` key in appConfig.
|
|
492
|
-
* Alias for convenience.
|
|
493
|
-
*/
|
|
462
|
+
/** Convenience alias for an array of DeepLinkEntry */
|
|
494
463
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
495
464
|
```
|
|
496
465
|
|
|
@@ -500,17 +469,20 @@ export type DeepLinkRegistry = DeepLinkEntry[];
|
|
|
500
469
|
|
|
501
470
|
### Rules
|
|
502
471
|
|
|
503
|
-
1.
|
|
504
|
-
2.
|
|
505
|
-
3. **`
|
|
506
|
-
4. **
|
|
507
|
-
5.
|
|
472
|
+
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.admin.json`. Do not write it to `appConfig` on first run.
|
|
473
|
+
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
474
|
+
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
475
|
+
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
476
|
+
5. **`title` is required** — every entry must have a human-readable label.
|
|
477
|
+
6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
|
|
478
|
+
7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
|
|
479
|
+
8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
508
480
|
|
|
509
481
|
### Best Practices
|
|
510
482
|
|
|
511
483
|
- **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
512
|
-
- **
|
|
513
|
-
- **Sync eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
514
|
-
- **Handle missing
|
|
515
|
-
- **Don't duplicate the default route** — if your app has only one view,
|
|
516
|
-
- **Test URL resolution** — confirm that your entries produce the correct URLs
|
|
484
|
+
- **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
|
|
485
|
+
- **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
486
|
+
- **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
487
|
+
- **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
|
|
488
|
+
- **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -7,20 +7,18 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
9
9
|
- [Overview](#overview)
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
10
|
+
- [Two Sources of Truth](#two-sources-of-truth)
|
|
11
|
+
- [Static Links — App Manifest](#static-links--app-manifest)
|
|
12
|
+
- [Dynamic Links — App Config](#dynamic-links--app-config)
|
|
12
13
|
- [Schema: DeepLinkEntry](#schema-deeplinkentry)
|
|
13
|
-
- [
|
|
14
|
-
- [Data-Driven (Dynamic)](#1-data-driven-entries-dynamic)
|
|
15
|
-
- [Route-Based (Static)](#2-route-based-entries-static)
|
|
16
|
-
- [Mixed](#3-mixed)
|
|
14
|
+
- [Quick Start](#quick-start)
|
|
17
15
|
- [URL Resolution](#url-resolution)
|
|
18
|
-
- [Syncing
|
|
16
|
+
- [Syncing Dynamic Links](#syncing-dynamic-links)
|
|
19
17
|
- [Consumer Patterns](#consumer-patterns)
|
|
18
|
+
- [Merging Both Sources](#merging-both-sources)
|
|
20
19
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
21
20
|
- [AI Orchestration](#ai-orchestration)
|
|
22
21
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
23
|
-
- [Manifest Declaration](#manifest-declaration)
|
|
24
22
|
- [TypeScript Types](#typescript-types)
|
|
25
23
|
- [Rules & Best Practices](#rules--best-practices)
|
|
26
24
|
|
|
@@ -28,113 +26,112 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
28
26
|
|
|
29
27
|
## Overview
|
|
30
28
|
|
|
31
|
-
SmartLinks apps
|
|
29
|
+
SmartLinks apps expose **deep-linkable states** — specific views, pages, or configurations that the portal, AI orchestrators, or other apps can navigate to directly without loading the app first.
|
|
30
|
+
|
|
31
|
+
Deep-linkable states come from **two sources** depending on their nature:
|
|
32
|
+
|
|
33
|
+
| Source | What goes here | When it changes |
|
|
34
|
+
|--------|---------------|-----------------|
|
|
35
|
+
| **App manifest** (`app.admin.json`) | Fixed routes built into the app | Only when the app itself is updated |
|
|
36
|
+
| **App config** (`appConfig.linkable`) | Content-driven entries that vary by collection | When admins create, remove, or rename content |
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
Consumers merge both sources to get the full set of navigable states for an app.
|
|
34
39
|
|
|
35
40
|
```text
|
|
36
|
-
|
|
37
|
-
│
|
|
38
|
-
│
|
|
39
|
-
│
|
|
40
|
-
│
|
|
41
|
-
│
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
42
|
+
│ App Definition (build time) │
|
|
43
|
+
│ app.admin.json → "linkable": [ │
|
|
44
|
+
│ { "title": "Gallery", "path": "/gallery" }, │
|
|
45
|
+
│ { "title": "Settings", "path": "/settings" } │
|
|
46
|
+
│ ] │
|
|
47
|
+
└──────────────────────────────────────┬──────────────────────────────────┘
|
|
48
|
+
│ static (always present)
|
|
49
|
+
▼
|
|
50
|
+
┌────────────────────────┐
|
|
51
|
+
│ merged linkable[] │◄──── dynamic (per collection)
|
|
52
|
+
└────────────────────────┘
|
|
53
|
+
▲
|
|
54
|
+
┌──────────────────────────────────────┴──────────────────────────────────┐
|
|
55
|
+
│ App Config (runtime, per collection) │
|
|
56
|
+
│ appConfig.linkable = [ │
|
|
57
|
+
│ { "title": "About Us", "params": { "pageId": "about-us" } }, │
|
|
58
|
+
│ { "title": "FAQ", "params": { "pageId": "faq" } } │
|
|
59
|
+
│ ] │
|
|
60
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
### Key Benefits
|
|
55
64
|
|
|
56
65
|
- ✅ **Discoverable** — consumers don't need to know the app's internal routing
|
|
57
|
-
- ✅ **
|
|
58
|
-
- ✅ **Dynamic** —
|
|
66
|
+
- ✅ **No first-run initialisation** — static routes are available from the moment the app is installed
|
|
67
|
+
- ✅ **Dynamic** — content-driven entries update automatically when data changes
|
|
59
68
|
- ✅ **AI-friendly** — machine-readable titles make states naturally addressable by LLMs
|
|
60
|
-
- ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API
|
|
69
|
+
- ✅ **No extra endpoints** — built on top of the existing `appConfiguration` API and the app manifest
|
|
61
70
|
|
|
62
71
|
---
|
|
63
72
|
|
|
64
|
-
##
|
|
73
|
+
## Two Sources of Truth
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
### Static Links — App Manifest
|
|
67
76
|
|
|
68
|
-
|
|
69
|
-
import { appConfiguration } from '@proveanything/smartlinks';
|
|
77
|
+
Static links are routes that are **built into the app itself** — they exist regardless of what content admins have created. They belong in `app.admin.json` as a top-level `linkable` array:
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
]
|
|
81
|
-
},
|
|
82
|
-
admin: true
|
|
83
|
-
});
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"linkable": [
|
|
82
|
+
{ "title": "Gallery", "path": "/gallery" },
|
|
83
|
+
{ "title": "Settings", "path": "/settings" },
|
|
84
|
+
{ "title": "Advanced Settings", "path": "/settings", "params": { "tab": "advanced" } }
|
|
85
|
+
],
|
|
86
|
+
"setup": { }
|
|
87
|
+
}
|
|
84
88
|
```
|
|
85
89
|
|
|
86
|
-
**
|
|
90
|
+
**Use this for:**
|
|
91
|
+
- Named sections or tabs that are always present in the app
|
|
92
|
+
- Admin-accessible pages that exist at fixed routes
|
|
93
|
+
- Any navigable state whose existence doesn't depend on content
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
92
|
-
const linkable = config?.linkable ?? [];
|
|
93
|
-
|
|
94
|
-
// linkable = [
|
|
95
|
-
// { title: 'Gallery', path: '/gallery' },
|
|
96
|
-
// { title: 'About Us', params: { pageId: 'about-us' } },
|
|
97
|
-
// { title: 'FAQ', params: { pageId: 'faq' } },
|
|
98
|
-
// ]
|
|
99
|
-
```
|
|
95
|
+
**Why manifest, not config?**
|
|
96
|
+
These routes are part of the app's *definition*, not its *runtime state*. Putting them in the manifest means they're immediately available the moment the app is installed — no first-run initialisation, no sync step, no risk of them being missing if the app has never been launched.
|
|
100
97
|
|
|
101
98
|
---
|
|
102
99
|
|
|
103
|
-
|
|
100
|
+
### Dynamic Links — App Config
|
|
104
101
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
### Storage Location
|
|
108
|
-
|
|
109
|
-
The `linkable` key is **reserved** at the top level of `appConfig` for this purpose. Do not use it for anything else.
|
|
102
|
+
Dynamic links are entries **generated from content** — CMS pages, product guides, FAQ items, or anything else that admins create and manage. These are stored at `appConfig.linkable` and updated whenever the content changes:
|
|
110
103
|
|
|
111
104
|
```typescript
|
|
112
|
-
|
|
105
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
106
|
+
|
|
113
107
|
await appConfiguration.setConfig({
|
|
114
108
|
collectionId,
|
|
115
109
|
appId,
|
|
116
110
|
config: {
|
|
117
111
|
// ... other app config ...
|
|
118
|
-
linkable: [
|
|
112
|
+
linkable: [
|
|
113
|
+
{ title: 'About Us', params: { pageId: 'about-us' } },
|
|
114
|
+
{ title: 'Care Guide', params: { pageId: 'care-guide' } },
|
|
115
|
+
{ title: 'FAQ', params: { pageId: 'faq' } },
|
|
116
|
+
]
|
|
119
117
|
},
|
|
120
118
|
admin: true
|
|
121
119
|
});
|
|
122
|
-
|
|
123
|
-
// Reading
|
|
124
|
-
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
125
|
-
const linkable: DeepLinkEntry[] = config?.linkable ?? [];
|
|
126
120
|
```
|
|
127
121
|
|
|
128
|
-
|
|
122
|
+
**Use this for:**
|
|
123
|
+
- Pages or content entries created by admins that should be directly navigable
|
|
124
|
+
- Any set of linkable states that varies between collections or changes over time
|
|
125
|
+
|
|
126
|
+
The `linkable` key is **reserved** at the top level of `appConfig` for this purpose.
|
|
129
127
|
|
|
130
|
-
|
|
131
|
-
- **Reading** can be done publicly — portals and consumers typically read the registry without admin credentials.
|
|
128
|
+
> **`appConfig.linkable` is for dynamic content only.** Static routes belong in the manifest. Don't write fixed routes to `appConfig` on first install just to make them discoverable — that's the problem this split is designed to avoid.
|
|
132
129
|
|
|
133
130
|
---
|
|
134
131
|
|
|
135
132
|
## Schema: DeepLinkEntry
|
|
136
133
|
|
|
137
|
-
|
|
134
|
+
Both sources use the same entry shape:
|
|
138
135
|
|
|
139
136
|
```typescript
|
|
140
137
|
interface DeepLinkEntry {
|
|
@@ -161,65 +158,63 @@ interface DeepLinkEntry {
|
|
|
161
158
|
}
|
|
162
159
|
```
|
|
163
160
|
|
|
164
|
-
### Field Notes
|
|
165
|
-
|
|
166
161
|
| Field | Required | Notes |
|
|
167
162
|
|-------|----------|-------|
|
|
168
163
|
| `title` | ✅ | Displayed in menus and offered to AI agents. Should be concise and human-readable. |
|
|
169
164
|
| `path` | ❌ | Hash route within the app. Defaults to `/` if omitted. |
|
|
170
165
|
| `params` | ❌ | App-specific query params only. Platform params are injected automatically. |
|
|
171
166
|
|
|
172
|
-
> **Tip:**
|
|
167
|
+
> **Tip:** Different entries can share the same `path` if they differ by `params` — for example, two entries pointing at `/settings` with different `tab` params.
|
|
173
168
|
|
|
174
169
|
---
|
|
175
170
|
|
|
176
|
-
##
|
|
177
|
-
|
|
178
|
-
### 1. Data-Driven Entries (Dynamic)
|
|
171
|
+
## Quick Start
|
|
179
172
|
|
|
180
|
-
|
|
173
|
+
**An app with both static routes and dynamic content pages:**
|
|
181
174
|
|
|
175
|
+
`app.admin.json` — declare static routes once, at build time:
|
|
182
176
|
```json
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
]
|
|
177
|
+
{
|
|
178
|
+
"linkable": [
|
|
179
|
+
{ "title": "Gallery", "path": "/gallery" },
|
|
180
|
+
{ "title": "Settings", "path": "/settings" }
|
|
181
|
+
]
|
|
182
|
+
}
|
|
188
183
|
```
|
|
189
184
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
185
|
+
App code — sync dynamic content entries whenever pages change:
|
|
186
|
+
```typescript
|
|
187
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
193
188
|
|
|
194
|
-
|
|
189
|
+
async function syncDynamicLinks(collectionId: string, appId: string): Promise<void> {
|
|
190
|
+
// Always fetch fresh data — never rely on cache
|
|
191
|
+
const pages = await myApi.getPublishedPages({ forceRefresh: true });
|
|
192
|
+
const entries = pages
|
|
193
|
+
.filter(p => p.deepLinkable)
|
|
194
|
+
.map(p => ({ title: p.title, params: { pageId: p.slug } }));
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
const config = await appConfiguration.getConfig({ collectionId, appId, admin: true }) ?? {};
|
|
197
|
+
await appConfiguration.setConfig({
|
|
198
|
+
collectionId, appId,
|
|
199
|
+
config: { ...config, linkable: entries },
|
|
200
|
+
admin: true,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
202
203
|
```
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
### 3. Mixed
|
|
205
|
+
Consumer — merge both sources:
|
|
206
|
+
```typescript
|
|
207
|
+
// staticLinks come from the app's manifest (loaded by the platform when the app was registered)
|
|
208
|
+
const staticLinks: DeepLinkEntry[] = appManifest?.linkable ?? [];
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
// dynamicLinks come from appConfig (per-collection, fetched at runtime)
|
|
211
|
+
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
212
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
211
213
|
|
|
212
|
-
|
|
213
|
-
[
|
|
214
|
-
{ "title": "Gallery", "path": "/gallery" },
|
|
215
|
-
{ "title": "About Us", "params": { "pageId": "about-us" } },
|
|
216
|
-
{ "title": "FAQ", "params": { "pageId": "faq" } },
|
|
217
|
-
{ "title": "Settings", "path": "/settings" }
|
|
218
|
-
]
|
|
214
|
+
// Full set — static (structural) first, then dynamic (content)
|
|
215
|
+
const allLinks = [...staticLinks, ...dynamicLinks];
|
|
219
216
|
```
|
|
220
217
|
|
|
221
|
-
**Ordering:** Entries are displayed in array order. Place the most commonly navigated states first.
|
|
222
|
-
|
|
223
218
|
---
|
|
224
219
|
|
|
225
220
|
## URL Resolution
|
|
@@ -256,16 +251,17 @@ The platform automatically injects the following — **never include these in `p
|
|
|
256
251
|
|
|
257
252
|
---
|
|
258
253
|
|
|
259
|
-
## Syncing
|
|
254
|
+
## Syncing Dynamic Links
|
|
255
|
+
|
|
256
|
+
This section only applies to **dynamic links stored in `appConfig.linkable`**. Static links declared in the manifest require no syncing.
|
|
260
257
|
|
|
261
258
|
### When to Sync
|
|
262
259
|
|
|
263
|
-
Apps **MUST** update
|
|
260
|
+
Apps **MUST** update `appConfig.linkable` whenever the set of dynamic navigable states changes:
|
|
264
261
|
|
|
265
262
|
- A page is created, deleted, published, or unpublished
|
|
266
263
|
- A page's `deepLinkable` flag is toggled
|
|
267
264
|
- A page title changes
|
|
268
|
-
- App routes are added or removed
|
|
269
265
|
|
|
270
266
|
### How to Sync
|
|
271
267
|
|
|
@@ -275,9 +271,9 @@ The sync pattern is a **full replace** — read current config, overwrite `linka
|
|
|
275
271
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
276
272
|
|
|
277
273
|
async function syncLinkable(collectionId: string, appId: string): Promise<void> {
|
|
278
|
-
// 1. Fetch the current set of
|
|
279
|
-
// Always fetch fresh — never rely on a local cache
|
|
280
|
-
const entries = await
|
|
274
|
+
// 1. Fetch the current set of dynamic entries from your data source.
|
|
275
|
+
// Always fetch fresh — never rely on a local cache.
|
|
276
|
+
const entries = await fetchDynamicLinkEntries(); // app-specific
|
|
281
277
|
|
|
282
278
|
// 2. Read the existing appConfig to preserve other keys
|
|
283
279
|
let config: Record<string, unknown> = {};
|
|
@@ -291,7 +287,7 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
|
|
|
291
287
|
// Config may not exist yet on first run — that's fine
|
|
292
288
|
}
|
|
293
289
|
|
|
294
|
-
// 3.
|
|
290
|
+
// 3. Overwrite only the linkable key and save
|
|
295
291
|
await appConfiguration.setConfig({
|
|
296
292
|
collectionId,
|
|
297
293
|
appId,
|
|
@@ -301,42 +297,13 @@ async function syncLinkable(collectionId: string, appId: string): Promise<void>
|
|
|
301
297
|
}
|
|
302
298
|
```
|
|
303
299
|
|
|
304
|
-
### What `fetchLinkableEntries` looks like
|
|
305
|
-
|
|
306
|
-
This function is app-specific. A typical CMS app might look like:
|
|
307
|
-
|
|
308
|
-
```typescript
|
|
309
|
-
async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
|
|
310
|
-
// Fetch your pages/content from the server (not from cache)
|
|
311
|
-
const pages = await myApi.getPublishedPages({ forceRefresh: true });
|
|
312
|
-
|
|
313
|
-
return pages
|
|
314
|
-
.filter(page => page.deepLinkable)
|
|
315
|
-
.map(page => ({
|
|
316
|
-
title: page.title,
|
|
317
|
-
params: { pageId: page.slug },
|
|
318
|
-
}));
|
|
319
|
-
}
|
|
320
|
-
```
|
|
321
|
-
|
|
322
|
-
A route-based app simply returns a static array:
|
|
323
|
-
|
|
324
|
-
```typescript
|
|
325
|
-
async function fetchLinkableEntries(): Promise<DeepLinkEntry[]> {
|
|
326
|
-
return [
|
|
327
|
-
{ title: 'Gallery', path: '/gallery' },
|
|
328
|
-
{ title: 'Settings', path: '/settings' },
|
|
329
|
-
];
|
|
330
|
-
}
|
|
331
|
-
```
|
|
332
|
-
|
|
333
300
|
### Idempotency
|
|
334
301
|
|
|
335
302
|
The sync is always a full replace of the `linkable` array. Running it multiple times with the same data produces the same result — no duplicates, no orphans.
|
|
336
303
|
|
|
337
|
-
### Clearing
|
|
304
|
+
### Clearing Dynamic Links
|
|
338
305
|
|
|
339
|
-
To remove all
|
|
306
|
+
To remove all dynamic entries (e.g., all content was unpublished):
|
|
340
307
|
|
|
341
308
|
```typescript
|
|
342
309
|
await appConfiguration.setConfig({
|
|
@@ -347,13 +314,48 @@ await appConfiguration.setConfig({
|
|
|
347
314
|
});
|
|
348
315
|
```
|
|
349
316
|
|
|
317
|
+
Note that clearing `appConfig.linkable` only removes dynamic entries. Static links declared in the manifest are unaffected.
|
|
318
|
+
|
|
350
319
|
---
|
|
351
320
|
|
|
352
321
|
## Consumer Patterns
|
|
353
322
|
|
|
323
|
+
### Merging Both Sources
|
|
324
|
+
|
|
325
|
+
Consumers must always merge static (manifest) and dynamic (appConfig) entries. The platform provides the manifest when the app is registered; the consumer fetches the config at runtime:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { appConfiguration } from '@proveanything/smartlinks';
|
|
329
|
+
|
|
330
|
+
interface ResolvedLink extends DeepLinkEntry {
|
|
331
|
+
appId: string;
|
|
332
|
+
source: 'manifest' | 'config';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function getAppLinks(
|
|
336
|
+
collectionId: string,
|
|
337
|
+
appId: string,
|
|
338
|
+
appManifest: { linkable?: DeepLinkEntry[] }
|
|
339
|
+
): Promise<ResolvedLink[]> {
|
|
340
|
+
// Static entries from the manifest — always present, no network call needed
|
|
341
|
+
const staticLinks = (appManifest.linkable ?? []).map(e => ({
|
|
342
|
+
...e, appId, source: 'manifest' as const
|
|
343
|
+
}));
|
|
344
|
+
|
|
345
|
+
// Dynamic entries from appConfig — vary per collection
|
|
346
|
+
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
347
|
+
const dynamicLinks = (config?.linkable ?? []).map(e => ({
|
|
348
|
+
...e, appId, source: 'config' as const
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
// Static first (fixed structure), then dynamic (content entries)
|
|
352
|
+
return [...staticLinks, ...dynamicLinks];
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
354
356
|
### Portal Navigation Menu
|
|
355
357
|
|
|
356
|
-
A portal shell
|
|
358
|
+
A portal shell aggregates links from all installed apps to build a unified navigation menu:
|
|
357
359
|
|
|
358
360
|
```typescript
|
|
359
361
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
@@ -365,15 +367,22 @@ interface NavLink {
|
|
|
365
367
|
params?: Record<string, string>;
|
|
366
368
|
}
|
|
367
369
|
|
|
368
|
-
async function buildNavMenu(
|
|
370
|
+
async function buildNavMenu(
|
|
371
|
+
collectionId: string,
|
|
372
|
+
apps: Array<{ appId: string; manifest: { linkable?: DeepLinkEntry[] } }>
|
|
373
|
+
): Promise<NavLink[]> {
|
|
369
374
|
const links: NavLink[] = [];
|
|
370
375
|
|
|
371
|
-
for (const appId of
|
|
376
|
+
for (const { appId, manifest } of apps) {
|
|
377
|
+
// Static links from manifest
|
|
378
|
+
const staticLinks = manifest.linkable ?? [];
|
|
379
|
+
|
|
380
|
+
// Dynamic links from appConfig
|
|
372
381
|
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
373
|
-
const
|
|
382
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
374
383
|
|
|
375
384
|
links.push(
|
|
376
|
-
...
|
|
385
|
+
...[...staticLinks, ...dynamicLinks].map(entry => ({ ...entry, appId }))
|
|
377
386
|
);
|
|
378
387
|
}
|
|
379
388
|
|
|
@@ -383,19 +392,22 @@ async function buildNavMenu(collectionId: string, appIds: string[]): Promise<Nav
|
|
|
383
392
|
|
|
384
393
|
### AI Orchestration
|
|
385
394
|
|
|
386
|
-
An AI agent
|
|
395
|
+
An AI agent discovers all navigable states and offers them to the user:
|
|
387
396
|
|
|
388
397
|
```typescript
|
|
389
398
|
import { appConfiguration } from '@proveanything/smartlinks';
|
|
390
399
|
|
|
391
|
-
async function getNavigationOptions(
|
|
400
|
+
async function getNavigationOptions(
|
|
401
|
+
collectionId: string,
|
|
402
|
+
appId: string,
|
|
403
|
+
appManifest: { linkable?: DeepLinkEntry[] }
|
|
404
|
+
): Promise<string[]> {
|
|
405
|
+
const staticLinks = appManifest.linkable ?? [];
|
|
392
406
|
const config = await appConfiguration.getConfig({ collectionId, appId });
|
|
393
|
-
const
|
|
407
|
+
const dynamicLinks: DeepLinkEntry[] = config?.linkable ?? [];
|
|
394
408
|
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
// Which would you prefer?"
|
|
398
|
-
return linkable.map(entry => entry.title);
|
|
409
|
+
// "I can navigate you to: Gallery, Settings, About Us, or FAQ."
|
|
410
|
+
return [...staticLinks, ...dynamicLinks].map(entry => entry.title);
|
|
399
411
|
}
|
|
400
412
|
```
|
|
401
413
|
|
|
@@ -403,16 +415,15 @@ The `title` field is intentionally human-readable so it can be injected directly
|
|
|
403
415
|
|
|
404
416
|
### Cross-App Navigation
|
|
405
417
|
|
|
406
|
-
An app
|
|
418
|
+
An app constructs a `NavigationRequest` using the target app's merged links:
|
|
407
419
|
|
|
408
420
|
```typescript
|
|
409
|
-
//
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
});
|
|
421
|
+
// Search static links first, then dynamic
|
|
422
|
+
const staticEntry = targetManifest?.linkable?.find(e => e.title === 'Gallery');
|
|
423
|
+
const config = await appConfiguration.getConfig({ collectionId, appId: 'target-app-id' });
|
|
424
|
+
const dynamicEntry = config?.linkable?.find(e => e.title === 'About Us');
|
|
414
425
|
|
|
415
|
-
const entry =
|
|
426
|
+
const entry = staticEntry ?? dynamicEntry;
|
|
416
427
|
|
|
417
428
|
if (entry) {
|
|
418
429
|
onNavigate({
|
|
@@ -425,51 +436,12 @@ if (entry) {
|
|
|
425
436
|
|
|
426
437
|
---
|
|
427
438
|
|
|
428
|
-
## Manifest Declaration
|
|
429
|
-
|
|
430
|
-
Apps that support deep linking **SHOULD** declare the `linkable` key in their `app.admin.json` config schema. This signals to platform tooling that the key is auto-managed and prevents accidental manual edits:
|
|
431
|
-
|
|
432
|
-
```json
|
|
433
|
-
{
|
|
434
|
-
"setup": {
|
|
435
|
-
"configSchema": {
|
|
436
|
-
"properties": {
|
|
437
|
-
"linkable": {
|
|
438
|
-
"type": "array",
|
|
439
|
-
"description": "Auto-managed deep link registry. Do not edit manually.",
|
|
440
|
-
"readOnly": true,
|
|
441
|
-
"items": {
|
|
442
|
-
"type": "object",
|
|
443
|
-
"required": ["title"],
|
|
444
|
-
"properties": {
|
|
445
|
-
"title": { "type": "string", "description": "Human-readable label" },
|
|
446
|
-
"path": { "type": "string", "description": "Hash route path, e.g. '/gallery'" },
|
|
447
|
-
"params": {
|
|
448
|
-
"type": "object",
|
|
449
|
-
"description": "App-specific query params. Do not include platform context params.",
|
|
450
|
-
"additionalProperties": { "type": "string" }
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
```
|
|
460
|
-
|
|
461
|
-
> **Note:** The `readOnly: true` flag is a hint to the admin UI to hide or disable this field in manual config editors.
|
|
462
|
-
|
|
463
|
-
---
|
|
464
|
-
|
|
465
439
|
## TypeScript Types
|
|
466
440
|
|
|
467
|
-
Copy these types into your app, or import them if they become part of the SDK's public API:
|
|
468
|
-
|
|
469
441
|
```typescript
|
|
470
442
|
/**
|
|
471
443
|
* A single navigable state exposed by a SmartLinks app.
|
|
472
|
-
*
|
|
444
|
+
* Used in both app.admin.json `linkable` (static) and appConfig `linkable` (dynamic).
|
|
473
445
|
*/
|
|
474
446
|
export interface DeepLinkEntry {
|
|
475
447
|
/** Human-readable label shown in menus and offered to AI agents */
|
|
@@ -487,10 +459,7 @@ export interface DeepLinkEntry {
|
|
|
487
459
|
params?: Record<string, string>;
|
|
488
460
|
}
|
|
489
461
|
|
|
490
|
-
/**
|
|
491
|
-
* The shape of the `linkable` key in appConfig.
|
|
492
|
-
* Alias for convenience.
|
|
493
|
-
*/
|
|
462
|
+
/** Convenience alias for an array of DeepLinkEntry */
|
|
494
463
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
495
464
|
```
|
|
496
465
|
|
|
@@ -500,17 +469,20 @@ export type DeepLinkRegistry = DeepLinkEntry[];
|
|
|
500
469
|
|
|
501
470
|
### Rules
|
|
502
471
|
|
|
503
|
-
1.
|
|
504
|
-
2.
|
|
505
|
-
3. **`
|
|
506
|
-
4. **
|
|
507
|
-
5.
|
|
472
|
+
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.admin.json`. Do not write it to `appConfig` on first run.
|
|
473
|
+
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
474
|
+
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
475
|
+
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
476
|
+
5. **`title` is required** — every entry must have a human-readable label.
|
|
477
|
+
6. **Consumers must merge both sources** — never assume all links come from `appConfig` alone.
|
|
478
|
+
7. **Sync from server** — when updating `appConfig.linkable`, always fetch the latest content state; never rely on a local cache.
|
|
479
|
+
8. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
508
480
|
|
|
509
481
|
### Best Practices
|
|
510
482
|
|
|
511
483
|
- **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
512
|
-
- **
|
|
513
|
-
- **Sync eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
514
|
-
- **Handle missing
|
|
515
|
-
- **Don't duplicate the default route** — if your app has only one view,
|
|
516
|
-
- **Test URL resolution** — confirm that your entries produce the correct URLs
|
|
484
|
+
- **Static first, dynamic second** — when rendering merged links, show static (structural) entries before dynamic (content) entries. This provides a consistent layout even while content is loading.
|
|
485
|
+
- **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
486
|
+
- **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
487
|
+
- **Don't duplicate the default route** — if your app has only one view, neither source needs a `linkable` entry for it. The app can always be opened to its default state without a deep link.
|
|
488
|
+
- **Test URL resolution** — confirm that your entries produce the correct URLs. Check that params are correctly appended and that platform context params are not doubled up.
|