@proveanything/smartlinks 2.0.36 → 2.0.38

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/api/ai.d.ts CHANGED
@@ -111,8 +111,8 @@ declare namespace aiInternal {
111
111
  }>;
112
112
  }
113
113
  /**
114
- * AI usage / cost report for a collection, grouped by any of model/serviceTier/appId/feature/mode
115
- * over an optional date window. `costUnits` are OPAQUE internal units, never provider currency.
114
+ * AI usage for a collection (daily totals), grouped by any of model/appId/feature/mode/surface/provider/day
115
+ * over an optional date window (YYYY-MM-DD). Usage only — requests, tokens, images; no cost figures.
116
116
  */
117
117
  function usage(collectionId: string, params?: {
118
118
  groupBy?: string | string[];
package/dist/api/ai.js CHANGED
@@ -224,8 +224,8 @@ var aiInternal;
224
224
  sessions.clear = clear;
225
225
  })(sessions = aiInternal.sessions || (aiInternal.sessions = {}));
226
226
  /**
227
- * AI usage / cost report for a collection, grouped by any of model/serviceTier/appId/feature/mode
228
- * over an optional date window. `costUnits` are OPAQUE internal units, never provider currency.
227
+ * AI usage for a collection (daily totals), grouped by any of model/appId/feature/mode/surface/provider/day
228
+ * over an optional date window (YYYY-MM-DD). Usage only — requests, tokens, images; no cost figures.
229
229
  */
230
230
  async function usage(collectionId, params) {
231
231
  const groupBy = Array.isArray(params === null || params === void 0 ? void 0 : params.groupBy) ? params.groupBy.join(',') : params === null || params === void 0 ? void 0 : params.groupBy;
package/dist/context.js CHANGED
@@ -40,6 +40,21 @@ export function readContext(overrides) {
40
40
  if (window.location.search)
41
41
  readSearchParamsInto(out, new URLSearchParams(window.location.search));
42
42
  }
43
+ // 4. the SITE the page is served as (lowest precedence). When an app runs as a public website at
44
+ // its own hostname (an "app site"), the host page injects window.__SMARTLINKS_SITE__ =
45
+ // { collectionId, appId, channel, host } — so a site knows its collection with no URL params.
46
+ const site = globalThis.__SMARTLINKS_SITE__;
47
+ if (site && typeof site === 'object') {
48
+ const fill = (key, value) => { if (out[key] == null && typeof value === 'string' && value)
49
+ out[key] = value; };
50
+ fill('collectionId', site.collectionId);
51
+ fill('appId', site.appId);
52
+ // A site on a pre-release channel calls that channel's server functions (stable needs no channel).
53
+ if (site.channel && site.channel !== 'stable') {
54
+ fill('appChannel', site.channel);
55
+ fill('appChannelApp', site.appId);
56
+ }
57
+ }
43
58
  }
44
59
  catch (_a) {
45
60
  /* non-browser / bad URL — return what we have */
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.36 | Generated: 2026-10-03T15:04:37.396Z
3
+ Version: 2.0.38 | Generated: 2026-10-05T12:10:36.598Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -918,8 +918,20 @@ interface AiUsageReport {
918
918
  groupBy: string[]
919
919
  from?: string | null
920
920
  to?: string | null
921
- totals: { promptTokens: number; outputTokens: number; totalTokens: number; requests: number; costUnits: number }
922
- groups: Array<Record<string, any> & { promptTokens: number; outputTokens: number; totalTokens: number; requests: number; costUnits: number }>
921
+ totals: AiUsageTotals
922
+ groups: Array<Record<string, any> & AiUsageTotals>
923
+ }
924
+ ```
925
+
926
+ **AiUsageTotals** (interface)
927
+ ```typescript
928
+ interface AiUsageTotals {
929
+ promptTokens: number
930
+ outputTokens: number
931
+ cachedTokens: number
932
+ totalTokens: number
933
+ requests: number
934
+ images: number
923
935
  }
924
936
  ```
925
937
 
@@ -2736,6 +2748,8 @@ interface AppManifest {
2736
2748
  publicViews?: PublicView[];
2737
2749
  executor?: AppManifestExecutor;
2738
2750
  functions?: AppManifestFunctions;
2751
+ data?: AppDataDeclaration;
2752
+ headless?: AppHeadlessDeclaration;
2739
2753
  [key: string]: any;
2740
2754
  }
2741
2755
  ```
@@ -6447,6 +6461,65 @@ interface FacetValueGetParams {
6447
6461
 
6448
6462
  **FacetValueDefinition** = `FacetValue`
6449
6463
 
6464
+ ### headless
6465
+
6466
+ **AppDataField** (interface)
6467
+ ```typescript
6468
+ interface AppDataField {
6469
+ type: AppDataFieldType;
6470
+ label?: string;
6471
+ description?: string;
6472
+ required?: boolean;
6473
+ localized?: boolean;
6474
+ zone?: 'data' | 'owner' | 'admin';
6475
+ public?: boolean;
6476
+ to?: string;
6477
+ options?: string[];
6478
+ }
6479
+ ```
6480
+
6481
+ **AppDataType** (interface)
6482
+ ```typescript
6483
+ interface AppDataType {
6484
+ description: string;
6485
+ storage: AppDataStorage;
6486
+ visibility?: 'public' | 'owner' | 'admin';
6487
+ anchors?: Array<'product' | 'variant' | 'batch' | 'proof' | 'contact'>;
6488
+ fields: Record<string, AppDataField>;
6489
+ listing?: { sort?: string[]; filters?: string[] };
6490
+ examples?: Array<Record<string, unknown>>;
6491
+ read?: { list?: string; get?: string; notes?: string };
6492
+ since?: string;
6493
+ }
6494
+ ```
6495
+
6496
+ **AppDataDeclaration** (interface)
6497
+ ```typescript
6498
+ interface AppDataDeclaration {
6499
+ schemaVersion: string;
6500
+ types: Record<string, AppDataType>;
6501
+ }
6502
+ ```
6503
+
6504
+ **AppHeadlessDeclaration** (interface)
6505
+ ```typescript
6506
+ interface AppHeadlessDeclaration {
6507
+ purpose: string;
6508
+ categories: HeadlessCategory[];
6509
+ primaryTypes: string[];
6510
+ editedIn: { label: string; adminPath?: string; notes?: string };
6511
+ render?: { guidance?: string; seo?: HeadlessSeoHelper[] };
6512
+ }
6513
+ ```
6514
+
6515
+ **AppDataFieldType** = ``
6516
+
6517
+ **AppDataStorage** = ``
6518
+
6519
+ **HeadlessCategory** = ``
6520
+
6521
+ **HeadlessSeoHelper** = `'faqPage' | 'product' | 'article' | 'breadcrumbs' | 'organization' | 'localBusiness'`
6522
+
6450
6523
  ### iframeResponder
6451
6524
 
6452
6525
  **CachedData** (interface)
@@ -0,0 +1,104 @@
1
+ # Headless providers: declaring an app's content for other sites
2
+
3
+ A **headless provider** is an app whose content other sites and apps may use: an FAQ app, a media gallery, content pages, a blog. The site renders the content its own way. The business keeps editing it in the provider's own admin, inside SmartLinks. A site never builds an editor for provider content.
4
+
5
+ To become a provider, an app declares two blocks in `app.manifest.json`:
6
+ - **`data`**: its data types, meaning how each is stored, its fields, examples and read recipes;
7
+ - **`headless`**: the opt-in, meaning its purpose, categories, which types a site renders, where the owner edits it, and rendering guidance.
8
+
9
+ Site builders (the Forge agent included) read these declarations to decide "install the FAQ app and use its content" instead of inventing their own.
10
+
11
+ ## The `data` block
12
+
13
+ ```jsonc
14
+ "data": {
15
+ "schemaVersion": "1.0.0", // semver of THIS contract; a major = a breaking change
16
+ "types": {
17
+ "<app>.<thing>": { // lowercase, dot-separated, e.g. "faq.item"
18
+ "description": "What one item is, in a sentence.",
19
+ "storage": { "kind": "record", "recordType": "<the recordType the app writes>" },
20
+ "visibility": "public", // the visibility items normally have: public | owner | admin
21
+ "anchors": ["product"], // optional: what items can be attached to
22
+ "fields": {
23
+ "<key>": {
24
+ "type": "string", // see field types
25
+ "label": "Question",
26
+ "required": true,
27
+ "localized": true, // translated per language (text types only)
28
+ "public": true, // readable by anonymous visitors
29
+ "zone": "data" // data (default) | owner | admin
30
+ }
31
+ },
32
+ "listing": { "sort": ["order"], "filters": ["category"] },
33
+ "examples": [ { "<key>": "realistic value" } ],
34
+ "read": { // optional: defaults to the standard calls for the storage
35
+ "list": "SL.app.records.list(collectionId, appId, { recordType: '...', limit: 50 })",
36
+ "get": "SL.app.records.get(collectionId, appId, recordId)",
37
+ "notes": "anything a site must know, e.g. 'sort by order, then title'"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ **Storage kinds:**
45
+
46
+ | `storage.kind` | The app keeps items in | Read with |
47
+ |---|---|---|
48
+ | `record` (+ `recordType`) | App records: the usual home for content | `SL.app.records.list / get`, filtered by `recordType` |
49
+ | `case` | App cases: requests that get resolved | `SL.app.cases.list / get` |
50
+ | `thread` | App threads: discussions, Q&A, reviews | `SL.app.threads.list / get` |
51
+ | `config` (+ optional `key`) | App configuration: settings, small fixed lists | `SL.appConfiguration.getConfig({ collectionId, appId })` |
52
+
53
+ Products, contacts and proofs are platform data. Reference them with `ref` fields (`"to": "product"`); never redeclare them.
54
+
55
+ **Field types:** `string`, `text`, `richtext` (HTML), `markdown`, `number`, `boolean`, `date` (YYYY-MM-DD), `datetime`, `enum` (+ `options`), `url`, `image` and `file` (a URL or `{ url, ... }`), `ref` and `ref[]` (+ `to`: a declared type id, or `product` / `contact` / `proof`), `string[]`, `json` (avoid where a typed field fits).
56
+
57
+ **Zones and what's public.** App records, cases and threads have three JSONB zones: `data`, `owner` and `admin`. Only `data` can be public, and only when the item's visibility is `public`. Mark each field a visitor may read with `"public": true`. Internal fields (notes, moderation flags) are `zone: "admin"` and never public.
58
+
59
+ **Examples** are the item's `data` JSON exactly as the app writes it, with realistic content, not lorem ipsum.
60
+
61
+ ## The `headless` block
62
+
63
+ ```jsonc
64
+ "headless": {
65
+ "purpose": "What content this holds and when a site should use it instead of building its own.",
66
+ "categories": ["faq"], // faq | media | pages | articles | catalog | events | locations
67
+ // | people | reviews | documents | forms | other
68
+ "primaryTypes": ["faq.item"], // the types a site renders; others are supporting (e.g. categories)
69
+ "editedIn": { "label": "FAQ → Questions", "adminPath": "#/questions" },
70
+ "render": {
71
+ "guidance": "Group by category; questions expand to show answers.",
72
+ "seo": ["faqPage"] // SL.seo.schema helpers a site should use
73
+ }
74
+ }
75
+ ```
76
+
77
+ A primary type must be readable by visitors: public visibility, at least one public field, and at least one example.
78
+
79
+ ## Checking a declaration
80
+
81
+ - **In Forge:** `forge-headless check` validates it against the app's real public data on its test collection. `forge-headless register` validates it and records it in Forge, which makes the app a provider that site builds can find.
82
+ - **Anywhere else:**
83
+ - `npx smartlinks-headless [appDir] [--collection <id> --app <appId>]`;
84
+ - or `SL.headless.validate(manifest, { samples })` in code.
85
+
86
+ Errors block. Warnings are worth fixing, and the most useful one is *real data has "x" but it isn't declared*.
87
+
88
+ ## Adding a headless mode to an existing app (procedure)
89
+
90
+ 1. **Find what the app stores.** Search the source for its writes and reads:
91
+ - `SL.app.records.create / upsert / bulkUpsert` (note each `recordType`);
92
+ - `SL.app.cases`, `SL.app.threads`;
93
+ - `appConfiguration.setConfig` / data items.
94
+
95
+ Each distinct `recordType` (or config key) the business edits is a candidate type.
96
+ 2. **Find the fields.** Read the admin forms and the TypeScript types for what each item holds. Note which zone each field is written to.
97
+ 3. **Decide what's public.** Content a visitor sees is public. Notes, flags and anything personal are not, and move to `zone: "admin"` if the app keeps them in `data`.
98
+ 4. **Write the `data` block:** a type per content thing, its storage, fields, listing, and one or two realistic examples taken from the app's real data.
99
+ 5. **Write the `headless` block:** purpose, categories, primary types, where it's edited (the admin screen's name and route), and render and SEO guidance.
100
+ 6. **Check against real data** (`forge-headless check`). Declare any real fields you missed, or confirm they're internal.
101
+ 7. **Register** (`forge-headless register`) once it's valid.
102
+ 8. **Keep the data contract stable.** Adding fields or types is a minor bump of `schemaVersion`; renaming or removing them is a major one.
103
+
104
+ Don't change how the app stores data just to declare it. The declaration describes what already exists. If something must change (say, an internal field sitting in the public `data` zone), make that a separate, deliberate change.
@@ -275,7 +275,7 @@ await SL.functions.call(collectionId, 'pressCounter', {}, { channel: null }) /
275
275
  ```
276
276
 
277
277
  **Public address on the collection's own site (webhooks, integrations).** Every collection has a
278
- site host — `<name>.smartlinks.host` once a name is claimed, else `c-<shortId>.smartlinks.host` —
278
+ site host — `<name>.smartlinks.host` once a name is claimed, else `c-<collectionId>.smartlinks.host` —
279
279
  returned as `collection.siteHost`. App functions are reachable there:
280
280
 
281
281
  ```
@@ -0,0 +1,67 @@
1
+ # Websites: search engines and AI crawlers (SEO + GEO)
2
+
3
+ For apps served as websites (an app site at `<name>.smartlinks.host` or a custom domain). Hub sites
4
+ get all of this from Hub.
5
+
6
+ ## What the platform does for you
7
+
8
+ - **`/robots.txt`, `/sitemap.xml`, `/llms.txt`** are generated on the site's own address. Don't ship
9
+ `robots.txt` or `sitemap.xml`: one build serves many sites, and sitemap URLs must be absolute on
10
+ the site's host, which the build can't know.
11
+ - **Declare your routes** in `public/sitemap-paths.txt`, one path per line (`/`, `/menu`,
12
+ `/book`). They go into the sitemap and `llms.txt` on the right host. Pre-rendered pages
13
+ (`about/index.html`) are found automatically.
14
+ - **Canonical address.** A site can answer on its automatic address, a chosen name and a custom
15
+ domain. Every page gets `Link: <https://{canonical}{path}>; rel="canonical"`, so search engines
16
+ consolidate on one: the custom domain, else the chosen name, else the automatic address. Don't set
17
+ your own canonical unless a page has a different canonical page.
18
+ - **Previews stay out of search.** A sandbox collection's sites, and any test build (dev, alpha,
19
+ beta), are served with `X-Robots-Tag: noindex` and a `robots.txt` that disallows everything,
20
+ whatever the build ships.
21
+
22
+ ## What you do: `SL.seo` and `SL.site`
23
+
24
+ ```ts
25
+ import * as SL from '@proveanything/smartlinks'
26
+
27
+ // Per route, from its data — on every route change:
28
+ SL.seo.head({
29
+ title: `${product.name} — ${brand}`,
30
+ description: product.description, // ~150 characters, says what the page is
31
+ image: product.heroImage?.url, // absolute; used for link previews
32
+ type: 'product', // 'website' | 'article' | 'product'
33
+ })
34
+
35
+ // Structured data (schema.org JSON-LD). The id names the block, so calling again replaces it:
36
+ SL.seo.jsonLd('page', SL.seo.schema.product(product, { url: location.href, brand }))
37
+ SL.seo.jsonLd('faq', SL.seo.schema.faqPage(faqs.map((f) => ({ question: f.q, answer: f.a }))))
38
+ SL.seo.jsonLd('org', SL.seo.schema.organization(collection))
39
+
40
+ // When the page's data has rendered:
41
+ SL.site.ready()
42
+ ```
43
+
44
+ | Builder | For |
45
+ |---|---|
46
+ | `schema.product(product, { url, brand, offer, rating })` | product pages (`offer` only when it's really sold) |
47
+ | `schema.faqPage([{ question, answer }])` | FAQs: rich results, and the answer-shaped content AI search cites |
48
+ | `schema.organization(collection)` | the brand behind the site (home page) |
49
+ | `schema.localBusiness(collection, { type, address, telephone, openingHours })` | a business with a place (`type: 'Florist'`, `'Restaurant'`…) |
50
+ | `schema.breadcrumbs([{ name, url }])` | nested pages |
51
+ | `schema.article({ headline, datePublished, … })` | posts, guides, news |
52
+
53
+ `seo.head` removes tags a previous route set and the next one doesn't. Both are no-ops without a
54
+ `document` (SSR, tests).
55
+
56
+ `site.ready()` tells the platform's page renderer the page is complete, so it can snapshot the
57
+ content for crawlers that don't run JavaScript. Without it, the renderer waits for the network to go
58
+ quiet.
59
+
60
+ ## Content that gets found
61
+
62
+ - **Content in the page, not behind interaction.** FAQ answers in `<details>` are fine; answers
63
+ fetched only when a question is clicked aren't seen.
64
+ - **One `<h1>` per page**, a logical heading order, `alt` text on content images.
65
+ - **Answer-shaped writing.** A short summary near the top, question-style headings where they fit.
66
+ This is what AI assistants quote.
67
+ - **Real links** (`<a href>`) between pages, never hash routes.
@@ -0,0 +1,31 @@
1
+ import type { AppDataDeclaration, AppDataType, AppHeadlessDeclaration } from './types/headless.js';
2
+ export interface HeadlessIssue {
3
+ /** Where: e.g. `headless.purpose`, `data.types.faq.item.fields.answer`. */
4
+ path: string;
5
+ message: string;
6
+ }
7
+ export interface HeadlessValidation {
8
+ ok: boolean;
9
+ errors: HeadlessIssue[];
10
+ warnings: HeadlessIssue[];
11
+ /** The read recipe for each type: the declared one, else the standard one for its storage. */
12
+ recipes: Record<string, {
13
+ list: string;
14
+ get?: string;
15
+ }>;
16
+ }
17
+ /** The standard SDK read calls for a type, from how it's stored. `appId` / `collectionId` are the caller's variables. */
18
+ export declare function standardRecipe(type: AppDataType): {
19
+ list: string;
20
+ get?: string;
21
+ };
22
+ export interface ValidateOptions {
23
+ /** Real items per type id (each item's `data` zone JSON), e.g. sampled from a test collection. */
24
+ samples?: Record<string, Array<Record<string, unknown>>>;
25
+ }
26
+ /** Validate a manifest's `data` + `headless` blocks. */
27
+ export declare function validate(manifest: {
28
+ data?: AppDataDeclaration;
29
+ headless?: AppHeadlessDeclaration;
30
+ [k: string]: any;
31
+ }, opts?: ValidateOptions): HeadlessValidation;
@@ -0,0 +1,225 @@
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
+ /** The standard SDK read calls for a type, from how it's stored. `appId` / `collectionId` are the caller's variables. */
22
+ export function standardRecipe(type) {
23
+ const s = type.storage;
24
+ switch (s && s.kind) {
25
+ case 'record':
26
+ return {
27
+ list: `SL.app.records.list(collectionId, appId, { recordType: '${s.recordType}', limit: 50 }) // → { data: AppRecord[] }; fields in record.data`,
28
+ get: 'SL.app.records.get(collectionId, appId, recordId)',
29
+ };
30
+ case 'case':
31
+ return { list: 'SL.app.cases.list(collectionId, appId, { limit: 50 })', get: 'SL.app.cases.get(collectionId, appId, caseId)' };
32
+ case 'thread':
33
+ return { list: 'SL.app.threads.list(collectionId, appId, { limit: 50 })', get: 'SL.app.threads.get(collectionId, appId, threadId)' };
34
+ case 'config':
35
+ return { list: `SL.appConfiguration.getConfig({ collectionId, appId })${s.key ? ` // → config.${s.key}` : ''}` };
36
+ default:
37
+ return { list: '(unknown storage)' };
38
+ }
39
+ }
40
+ function isPlaceholder(v) {
41
+ return typeof v === 'string' && /lorem ipsum|^(todo|tbd|example|test|foo|bar)$/i.test(v.trim());
42
+ }
43
+ /** Does a value fit a declared field? Returns a reason when it doesn't. */
44
+ function mismatch(field, value) {
45
+ if (value === null || value === undefined)
46
+ return null;
47
+ const t = field.type;
48
+ const isLocalizedObject = field.localized && typeof value === 'object' && !Array.isArray(value) &&
49
+ Object.values(value).every((x) => typeof x === 'string');
50
+ switch (t) {
51
+ case 'string':
52
+ case 'text':
53
+ case 'richtext':
54
+ case 'markdown':
55
+ return typeof value === 'string' || isLocalizedObject ? null : `expected ${t} (a string${field.localized ? ' or { lang: string }' : ''})`;
56
+ case 'url':
57
+ return typeof value === 'string' ? null : 'expected a URL string';
58
+ case 'number':
59
+ return typeof value === 'number' && Number.isFinite(value) ? null : 'expected a number';
60
+ case 'boolean':
61
+ return typeof value === 'boolean' ? null : 'expected true/false';
62
+ case 'date':
63
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? null : 'expected an ISO date (YYYY-MM-DD)';
64
+ case 'datetime':
65
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? null : 'expected an ISO date-time';
66
+ case 'enum':
67
+ return (field.options || []).includes(value) ? null : `expected one of ${(field.options || []).join(', ')}`;
68
+ case 'image':
69
+ case 'file':
70
+ return typeof value === 'string' || (typeof value === 'object' && !Array.isArray(value) && typeof value.url === 'string')
71
+ ? null : `expected a URL string or { url }`;
72
+ case 'ref':
73
+ return typeof value === 'string' ? null : 'expected an id string';
74
+ case 'string[]':
75
+ case 'ref[]':
76
+ return Array.isArray(value) && value.every((x) => typeof x === 'string') ? null : 'expected an array of strings';
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+ function checkItems(label, typeId, type, items, out) {
82
+ const fields = type.fields || {};
83
+ const undeclared = new Map();
84
+ items.forEach((item, i) => {
85
+ const at = `data.types.${typeId}.${label === 'example' ? 'examples' : 'realData'}[${i}]`;
86
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
87
+ out.errors.push({ path: at, message: `${label} must be an object (the item's data JSON)` });
88
+ return;
89
+ }
90
+ for (const [key, field] of Object.entries(fields)) {
91
+ if ((field.zone || 'data') !== 'data')
92
+ continue; // examples / samples are the data zone
93
+ const value = item[key];
94
+ if (field.required && (value === undefined || value === null || value === '')) {
95
+ ;
96
+ (label === 'example' ? out.errors : out.warnings).push({ path: `${at}.${key}`, message: `required field "${key}" is missing` });
97
+ continue;
98
+ }
99
+ const why = mismatch(field, value);
100
+ if (why)
101
+ (label === 'example' ? out.errors : out.warnings).push({ path: `${at}.${key}`, message: `"${key}": ${why}` });
102
+ if (label === 'example' && isPlaceholder(value))
103
+ out.warnings.push({ path: `${at}.${key}`, message: 'looks like placeholder text — use realistic content' });
104
+ }
105
+ for (const key of Object.keys(item))
106
+ if (!(key in fields))
107
+ undeclared.set(key, (undeclared.get(key) || 0) + 1);
108
+ });
109
+ for (const [key, n] of undeclared) {
110
+ out.warnings.push({
111
+ path: `data.types.${typeId}.fields`,
112
+ message: label === 'example'
113
+ ? `example uses "${key}", which isn't a declared field`
114
+ : `real data has "${key}" (in ${n} of ${items.length} items) but it isn't declared — declare it, or confirm it's internal`,
115
+ });
116
+ }
117
+ }
118
+ /** Validate a manifest's `data` + `headless` blocks. */
119
+ export function validate(manifest, opts = {}) {
120
+ const errors = [];
121
+ const warnings = [];
122
+ const recipes = {};
123
+ const err = (path, message) => errors.push({ path, message });
124
+ const warn = (path, message) => warnings.push({ path, message });
125
+ const data = manifest && manifest.data;
126
+ const headless = manifest && manifest.headless;
127
+ // ---- data
128
+ if (!data) {
129
+ err('data', headless ? 'a headless app needs a `data` block declaring its types' : 'no `data` block');
130
+ }
131
+ else {
132
+ if (!data.schemaVersion || !SEMVER.test(data.schemaVersion))
133
+ err('data.schemaVersion', 'set a semver, e.g. "1.0.0"');
134
+ const types = data.types || {};
135
+ if (!Object.keys(types).length)
136
+ err('data.types', 'declare at least one type');
137
+ const typeIds = new Set(Object.keys(types));
138
+ for (const [typeId, type] of Object.entries(types)) {
139
+ const at = `data.types.${typeId}`;
140
+ if (!/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/.test(typeId))
141
+ err(at, 'type ids are lowercase, dot-separated (e.g. "faq.item")');
142
+ if (!type.description || type.description.trim().length < 10)
143
+ err(`${at}.description`, 'say what one item is, in a sentence');
144
+ const kind = type.storage && type.storage.kind;
145
+ if (!STORAGE_KINDS.has(kind))
146
+ err(`${at}.storage`, 'storage.kind must be record, case, thread or config');
147
+ if (kind === 'record' && !type.storage.recordType)
148
+ err(`${at}.storage.recordType`, 'record storage needs the recordType the app writes');
149
+ if (type.visibility && !['public', 'owner', 'admin'].includes(type.visibility))
150
+ err(`${at}.visibility`, 'public, owner or admin');
151
+ const fields = type.fields || {};
152
+ if (!Object.keys(fields).length)
153
+ err(`${at}.fields`, 'declare the fields');
154
+ for (const [key, f] of Object.entries(fields)) {
155
+ const fat = `${at}.fields.${key}`;
156
+ if (!f || !FIELD_TYPES.has(f.type)) {
157
+ err(fat, `unknown type "${f && f.type}" (one of ${[...FIELD_TYPES].join(', ')})`);
158
+ continue;
159
+ }
160
+ if (f.type === 'enum' && !(f.options && f.options.length))
161
+ err(fat, 'an enum needs options');
162
+ if ((f.type === 'ref' || f.type === 'ref[]') && !(f.to && (typeIds.has(f.to) || PLATFORM_REFS.has(f.to)))) {
163
+ err(fat, `"to" must name a declared type or product/contact/proof`);
164
+ }
165
+ if (f.localized && !TEXT_TYPES.has(f.type))
166
+ warn(fat, 'only text fields can be localized');
167
+ if (f.public && f.zone && f.zone !== 'data')
168
+ err(fat, `a ${f.zone}-zone field can't be public — only the data zone is readable publicly`);
169
+ }
170
+ for (const k of (type.listing && type.listing.sort) || [])
171
+ if (!(k.replace(/^-/, '') in fields))
172
+ err(`${at}.listing.sort`, `"${k}" isn't a field`);
173
+ for (const k of (type.listing && type.listing.filters) || [])
174
+ if (!(k in fields))
175
+ err(`${at}.listing.filters`, `"${k}" isn't a field`);
176
+ if (type.examples)
177
+ checkItems('example', typeId, type, type.examples, { errors, warnings });
178
+ const samples = opts.samples && opts.samples[typeId];
179
+ if (samples && samples.length)
180
+ checkItems('real item', typeId, type, samples, { errors, warnings });
181
+ const std = standardRecipe(type);
182
+ recipes[typeId] = Object.assign({ list: (type.read && type.read.list) || std.list }, ((type.read && type.read.get) || std.get ? { get: (type.read && type.read.get) || std.get } : {}));
183
+ }
184
+ }
185
+ // ---- headless
186
+ if (headless) {
187
+ if (!headless.purpose || headless.purpose.trim().length < 40) {
188
+ err('headless.purpose', 'explain what content this holds and when a site should use it (a few sentences)');
189
+ }
190
+ const cats = headless.categories || [];
191
+ if (!cats.length)
192
+ err('headless.categories', `pick at least one: ${HEADLESS_CATEGORIES.join(', ')}`);
193
+ for (const c of cats)
194
+ if (!HEADLESS_CATEGORIES.includes(c))
195
+ err('headless.categories', `"${c}" isn't a category (${HEADLESS_CATEGORIES.join(', ')})`);
196
+ if (!headless.editedIn || !headless.editedIn.label)
197
+ err('headless.editedIn.label', 'say where the business edits this content (the app admin screen)');
198
+ for (const s of (headless.render && headless.render.seo) || [])
199
+ if (!SEO_HELPERS.has(s))
200
+ err('headless.render.seo', `"${s}" isn't an SEO helper (${[...SEO_HELPERS].join(', ')})`);
201
+ const types = (data && data.types) || {};
202
+ const primary = headless.primaryTypes || [];
203
+ if (!primary.length)
204
+ err('headless.primaryTypes', 'name the type(s) a site renders');
205
+ for (const id of primary) {
206
+ const type = types[id];
207
+ if (!type) {
208
+ err('headless.primaryTypes', `"${id}" isn't a declared type`);
209
+ continue;
210
+ }
211
+ if (type.visibility === 'admin')
212
+ err(`data.types.${id}.visibility`, 'a primary type must be readable by visitors (visibility public)');
213
+ const publicFields = Object.entries(type.fields || {}).filter(([, f]) => f.public);
214
+ if (!publicFields.length)
215
+ err(`data.types.${id}.fields`, 'mark the fields a visitor may read with "public": true');
216
+ if (!type.examples || !type.examples.length)
217
+ err(`data.types.${id}.examples`, 'add at least one realistic example item');
218
+ if (!type.read)
219
+ warn(`data.types.${id}.read`, 'no read recipe — the standard one for its storage is used (see recipes)');
220
+ }
221
+ if (!(headless.render && headless.render.guidance))
222
+ warn('headless.render.guidance', 'add a line on how a site should present this content');
223
+ }
224
+ return { ok: errors.length === 0, errors, warnings, recipes };
225
+ }
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)