@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.
- package/dist/api/functions.d.ts +2 -2
- package/dist/api/functions.js +7 -3
- package/dist/docs/API_SUMMARY.md +78 -11
- package/dist/docs/headless-providers.md +112 -0
- package/dist/docs/site-seo.md +68 -0
- package/dist/headless.d.ts +45 -0
- package/dist/headless.js +249 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/openapi.yaml +159 -0
- package/dist/seo.d.ts +85 -0
- package/dist/seo.js +172 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +18 -0
- package/dist/types/appManifest.d.ts +11 -0
- package/dist/types/appManifest.js +0 -1
- package/dist/types/headless.d.ts +96 -0
- package/dist/types/headless.js +16 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/docs/API_SUMMARY.md +78 -11
- package/docs/headless-providers.md +112 -0
- package/docs/site-seo.md +68 -0
- package/openapi.yaml +159 -0
- package/package.json +4 -2
- package/scripts/headless-check.mjs +87 -0
package/docs/site-seo.md
ADDED
|
@@ -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.
|
|
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)
|