@tanqory/dev-mcp 0.0.0-stage → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +94 -2
- package/bin/tanqory-dev-mcp.mjs +16 -0
- package/bundled/examples/blank/config/settings.json +3 -0
- package/bundled/examples/blank/locales/en.json +22 -0
- package/bundled/examples/blank/locales/th.json +22 -0
- package/bundled/examples/blank/regions/footer.json +35 -0
- package/bundled/examples/blank/regions/header.json +13 -0
- package/bundled/examples/blank/src/bundle.ts +15 -0
- package/bundled/examples/blank/src/components/parts.tsx +32 -0
- package/bundled/examples/blank/src/entry.client.tsx +6 -0
- package/bundled/examples/blank/src/entry.server.tsx +13 -0
- package/bundled/examples/blank/src/sections/chrome.tsx +132 -0
- package/bundled/examples/blank/src/sections/commerce.tsx +242 -0
- package/bundled/examples/blank/src/sections/content.tsx +111 -0
- package/bundled/examples/blank/src/sections/editorial.tsx +107 -0
- package/bundled/examples/blank/src/settings.ts +15 -0
- package/bundled/examples/blank/src/styles/theme.css +195 -0
- package/bundled/examples/blank/src/templates.ts +14 -0
- package/bundled/examples/blank/src/theme.tsx +76 -0
- package/bundled/examples/blank/template.json +24 -0
- package/bundled/examples/blank/templates/404.json +14 -0
- package/bundled/examples/blank/templates/account.json +14 -0
- package/bundled/examples/blank/templates/article.json +14 -0
- package/bundled/examples/blank/templates/blog.json +14 -0
- package/bundled/examples/blank/templates/cart.json +14 -0
- package/bundled/examples/blank/templates/collection.json +14 -0
- package/bundled/examples/blank/templates/index.json +36 -0
- package/bundled/examples/blank/templates/page.contact.json +18 -0
- package/bundled/examples/blank/templates/page.json +14 -0
- package/bundled/examples/blank/templates/product.json +18 -0
- package/bundled/examples/blank/templates/search.json +14 -0
- package/bundled/examples/blank/tests/theme.test.ts +49 -0
- package/bundled/theme-abi/contracts/render-context.v2.d.ts +849 -0
- package/bundled/theme-abi/contracts/theme-abi-v2.md +1833 -0
- package/bundled/theme-abi/generated/capabilities.json +80 -0
- package/bundled/theme-abi/generated/field-types.json +869 -0
- package/bundled/theme-abi/generated/limits.json +173 -0
- package/bundled/theme-abi/schemas/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-abi/schemas/theme-artifact.v1.schema.json +230 -0
- package/bundled/theme-abi/schemas/theme-manifest.v2.schema.json +254 -0
- package/bundled/theme-conformance/docs/storefront-tier.md +221 -0
- package/bundled/theme-conformance/schema/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-conformance/schema/storefront-report.v1.schema.json +110 -0
- package/bundled/theme-conformance/schema/x-tanqory-ai.v0.schema.json +70 -0
- package/bundled/theme-kit/docs/headless-api.md +220 -0
- package/bundled/theme-kit/generated/kit-strings.json +180 -0
- package/bundled/theme-kit/types/headless/index.d.ts +36 -0
- package/bundled/theme-kit/types/headless/theme.d.ts +99 -0
- package/bundled/theme-kit/types/sdk/schema.d.ts +197 -0
- package/bundled/versions.json +8 -0
- package/package.json +53 -3
- package/src/bm25.mjs +69 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +127 -0
- package/src/devserver.mjs +82 -0
- package/src/docs.mjs +329 -0
- package/src/index.mjs +33 -0
- package/src/kit.mjs +150 -0
- package/src/manifest.mjs +82 -0
- package/src/prompts.mjs +97 -0
- package/src/scaffold.mjs +471 -0
- package/src/schemas.mjs +127 -0
- package/src/server.mjs +595 -0
- package/src/theme.mjs +91 -0
package/src/docs.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documentation corpus: the platform contract, the kit API, the storefront-tier spec, schemas and registry
|
|
3
|
+
* tables. Every document is read from the theme's own node_modules first, so it matches the versions the theme
|
|
4
|
+
* builds against; when the installed package does not ship that file (or no theme is open) the copy bundled in
|
|
5
|
+
* this package is used and the answer says so.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
import { Bm25 } from './bm25.mjs'
|
|
11
|
+
import { installedPackageDir } from './theme.mjs'
|
|
12
|
+
|
|
13
|
+
export const BUNDLED_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'bundled')
|
|
14
|
+
export const BUNDLED_VERSIONS = JSON.parse(readFileSync(join(BUNDLED_DIR, 'versions.json'), 'utf8'))
|
|
15
|
+
|
|
16
|
+
const ABI = '@tanqory/theme-abi'
|
|
17
|
+
const KIT = '@tanqory/theme-kit'
|
|
18
|
+
const CONF = '@tanqory/theme-conformance'
|
|
19
|
+
const short = (pkg) => pkg.split('/')[1]
|
|
20
|
+
|
|
21
|
+
/** Every document, in the order search ties are broken. `file` is relative to the package root. */
|
|
22
|
+
export const DOCS = [
|
|
23
|
+
{ id: 'kit/headless-api', pkg: KIT, file: 'docs/headless-api.md', kind: 'markdown', title: 'Kit headless API (@tanqory/theme-kit/headless)', summary: 'Hooks, primitives, defineSection/defineBlock/defineTheme, field.*, t(), formatMoney, <Seo>, <Image>, ai.* roles, buildManifest and the data-tq-* hooks the kit emits.' },
|
|
24
|
+
{ id: 'kit/section-types', pkg: KIT, file: 'dist/headless/theme.d.ts', kind: 'typescript', title: 'defineSection / defineBlock / defineTheme types', summary: 'The TypeScript declarations of a section, block, preset and headless theme.' },
|
|
25
|
+
{ id: 'kit/field-helpers', pkg: KIT, file: 'dist/sdk/schema.d.ts', kind: 'typescript', title: 'field.* helpers and settings schema types', summary: 'The field.text/richtext/select/range/resource… helpers and how settings resolve.' },
|
|
26
|
+
{ id: 'kit/exports', pkg: KIT, file: 'dist/headless/index.d.ts', kind: 'typescript', title: 'Kit headless exports', summary: 'Every name @tanqory/theme-kit/headless exports.', searchable: false },
|
|
27
|
+
{ id: 'kit/strings', pkg: KIT, generated: 'kit-strings', kind: 'markdown', title: 'Kit strings (KIT_STRINGS, en + th)', summary: 'Every locale key the kit primitives already translate; a theme adds its own keys on top.' },
|
|
28
|
+
{ id: 'conformance/storefront-tier', pkg: CONF, file: 'docs/storefront-tier.md', kind: 'markdown', title: 'Storefront-ready and budgets tiers (tanqory theme check)', summary: 'Every storefront and budgets check, what it drives, the hooks it reads, the locale-key patterns, fix hints.' },
|
|
29
|
+
{ id: 'abi/contract', pkg: ABI, file: 'contracts/theme-abi-v2.md', kind: 'markdown', title: 'Theme ABI v2 contract', summary: 'The platform contract: entry module, RenderContext, ctx.data, ctx.actions, responses, manifest and editor protocol, limits, versioning, conformance, allowed globals, field-type registry.' },
|
|
30
|
+
{ id: 'abi/render-context', pkg: ABI, file: 'contracts/render-context.v2.d.ts', kind: 'typescript', title: 'RenderContext v2 types (render-context.v2.d.ts)', summary: 'The normative TypeScript of render(request, ctx): request, route, data, actions, content, assets.' },
|
|
31
|
+
{ id: 'abi/field-types', pkg: ABI, generated: 'field-types', kind: 'markdown', title: 'Field-type registry (setting types)', summary: 'Every setting type a section or theme setting may use, its stored value, constraints and editor control.' },
|
|
32
|
+
{ id: 'abi/limits', pkg: ABI, generated: 'limits', kind: 'markdown', title: 'Limits table (budgets and hard limits)', summary: 'Every size, count and time limit with its budget and hard value.' },
|
|
33
|
+
{ id: 'abi/capabilities', pkg: ABI, generated: 'capabilities', kind: 'markdown', title: 'Capabilities', summary: 'Which data and action capabilities exist, which are implicit, default-granted, opt-in, browser-allowed, preview-allowed.' },
|
|
34
|
+
{ id: 'abi/manifest-schema', pkg: ABI, file: 'schemas/theme-manifest.v2.schema.json', kind: 'json', title: 'Theme manifest v2 JSON Schema', summary: 'The JSON Schema every manifest.json is validated against.' },
|
|
35
|
+
{ id: 'abi/artifact-schema', pkg: ABI, file: 'schemas/theme-artifact.v1.schema.json', kind: 'json', title: 'Theme artifact v1 JSON Schema', summary: 'The built artifact description (artifact.json).' },
|
|
36
|
+
{ id: 'abi/conformance-report-schema', pkg: ABI, file: 'schemas/conformance-report.v2.schema.json', kind: 'json', title: 'Conformance report v2 JSON Schema', summary: 'The security tier report (the nine ABI suites).' },
|
|
37
|
+
{ id: 'conformance/storefront-report-schema', pkg: CONF, file: 'schema/storefront-report.v1.schema.json', kind: 'json', title: 'Storefront report v1 JSON Schema', summary: 'The storefront and budgets tier report.' },
|
|
38
|
+
{ id: 'conformance/x-tanqory-ai-schema', pkg: CONF, file: 'schema/x-tanqory-ai.v0.schema.json', kind: 'json', title: 'x-tanqory-ai profile v0 JSON Schema', summary: 'The AI-compat profile a manifest declares (draft, warn only).' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const ABI_DIST = { 'field-types': 'dist/field-types.js', limits: 'dist/limits.js', capabilities: 'dist/capabilities.js' }
|
|
42
|
+
const BUNDLED_JSON = {
|
|
43
|
+
'field-types': 'theme-abi/generated/field-types.json',
|
|
44
|
+
limits: 'theme-abi/generated/limits.json',
|
|
45
|
+
capabilities: 'theme-abi/generated/capabilities.json',
|
|
46
|
+
'kit-strings': 'theme-kit/generated/kit-strings.json',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sets = (m) => Object.fromEntries(Object.entries(m).filter(([, v]) => v instanceof Set).map(([k, v]) => [k, [...v].sort()]))
|
|
50
|
+
|
|
51
|
+
/** The registry data behind a generated document: from the installed package's exports, else the bundled snapshot. */
|
|
52
|
+
async function generatedData(kind, dir) {
|
|
53
|
+
if (dir) {
|
|
54
|
+
try {
|
|
55
|
+
if (kind === 'kit-strings') {
|
|
56
|
+
const p = join(dir, 'dist', 'headless', 'strings.js')
|
|
57
|
+
if (existsSync(p)) return (await import(pathToFileURL(p).href)).KIT_STRINGS
|
|
58
|
+
} else if (existsSync(join(dir, ABI_DIST[kind]))) {
|
|
59
|
+
const m = await import(pathToFileURL(join(dir, ABI_DIST[kind])).href)
|
|
60
|
+
if (kind === 'field-types')
|
|
61
|
+
return {
|
|
62
|
+
registryVersions: m.FIELD_REGISTRY_VERSIONS,
|
|
63
|
+
universalConstraints: m.UNIVERSAL_FIELD_CONSTRAINTS,
|
|
64
|
+
requiredConstraints: m.FIELD_TYPE_REQUIRED_CONSTRAINTS,
|
|
65
|
+
types: m.FIELD_TYPES_BY_REGISTRY,
|
|
66
|
+
valueShapes: m.FIELD_VALUE_SHAPES_BY_REGISTRY,
|
|
67
|
+
}
|
|
68
|
+
if (kind === 'limits') return { version: m.LIMITS_VERSION, limits: m.LIMITS }
|
|
69
|
+
if (kind === 'capabilities') return sets(m)
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// fall through to the bundled snapshot
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cell = (v) => (v === null || v === undefined ? '—' : Array.isArray(v) ? v.join(', ') || '—' : String(v)).replace(/\|/g, '\\|')
|
|
79
|
+
|
|
80
|
+
function renderGenerated(kind, data) {
|
|
81
|
+
if (kind === 'field-types') {
|
|
82
|
+
const latest = String(data.registryVersions[data.registryVersions.length - 1])
|
|
83
|
+
const types = data.types[latest] ?? {}
|
|
84
|
+
const shapes = data.valueShapes?.[latest] ?? {}
|
|
85
|
+
const lines = [
|
|
86
|
+
`# Field-type registry (tanqory-field-types@${latest})`,
|
|
87
|
+
'',
|
|
88
|
+
'Every setting (section, block or theme setting) has one of these types. The kit helpers map to them: `field.text` → text, `field.textarea` → textarea, `field.richtext` → richtext, `field.url` → url, `field.color` → color, `field.boolean` → boolean, `field.select` → select, `field.range` → range, `field.number` → number, `field.resource(kind)` → image / video / menu / collection / product / page / blog / article.',
|
|
89
|
+
'',
|
|
90
|
+
`Universal constraint keys (any type): ${data.universalConstraints.map((c) => `\`${c}\``).join(', ')}.`,
|
|
91
|
+
'',
|
|
92
|
+
'## Types',
|
|
93
|
+
'',
|
|
94
|
+
'| Type | Stored value | Editor control | Extra constraints | Required constraints | Resource / media | Notes |',
|
|
95
|
+
'|---|---|---|---|---|---|---|',
|
|
96
|
+
...Object.values(types).map((t) => {
|
|
97
|
+
const s = shapes[t.id]
|
|
98
|
+
const value = s ? [s.kind, s.enum ? `one of ${s.enum.join(' / ')}` : '', s.schemes ? `schemes ${s.schemes.join(' ')}` : '', s.siteRelative ? 'or a site-relative /path' : '', s.assetRef ? 'or asset:client/assets/<file> in defaults and presets' : '', s.note ?? ''].filter(Boolean).join('; ') : t.value
|
|
99
|
+
return `| \`${t.id}\` | ${cell(value)} | ${cell(t.control)} | ${cell(t.constraints)} | ${cell(data.requiredConstraints?.[t.id])} | ${cell(t.resource ?? t.media)} | ${cell(t.doc)} |`
|
|
100
|
+
}),
|
|
101
|
+
'',
|
|
102
|
+
`Registry versions this package implements: ${data.registryVersions.join(', ')}. A theme names its version in \`engines["tanqory-field-types"]\` (the kit's buildManifest writes "2").`,
|
|
103
|
+
]
|
|
104
|
+
return lines.join('\n')
|
|
105
|
+
}
|
|
106
|
+
if (kind === 'limits') {
|
|
107
|
+
return [
|
|
108
|
+
`# Limits (table version ${data.version})`,
|
|
109
|
+
'',
|
|
110
|
+
'Over **budget** is a warning; over **hard** fails the build or the render. `—` means no value in that column. Static limits are measured on the built theme; runtime limits per render or request.',
|
|
111
|
+
'',
|
|
112
|
+
'## Table',
|
|
113
|
+
'',
|
|
114
|
+
'| Key | Budget | Hard | Class |',
|
|
115
|
+
'|---|---|---|---|',
|
|
116
|
+
...Object.entries(data.limits).map(([k, v]) => `| \`${k}\` | ${cell(v.budget)} | ${cell(v.hard)} | ${cell(v.class)} |`),
|
|
117
|
+
].join('\n')
|
|
118
|
+
}
|
|
119
|
+
if (kind === 'capabilities') {
|
|
120
|
+
return [
|
|
121
|
+
'# Capabilities',
|
|
122
|
+
'',
|
|
123
|
+
'A theme declares the capabilities it uses in `manifest.capabilities.server` and `.browser`. The kit declares what its runtime calls (and always `data:shop`); add `data:inventory` for stock counts. A capability not declared is refused at runtime.',
|
|
124
|
+
'',
|
|
125
|
+
...Object.entries(data).flatMap(([k, v]) => [`## ${k}`, '', v.map((c) => `- \`${c}\``).join('\n') || '- (none)', '']),
|
|
126
|
+
].join('\n')
|
|
127
|
+
}
|
|
128
|
+
if (kind === 'kit-strings') {
|
|
129
|
+
const en = data.en ?? {}
|
|
130
|
+
const th = data.th ?? {}
|
|
131
|
+
return [
|
|
132
|
+
'# Kit strings (en + th)',
|
|
133
|
+
'',
|
|
134
|
+
"Keys the kit's primitives already render with `t()`. Use them as they are; add your theme's own keys (prefix `theme.`) to `locales/en.json` **and** `locales/th.json`. Lookup order: merchant wording → your locale file → these → English → the key.",
|
|
135
|
+
'',
|
|
136
|
+
'## Keys',
|
|
137
|
+
'',
|
|
138
|
+
'| Key | en | th |',
|
|
139
|
+
'|---|---|---|',
|
|
140
|
+
...Object.keys(en).sort().map((k) => `| \`${k}\` | ${cell(en[k])} | ${cell(th[k])} |`),
|
|
141
|
+
].join('\n')
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`unknown generated document ${kind}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const slug = (s) =>
|
|
147
|
+
s
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.replace(/`/g, '')
|
|
150
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
151
|
+
.replace(/^-+|-+$/g, '')
|
|
152
|
+
|
|
153
|
+
/** Split a document into sections: markdown headings, top-level TypeScript declarations, JSON Schema members. */
|
|
154
|
+
function chunk(doc, text) {
|
|
155
|
+
const out = []
|
|
156
|
+
const add = (title, start, level) => out.push({ title, start, level })
|
|
157
|
+
if (doc.kind === 'markdown') {
|
|
158
|
+
let fence = false
|
|
159
|
+
let pos = 0
|
|
160
|
+
for (const line of text.split('\n')) {
|
|
161
|
+
if (/^\s*```/.test(line)) fence = !fence
|
|
162
|
+
const m = !fence && /^(#{1,3})\s+(.+?)\s*$/.exec(line)
|
|
163
|
+
if (m) add(m[2], pos, m[1].length)
|
|
164
|
+
pos += line.length + 1
|
|
165
|
+
}
|
|
166
|
+
} else if (doc.kind === 'typescript') {
|
|
167
|
+
const lines = text.split('\n')
|
|
168
|
+
let pos = 0
|
|
169
|
+
let commentStart = null
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (/^\/\*\*/.test(line)) commentStart = pos
|
|
172
|
+
const m = /^export\s+(?:declare\s+)?(?:type\s+)?(interface|type|function|const|class|enum|\{)\s*([A-Za-z0-9_$]*)/.exec(line)
|
|
173
|
+
if (m) {
|
|
174
|
+
add(m[1] === '{' ? `export { … } (line ${out.length + 1})` : `${m[1]} ${m[2]}`, commentStart ?? pos, 2)
|
|
175
|
+
commentStart = null
|
|
176
|
+
} else if (!/^\s*\*|^\/\*\*/.test(line) && line.trim() !== '') commentStart = null
|
|
177
|
+
pos += line.length + 1
|
|
178
|
+
}
|
|
179
|
+
} else if (doc.kind === 'json') {
|
|
180
|
+
add(doc.title, 0, 1)
|
|
181
|
+
}
|
|
182
|
+
if (!out.length || out[0].start > 0) out.unshift({ title: doc.title, start: 0, level: 1 })
|
|
183
|
+
// ids unique within the document
|
|
184
|
+
const seen = new Map()
|
|
185
|
+
return out.map((c, i) => {
|
|
186
|
+
const end = out.slice(i + 1).find((n) => n.level <= c.level)?.start ?? text.length
|
|
187
|
+
const ownEnd = out[i + 1]?.start ?? text.length
|
|
188
|
+
let id = i === 0 && c.start === 0 && c.level === 1 ? '' : slug(c.title) || `part-${i}`
|
|
189
|
+
if (id && seen.has(id)) id = `${id}-${seen.get(id) + 1}`
|
|
190
|
+
seen.set(id, (seen.get(id) ?? 0) + 1)
|
|
191
|
+
return { ...c, id, end, ownEnd }
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** For a JSON Schema: one searchable section per top-level property and $defs entry (search only). */
|
|
196
|
+
function jsonSections(doc, text) {
|
|
197
|
+
let schema
|
|
198
|
+
try {
|
|
199
|
+
schema = JSON.parse(text)
|
|
200
|
+
} catch {
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
203
|
+
const out = []
|
|
204
|
+
for (const [k, v] of Object.entries(schema.properties ?? {})) out.push({ title: `${doc.title}: property ${k}`, text: JSON.stringify(v, null, 2) })
|
|
205
|
+
for (const [k, v] of Object.entries(schema.$defs ?? schema.definitions ?? {})) out.push({ title: `${doc.title}: $defs ${k}`, text: JSON.stringify(v, null, 2) })
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class DocCorpus {
|
|
210
|
+
constructor(theme) {
|
|
211
|
+
this.theme = theme
|
|
212
|
+
this.loaded = null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Resolve one document's text and where it came from. */
|
|
216
|
+
async read(doc) {
|
|
217
|
+
if (doc.theme) {
|
|
218
|
+
const p = this.theme.root && join(this.theme.root, doc.file)
|
|
219
|
+
return p && existsSync(p) ? { text: readFileSync(p, 'utf8'), source: 'theme', version: null, installedVersion: null } : null
|
|
220
|
+
}
|
|
221
|
+
const installedVersion = this.theme.versions?.[doc.pkg] ?? null
|
|
222
|
+
const dir = installedPackageDir(this.theme.root, doc.pkg)
|
|
223
|
+
if (doc.generated) {
|
|
224
|
+
const live = await generatedData(doc.generated, dir)
|
|
225
|
+
if (live) return { text: renderGenerated(doc.generated, live), source: 'installed', version: installedVersion, installedVersion }
|
|
226
|
+
const data = JSON.parse(readFileSync(join(BUNDLED_DIR, BUNDLED_JSON[doc.generated]), 'utf8'))
|
|
227
|
+
return { text: renderGenerated(doc.generated, data), source: 'bundled', version: BUNDLED_VERSIONS[doc.pkg], installedVersion }
|
|
228
|
+
}
|
|
229
|
+
if (dir && existsSync(join(dir, doc.file))) return { text: readFileSync(join(dir, doc.file), 'utf8'), source: 'installed', version: installedVersion, installedVersion }
|
|
230
|
+
// bundled/ keeps .d.ts files under types/, not dist/ (dist/ is git-ignored repo-wide)
|
|
231
|
+
const b = join(BUNDLED_DIR, short(doc.pkg), doc.file.replace(/^dist\//, 'types/'))
|
|
232
|
+
if (existsSync(b)) return { text: readFileSync(b, 'utf8'), source: 'bundled', version: BUNDLED_VERSIONS[doc.pkg], installedVersion }
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async load() {
|
|
237
|
+
if (this.loaded) return this.loaded
|
|
238
|
+
const docs = [...DOCS]
|
|
239
|
+
if (this.theme.root && existsSync(join(this.theme.root, 'AGENTS.md')))
|
|
240
|
+
docs.unshift({ id: 'theme/agents', theme: true, file: 'AGENTS.md', kind: 'markdown', title: "This theme's agent instructions (AGENTS.md)", summary: 'The rules, commands and layout of the open theme.' })
|
|
241
|
+
const entries = []
|
|
242
|
+
const sections = []
|
|
243
|
+
for (const doc of docs) {
|
|
244
|
+
const r = await this.read(doc)
|
|
245
|
+
if (!r) continue
|
|
246
|
+
const chunks = chunk(doc, r.text)
|
|
247
|
+
const entry = { ...doc, ...r, chunks }
|
|
248
|
+
entries.push(entry)
|
|
249
|
+
if (doc.searchable === false) continue
|
|
250
|
+
for (const c of chunks) {
|
|
251
|
+
const path = c.id ? `${doc.id}#${c.id}` : doc.id
|
|
252
|
+
const body = r.text.slice(c.start, c.ownEnd)
|
|
253
|
+
sections.push({ doc: entry, path, title: c.title, text: body })
|
|
254
|
+
// each row of a long table is findable on its own (check ids, hooks, primitives)
|
|
255
|
+
if (doc.kind === 'markdown' && !doc.generated)
|
|
256
|
+
for (const row of body.split('\n').filter((l) => /^\|\s*`/.test(l)))
|
|
257
|
+
sections.push({ doc: entry, path, title: `${c.title}: ${row.split('|')[1].replace(/`/g, '').trim()}`, text: row })
|
|
258
|
+
}
|
|
259
|
+
if (doc.kind === 'json') for (const s of jsonSections(doc, r.text)) sections.push({ doc: entry, path: doc.id, title: s.title, text: s.text })
|
|
260
|
+
}
|
|
261
|
+
this.loaded = { entries, sections, index: new Bm25(sections) }
|
|
262
|
+
return this.loaded
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async list() {
|
|
266
|
+
const { entries } = await this.load()
|
|
267
|
+
return entries.map((e) => ({
|
|
268
|
+
path: e.id,
|
|
269
|
+
title: e.title,
|
|
270
|
+
summary: e.summary,
|
|
271
|
+
source: e.source,
|
|
272
|
+
package: e.pkg ?? null,
|
|
273
|
+
version: e.version,
|
|
274
|
+
sections: e.chunks.filter((c) => c.id).length,
|
|
275
|
+
uri: `tanqory://docs/${e.id}`,
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async search(query, limit = 8) {
|
|
280
|
+
const { sections, index } = await this.load()
|
|
281
|
+
const terms = new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 1))
|
|
282
|
+
const seen = new Set()
|
|
283
|
+
const hits = index.search(query, limit * 4).filter(({ index: i }) => !seen.has(sections[i].path) && seen.add(sections[i].path)).slice(0, limit)
|
|
284
|
+
return hits.map(({ index: i, score }) => {
|
|
285
|
+
const s = sections[i]
|
|
286
|
+
const lines = s.text.split('\n').filter((l) => l.trim() !== '')
|
|
287
|
+
const scored = lines.map((l, n) => ({ n, hits: [...terms].filter((t) => l.toLowerCase().includes(t)).length }))
|
|
288
|
+
const best = scored.sort((a, b) => b.hits - a.hits || a.n - b.n)[0]
|
|
289
|
+
const at = best ? best.n : 0
|
|
290
|
+
const snippet = lines.slice(Math.max(0, at - 1), at + 2).join('\n').slice(0, 400)
|
|
291
|
+
return { path: s.path, title: s.title, document: s.doc.title, score: Math.round(score * 1000) / 1000, snippet, source: s.doc.source, version: s.doc.version }
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* One document or one section of it (`doc#section`), paginated by character offset so a long contract can be
|
|
297
|
+
* read in pieces.
|
|
298
|
+
*/
|
|
299
|
+
async get(path, { offset = 0, maxChars = 12000 } = {}) {
|
|
300
|
+
const { entries } = await this.load()
|
|
301
|
+
const [id, section] = String(path).replace(/^tanqory:\/\/docs\//, '').split('#')
|
|
302
|
+
const entry = entries.find((e) => e.id === id)
|
|
303
|
+
if (!entry) return { error: 'unknown_doc', known: entries.map((e) => e.id) }
|
|
304
|
+
let text = entry.text
|
|
305
|
+
let title = entry.title
|
|
306
|
+
if (section) {
|
|
307
|
+
const c = entry.chunks.find((x) => x.id === section)
|
|
308
|
+
if (!c) return { error: 'unknown_section', known: entry.chunks.filter((x) => x.id).map((x) => `${id}#${x.id}`) }
|
|
309
|
+
text = entry.text.slice(c.start, c.end)
|
|
310
|
+
title = c.title
|
|
311
|
+
}
|
|
312
|
+
const start = Math.max(0, Math.min(offset, text.length))
|
|
313
|
+
const body = text.slice(start, start + maxChars)
|
|
314
|
+
const next = start + body.length < text.length ? start + body.length : null
|
|
315
|
+
return {
|
|
316
|
+
path: section ? `${id}#${section}` : id,
|
|
317
|
+
title,
|
|
318
|
+
source: entry.source,
|
|
319
|
+
package: entry.pkg ?? null,
|
|
320
|
+
version: entry.version,
|
|
321
|
+
installedVersion: entry.installedVersion,
|
|
322
|
+
totalChars: text.length,
|
|
323
|
+
offset: start,
|
|
324
|
+
nextOffset: next,
|
|
325
|
+
text: body,
|
|
326
|
+
sections: section ? undefined : entry.chunks.filter((x) => x.id && x.level <= 3).map((x) => `${id}#${x.id}`).slice(0, 120),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tanqory/dev-mcp — the local stdio MCP server for Tanqory theme development.
|
|
3
|
+
*
|
|
4
|
+
* npx -y @tanqory/dev-mcp (or `tanqory mcp`, which starts this with the CLI that launched it)
|
|
5
|
+
*
|
|
6
|
+
* Programmatic: `createTanqoryMcpServer({ cwd, cli })` returns the McpServer (not connected); `main()` connects it
|
|
7
|
+
* to stdio. Only MCP messages go to stdout; every log line goes to stderr.
|
|
8
|
+
*/
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
10
|
+
import { VERSION, createTanqoryMcpServer } from './server.mjs'
|
|
11
|
+
|
|
12
|
+
export { VERSION, createTanqoryMcpServer }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {{ cwd?: string, cli?: { command: string, args?: string[], via?: string } }} [options]
|
|
16
|
+
*/
|
|
17
|
+
export async function main(options = {}) {
|
|
18
|
+
const log = (m) => process.stderr.write(`[tanqory-mcp] ${m}\n`)
|
|
19
|
+
const { server, theme } = createTanqoryMcpServer({ ...options, log })
|
|
20
|
+
const transport = new StdioServerTransport()
|
|
21
|
+
let closing = false
|
|
22
|
+
const shutdown = async () => {
|
|
23
|
+
if (closing) return
|
|
24
|
+
closing = true
|
|
25
|
+
await server.close().catch(() => {})
|
|
26
|
+
process.exit(0)
|
|
27
|
+
}
|
|
28
|
+
process.stdin.on('end', shutdown)
|
|
29
|
+
process.on('SIGINT', shutdown)
|
|
30
|
+
process.on('SIGTERM', shutdown)
|
|
31
|
+
await server.connect(transport)
|
|
32
|
+
log(`v${VERSION} on stdio · theme: ${theme.root ?? '(none: started outside a theme folder)'}${theme.root ? ` · layout ${theme.layout}` : ''}`)
|
|
33
|
+
}
|
package/src/kit.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kit's public API as a catalogue (from the installed kit's docs/headless-api.md and its export list), and real
|
|
3
|
+
* usage snippets for any entry: from the open theme's own source, then from the Blank starter bundled here
|
|
4
|
+
* (the starter `npm create tanqory-theme` writes, so the snippet is code that builds and passes the checks).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
7
|
+
import { join, relative } from 'node:path'
|
|
8
|
+
import { BUNDLED_DIR } from './docs.mjs'
|
|
9
|
+
|
|
10
|
+
const KIND_BY_HEADING = [
|
|
11
|
+
[/data hooks/i, 'hook'],
|
|
12
|
+
[/primitives/i, 'primitive'],
|
|
13
|
+
[/ai compatibility/i, 'ai-role'],
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
/** Entries the docs describe in prose rather than in a table. */
|
|
17
|
+
const PROSE = [
|
|
18
|
+
{ name: 'defineTheme', kind: 'declaration', signature: 'defineTheme({ name, sections, Layout?, Provider?, stylesheets?, shippedCopy? })', summary: 'The theme: every section in picker order and the Layout around every page (put <CartDrawer> and <ConsentBanner> there).' },
|
|
19
|
+
{ name: 'defineSection', kind: 'declaration', signature: "defineSection({ type, title, description?, category?, zone?, settings, blocks?, presets?, requires?, ai?, component })", summary: 'A section with its settings schema. `type` is persisted: never rename it. The kit wraps it in its data-tq-node element and live-edits it.' },
|
|
20
|
+
{ name: 'defineBlock', kind: 'declaration', signature: 'defineBlock({ type, title, description?, settings, parents?, requires? })', summary: 'A block a section accepts; the section component receives blocks in the merchant order as { type, settings }.' },
|
|
21
|
+
{ name: 'defineSettings', kind: 'declaration', signature: 'defineSettings({ key: field.*(…, { group }) })', summary: "The theme's global settings (the editor's Theme panel), read with useSettings()." },
|
|
22
|
+
{ name: 'field', kind: 'declaration', signature: "field.text | textarea | richtext | url | color | boolean | select(options, default) | range(default, min, max, step) | number | resource('image' | 'video' | 'menu' | 'collection' | 'product' | 'page' | 'blog' | 'article', default?)", summary: 'Setting field helpers; every field takes { label, info, placeholder, group }.' },
|
|
23
|
+
{ name: 'useTranslate', kind: 'helper', signature: "const t = useTranslate(); t('product.addToCart'); t('cart.cartCount', { count })", summary: "i18n: merchant wording → locales/<lang>.json → the kit's strings → English → the key." },
|
|
24
|
+
{ name: 'formatMoney', kind: 'helper', signature: 'formatMoney(money, { display, trimZeros })', summary: "Money in the market currency with the currency's own minor units, identical on server and browser." },
|
|
25
|
+
{ name: 'Image', kind: 'primitive', signature: '<Image image alt fallback? loading? sizes?>', summary: 'An image from the data or an image setting (https: or asset:client/<file>); anything else renders `fallback`.' },
|
|
26
|
+
{ name: 'Seo', kind: 'primitive', signature: '<Seo title description image noindex jsonLd>', summary: "Overrides for the page head; canonical, Open Graph, hreflang and the Product/Collection/Breadcrumb JSON-LD are printed already." },
|
|
27
|
+
{ name: 'SkipLink', kind: 'primitive', signature: '<SkipLink className>', summary: 'Skip link to #main (the <main> the section renderer wraps the page body in).' },
|
|
28
|
+
{ name: 'createRender', kind: 'entry', signature: "createRender({ manifest, bundle, storefront: {} })", summary: 'The ABI render(request, ctx) (import from @tanqory/theme-kit/server); entry.server.tsx, leave as scaffolded.', importFrom: '@tanqory/theme-kit/server' },
|
|
29
|
+
{ name: 'hydrate', kind: 'entry', signature: 'hydrate({ bundle })', summary: 'The browser entry with live edit on the editor canvas (import from @tanqory/theme-kit/client).', importFrom: '@tanqory/theme-kit/client' },
|
|
30
|
+
{ name: 'buildManifest', kind: 'build', signature: 'buildManifest({ name, version, theme, settings, templates, forms, capabilities, checkValue })', summary: 'The manifest from your declarations (import from @tanqory/theme-kit/build); scripts/build.mjs calls it.', importFrom: '@tanqory/theme-kit/build' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** `<ProductForm product?>` → ProductForm; `useProduct(x?)` → useProduct; `ai.hero({…})` → ai.hero. */
|
|
34
|
+
function namesOf(cell) {
|
|
35
|
+
const names = []
|
|
36
|
+
for (const m of cell.matchAll(/`([^`]+)`/g)) {
|
|
37
|
+
const raw = m[1].trim()
|
|
38
|
+
const n = /^<\s*([A-Za-z][\w.]*)/.exec(raw)?.[1] ?? /^([A-Za-z_$][\w$.]*)\s*\(/.exec(raw)?.[1] ?? null
|
|
39
|
+
if (n) names.push({ name: n, signature: raw })
|
|
40
|
+
}
|
|
41
|
+
return names
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseKitDoc(markdown) {
|
|
45
|
+
const out = []
|
|
46
|
+
let kind = null
|
|
47
|
+
let header = null
|
|
48
|
+
for (const line of markdown.split('\n')) {
|
|
49
|
+
const h = /^##\s+(.+)$/.exec(line)
|
|
50
|
+
if (h) {
|
|
51
|
+
kind = KIND_BY_HEADING.find(([re]) => re.test(h[1]))?.[1] ?? null
|
|
52
|
+
header = null
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (!kind || !line.startsWith('|')) continue
|
|
56
|
+
const cells = line.split(/(?<!\\)\|/).slice(1, -1).map((c) => c.trim())
|
|
57
|
+
if (!header) {
|
|
58
|
+
header = cells
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (cells.every((c) => /^-+$/.test(c))) continue
|
|
62
|
+
const rest = cells.slice(1).filter(Boolean).join(' — ')
|
|
63
|
+
for (const { name, signature } of namesOf(cells[0])) out.push({ name, kind, signature, summary: rest })
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every name in `export { … }` lists of a .d.ts (types excluded). */
|
|
69
|
+
export function exportedNames(dts) {
|
|
70
|
+
const names = new Set()
|
|
71
|
+
for (const m of dts.matchAll(/export\s*\{([^}]*)\}/g))
|
|
72
|
+
for (const part of m[1].split(','))
|
|
73
|
+
if (part.trim() && !/^\s*type\s/.test(part)) names.add(part.trim().split(/\s+as\s+/).pop().trim())
|
|
74
|
+
return names
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function kitCatalogue({ docText, exportsDts, installedVersion, source }) {
|
|
78
|
+
const exported = exportsDts ? exportedNames(exportsDts) : null
|
|
79
|
+
const byName = new Map()
|
|
80
|
+
for (const e of [...parseKitDoc(docText ?? ''), ...PROSE]) {
|
|
81
|
+
if (byName.has(e.name)) continue
|
|
82
|
+
const root = e.name.split('.')[0]
|
|
83
|
+
byName.set(e.name, {
|
|
84
|
+
...e,
|
|
85
|
+
importFrom: e.importFrom ?? '@tanqory/theme-kit/headless',
|
|
86
|
+
exported: e.importFrom ? null : exported ? exported.has(root) : null,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
const undocumented = exported ? [...exported].filter((n) => ![...byName.keys()].some((k) => k.split('.')[0] === n)).sort() : []
|
|
90
|
+
return { kitVersion: installedVersion ?? null, docSource: source, components: [...byName.values()], undocumentedExports: undocumented }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const SOURCE_EXT = /\.(tsx?|jsx?|mjs)$/
|
|
94
|
+
|
|
95
|
+
function walk(dir, out = []) {
|
|
96
|
+
if (!existsSync(dir)) return out
|
|
97
|
+
for (const name of readdirSync(dir).sort()) {
|
|
98
|
+
if (name === 'node_modules' || name === 'generated' || name.startsWith('.')) continue
|
|
99
|
+
const p = join(dir, name)
|
|
100
|
+
if (statSync(p).isDirectory()) walk(p, out)
|
|
101
|
+
else if (SOURCE_EXT.test(name)) out.push(p)
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function usageRegex(name) {
|
|
107
|
+
const esc = name.replace(/[.$]/g, (c) => `\\${c}`)
|
|
108
|
+
if (name === 'field') return /\bfield\.\w+\(/
|
|
109
|
+
if (/^[A-Z]/.test(name)) return new RegExp(`<${esc}[\\s/>]`)
|
|
110
|
+
return new RegExp(`\\b${esc}\\s*[(<]`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The top-level declaration around line `at` (column-0 `function` / `const` / `export` … to its closing line). */
|
|
114
|
+
function enclosing(lines, at, maxLines = 70) {
|
|
115
|
+
let start = at
|
|
116
|
+
while (start > 0 && !/^(export\s+)?(default\s+)?(async\s+)?(function|const|let|class)\b/.test(lines[start])) start--
|
|
117
|
+
while (start > 0 && /^\s*(\/\/|\/\*\*|\*)/.test(lines[start - 1])) start--
|
|
118
|
+
let end = at
|
|
119
|
+
while (end < lines.length - 1 && !/^\}\)?;?\s*$/.test(lines[end])) end++
|
|
120
|
+
if (end - start + 1 > maxLines) {
|
|
121
|
+
start = Math.max(start, at - Math.floor(maxLines / 2))
|
|
122
|
+
end = Math.min(lines.length - 1, start + maxLines - 1)
|
|
123
|
+
}
|
|
124
|
+
return { start, end }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function snippetsIn(files, base, name, origin, limit) {
|
|
128
|
+
const re = usageRegex(name)
|
|
129
|
+
const out = []
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const text = readFileSync(file, 'utf8')
|
|
132
|
+
const lines = text.split('\n')
|
|
133
|
+
const at = lines.findIndex((l) => re.test(l) && !/^\s*(import|\*|\/\/)/.test(l))
|
|
134
|
+
if (at < 0) continue
|
|
135
|
+
const { start, end } = enclosing(lines, at)
|
|
136
|
+
const importLine = lines.find((l) => /^import\b.*from '@tanqory\/theme-kit/.test(l) && new RegExp(`\\b${name.split('.')[0]}\\b`).test(l)) ?? null
|
|
137
|
+
out.push({ origin, file: relative(base, file).split('\\').join('/'), startLine: start + 1, endLine: end + 1, import: importLine, code: lines.slice(start, end + 1).join('\n') })
|
|
138
|
+
if (out.length >= limit) break
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function kitExamples({ name, themeRoot, limit = 2 }) {
|
|
144
|
+
const out = []
|
|
145
|
+
if (themeRoot) out.push(...snippetsIn(walk(join(themeRoot, 'src')), themeRoot, name, 'theme', limit))
|
|
146
|
+
const blank = join(BUNDLED_DIR, 'examples', 'blank')
|
|
147
|
+
for (const s of snippetsIn(walk(join(blank, 'src')), blank, name, 'blank-starter', limit))
|
|
148
|
+
if (!out.some((o) => o.code === s.code) && out.length < limit * 2) out.push(s)
|
|
149
|
+
return out
|
|
150
|
+
}
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate_manifest: dist/abi/manifest.json against the theme's own @tanqory/theme-abi (`validateManifest`) and the
|
|
3
|
+
* JCS byte rule (RFC 8785 bytes + one trailing LF). Component errors are traced back to the src/ declaration.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { join, relative } from 'node:path'
|
|
7
|
+
import { pathToFileURL } from 'node:url'
|
|
8
|
+
import { installedPackageDir } from './theme.mjs'
|
|
9
|
+
|
|
10
|
+
export const MANIFEST_PATH = 'dist/abi/manifest.json'
|
|
11
|
+
|
|
12
|
+
/** RFC 8785 for JSON parsed by JSON.parse: keys sorted by UTF-16 code units, ECMAScript number serialisation. */
|
|
13
|
+
export const jcs = (v) =>
|
|
14
|
+
v === null || typeof v !== 'object'
|
|
15
|
+
? JSON.stringify(v)
|
|
16
|
+
: Array.isArray(v)
|
|
17
|
+
? `[${v.map(jcs).join(',')}]`
|
|
18
|
+
: `{${Object.keys(v)
|
|
19
|
+
.sort()
|
|
20
|
+
.map((k) => `${JSON.stringify(k)}:${jcs(v[k])}`)
|
|
21
|
+
.join(',')}}`
|
|
22
|
+
|
|
23
|
+
/** Where `type: '<name>'` is declared under src/ (file:line), or null. */
|
|
24
|
+
export function findDeclaration(root, name) {
|
|
25
|
+
const re = new RegExp(`\\btype:\\s*['"]${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`)
|
|
26
|
+
const walk = (d) => {
|
|
27
|
+
if (!existsSync(d)) return null
|
|
28
|
+
for (const n of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
29
|
+
if (n.name === 'generated' || n.name === 'node_modules') continue
|
|
30
|
+
const p = join(d, n.name)
|
|
31
|
+
if (n.isDirectory()) {
|
|
32
|
+
const r = walk(p)
|
|
33
|
+
if (r) return r
|
|
34
|
+
} else if (/\.(tsx?|jsx?)$/.test(n.name)) {
|
|
35
|
+
const lines = readFileSync(p, 'utf8').split('\n')
|
|
36
|
+
const i = lines.findIndex((l) => re.test(l))
|
|
37
|
+
if (i >= 0) return { file: relative(root, p).split('\\').join('/'), line: i + 1 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
return walk(join(root, 'src'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function validateManifestFile(root, abiVersion) {
|
|
46
|
+
const path = join(root, MANIFEST_PATH)
|
|
47
|
+
if (!existsSync(path)) return { error: { code: 'manifest_not_built', message: `${MANIFEST_PATH} does not exist.`, hint: 'Run `npm run build` in the theme first (it writes the manifest from your declarations).' } }
|
|
48
|
+
const dir = installedPackageDir(root, '@tanqory/theme-abi')
|
|
49
|
+
if (!dir) return { error: { code: 'abi_not_installed', message: '@tanqory/theme-abi is not installed in this theme.', hint: 'Run `npm install` in the theme.' } }
|
|
50
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
51
|
+
const entry = typeof pkg.exports?.['.'] === 'object' ? pkg.exports['.'].default ?? pkg.exports['.'].import : pkg.main ?? 'dist/index.js'
|
|
52
|
+
const abi = await import(pathToFileURL(join(dir, entry)).href)
|
|
53
|
+
if (typeof abi.validateManifest !== 'function') return { error: { code: 'abi_too_old', message: `@tanqory/theme-abi ${pkg.version} has no validateManifest.`, hint: 'Update @tanqory/theme-abi in the theme.' } }
|
|
54
|
+
|
|
55
|
+
const bytes = readFileSync(path, 'utf8')
|
|
56
|
+
let manifest
|
|
57
|
+
try {
|
|
58
|
+
manifest = JSON.parse(bytes)
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return { valid: false, manifestPath: MANIFEST_PATH, abiVersion: abiVersion ?? pkg.version, errors: [{ path: '', message: `not JSON: ${e.message}`, code: 'json', hint: 'Rebuild with `npm run build`; never edit the manifest by hand.' }] }
|
|
61
|
+
}
|
|
62
|
+
const errors = []
|
|
63
|
+
const canonical = `${jcs(manifest)}\n`
|
|
64
|
+
if (bytes !== canonical)
|
|
65
|
+
errors.push({
|
|
66
|
+
path: '',
|
|
67
|
+
code: 'jcs',
|
|
68
|
+
message: bytes === canonical.slice(0, -1) ? 'the JCS bytes are not followed by exactly one trailing newline' : 'the file is not the JCS (RFC 8785) serialisation of its content',
|
|
69
|
+
hint: 'The build writes `canonicalize(manifest) + "\\n"`; rebuild with `npm run build`, never edit the file.',
|
|
70
|
+
})
|
|
71
|
+
const r = abi.validateManifest(manifest)
|
|
72
|
+
for (const e of r.ok ? [] : r.errors ?? []) {
|
|
73
|
+
const p = e.path ?? e.instancePath ?? ''
|
|
74
|
+
const out = { path: p || '/', message: e.message ?? String(e), code: e.code ?? e.keyword }
|
|
75
|
+
const m = /^\/components\/([^/]+)/.exec(p)
|
|
76
|
+
const decl = m ? findDeclaration(root, decodeURIComponent(m[1].replace(/~1/g, '/').replace(/~0/g, '~'))) : null
|
|
77
|
+
if (decl) Object.assign(out, decl, { hint: `Fix the declaration of "${m[1]}" (defineSection/defineBlock) at ${decl.file}:${decl.line}, then \`npm run build\`.` })
|
|
78
|
+
else out.hint = 'The manifest is generated by scripts/build.mjs (buildManifest) from src/ declarations, settings and templates: fix those, then `npm run build`.'
|
|
79
|
+
errors.push(out)
|
|
80
|
+
}
|
|
81
|
+
return { valid: errors.length === 0, manifestPath: MANIFEST_PATH, abiVersion: abiVersion ?? pkg.version, errors }
|
|
82
|
+
}
|