@proveanything/smartlinks 2.0.37 → 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.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.37 | Generated: 2026-10-04T09:59:59.885Z
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
 
@@ -2748,6 +2748,8 @@ interface AppManifest {
2748
2748
  publicViews?: PublicView[];
2749
2749
  executor?: AppManifestExecutor;
2750
2750
  functions?: AppManifestFunctions;
2751
+ data?: AppDataDeclaration;
2752
+ headless?: AppHeadlessDeclaration;
2751
2753
  [key: string]: any;
2752
2754
  }
2753
2755
  ```
@@ -6459,6 +6461,65 @@ interface FacetValueGetParams {
6459
6461
 
6460
6462
  **FacetValueDefinition** = `FacetValue`
6461
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
+
6462
6523
  ### iframeResponder
6463
6524
 
6464
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.
@@ -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.
package/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.37",
3
+ "version": "2.0.38",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,7 +30,8 @@
30
30
  "bin": {
31
31
  "smartlinks-register-release": "scripts/register-release.mjs",
32
32
  "smartlinks-publish": "scripts/publish.mjs",
33
- "smartlinks-doctor": "scripts/doctor.mjs"
33
+ "smartlinks-doctor": "scripts/doctor.mjs",
34
+ "smartlinks-headless": "scripts/headless-check.mjs"
34
35
  },
35
36
  "files": [
36
37
  "dist/",
@@ -38,6 +39,7 @@
38
39
  "scripts/register-release.mjs",
39
40
  "scripts/publish.mjs",
40
41
  "scripts/doctor.mjs",
42
+ "scripts/headless-check.mjs",
41
43
  "scripts/lib/",
42
44
  "openapi.yaml",
43
45
  "README.md"
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // smartlinks-headless — check an app's headless-provider declaration (manifest `data` + `headless`).
4
+ //
5
+ // smartlinks-headless [appDir] # the declaration on its own
6
+ // smartlinks-headless [appDir] --collection <id> --app <appId> # also against real public data
7
+ // smartlinks-headless ... --json # machine-readable result
8
+ //
9
+ // With --collection/--app it samples each record type's PUBLIC items from that collection and reports
10
+ // fields the real data has that the declaration doesn't (and values that don't fit their type).
11
+ // API base: --api <url>, else SMARTLINKS_API_BASE, else https://smartlinks.app/api/v1.
12
+ // Spec: docs/headless-providers.md. Exit code: 0 = valid, 1 = errors.
13
+ // =============================================================================
14
+
15
+ import { readFileSync, existsSync } from 'node:fs'
16
+ import { resolve, dirname, join } from 'node:path'
17
+ import { fileURLToPath, pathToFileURL } from 'node:url'
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url))
20
+ const { validate } = await import(pathToFileURL(resolve(here, '../dist/headless.js')).href)
21
+
22
+ const args = process.argv.slice(2)
23
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined }
24
+ const json = args.includes('--json')
25
+ const positional = args.filter((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && args[i - 1] !== '--json'))
26
+ const appDir = resolve(positional[0] || process.cwd())
27
+ const collectionId = flag('collection')
28
+ const appId = flag('app')
29
+ const apiBase = (flag('api') || process.env.SMARTLINKS_API_BASE || 'https://smartlinks.app/api/v1').replace(/\/+$/, '')
30
+
31
+ const C = process.stdout.isTTY && !json
32
+ ? { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m' }
33
+ : { red: '', green: '', yellow: '', dim: '', bold: '', reset: '' }
34
+
35
+ function fail(message) {
36
+ if (json) console.log(JSON.stringify({ ok: false, errors: [{ path: '', message }], warnings: [], recipes: {} }))
37
+ else console.error(`${C.red}smartlinks-headless: ${message}${C.reset}`)
38
+ process.exit(1)
39
+ }
40
+
41
+ const manifestPath = ['public/app.manifest.json', 'app.manifest.json', 'dist/app.manifest.json'].map((p) => join(appDir, p)).find(existsSync)
42
+ if (!manifestPath) fail(`no app.manifest.json under ${appDir} (looked in public/, ., dist/)`)
43
+ let manifest
44
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch (e) { fail(`can't parse ${manifestPath}: ${e.message}`) }
45
+
46
+ // Real public items per record type, when a collection is given.
47
+ const samples = {}
48
+ const sampled = []
49
+ if (collectionId && appId && manifest.data && manifest.data.types) {
50
+ for (const [typeId, type] of Object.entries(manifest.data.types)) {
51
+ if (!type || !type.storage || type.storage.kind !== 'record' || !type.storage.recordType) continue
52
+ const url = `${apiBase}/public/collection/${encodeURIComponent(collectionId)}/app/${encodeURIComponent(appId)}/records?recordType=${encodeURIComponent(type.storage.recordType)}&limit=25`
53
+ try {
54
+ const res = await fetch(url, { headers: { accept: 'application/json' } })
55
+ if (!res.ok) { sampled.push(`${typeId}: HTTP ${res.status}`); continue }
56
+ const body = await res.json()
57
+ const items = (Array.isArray(body) ? body : body.data || body.items || []).map((r) => (r && r.data) || {})
58
+ samples[typeId] = items
59
+ sampled.push(`${typeId}: ${items.length} public item${items.length === 1 ? '' : 's'}`)
60
+ } catch (e) {
61
+ sampled.push(`${typeId}: ${e.message}`)
62
+ }
63
+ }
64
+ } else if (collectionId || appId) {
65
+ fail('--collection and --app go together')
66
+ }
67
+
68
+ const result = validate(manifest, { samples })
69
+
70
+ if (json) {
71
+ console.log(JSON.stringify({ ...result, manifest: manifestPath, sampled }, null, 2))
72
+ process.exit(result.ok ? 0 : 1)
73
+ }
74
+
75
+ console.log(`${C.bold}smartlinks-headless${C.reset} ${C.dim}${manifestPath}${C.reset}`)
76
+ if (!manifest.headless) console.log(`${C.dim}No "headless" block: checking the data declaration only.${C.reset}`)
77
+ if (sampled.length) console.log(`${C.dim}Sampled from ${collectionId}: ${sampled.join('; ')}${C.reset}`)
78
+ for (const e of result.errors) console.log(`${C.red}✗ ${e.path}${C.reset} ${e.message}`)
79
+ for (const w of result.warnings) console.log(`${C.yellow}! ${w.path}${C.reset} ${w.message}`)
80
+ if (Object.keys(result.recipes).length) {
81
+ console.log(`\n${C.bold}Read recipes${C.reset}`)
82
+ for (const [t, r] of Object.entries(result.recipes)) console.log(` ${t}\n list: ${r.list}${r.get ? `\n get: ${r.get}` : ''}`)
83
+ }
84
+ console.log(result.ok
85
+ ? `\n${C.green}✓ valid${result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : ''}${C.reset}`
86
+ : `\n${C.red}${result.errors.length} error${result.errors.length === 1 ? '' : 's'}${C.reset}`)
87
+ process.exit(result.ok ? 0 : 1)