@phis/contracts 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * What one side of the house means when it points at a Page: `@phis/server/references:v1`.
3
+ *
4
+ * The codec stood in two copies, in phis-ui and in phi-server, for the same reason the CMS instance id
5
+ * did before it moved here -- and with the same risk, which is that two sides agreeing byte for byte is
6
+ * a thing nobody notices stopping. It is one file now.
7
+ *
8
+ * **A reference is short because a Page already has a name.** It used to be base64-encoded JSON carrying
9
+ * the whole identity, which for a Module Page ran to 98 characters in every Markdown body that linked to
10
+ * one. The envelope was never protection -- it is encoding, not a signature, and the server revalidates
11
+ * Site and Area regardless -- so what it actually bought was a payload the server could unpack. It no
12
+ * longer needs to: a Module Page is named by the CMS Page id derived from `(ownerModuleId, presetKey)`,
13
+ * which is recomputable rather than allocated and therefore stands before any row does.
14
+ *
15
+ * The two variants are shaped differently on purpose. A Site Page carries its Page Scope id as a plain
16
+ * number because the server writes that number into a foreign key with `ON DELETE RESTRICT`: the
17
+ * database itself refuses to delete a Page something still points at, and nothing derived could take
18
+ * that column's place. A Module Page has no row to point a foreign key at -- the Site may never have
19
+ * touched it -- so it carries the derived id, which the server passes on without ever unpacking.
20
+ */
21
+ import { type PhiCmsAreaKey, type PhiCmsInstanceId } from "./cms.js";
22
+ export declare const PHIS_INTERNAL_PAGE_SCHEME: "phis:page/";
23
+ export declare const PHIS_INTERNAL_ASSET_SCHEME: "phis:asset/";
24
+ /**
25
+ * A Module Page's identity, hashed to one token both sides can recompute.
26
+ *
27
+ * Recomputable rather than allocated, so no row has to exist before the Page can be addressed, and it
28
+ * never mentions the path: reassigning where a Page answers leaves everything pointing at it alone.
29
+ */
30
+ export declare function createPhiPresetCmsPageId(identity: {
31
+ ownerModuleId: string;
32
+ presetKey: string;
33
+ }): PhiCmsInstanceId;
34
+ /** What a reference says, once read. A Module Page answers with its id, never with what was hashed. */
35
+ export type PhiPageTarget = {
36
+ kind: "site";
37
+ pageScopeId: number;
38
+ } | {
39
+ kind: "module";
40
+ pageId: PhiCmsInstanceId;
41
+ };
42
+ /** What a reference is made from. The Module variant differs: the identity goes in, the id comes out. */
43
+ export type PhiPageTargetInput = {
44
+ kind: "site";
45
+ pageScopeId: number;
46
+ } | {
47
+ kind: "module";
48
+ ownerModuleId: string;
49
+ presetKey: string;
50
+ };
51
+ export type PhiPageReference = string & {
52
+ readonly __phiPageReference: unique symbol;
53
+ };
54
+ export type PhiInternalReference = {
55
+ kind: "page";
56
+ reference: PhiPageReference;
57
+ target: PhiPageTarget;
58
+ fragment: string | null;
59
+ } | {
60
+ kind: "asset";
61
+ assetId: number;
62
+ };
63
+ export declare function createPhiPageReference(target: PhiPageTargetInput): PhiPageReference;
64
+ export declare function readPhiPageReference(value: unknown): {
65
+ reference: PhiPageReference;
66
+ target: PhiPageTarget;
67
+ } | null;
68
+ export declare function createPhiPageUri(reference: PhiPageReference, fragment?: string | null): string;
69
+ export declare function createPhiAssetUri(assetId: number): string;
70
+ export declare function readPhiInternalReference(value: unknown): PhiInternalReference | null;
71
+ /**
72
+ * Where a Control that offers one link leads.
73
+ *
74
+ * Two kinds and no third. A Page is named by its reference, which is identity and outlives the path
75
+ * moving under it; everything else is a literal URL somebody typed. What this refuses is the middle --
76
+ * the root-relative string that reads as external and means internal -- because that is the shape that
77
+ * rots in silence: the Page moves, the string does not, and nothing in the system ever knew the two were
78
+ * related.
79
+ *
80
+ * `newTab` sits inside the target rather than beside it. A Widget that offers two links -- a Card with a
81
+ * heading and an action -- otherwise grows one flag per link and names them in parallel (`newTab`,
82
+ * `actionNewTab`, and a third the day a third link lands). That is one decision written twice, and it
83
+ * drifts the first time only one of the two is read.
84
+ */
85
+ export type PhiLinkTarget = {
86
+ kind: "page";
87
+ reference: PhiPageReference;
88
+ /**
89
+ * Which Area to resolve the reference in, where it is not the one asking.
90
+ *
91
+ * A reference names a Page and not where to look for it: the Area is resolution context, supplied
92
+ * by whoever asks. Every asker supplied its own, so a link out of one Area into another resolved
93
+ * to nothing -- the Navigation source tree let an author pick a Page from another Area, the drop
94
+ * kept it, and the table then showed it as unresolvable.
95
+ *
96
+ * Absent means the asking Area, which is nearly every link, so nothing is stored for the ordinary
97
+ * case. Present, it does not make the reference mean something else; it says where the question
98
+ * goes. Whether the reader may follow it is still the target Area's own answer.
99
+ */
100
+ area?: PhiCmsAreaKey;
101
+ fragment?: string | null;
102
+ newTab?: boolean;
103
+ } | {
104
+ kind: "external";
105
+ href: string;
106
+ newTab?: boolean;
107
+ };
108
+ /**
109
+ * The field names a stored link target may stand under, and nothing else identifies one in persisted
110
+ * config.
111
+ *
112
+ * The Asset rule is the precedent and the reason: the server's reference collector reads persisted JSON,
113
+ * not the Widget catalogue, so it cannot ask a plugin which of its fields hold targets. A Widget that
114
+ * keeps one under another name authors a reference the delete guard cannot see, and the Page it points
115
+ * at can be removed while the Widget still draws a link to it.
116
+ *
117
+ * One plain name for the single-link case and a suffix for the rest, so a Card's second link is
118
+ * `actionLinkTarget` and is found by the same rule that finds the first.
119
+ */
120
+ export declare const PHI_LINK_TARGET_CONFIG_KEY = "linkTarget";
121
+ export declare function isPhiLinkTargetConfigKey(key: string): boolean;
122
+ export declare function isPhiStorableExternalHref(value: string): boolean;
123
+ /**
124
+ * A stored link target, read back under the contract rather than trusted.
125
+ *
126
+ * Anything that is not one of the two shapes answers `null`, and a Control that gets `null` draws no
127
+ * link at all. That is the same answer the contract gives for a Page reference that no longer resolves:
128
+ * non-interactive text beats a link that goes somewhere nobody chose.
129
+ */
130
+ export declare function readPhiLinkTarget(value: unknown): PhiLinkTarget | null;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * What one side of the house means when it points at a Page: `@phis/server/references:v1`.
3
+ *
4
+ * The codec stood in two copies, in phis-ui and in phi-server, for the same reason the CMS instance id
5
+ * did before it moved here -- and with the same risk, which is that two sides agreeing byte for byte is
6
+ * a thing nobody notices stopping. It is one file now.
7
+ *
8
+ * **A reference is short because a Page already has a name.** It used to be base64-encoded JSON carrying
9
+ * the whole identity, which for a Module Page ran to 98 characters in every Markdown body that linked to
10
+ * one. The envelope was never protection -- it is encoding, not a signature, and the server revalidates
11
+ * Site and Area regardless -- so what it actually bought was a payload the server could unpack. It no
12
+ * longer needs to: a Module Page is named by the CMS Page id derived from `(ownerModuleId, presetKey)`,
13
+ * which is recomputable rather than allocated and therefore stands before any row does.
14
+ *
15
+ * The two variants are shaped differently on purpose. A Site Page carries its Page Scope id as a plain
16
+ * number because the server writes that number into a foreign key with `ON DELETE RESTRICT`: the
17
+ * database itself refuses to delete a Page something still points at, and nothing derived could take
18
+ * that column's place. A Module Page has no row to point a foreign key at -- the Site may never have
19
+ * touched it -- so it carries the derived id, which the server passes on without ever unpacking.
20
+ */
21
+ import { createPhiPresetCmsInstanceId, isPhiCmsAreaKey, readPhiCmsInstanceIdDescriptor, } from "./cms.js";
22
+ export const PHIS_INTERNAL_PAGE_SCHEME = "phis:page/";
23
+ export const PHIS_INTERNAL_ASSET_SCHEME = "phis:asset/";
24
+ /** The Page itself, not a node inside it -- the one place a Page id differs from a node id. */
25
+ const PHI_CMS_PAGE_NODE_KEY = "page";
26
+ const PHI_PAGE_REFERENCE_VERSION = "v1";
27
+ const PHI_PAGE_REFERENCE_SITE_TAG = "s";
28
+ const PHI_PAGE_REFERENCE_MODULE_TAG = "p";
29
+ const PHI_MODULE_ID_PATTERN = /^@[^/]+\/[^/]+(?:\/[^/]+)*$/;
30
+ const PHI_PAGE_SCOPE_ID_PATTERN = /^[1-9]\d*$/u;
31
+ /**
32
+ * A Module Page's identity, hashed to one token both sides can recompute.
33
+ *
34
+ * Recomputable rather than allocated, so no row has to exist before the Page can be addressed, and it
35
+ * never mentions the path: reassigning where a Page answers leaves everything pointing at it alone.
36
+ */
37
+ export function createPhiPresetCmsPageId(identity) {
38
+ return createPhiPresetCmsInstanceId({
39
+ ...identity,
40
+ domain: "page",
41
+ nodeKey: PHI_CMS_PAGE_NODE_KEY,
42
+ });
43
+ }
44
+ function isPositiveInteger(value) {
45
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
46
+ }
47
+ function readPresetPageId(value) {
48
+ const descriptor = readPhiCmsInstanceIdDescriptor(value);
49
+ return descriptor?.origin === "preset" && descriptor.domain === "page"
50
+ ? value
51
+ : null;
52
+ }
53
+ export function createPhiPageReference(target) {
54
+ if (target.kind === "site") {
55
+ if (!isPositiveInteger(target.pageScopeId)) {
56
+ throw new Error("Phi Page Scope ids must be positive integers.");
57
+ }
58
+ return `${PHI_PAGE_REFERENCE_VERSION}${PHI_PAGE_REFERENCE_SITE_TAG}${target.pageScopeId}`;
59
+ }
60
+ /*
61
+ * Checked here rather than left to the hash. A malformed owner id hashes perfectly well and produces
62
+ * a reference that resolves to nothing, for a reason nobody can read back out of it -- the one real
63
+ * cost of a one-way id, paid once at the only place that can still see what went in.
64
+ */
65
+ if (!PHI_MODULE_ID_PATTERN.test(target.ownerModuleId.trim())) {
66
+ throw new Error("Invalid Phi Module id.");
67
+ }
68
+ const pageId = createPhiPresetCmsPageId({
69
+ ownerModuleId: target.ownerModuleId,
70
+ presetKey: target.presetKey,
71
+ });
72
+ return `${PHI_PAGE_REFERENCE_VERSION}${PHI_PAGE_REFERENCE_MODULE_TAG}${pageId}`;
73
+ }
74
+ export function readPhiPageReference(value) {
75
+ if (typeof value !== "string" || !value.startsWith(PHI_PAGE_REFERENCE_VERSION)) {
76
+ return null;
77
+ }
78
+ const tag = value.slice(PHI_PAGE_REFERENCE_VERSION.length, PHI_PAGE_REFERENCE_VERSION.length + 1);
79
+ const body = value.slice(PHI_PAGE_REFERENCE_VERSION.length + 1);
80
+ if (tag === PHI_PAGE_REFERENCE_SITE_TAG) {
81
+ const pageScopeId = PHI_PAGE_SCOPE_ID_PATTERN.test(body) ? Number(body) : NaN;
82
+ return isPositiveInteger(pageScopeId)
83
+ ? { reference: value, target: { kind: "site", pageScopeId } }
84
+ : null;
85
+ }
86
+ if (tag === PHI_PAGE_REFERENCE_MODULE_TAG) {
87
+ const pageId = readPresetPageId(body);
88
+ return pageId
89
+ ? { reference: value, target: { kind: "module", pageId } }
90
+ : null;
91
+ }
92
+ return null;
93
+ }
94
+ export function createPhiPageUri(reference, fragment) {
95
+ if (!readPhiPageReference(reference)) {
96
+ throw new Error("Invalid Phi Page reference.");
97
+ }
98
+ const normalizedFragment = fragment?.replace(/^#/u, "").trim();
99
+ return `${PHIS_INTERNAL_PAGE_SCHEME}${reference}${normalizedFragment ? `#${encodeURIComponent(normalizedFragment)}` : ""}`;
100
+ }
101
+ export function createPhiAssetUri(assetId) {
102
+ if (!isPositiveInteger(assetId)) {
103
+ throw new Error("Phi Asset ids must be positive integers.");
104
+ }
105
+ return `${PHIS_INTERNAL_ASSET_SCHEME}${assetId}`;
106
+ }
107
+ export function readPhiInternalReference(value) {
108
+ if (typeof value !== "string") {
109
+ return null;
110
+ }
111
+ if (value.startsWith(PHIS_INTERNAL_ASSET_SCHEME)) {
112
+ const id = value.slice(PHIS_INTERNAL_ASSET_SCHEME.length);
113
+ return /^[1-9]\d*$/u.test(id) && Number.isSafeInteger(Number(id))
114
+ ? { kind: "asset", assetId: Number(id) }
115
+ : null;
116
+ }
117
+ if (!value.startsWith(PHIS_INTERNAL_PAGE_SCHEME)) {
118
+ return null;
119
+ }
120
+ const [encodedReference, ...fragmentParts] = value.slice(PHIS_INTERNAL_PAGE_SCHEME.length).split("#");
121
+ const parsed = readPhiPageReference(encodedReference);
122
+ if (!parsed) {
123
+ return null;
124
+ }
125
+ try {
126
+ const fragment = fragmentParts.length > 0 ? decodeURIComponent(fragmentParts.join("#")) : null;
127
+ return { kind: "page", ...parsed, fragment };
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ /**
134
+ * The field names a stored link target may stand under, and nothing else identifies one in persisted
135
+ * config.
136
+ *
137
+ * The Asset rule is the precedent and the reason: the server's reference collector reads persisted JSON,
138
+ * not the Widget catalogue, so it cannot ask a plugin which of its fields hold targets. A Widget that
139
+ * keeps one under another name authors a reference the delete guard cannot see, and the Page it points
140
+ * at can be removed while the Widget still draws a link to it.
141
+ *
142
+ * One plain name for the single-link case and a suffix for the rest, so a Card's second link is
143
+ * `actionLinkTarget` and is found by the same rule that finds the first.
144
+ */
145
+ export const PHI_LINK_TARGET_CONFIG_KEY = "linkTarget";
146
+ const PHI_LINK_TARGET_CONFIG_KEY_SUFFIX = "LinkTarget";
147
+ export function isPhiLinkTargetConfigKey(key) {
148
+ return key === PHI_LINK_TARGET_CONFIG_KEY
149
+ || (key.endsWith(PHI_LINK_TARGET_CONFIG_KEY_SUFFIX) && key.length > PHI_LINK_TARGET_CONFIG_KEY_SUFFIX.length);
150
+ }
151
+ /**
152
+ * Whether a string may be kept as an external target.
153
+ *
154
+ * Absolute, or an anchor on the document the reader already has. Everything else is refused, and the
155
+ * refusal is the whole point rather than strictness for its own sake: a stored `/pricing` is a Page link
156
+ * that has thrown its identity away, and one of them is enough to give the Site a second internal format
157
+ * -- one the resolver cannot resolve, the index cannot see, and the delete guard cannot warn about.
158
+ *
159
+ * The reserved scheme is rejected here in its plain form only. Normalising the encodings it can hide in
160
+ * belongs to the server's sanitizer, which sees the whole document; this is the near guard, so a target
161
+ * never carries an unresolved `phis:` URI in the field where a literal URL belongs.
162
+ */
163
+ const PHI_STORABLE_EXTERNAL_HREF_PATTERN = /^(?:https?:\/\/|\/\/|mailto:|tel:|#)/iu;
164
+ export function isPhiStorableExternalHref(value) {
165
+ const trimmed = value.trim();
166
+ return trimmed.length > 0
167
+ && !trimmed.toLowerCase().startsWith("phis:")
168
+ && PHI_STORABLE_EXTERNAL_HREF_PATTERN.test(trimmed);
169
+ }
170
+ /**
171
+ * A stored link target, read back under the contract rather than trusted.
172
+ *
173
+ * Anything that is not one of the two shapes answers `null`, and a Control that gets `null` draws no
174
+ * link at all. That is the same answer the contract gives for a Page reference that no longer resolves:
175
+ * non-interactive text beats a link that goes somewhere nobody chose.
176
+ */
177
+ export function readPhiLinkTarget(value) {
178
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
179
+ return null;
180
+ }
181
+ const record = value;
182
+ const newTab = record.newTab === true ? { newTab: true } : {};
183
+ if (record.kind === "page") {
184
+ const parsed = readPhiPageReference(record.reference);
185
+ if (!parsed) {
186
+ return null;
187
+ }
188
+ const fragment = typeof record.fragment === "string" ? record.fragment.replace(/^#/u, "").trim() : "";
189
+ /*
190
+ * An Area that is not one is dropped rather than kept: the alternative is a stored value that asks
191
+ * a question nowhere can answer, and a link drawing nothing with no way to see why. Falling back to
192
+ * the asking Area is the behaviour of every link that names none, which is the honest default.
193
+ */
194
+ const area = isPhiCmsAreaKey(record.area) ? { area: record.area } : {};
195
+ return {
196
+ kind: "page",
197
+ reference: parsed.reference,
198
+ ...area,
199
+ ...(fragment ? { fragment } : {}),
200
+ ...newTab,
201
+ };
202
+ }
203
+ if (record.kind === "external") {
204
+ const href = typeof record.href === "string" ? record.href.trim() : "";
205
+ return isPhiStorableExternalHref(href) ? { kind: "external", href, ...newTab } : null;
206
+ }
207
+ return null;
208
+ }
package/dist/signals.d.ts CHANGED
@@ -78,6 +78,7 @@ export declare function isPhiSignalAddressSegment(value: string): boolean;
78
78
  */
79
79
  export declare function isPhiControllerPluginKey(value: string): boolean;
80
80
  export declare function isPhiControllerSignalAddress(address: unknown): address is PhiControllerSignalAddress;
81
+ export declare function readPhiControllerSignalAddress(value: unknown): PhiControllerSignalAddress | undefined;
81
82
  /** The instance a `cms:` address names, or null when the string is not one. */
82
83
  export declare function readPhiCmsSignalAddressInstanceId(address: string): PhiCmsInstanceId | null;
83
84
  export declare function isPhiRegionSignalAddress(address: string): boolean;
package/dist/signals.js CHANGED
@@ -146,6 +146,9 @@ export function isPhiControllerSignalAddress(address) {
146
146
  isPhiSignalAddressSegment(namespacedType.slice(controllerSeparatorIndex + 1)) &&
147
147
  isPhiSignalAddressSegment(instanceKey));
148
148
  }
149
+ export function readPhiControllerSignalAddress(value) {
150
+ return isPhiControllerSignalAddress(value) ? value.trim() : undefined;
151
+ }
149
152
  /** The instance a `cms:` address names, or null when the string is not one. */
150
153
  export function readPhiCmsSignalAddressInstanceId(address) {
151
154
  if (!address.startsWith("cms:")) {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@phis/contracts",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "sideEffects": false,
10
- "description": "The agreements phis keeps with the site UI, Sites and Add-ons: the Add-on ABI, access, signals, catalog, CMS identity, media, HTTP headers, locale, logging, capabilities and group levels.",
10
+ "description": "The agreements phis keeps with the site UI, Sites and Add-ons: the Add-on ABI, access, signals, conditions, forms, controls, layout, catalog, CMS identity, media, HTTP headers, locale, logging, capabilities and group levels.",
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.build.json",
13
13
  "typecheck": "tsc --noEmit",
@@ -15,6 +15,11 @@
15
15
  "prepack": "npm run build"
16
16
  },
17
17
  "exports": {
18
+ "./references": {
19
+ "types": "./dist/references.d.ts",
20
+ "import": "./dist/references.js",
21
+ "default": "./dist/references.js"
22
+ },
18
23
  "./addon": {
19
24
  "types": "./dist/addon-index.d.ts",
20
25
  "import": "./dist/addon-index.js",
@@ -89,6 +94,26 @@
89
94
  "types": "./dist/user-state.d.ts",
90
95
  "import": "./dist/user-state.js",
91
96
  "default": "./dist/user-state.js"
97
+ },
98
+ "./conditions": {
99
+ "types": "./dist/conditions.d.ts",
100
+ "import": "./dist/conditions.js",
101
+ "default": "./dist/conditions.js"
102
+ },
103
+ "./controls": {
104
+ "types": "./dist/controls.d.ts",
105
+ "import": "./dist/controls.js",
106
+ "default": "./dist/controls.js"
107
+ },
108
+ "./forms": {
109
+ "types": "./dist/forms.d.ts",
110
+ "import": "./dist/forms.js",
111
+ "default": "./dist/forms.js"
112
+ },
113
+ "./layout": {
114
+ "types": "./dist/layout.d.ts",
115
+ "import": "./dist/layout.js",
116
+ "default": "./dist/layout.js"
92
117
  }
93
118
  },
94
119
  "files": [