@proveanything/smartlinks 2.0.37 → 2.0.39

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,249 @@
1
+ // =============================================================================
2
+ // headless — validate an app's headless-provider declaration (manifest `data` + `headless`).
3
+ //
4
+ // SL.headless.validate(manifest, { samples }) → { ok, errors, warnings, recipes }
5
+ //
6
+ // Pure (no network): the `smartlinks-headless` CLI and Forge both call it. `samples` are real items
7
+ // (each type's `data` zone JSON) — the check then reports fields the real data has but the
8
+ // declaration doesn't, and values that don't match their declared type.
9
+ // Spec: docs/headless-providers.md.
10
+ // =============================================================================
11
+ import { HEADLESS_CATEGORIES } from './types/headless.js';
12
+ const FIELD_TYPES = new Set([
13
+ 'string', 'text', 'richtext', 'markdown', 'number', 'boolean', 'date', 'datetime', 'enum', 'url',
14
+ 'image', 'file', 'ref', 'string[]', 'ref[]', 'json',
15
+ ]);
16
+ const TEXT_TYPES = new Set(['string', 'text', 'richtext', 'markdown']);
17
+ const PLATFORM_REFS = new Set(['product', 'contact', 'proof']);
18
+ const SEO_HELPERS = new Set(['faqPage', 'product', 'article', 'breadcrumbs', 'organization', 'localBusiness']);
19
+ const STORAGE_KINDS = new Set(['record', 'case', 'thread', 'config']);
20
+ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
21
+ /**
22
+ * What a type's read calls return, and where one item's declared fields live — fixed by the storage
23
+ * kind (see app-objects.md "Paginated List Responses"). Sites must read items from `response.data`.
24
+ */
25
+ export function responseShape(type) {
26
+ const s = type.storage;
27
+ const first = Object.keys(type.fields || {})[0] || 'field';
28
+ const paged = (name) => `list → { data: ${name}[], pagination: { total, limit, offset, hasMore } }. The items are in response.data (not response.items / response.records); page with offset/limit while pagination.hasMore. get → one ${name}.`;
29
+ switch (s && s.kind) {
30
+ case 'record':
31
+ return { returns: paged('AppRecord'), item: `Each record's declared fields are in record.data (e.g. record.data.${first}); record.id, record.productId, record.status, record.createdAt are top-level.` };
32
+ case 'case':
33
+ return { returns: paged('AppCase'), item: `Each case's declared fields are in case.data (e.g. case.data.${first}); id, status, category and dates are top-level.` };
34
+ case 'thread':
35
+ return { returns: paged('AppThread'), item: `Each thread's declared fields are in thread.data (e.g. thread.data.${first}); replies are in thread.replies.` };
36
+ case 'config': {
37
+ const key = s.key;
38
+ return { returns: "The app's configuration object for the collection.", item: key ? `The items are in config.${key}; each item's fields are its declared fields.` : 'The declared fields are top-level properties of the config object.' };
39
+ }
40
+ default:
41
+ return { returns: '(unknown storage)', item: '' };
42
+ }
43
+ }
44
+ /** The standard SDK read calls for a type, from how it's stored. `appId` / `collectionId` are the caller's variables. */
45
+ export function standardRecipe(type) {
46
+ const s = type.storage;
47
+ switch (s && s.kind) {
48
+ case 'record':
49
+ return {
50
+ list: `SL.app.records.list(collectionId, appId, { recordType: '${s.recordType}', limit: 50 })`,
51
+ get: 'SL.app.records.get(collectionId, appId, recordId)',
52
+ };
53
+ case 'case':
54
+ return { list: 'SL.app.cases.list(collectionId, appId, { limit: 50 })', get: 'SL.app.cases.get(collectionId, appId, caseId)' };
55
+ case 'thread':
56
+ return { list: 'SL.app.threads.list(collectionId, appId, { limit: 50 })', get: 'SL.app.threads.get(collectionId, appId, threadId)' };
57
+ case 'config':
58
+ return { list: `SL.appConfiguration.getConfig({ collectionId, appId })${s.key ? ` // → config.${s.key}` : ''}` };
59
+ default:
60
+ return { list: '(unknown storage)' };
61
+ }
62
+ }
63
+ function isPlaceholder(v) {
64
+ return typeof v === 'string' && /lorem ipsum|^(todo|tbd|example|test|foo|bar)$/i.test(v.trim());
65
+ }
66
+ /** Does a value fit a declared field? Returns a reason when it doesn't. */
67
+ function mismatch(field, value) {
68
+ if (value === null || value === undefined)
69
+ return null;
70
+ const t = field.type;
71
+ const isLocalizedObject = field.localized && typeof value === 'object' && !Array.isArray(value) &&
72
+ Object.values(value).every((x) => typeof x === 'string');
73
+ switch (t) {
74
+ case 'string':
75
+ case 'text':
76
+ case 'richtext':
77
+ case 'markdown':
78
+ return typeof value === 'string' || isLocalizedObject ? null : `expected ${t} (a string${field.localized ? ' or { lang: string }' : ''})`;
79
+ case 'url':
80
+ return typeof value === 'string' ? null : 'expected a URL string';
81
+ case 'number':
82
+ return typeof value === 'number' && Number.isFinite(value) ? null : 'expected a number';
83
+ case 'boolean':
84
+ return typeof value === 'boolean' ? null : 'expected true/false';
85
+ case 'date':
86
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? null : 'expected an ISO date (YYYY-MM-DD)';
87
+ case 'datetime':
88
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? null : 'expected an ISO date-time';
89
+ case 'enum':
90
+ return (field.options || []).includes(value) ? null : `expected one of ${(field.options || []).join(', ')}`;
91
+ case 'image':
92
+ case 'file':
93
+ return typeof value === 'string' || (typeof value === 'object' && !Array.isArray(value) && typeof value.url === 'string')
94
+ ? null : `expected a URL string or { url }`;
95
+ case 'ref':
96
+ return typeof value === 'string' ? null : 'expected an id string';
97
+ case 'string[]':
98
+ case 'ref[]':
99
+ return Array.isArray(value) && value.every((x) => typeof x === 'string') ? null : 'expected an array of strings';
100
+ default:
101
+ return null;
102
+ }
103
+ }
104
+ function checkItems(label, typeId, type, items, out) {
105
+ const fields = type.fields || {};
106
+ const undeclared = new Map();
107
+ items.forEach((item, i) => {
108
+ const at = `data.types.${typeId}.${label === 'example' ? 'examples' : 'realData'}[${i}]`;
109
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
110
+ out.errors.push({ path: at, message: `${label} must be an object (the item's data JSON)` });
111
+ return;
112
+ }
113
+ for (const [key, field] of Object.entries(fields)) {
114
+ if ((field.zone || 'data') !== 'data')
115
+ continue; // examples / samples are the data zone
116
+ const value = item[key];
117
+ if (field.required && (value === undefined || value === null || value === '')) {
118
+ ;
119
+ (label === 'example' ? out.errors : out.warnings).push({ path: `${at}.${key}`, message: `required field "${key}" is missing` });
120
+ continue;
121
+ }
122
+ const why = mismatch(field, value);
123
+ if (why)
124
+ (label === 'example' ? out.errors : out.warnings).push({ path: `${at}.${key}`, message: `"${key}": ${why}` });
125
+ if (label === 'example' && isPlaceholder(value))
126
+ out.warnings.push({ path: `${at}.${key}`, message: 'looks like placeholder text — use realistic content' });
127
+ }
128
+ for (const key of Object.keys(item))
129
+ if (!(key in fields))
130
+ undeclared.set(key, (undeclared.get(key) || 0) + 1);
131
+ });
132
+ for (const [key, n] of undeclared) {
133
+ out.warnings.push({
134
+ path: `data.types.${typeId}.fields`,
135
+ message: label === 'example'
136
+ ? `example uses "${key}", which isn't a declared field`
137
+ : `real data has "${key}" (in ${n} of ${items.length} items) but it isn't declared — declare it, or confirm it's internal`,
138
+ });
139
+ }
140
+ }
141
+ /** Validate a manifest's `data` + `headless` blocks. */
142
+ export function validate(manifest, opts = {}) {
143
+ const errors = [];
144
+ const warnings = [];
145
+ const recipes = {};
146
+ const err = (path, message) => errors.push({ path, message });
147
+ const warn = (path, message) => warnings.push({ path, message });
148
+ const data = manifest && manifest.data;
149
+ const headless = manifest && manifest.headless;
150
+ // ---- data
151
+ if (!data) {
152
+ err('data', headless ? 'a headless app needs a `data` block declaring its types' : 'no `data` block');
153
+ }
154
+ else {
155
+ if (!data.schemaVersion || !SEMVER.test(data.schemaVersion))
156
+ err('data.schemaVersion', 'set a semver, e.g. "1.0.0"');
157
+ const types = data.types || {};
158
+ if (!Object.keys(types).length)
159
+ err('data.types', 'declare at least one type');
160
+ const typeIds = new Set(Object.keys(types));
161
+ for (const [typeId, type] of Object.entries(types)) {
162
+ const at = `data.types.${typeId}`;
163
+ if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/.test(typeId))
164
+ err(at, 'type ids are lowercase, dot-separated (e.g. "faq.item")');
165
+ if (!type.description || type.description.trim().length < 10)
166
+ err(`${at}.description`, 'say what one item is, in a sentence');
167
+ const kind = type.storage && type.storage.kind;
168
+ if (!STORAGE_KINDS.has(kind))
169
+ err(`${at}.storage`, 'storage.kind must be record, case, thread or config');
170
+ if (kind === 'record' && !type.storage.recordType)
171
+ err(`${at}.storage.recordType`, 'record storage needs the recordType the app writes');
172
+ if (type.visibility && !['public', 'owner', 'admin'].includes(type.visibility))
173
+ err(`${at}.visibility`, 'public, owner or admin');
174
+ const fields = type.fields || {};
175
+ if (!Object.keys(fields).length)
176
+ err(`${at}.fields`, 'declare the fields');
177
+ for (const [key, f] of Object.entries(fields)) {
178
+ const fat = `${at}.fields.${key}`;
179
+ if (!f || !FIELD_TYPES.has(f.type)) {
180
+ err(fat, `unknown type "${f && f.type}" (one of ${[...FIELD_TYPES].join(', ')})`);
181
+ continue;
182
+ }
183
+ if (f.type === 'enum' && !(f.options && f.options.length))
184
+ err(fat, 'an enum needs options');
185
+ if ((f.type === 'ref' || f.type === 'ref[]') && !(f.to && (typeIds.has(f.to) || PLATFORM_REFS.has(f.to)))) {
186
+ err(fat, `"to" must name a declared type or product/contact/proof`);
187
+ }
188
+ if (f.localized && !TEXT_TYPES.has(f.type))
189
+ warn(fat, 'only text fields can be localized');
190
+ if (f.public && f.zone && f.zone !== 'data')
191
+ err(fat, `a ${f.zone}-zone field can't be public — only the data zone is readable publicly`);
192
+ }
193
+ for (const k of (type.listing && type.listing.sort) || [])
194
+ if (!(k.replace(/^-/, '') in fields))
195
+ err(`${at}.listing.sort`, `"${k}" isn't a field`);
196
+ for (const k of (type.listing && type.listing.filters) || [])
197
+ if (!(k in fields))
198
+ err(`${at}.listing.filters`, `"${k}" isn't a field`);
199
+ if (type.examples)
200
+ checkItems('example', typeId, type, type.examples, { errors, warnings });
201
+ const samples = opts.samples && opts.samples[typeId];
202
+ if (samples && samples.length)
203
+ checkItems('real item', typeId, type, samples, { errors, warnings });
204
+ const std = standardRecipe(type);
205
+ const get = (type.read && type.read.get) || std.get;
206
+ recipes[typeId] = Object.assign(Object.assign({ list: (type.read && type.read.list) || std.list }, (get ? { get } : {})), responseShape(type));
207
+ }
208
+ }
209
+ // ---- headless
210
+ if (headless) {
211
+ if (!headless.purpose || headless.purpose.trim().length < 40) {
212
+ err('headless.purpose', 'explain what content this holds and when a site should use it (a few sentences)');
213
+ }
214
+ const cats = headless.categories || [];
215
+ if (!cats.length)
216
+ err('headless.categories', `pick at least one: ${HEADLESS_CATEGORIES.join(', ')}`);
217
+ for (const c of cats)
218
+ if (!HEADLESS_CATEGORIES.includes(c))
219
+ err('headless.categories', `"${c}" isn't a category (${HEADLESS_CATEGORIES.join(', ')})`);
220
+ if (!headless.editedIn || !headless.editedIn.label)
221
+ err('headless.editedIn.label', 'say where the business edits this content (the app admin screen)');
222
+ for (const s of (headless.render && headless.render.seo) || [])
223
+ if (!SEO_HELPERS.has(s))
224
+ err('headless.render.seo', `"${s}" isn't an SEO helper (${[...SEO_HELPERS].join(', ')})`);
225
+ const types = (data && data.types) || {};
226
+ const primary = headless.primaryTypes || [];
227
+ if (!primary.length)
228
+ err('headless.primaryTypes', 'name the type(s) a site renders');
229
+ for (const id of primary) {
230
+ const type = types[id];
231
+ if (!type) {
232
+ err('headless.primaryTypes', `"${id}" isn't a declared type`);
233
+ continue;
234
+ }
235
+ if (type.visibility === 'admin')
236
+ err(`data.types.${id}.visibility`, 'a primary type must be readable by visitors (visibility public)');
237
+ const publicFields = Object.entries(type.fields || {}).filter(([, f]) => f.public);
238
+ if (!publicFields.length)
239
+ err(`data.types.${id}.fields`, 'mark the fields a visitor may read with "public": true');
240
+ if (!type.examples || !type.examples.length)
241
+ err(`data.types.${id}.examples`, 'add at least one realistic example item');
242
+ if (!type.read)
243
+ warn(`data.types.${id}.read`, 'no read recipe — the standard one for its storage is used (see recipes)');
244
+ }
245
+ if (!(headless.render && headless.render.guidance))
246
+ warn('headless.render.guidance', 'add a line on how a site should present this content');
247
+ }
248
+ return { ok: errors.length === 0, errors, warnings, recipes };
249
+ }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,9 @@ export type { InvalidateCacheOptions } from "./http.js";
8
8
  export * as cache from './cache.js';
9
9
  export { IframeResponder, isAdminFromRoles, buildIframeSrc, } from './iframeResponder.js';
10
10
  export * as utils from './utils/index.js';
11
+ export * as seo from './seo.js';
12
+ export * as site from './site.js';
13
+ export * as headless from './headless.js';
11
14
  export { SHARED_DEPENDENCY_CONTRACT_VERSION, SHARED_DEPENDENCIES, SHARED_DEPENDENCY_SPECIFIERS, importMapPathFor, getHostSharedDependencies, } from './shared-dependencies.js';
12
15
  export type { SharedDependency, HostSharedDependencies } from './shared-dependencies.js';
13
16
  export { AI_TOOL_NAMES, BUILTIN_AI_TOOLS, getBuiltinAiTool } from './ai-tools.js';
package/dist/index.js CHANGED
@@ -13,6 +13,12 @@ export { cache_1 as cache };
13
13
  export { IframeResponder, isAdminFromRoles, buildIframeSrc, } from './iframeResponder.js';
14
14
  import * as utils_1 from './utils/index.js';
15
15
  export { utils_1 as utils };
16
+ import * as seo_1 from './seo.js';
17
+ export { seo_1 as seo };
18
+ import * as site_1 from './site.js';
19
+ export { site_1 as site };
20
+ import * as headless_1 from './headless.js';
21
+ export { headless_1 as headless };
16
22
  // Shared dependency contract (host↔app) — one source of truth for externalized deps
17
23
  export { SHARED_DEPENDENCY_CONTRACT_VERSION, SHARED_DEPENDENCIES, SHARED_DEPENDENCY_SPECIFIERS, importMapPathFor, getHostSharedDependencies, } from './shared-dependencies.js';
18
24
  // Built-in AI tool catalog (design-time discovery of the core agentic toolset)
package/dist/openapi.yaml CHANGED
@@ -19459,6 +19459,10 @@ components:
19459
19459
  $ref: "#/components/schemas/AppManifestExecutor"
19460
19460
  functions:
19461
19461
  $ref: "#/components/schemas/AppManifestFunctions"
19462
+ data:
19463
+ $ref: "#/components/schemas/AppDataDeclaration"
19464
+ headless:
19465
+ $ref: "#/components/schemas/AppHeadlessDeclaration"
19462
19466
  required:
19463
19467
  - name
19464
19468
  - version
@@ -25155,6 +25159,161 @@ components:
25155
25159
  type: boolean
25156
25160
  FacetValueDefinition:
25157
25161
  $ref: "#/components/schemas/FacetValue"
25162
+ AppDataField:
25163
+ type: object
25164
+ properties:
25165
+ type:
25166
+ $ref: "#/components/schemas/AppDataFieldType"
25167
+ label:
25168
+ type: string
25169
+ description:
25170
+ type: string
25171
+ required:
25172
+ type: boolean
25173
+ localized:
25174
+ type: boolean
25175
+ zone:
25176
+ type: string
25177
+ enum:
25178
+ - data
25179
+ - owner
25180
+ - admin
25181
+ public:
25182
+ type: boolean
25183
+ to:
25184
+ type: string
25185
+ options:
25186
+ type: array
25187
+ items:
25188
+ type: string
25189
+ required:
25190
+ - type
25191
+ AppDataType:
25192
+ type: object
25193
+ properties:
25194
+ description:
25195
+ type: string
25196
+ storage:
25197
+ $ref: "#/components/schemas/AppDataStorage"
25198
+ visibility:
25199
+ type: string
25200
+ enum:
25201
+ - public
25202
+ - owner
25203
+ - admin
25204
+ anchors:
25205
+ type: array
25206
+ items:
25207
+ type: string
25208
+ enum:
25209
+ - product
25210
+ - variant
25211
+ - batch
25212
+ - proof
25213
+ - contact
25214
+ fields:
25215
+ type: object
25216
+ additionalProperties:
25217
+ $ref: "#/components/schemas/AppDataField"
25218
+ listing:
25219
+ type: object
25220
+ additionalProperties: true
25221
+ examples:
25222
+ type: array
25223
+ items:
25224
+ type: object
25225
+ additionalProperties: true
25226
+ read:
25227
+ type: object
25228
+ additionalProperties: true
25229
+ since:
25230
+ type: string
25231
+ required:
25232
+ - description
25233
+ - storage
25234
+ - fields
25235
+ AppDataDeclaration:
25236
+ type: object
25237
+ properties:
25238
+ schemaVersion:
25239
+ type: string
25240
+ types:
25241
+ type: object
25242
+ additionalProperties:
25243
+ $ref: "#/components/schemas/AppDataType"
25244
+ required:
25245
+ - schemaVersion
25246
+ - types
25247
+ AppHeadlessDeclaration:
25248
+ type: object
25249
+ properties:
25250
+ purpose:
25251
+ type: string
25252
+ categories:
25253
+ type: array
25254
+ items:
25255
+ $ref: "#/components/schemas/HeadlessCategory"
25256
+ primaryTypes:
25257
+ type: array
25258
+ items:
25259
+ type: string
25260
+ editedIn:
25261
+ type: object
25262
+ additionalProperties: true
25263
+ render:
25264
+ type: object
25265
+ additionalProperties: true
25266
+ required:
25267
+ - purpose
25268
+ - categories
25269
+ - primaryTypes
25270
+ - editedIn
25271
+ AppDataFieldType:
25272
+ type: string
25273
+ enum:
25274
+ - string
25275
+ - text
25276
+ - richtext
25277
+ - markdown
25278
+ - number
25279
+ - boolean
25280
+ - date
25281
+ - datetime
25282
+ - enum
25283
+ - url
25284
+ - image
25285
+ - file
25286
+ - ref
25287
+ - "string[]"
25288
+ - "ref[]"
25289
+ - json
25290
+ AppDataStorage:
25291
+ type: object
25292
+ additionalProperties: true
25293
+ HeadlessCategory:
25294
+ type: string
25295
+ enum:
25296
+ - faq
25297
+ - media
25298
+ - pages
25299
+ - articles
25300
+ - catalog
25301
+ - events
25302
+ - locations
25303
+ - people
25304
+ - reviews
25305
+ - documents
25306
+ - forms
25307
+ - other
25308
+ HeadlessSeoHelper:
25309
+ type: string
25310
+ enum:
25311
+ - faqPage
25312
+ - product
25313
+ - article
25314
+ - breadcrumbs
25315
+ - organization
25316
+ - localBusiness
25158
25317
  CachedData:
25159
25318
  type: object
25160
25319
  properties:
package/dist/seo.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ export interface SeoHead {
2
+ title?: string;
3
+ description?: string;
4
+ /** Absolute image URL for link previews (og:image / twitter:image). */
5
+ image?: string;
6
+ /** Absolute canonical URL. Usually leave unset: the platform sends the site's canonical address. */
7
+ canonical?: string;
8
+ /** Keep this page out of search engines. */
9
+ noindex?: boolean;
10
+ /** og:type — 'website' (default), 'article' or 'product'. */
11
+ type?: 'website' | 'article' | 'product';
12
+ /** The site's name (og:site_name). */
13
+ siteName?: string;
14
+ }
15
+ /** Set this route's head tags. Call on every route change; unset fields are removed (if we added them). */
16
+ export declare function head(h: SeoHead): void;
17
+ /**
18
+ * Place a JSON-LD structured-data block in <head>. `id` names the block, so calling again replaces it
19
+ * (e.g. per route); pass null to remove it. `<` is escaped so data can't close the script tag.
20
+ */
21
+ export declare function jsonLd(id: string, data: object | object[] | null): void;
22
+ type Obj = Record<string, any>;
23
+ export interface ProductSchemaOptions {
24
+ /** The page's absolute URL. */
25
+ url?: string;
26
+ /** Brand name (defaults to none). */
27
+ brand?: string;
28
+ /** An offer, when the product is sold: price as a number or string, ISO currency, availability. */
29
+ offer?: {
30
+ price: number | string;
31
+ currency: string;
32
+ availability?: 'InStock' | 'OutOfStock' | 'PreOrder';
33
+ url?: string;
34
+ };
35
+ /** Aggregate rating, when the site shows one. */
36
+ rating?: {
37
+ value: number;
38
+ count: number;
39
+ };
40
+ }
41
+ export declare const schema: {
42
+ /** schema.org Product from a SmartLinks product. */
43
+ product(product: Obj, opts?: ProductSchemaOptions): Obj;
44
+ /** schema.org FAQPage — answer-shaped content AI search can cite, and FAQ rich results. */
45
+ faqPage(items: Array<{
46
+ question: string;
47
+ answer: string;
48
+ }>): Obj;
49
+ /** schema.org Organization from a collection (the brand behind the site). */
50
+ organization(collection: Obj, opts?: {
51
+ url?: string;
52
+ sameAs?: string[];
53
+ }): Obj;
54
+ /** schema.org LocalBusiness — for a business with a place (shop, restaurant, studio). */
55
+ localBusiness(collection: Obj, opts?: {
56
+ url?: string;
57
+ telephone?: string;
58
+ address?: {
59
+ street?: string;
60
+ locality?: string;
61
+ region?: string;
62
+ postalCode?: string;
63
+ country?: string;
64
+ };
65
+ openingHours?: string[];
66
+ type?: string;
67
+ }): Obj;
68
+ /** schema.org BreadcrumbList from a trail of { name, url } (home first). */
69
+ breadcrumbs(trail: Array<{
70
+ name: string;
71
+ url: string;
72
+ }>): Obj;
73
+ /** schema.org Article (a blog post, news item, guide). Dates as ISO strings. */
74
+ article(a: {
75
+ headline: string;
76
+ description?: string;
77
+ image?: string;
78
+ url?: string;
79
+ datePublished?: string;
80
+ dateModified?: string;
81
+ author?: string;
82
+ publisher?: string;
83
+ }): Obj;
84
+ };
85
+ export {};