@tanqory/dev-mcp 0.0.0-stage → 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -2
  3. package/bin/tanqory-dev-mcp.mjs +16 -0
  4. package/bundled/examples/blank/config/settings.json +3 -0
  5. package/bundled/examples/blank/locales/en.json +22 -0
  6. package/bundled/examples/blank/locales/th.json +22 -0
  7. package/bundled/examples/blank/regions/footer.json +35 -0
  8. package/bundled/examples/blank/regions/header.json +13 -0
  9. package/bundled/examples/blank/src/bundle.ts +15 -0
  10. package/bundled/examples/blank/src/components/parts.tsx +32 -0
  11. package/bundled/examples/blank/src/entry.client.tsx +6 -0
  12. package/bundled/examples/blank/src/entry.server.tsx +13 -0
  13. package/bundled/examples/blank/src/sections/chrome.tsx +132 -0
  14. package/bundled/examples/blank/src/sections/commerce.tsx +242 -0
  15. package/bundled/examples/blank/src/sections/content.tsx +111 -0
  16. package/bundled/examples/blank/src/sections/editorial.tsx +107 -0
  17. package/bundled/examples/blank/src/settings.ts +15 -0
  18. package/bundled/examples/blank/src/styles/theme.css +195 -0
  19. package/bundled/examples/blank/src/templates.ts +14 -0
  20. package/bundled/examples/blank/src/theme.tsx +76 -0
  21. package/bundled/examples/blank/template.json +24 -0
  22. package/bundled/examples/blank/templates/404.json +14 -0
  23. package/bundled/examples/blank/templates/account.json +14 -0
  24. package/bundled/examples/blank/templates/article.json +14 -0
  25. package/bundled/examples/blank/templates/blog.json +14 -0
  26. package/bundled/examples/blank/templates/cart.json +14 -0
  27. package/bundled/examples/blank/templates/collection.json +14 -0
  28. package/bundled/examples/blank/templates/index.json +36 -0
  29. package/bundled/examples/blank/templates/page.contact.json +18 -0
  30. package/bundled/examples/blank/templates/page.json +14 -0
  31. package/bundled/examples/blank/templates/product.json +18 -0
  32. package/bundled/examples/blank/templates/search.json +14 -0
  33. package/bundled/examples/blank/tests/theme.test.ts +49 -0
  34. package/bundled/theme-abi/contracts/render-context.v2.d.ts +849 -0
  35. package/bundled/theme-abi/contracts/theme-abi-v2.md +1833 -0
  36. package/bundled/theme-abi/generated/capabilities.json +80 -0
  37. package/bundled/theme-abi/generated/field-types.json +869 -0
  38. package/bundled/theme-abi/generated/limits.json +173 -0
  39. package/bundled/theme-abi/schemas/conformance-report.v2.schema.json +482 -0
  40. package/bundled/theme-abi/schemas/theme-artifact.v1.schema.json +230 -0
  41. package/bundled/theme-abi/schemas/theme-manifest.v2.schema.json +254 -0
  42. package/bundled/theme-conformance/docs/storefront-tier.md +250 -0
  43. package/bundled/theme-conformance/schema/conformance-report.v2.schema.json +482 -0
  44. package/bundled/theme-conformance/schema/storefront-report.v1.schema.json +110 -0
  45. package/bundled/theme-conformance/schema/x-tanqory-ai.v0.schema.json +70 -0
  46. package/bundled/theme-kit/docs/headless-api.md +220 -0
  47. package/bundled/theme-kit/generated/kit-strings.json +180 -0
  48. package/bundled/theme-kit/types/headless/index.d.ts +36 -0
  49. package/bundled/theme-kit/types/headless/theme.d.ts +99 -0
  50. package/bundled/theme-kit/types/sdk/schema.d.ts +197 -0
  51. package/bundled/versions.json +8 -0
  52. package/package.json +53 -3
  53. package/src/bm25.mjs +69 -0
  54. package/src/browser.mjs +123 -0
  55. package/src/cli.mjs +127 -0
  56. package/src/devserver.mjs +82 -0
  57. package/src/docs.mjs +329 -0
  58. package/src/index.mjs +33 -0
  59. package/src/kit.mjs +150 -0
  60. package/src/manifest.mjs +82 -0
  61. package/src/prompts.mjs +97 -0
  62. package/src/scaffold.mjs +471 -0
  63. package/src/schemas.mjs +127 -0
  64. package/src/server.mjs +595 -0
  65. 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
+ }
@@ -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
+ }