@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,68 @@
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
+ - **List every page** in `public/sitemap-paths.txt`, one `<path> [Title]` per line, in menu order
12
+ (`/ Home`, `/menu Menu`, `/book Book a table`). They go into the sitemap and `llms.txt` (titled)
13
+ on the right host, and Forge's preview uses the same list as its page menu. Keep it in step with
14
+ the router. Pre-rendered pages (`about/index.html`) are found automatically.
15
+ - **Canonical address.** A site can answer on its automatic address, a chosen name and a custom
16
+ domain. Every page gets `Link: <https://{canonical}{path}>; rel="canonical"`, so search engines
17
+ consolidate on one: the custom domain, else the chosen name, else the automatic address. Don't set
18
+ your own canonical unless a page has a different canonical page.
19
+ - **Previews stay out of search.** A sandbox collection's sites, and any test build (dev, alpha,
20
+ beta), are served with `X-Robots-Tag: noindex` and a `robots.txt` that disallows everything,
21
+ whatever the build ships.
22
+
23
+ ## What you do: `SL.seo` and `SL.site`
24
+
25
+ ```ts
26
+ import * as SL from '@proveanything/smartlinks'
27
+
28
+ // Per route, from its data — on every route change:
29
+ SL.seo.head({
30
+ title: `${product.name} — ${brand}`,
31
+ description: product.description, // ~150 characters, says what the page is
32
+ image: product.heroImage?.url, // absolute; used for link previews
33
+ type: 'product', // 'website' | 'article' | 'product'
34
+ })
35
+
36
+ // Structured data (schema.org JSON-LD). The id names the block, so calling again replaces it:
37
+ SL.seo.jsonLd('page', SL.seo.schema.product(product, { url: location.href, brand }))
38
+ SL.seo.jsonLd('faq', SL.seo.schema.faqPage(faqs.map((f) => ({ question: f.q, answer: f.a }))))
39
+ SL.seo.jsonLd('org', SL.seo.schema.organization(collection))
40
+
41
+ // When the page's data has rendered:
42
+ SL.site.ready()
43
+ ```
44
+
45
+ | Builder | For |
46
+ |---|---|
47
+ | `schema.product(product, { url, brand, offer, rating })` | product pages (`offer` only when it's really sold) |
48
+ | `schema.faqPage([{ question, answer }])` | FAQs: rich results, and the answer-shaped content AI search cites |
49
+ | `schema.organization(collection)` | the brand behind the site (home page) |
50
+ | `schema.localBusiness(collection, { type, address, telephone, openingHours })` | a business with a place (`type: 'Florist'`, `'Restaurant'`…) |
51
+ | `schema.breadcrumbs([{ name, url }])` | nested pages |
52
+ | `schema.article({ headline, datePublished, … })` | posts, guides, news |
53
+
54
+ `seo.head` removes tags a previous route set and the next one doesn't. Both are no-ops without a
55
+ `document` (SSR, tests).
56
+
57
+ `site.ready()` tells the platform's page renderer the page is complete, so it can snapshot the
58
+ content for crawlers that don't run JavaScript. Without it, the renderer waits for the network to go
59
+ quiet.
60
+
61
+ ## Content that gets found
62
+
63
+ - **Content in the page, not behind interaction.** FAQ answers in `<details>` are fine; answers
64
+ fetched only when a question is clicked aren't seen.
65
+ - **One `<h1>` per page**, a logical heading order, `alt` text on content images.
66
+ - **Answer-shaped writing.** A short summary near the top, question-style headings where they fit.
67
+ This is what AI assistants quote.
68
+ - **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.39",
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)