@proveanything/smartlinks 1.11.11 → 1.11.12
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 +209 -15
- package/docs/API_SUMMARY.md +1 -1
- package/docs/deep-link-discovery.md +209 -15
- package/package.json +1 -1
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -19,8 +19,11 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
19
19
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
20
20
|
- [AI Orchestration](#ai-orchestration)
|
|
21
21
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
22
|
+
- [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
|
|
22
23
|
- [TypeScript Types](#typescript-types)
|
|
23
|
-
- [
|
|
24
|
+
- [Supplier Responsibilities](#supplier-responsibilities)
|
|
25
|
+
- [Consumer Responsibilities](#consumer-responsibilities)
|
|
26
|
+
- [Anti-Patterns](#anti-patterns)
|
|
24
27
|
|
|
25
28
|
---
|
|
26
29
|
|
|
@@ -72,6 +75,33 @@ Consumers merge both sources to get the full set of navigable states for an app.
|
|
|
72
75
|
|
|
73
76
|
---
|
|
74
77
|
|
|
78
|
+
## Two Roles: Suppliers and Consumers
|
|
79
|
+
|
|
80
|
+
Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
|
|
81
|
+
|
|
82
|
+
| Role | What you do | Primary surface |
|
|
83
|
+
|------|-------------|-----------------|
|
|
84
|
+
| **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
|
|
85
|
+
| **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
|
|
86
|
+
|
|
87
|
+
Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
|
|
88
|
+
|
|
89
|
+
### Supplier checklist
|
|
90
|
+
|
|
91
|
+
- [ ] Static navigable views declared in `app.manifest.json#linkable`
|
|
92
|
+
- [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
|
|
93
|
+
- [ ] Every entry has a concise, human-readable `title`
|
|
94
|
+
|
|
95
|
+
### Consumer checklist
|
|
96
|
+
|
|
97
|
+
- [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
|
|
98
|
+
- [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
|
|
99
|
+
- [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
|
|
100
|
+
- [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
|
|
101
|
+
- [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
75
105
|
## Two Sources of Truth
|
|
76
106
|
|
|
77
107
|
### Static Links — App Manifest
|
|
@@ -462,6 +492,126 @@ if (entry) {
|
|
|
462
492
|
}
|
|
463
493
|
```
|
|
464
494
|
|
|
495
|
+
`onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
### Building a Cross-App Deep-Link Picker
|
|
500
|
+
|
|
501
|
+
When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App → Page), and persist the result as `AppDeepLink`.
|
|
502
|
+
|
|
503
|
+
#### Why not a free-text URL?
|
|
504
|
+
|
|
505
|
+
Hard-coded URLs break when:
|
|
506
|
+
- The target app is re-deployed on a new domain.
|
|
507
|
+
- The user is inside a container and full-page navigation would destroy their session.
|
|
508
|
+
- Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
|
|
509
|
+
|
|
510
|
+
#### Step 1 — Discover installed apps
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
|
|
514
|
+
// Returns all installed apps, each with its manifest and saved config
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
#### Step 2 — Merge linkable entries for the chosen app
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
const entries: DeepLinkEntry[] = [
|
|
521
|
+
...(app.manifest?.linkable ?? []),
|
|
522
|
+
...(app.config?.linkable ?? []),
|
|
523
|
+
];
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
|
|
527
|
+
|
|
528
|
+
#### Step 3 — Persist as `AppDeepLink`, never as a URL
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// Correct — portable, context-preserving
|
|
532
|
+
const link: AppDeepLink = {
|
|
533
|
+
appId: app.appId,
|
|
534
|
+
path: entry.path ?? '/',
|
|
535
|
+
params: entry.params,
|
|
536
|
+
label: entry.title, // cache for display; re-resolve at render time
|
|
537
|
+
};
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
|
|
541
|
+
|
|
542
|
+
#### Step 4 — Navigate at runtime
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
const { onNavigate } = useAppContext();
|
|
546
|
+
|
|
547
|
+
onNavigate({
|
|
548
|
+
appId: link.appId,
|
|
549
|
+
deepLink: link.path,
|
|
550
|
+
params: link.params,
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### Worked example — Raffle "See the prizes" button
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
// --- Admin side ---
|
|
558
|
+
function PrizesLinkField({ collectionId, value, onChange }) {
|
|
559
|
+
const [apps, setApps] = useState([]);
|
|
560
|
+
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
|
|
563
|
+
}, [collectionId]);
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<>
|
|
567
|
+
{/* App dropdown */}
|
|
568
|
+
<select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
|
|
569
|
+
<option value="">— Choose an app —</option>
|
|
570
|
+
{apps.map(a => (
|
|
571
|
+
<option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
|
|
572
|
+
))}
|
|
573
|
+
</select>
|
|
574
|
+
|
|
575
|
+
{/* Page dropdown for chosen app */}
|
|
576
|
+
{value?.appId && (() => {
|
|
577
|
+
const app = apps.find(a => a.appId === value.appId);
|
|
578
|
+
const entries = [
|
|
579
|
+
...(app?.manifest?.linkable ?? []),
|
|
580
|
+
...(app?.config?.linkable ?? []),
|
|
581
|
+
];
|
|
582
|
+
if (entries.length === 0) {
|
|
583
|
+
return <select disabled><option>Default route only</option></select>;
|
|
584
|
+
}
|
|
585
|
+
return (
|
|
586
|
+
<select onChange={e => {
|
|
587
|
+
const entry = entries[+e.target.value];
|
|
588
|
+
onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
|
|
589
|
+
}}>
|
|
590
|
+
{entries.map((entry, i) => (
|
|
591
|
+
<option key={i} value={i}>{entry.title}</option>
|
|
592
|
+
))}
|
|
593
|
+
</select>
|
|
594
|
+
);
|
|
595
|
+
})()}
|
|
596
|
+
</>
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- Public side ---
|
|
601
|
+
const { onNavigate } = useAppContext();
|
|
602
|
+
{prizesLink && (
|
|
603
|
+
<button onClick={() => onNavigate({
|
|
604
|
+
appId: prizesLink.appId,
|
|
605
|
+
deepLink: prizesLink.path,
|
|
606
|
+
params: prizesLink.params,
|
|
607
|
+
})}>
|
|
608
|
+
See {prizesLink.label ?? 'the prizes'}
|
|
609
|
+
</button>
|
|
610
|
+
)}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
> **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
|
|
614
|
+
|
|
465
615
|
---
|
|
466
616
|
|
|
467
617
|
## TypeScript Types
|
|
@@ -489,28 +639,72 @@ export interface DeepLinkEntry {
|
|
|
489
639
|
|
|
490
640
|
/** Convenience alias for an array of DeepLinkEntry */
|
|
491
641
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A cross-app deep link persisted by a consumer app's config.
|
|
645
|
+
* Never store a raw URL — store this instead.
|
|
646
|
+
*
|
|
647
|
+
* At render time, re-resolve via SL.collection.getAppsConfig to detect
|
|
648
|
+
* renamed or removed pages.
|
|
649
|
+
*/
|
|
650
|
+
export interface AppDeepLink {
|
|
651
|
+
/** The target app's appId */
|
|
652
|
+
appId: string;
|
|
653
|
+
/** Hash route path within the target app (e.g. "/prizes/2026") */
|
|
654
|
+
path: string;
|
|
655
|
+
/** App-specific query params (platform context is injected automatically) */
|
|
656
|
+
params?: Record<string, string>;
|
|
657
|
+
/**
|
|
658
|
+
* Cached display label from the time of selection.
|
|
659
|
+
* For display only — do not use for navigation decisions.
|
|
660
|
+
*/
|
|
661
|
+
label?: string;
|
|
662
|
+
}
|
|
492
663
|
```
|
|
493
664
|
|
|
494
665
|
---
|
|
495
666
|
|
|
496
|
-
##
|
|
667
|
+
## Supplier Responsibilities
|
|
497
668
|
|
|
498
|
-
|
|
669
|
+
Rules for apps that **expose** navigable states to the platform:
|
|
499
670
|
|
|
500
671
|
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
|
|
501
672
|
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
502
673
|
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
503
674
|
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
504
675
|
5. **`title` is required** — every entry must have a human-readable label.
|
|
505
|
-
6. **
|
|
506
|
-
7. **
|
|
507
|
-
8. **
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
676
|
+
6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
677
|
+
7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
678
|
+
8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## Consumer Responsibilities
|
|
683
|
+
|
|
684
|
+
Rules for apps that **navigate to** states in other installed apps:
|
|
685
|
+
|
|
686
|
+
1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
|
|
687
|
+
2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
|
|
688
|
+
3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
|
|
689
|
+
4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
|
|
690
|
+
5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
|
|
691
|
+
6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
|
|
692
|
+
7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
693
|
+
8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Anti-Patterns
|
|
698
|
+
|
|
699
|
+
| ❌ Anti-pattern | ✅ Correct approach |
|
|
700
|
+
|----------------|---------------------|
|
|
701
|
+
| Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
|
|
702
|
+
| Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
|
|
703
|
+
| Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
|
|
704
|
+
| `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
|
|
705
|
+
| Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
|
|
706
|
+
| Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
|
|
707
|
+
| Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
|
|
708
|
+
| Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
|
|
709
|
+
| Re-implementing picker logic per app | Share a canonical picker component across your app suite |
|
|
710
|
+
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -19,8 +19,11 @@ Complete guide to registering and discovering navigable states in SmartLinks app
|
|
|
19
19
|
- [Portal Navigation Menu](#portal-navigation-menu)
|
|
20
20
|
- [AI Orchestration](#ai-orchestration)
|
|
21
21
|
- [Cross-App Navigation](#cross-app-navigation)
|
|
22
|
+
- [Building a Cross-App Deep-Link Picker](#building-a-cross-app-deep-link-picker)
|
|
22
23
|
- [TypeScript Types](#typescript-types)
|
|
23
|
-
- [
|
|
24
|
+
- [Supplier Responsibilities](#supplier-responsibilities)
|
|
25
|
+
- [Consumer Responsibilities](#consumer-responsibilities)
|
|
26
|
+
- [Anti-Patterns](#anti-patterns)
|
|
24
27
|
|
|
25
28
|
---
|
|
26
29
|
|
|
@@ -72,6 +75,33 @@ Consumers merge both sources to get the full set of navigable states for an app.
|
|
|
72
75
|
|
|
73
76
|
---
|
|
74
77
|
|
|
78
|
+
## Two Roles: Suppliers and Consumers
|
|
79
|
+
|
|
80
|
+
Deep link discovery is a **two-sided contract**. Every app that participates plays at least one of two roles — often both:
|
|
81
|
+
|
|
82
|
+
| Role | What you do | Primary surface |
|
|
83
|
+
|------|-------------|-----------------|
|
|
84
|
+
| **Supplier** | Declare your navigable states so other apps and platform features can discover them | `app.manifest.json` and `appConfig.linkable` |
|
|
85
|
+
| **Consumer** | Discover and navigate to states in other installed apps without hard-coding IDs or URLs | Admin picker UI and `onNavigate()` at runtime |
|
|
86
|
+
|
|
87
|
+
Both roles are required for end-to-end cross-app linking to work. An app that only declares links but never consumes them is incomplete; an app that hard-codes target IDs instead of discovering them is fragile.
|
|
88
|
+
|
|
89
|
+
### Supplier checklist
|
|
90
|
+
|
|
91
|
+
- [ ] Static navigable views declared in `app.manifest.json#linkable`
|
|
92
|
+
- [ ] Dynamic content pages synced to `appConfig.linkable` on every create/rename/delete
|
|
93
|
+
- [ ] Every entry has a concise, human-readable `title`
|
|
94
|
+
|
|
95
|
+
### Consumer checklist
|
|
96
|
+
|
|
97
|
+
- [ ] Uses `SL.collection.getAppsConfig(collectionId)` to discover installed apps — never hard-codes `appId`s
|
|
98
|
+
- [ ] Merges `manifest.linkable` + `config.linkable` to present the full set of navigable states
|
|
99
|
+
- [ ] Persists chosen links as `AppDeepLink` (never a raw URL)
|
|
100
|
+
- [ ] Navigates with `onNavigate({ appId, deepLink, params })` — never `window.open` or `<a href>`
|
|
101
|
+
- [ ] Shows a picker UI in admin — never a free-text field for app IDs or URLs
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
75
105
|
## Two Sources of Truth
|
|
76
106
|
|
|
77
107
|
### Static Links — App Manifest
|
|
@@ -462,6 +492,126 @@ if (entry) {
|
|
|
462
492
|
}
|
|
463
493
|
```
|
|
464
494
|
|
|
495
|
+
`onNavigate` automatically forwards `collectionId`, `productId`, `proofId`, and theme — you do not need to pass them.
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
### Building a Cross-App Deep-Link Picker
|
|
500
|
+
|
|
501
|
+
When an admin needs to configure which app and page to link to, **never use a free-text field**. Use `SL.collection.getAppsConfig` to discover what's installed, present a two-step picker (App → Page), and persist the result as `AppDeepLink`.
|
|
502
|
+
|
|
503
|
+
#### Why not a free-text URL?
|
|
504
|
+
|
|
505
|
+
Hard-coded URLs break when:
|
|
506
|
+
- The target app is re-deployed on a new domain.
|
|
507
|
+
- The user is inside a container and full-page navigation would destroy their session.
|
|
508
|
+
- Platform context (`collectionId`, `productId`, `proofId`) needs to flow to the destination — URLs lose it, deep links keep it.
|
|
509
|
+
|
|
510
|
+
#### Step 1 — Discover installed apps
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
const apps = await SL.collection.getAppsConfig(collectionId, { admin: true });
|
|
514
|
+
// Returns all installed apps, each with its manifest and saved config
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
#### Step 2 — Merge linkable entries for the chosen app
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
const entries: DeepLinkEntry[] = [
|
|
521
|
+
...(app.manifest?.linkable ?? []),
|
|
522
|
+
...(app.config?.linkable ?? []),
|
|
523
|
+
];
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
If an app has no entries, show a single "Default route only" option that resolves to `path: '/'`. This signals to admins that the app exists but hasn't declared any specific deep links yet.
|
|
527
|
+
|
|
528
|
+
#### Step 3 — Persist as `AppDeepLink`, never as a URL
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// Correct — portable, context-preserving
|
|
532
|
+
const link: AppDeepLink = {
|
|
533
|
+
appId: app.appId,
|
|
534
|
+
path: entry.path ?? '/',
|
|
535
|
+
params: entry.params,
|
|
536
|
+
label: entry.title, // cache for display; re-resolve at render time
|
|
537
|
+
};
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The `label` field is a display-only cache. At render time, re-resolve the entry via `getAppsConfig` to detect renamed or removed pages and surface a warning if the stored path no longer exists.
|
|
541
|
+
|
|
542
|
+
#### Step 4 — Navigate at runtime
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
const { onNavigate } = useAppContext();
|
|
546
|
+
|
|
547
|
+
onNavigate({
|
|
548
|
+
appId: link.appId,
|
|
549
|
+
deepLink: link.path,
|
|
550
|
+
params: link.params,
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### Worked example — Raffle "See the prizes" button
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
// --- Admin side ---
|
|
558
|
+
function PrizesLinkField({ collectionId, value, onChange }) {
|
|
559
|
+
const [apps, setApps] = useState([]);
|
|
560
|
+
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
SL.collection.getAppsConfig(collectionId, { admin: true }).then(setApps);
|
|
563
|
+
}, [collectionId]);
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<>
|
|
567
|
+
{/* App dropdown */}
|
|
568
|
+
<select onChange={e => onChange({ appId: e.target.value, path: '/', label: '' })}>
|
|
569
|
+
<option value="">— Choose an app —</option>
|
|
570
|
+
{apps.map(a => (
|
|
571
|
+
<option key={a.appId} value={a.appId}>{a.manifest?.meta?.name ?? a.appId}</option>
|
|
572
|
+
))}
|
|
573
|
+
</select>
|
|
574
|
+
|
|
575
|
+
{/* Page dropdown for chosen app */}
|
|
576
|
+
{value?.appId && (() => {
|
|
577
|
+
const app = apps.find(a => a.appId === value.appId);
|
|
578
|
+
const entries = [
|
|
579
|
+
...(app?.manifest?.linkable ?? []),
|
|
580
|
+
...(app?.config?.linkable ?? []),
|
|
581
|
+
];
|
|
582
|
+
if (entries.length === 0) {
|
|
583
|
+
return <select disabled><option>Default route only</option></select>;
|
|
584
|
+
}
|
|
585
|
+
return (
|
|
586
|
+
<select onChange={e => {
|
|
587
|
+
const entry = entries[+e.target.value];
|
|
588
|
+
onChange({ appId: value.appId, path: entry.path ?? '/', params: entry.params, label: entry.title });
|
|
589
|
+
}}>
|
|
590
|
+
{entries.map((entry, i) => (
|
|
591
|
+
<option key={i} value={i}>{entry.title}</option>
|
|
592
|
+
))}
|
|
593
|
+
</select>
|
|
594
|
+
);
|
|
595
|
+
})()}
|
|
596
|
+
</>
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// --- Public side ---
|
|
601
|
+
const { onNavigate } = useAppContext();
|
|
602
|
+
{prizesLink && (
|
|
603
|
+
<button onClick={() => onNavigate({
|
|
604
|
+
appId: prizesLink.appId,
|
|
605
|
+
deepLink: prizesLink.path,
|
|
606
|
+
params: prizesLink.params,
|
|
607
|
+
})}>
|
|
608
|
+
See {prizesLink.label ?? 'the prizes'}
|
|
609
|
+
</button>
|
|
610
|
+
)}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
> **Tip:** Most consumer apps should use a shared `<DeepLinkPicker />` component from their UI library rather than re-implementing two-stage picker logic. The pattern above illustrates what it must do internally.
|
|
614
|
+
|
|
465
615
|
---
|
|
466
616
|
|
|
467
617
|
## TypeScript Types
|
|
@@ -489,28 +639,72 @@ export interface DeepLinkEntry {
|
|
|
489
639
|
|
|
490
640
|
/** Convenience alias for an array of DeepLinkEntry */
|
|
491
641
|
export type DeepLinkRegistry = DeepLinkEntry[];
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A cross-app deep link persisted by a consumer app's config.
|
|
645
|
+
* Never store a raw URL — store this instead.
|
|
646
|
+
*
|
|
647
|
+
* At render time, re-resolve via SL.collection.getAppsConfig to detect
|
|
648
|
+
* renamed or removed pages.
|
|
649
|
+
*/
|
|
650
|
+
export interface AppDeepLink {
|
|
651
|
+
/** The target app's appId */
|
|
652
|
+
appId: string;
|
|
653
|
+
/** Hash route path within the target app (e.g. "/prizes/2026") */
|
|
654
|
+
path: string;
|
|
655
|
+
/** App-specific query params (platform context is injected automatically) */
|
|
656
|
+
params?: Record<string, string>;
|
|
657
|
+
/**
|
|
658
|
+
* Cached display label from the time of selection.
|
|
659
|
+
* For display only — do not use for navigation decisions.
|
|
660
|
+
*/
|
|
661
|
+
label?: string;
|
|
662
|
+
}
|
|
492
663
|
```
|
|
493
664
|
|
|
494
665
|
---
|
|
495
666
|
|
|
496
|
-
##
|
|
667
|
+
## Supplier Responsibilities
|
|
497
668
|
|
|
498
|
-
|
|
669
|
+
Rules for apps that **expose** navigable states to the platform:
|
|
499
670
|
|
|
500
671
|
1. **Static routes belong in the manifest** — if a route exists regardless of content, declare it in `app.manifest.json`. Do not write it to `appConfig` on first run.
|
|
501
672
|
2. **`appConfig.linkable` is for dynamic content only** — it should contain entries generated from, and varying with, the app's data.
|
|
502
673
|
3. **`linkable` is reserved** — do not use this key for other purposes in either the manifest or `appConfig`.
|
|
503
674
|
4. **No platform context params in entries** — `collectionId`, `appId`, `productId`, `proofId`, `lang`, `theme` are injected by the platform automatically.
|
|
504
675
|
5. **`title` is required** — every entry must have a human-readable label.
|
|
505
|
-
6. **
|
|
506
|
-
7. **
|
|
507
|
-
8. **
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
676
|
+
6. **Sync dynamic links eagerly** — trigger a sync immediately after any content change rather than on a schedule. The operation is cheap and idempotent.
|
|
677
|
+
7. **Full replace** — always overwrite the entire `appConfig.linkable` array; never diff or patch individual entries.
|
|
678
|
+
8. **Keep titles short and scannable** — they appear in navigation menus and AI prompts. Prefer "About Us" over "Our Company Story & Mission".
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## Consumer Responsibilities
|
|
683
|
+
|
|
684
|
+
Rules for apps that **navigate to** states in other installed apps:
|
|
685
|
+
|
|
686
|
+
1. **Never hard-code `appId`s or URLs** — always discover installed apps via `SL.collection.getAppsConfig(collectionId)`.
|
|
687
|
+
2. **Merge both sources** — always combine `manifest.linkable` and `config.linkable`; never assume all links come from either source alone.
|
|
688
|
+
3. **Persist as `AppDeepLink`** — store `{ appId, path, params, label }`, never a raw URL.
|
|
689
|
+
4. **Navigate with `onNavigate`** — use `useAppContext().onNavigate({ appId, deepLink, params })` for all in-platform navigation; never `window.open` or `<a href>` between apps in the same collection.
|
|
690
|
+
5. **Re-resolve at render time** — cached `label` is for display only; re-resolve via `getAppsConfig` to surface renamed or removed pages.
|
|
691
|
+
6. **Use a picker UI** — admin forms that configure a cross-app link must use a two-stage dropdown (App → Page), never a free-text field.
|
|
692
|
+
7. **Handle missing entries gracefully** — older apps won't have manifest `linkable` entries or `appConfig.linkable`. Always use `?? []` on both sources.
|
|
693
|
+
8. **Show "Default route only" when empty** — if an app has no declared entries, still list it with a single disabled "Default route only" option so admins know it's installed.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Anti-Patterns
|
|
698
|
+
|
|
699
|
+
| ❌ Anti-pattern | ✅ Correct approach |
|
|
700
|
+
|----------------|---------------------|
|
|
701
|
+
| Free-text "Target app ID" field in admin UI | Two-stage picker using `getAppsConfig` |
|
|
702
|
+
| Free-text "URL" field for cross-app links | Persist as `AppDeepLink`; navigate with `onNavigate` |
|
|
703
|
+
| Hard-coded `https://…` links between apps in the same collection | `onNavigate({ appId, deepLink, params })` |
|
|
704
|
+
| `window.open()` / `<a target="_blank">` for in-platform navigation | `onNavigate` from `useAppContext` |
|
|
705
|
+
| Writing static routes to `appConfig.linkable` on install | Declare them in `app.manifest.json` once, at build time |
|
|
706
|
+
| Putting `collectionId` / `productId` in `DeepLinkEntry.params` | Platform injects context automatically — omit them |
|
|
707
|
+
| Patching individual entries in `appConfig.linkable` | Full-replace the array on every sync |
|
|
708
|
+
| Storing a resolved URL when the admin picks a link | Store `AppDeepLink`; resolve the URL only at navigation time |
|
|
709
|
+
| Re-implementing picker logic per app | Share a canonical picker component across your app suite |
|
|
710
|
+
|