@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.
- 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 +250 -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/prompts.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guided workflows (prompts/list, prompts/get). Each one chains this server's tools with the CLI, and ends on the
|
|
3
|
+
* same bar: `check_theme` green on every tier.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
const DONE = 'Before you say done: `npm run build && npm run typecheck && npm test`, then check_theme with no tier. It must pass every tier (security, storefront, budgets). Report what you checked, not what you assume.'
|
|
8
|
+
|
|
9
|
+
const user = (text) => ({ messages: [{ role: 'user', content: { type: 'text', text } }] })
|
|
10
|
+
|
|
11
|
+
export function registerPrompts(server) {
|
|
12
|
+
server.registerPrompt(
|
|
13
|
+
'new-theme',
|
|
14
|
+
{
|
|
15
|
+
title: 'Design a new theme on the kit',
|
|
16
|
+
description: 'Turn the open starter into a theme of your own design: same storefront functions (from the kit), completely different look.',
|
|
17
|
+
argsSchema: {
|
|
18
|
+
name: z.string().optional().describe('Theme name'),
|
|
19
|
+
direction: z.string().optional().describe('Design direction, e.g. "editorial black-and-white with a mega menu"'),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
({ name, direction }) =>
|
|
23
|
+
user(`Build ${name ? `the theme "${name}"` : 'a new theme'} in this repository${direction ? `: ${direction}` : ''}.
|
|
24
|
+
|
|
25
|
+
1. get_doc {path: "theme/agents"}: the rules of this repository (kit for every function, no raw fetch, data-tq-node, i18n every string, a11y, deterministic render).
|
|
26
|
+
2. list_kit_components, then get_kit_example for each primitive you restyle (ProductForm, VariantPicker, CartDrawer, FacetFilters, SortSelect, SearchForm, LocalizationForm …). Keep the kit primitives; change markup around them and the CSS.
|
|
27
|
+
3. Replace src/styles/theme.css with your design system. Keep 44 px touch targets, a visible :focus-visible outline and no text-transform.
|
|
28
|
+
4. New sections: scaffold_section (plan, review, then confirm with the planDigest), then restyle what it wrote.
|
|
29
|
+
5. Every new visible string: locales/en.json AND locales/th.json, rendered with t().
|
|
30
|
+
6. Look at it: dev_server_status; if the dev server runs, screenshot mobile and desktop of /, a collection, a product, the cart, in en and th; fix what looks wrong.
|
|
31
|
+
7. ${DONE}`),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
server.registerPrompt(
|
|
35
|
+
'new-section',
|
|
36
|
+
{
|
|
37
|
+
title: 'Add a section',
|
|
38
|
+
description: 'Add one kit-wired section with settings, en + th strings and a test, and get the checks green.',
|
|
39
|
+
argsSchema: {
|
|
40
|
+
name: z.string().describe('Section name, e.g. "Lookbook"'),
|
|
41
|
+
purpose: z.string().describe('What it shows, e.g. "an image grid with a heading"'),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
({ name, purpose }) =>
|
|
45
|
+
user(`Add a "${name}" section: ${purpose}.
|
|
46
|
+
|
|
47
|
+
1. search_docs for what it needs (e.g. "defineSection presets", "Image", "field.resource"); get_kit_example {name: "defineSection"} for a complete section in this kit.
|
|
48
|
+
2. scaffold_section {name: "${name}", purpose: "${purpose}", template: "index"} to get the plan; read it; call again with confirm: true and the planDigest.
|
|
49
|
+
3. Adjust the markup and CSS to the theme's design. Fixed strings only via t() with keys in both locale files; merchant text stays in settings. presets[0] must render with a visible height (the editor's Add-section preview).
|
|
50
|
+
4. npm run build, npm test; screenshot / against the dev server when it runs.
|
|
51
|
+
5. ${DONE}`),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
server.registerPrompt(
|
|
55
|
+
'fix-check-report',
|
|
56
|
+
{
|
|
57
|
+
title: 'Fix a failing theme check',
|
|
58
|
+
description: 'Run the checks, fix every failure from its hint and file:line, and repeat until green.',
|
|
59
|
+
argsSchema: { tier: z.string().optional().describe('security | storefront | budgets | all (default all)') },
|
|
60
|
+
},
|
|
61
|
+
({ tier }) =>
|
|
62
|
+
user(`Make \`tanqory theme check\`${tier ? ` --tier ${tier}` : ''} pass.
|
|
63
|
+
|
|
64
|
+
1. npm run build (a build error comes first: fix it).
|
|
65
|
+
2. check_theme${tier ? ` {tier: "${tier}"}` : ''}. For each failure: read its message and fix hint; open file:line when given; search_docs {query: "<checkId>"} explains what the check drives and why it fails.
|
|
66
|
+
3. Fix the cause in the theme's source (never weaken a check, never edit dist/, reports/ or src/generated/). Function belongs to the kit: if a check fails because a store function is hand-written, replace it with the kit primitive (get_kit_example).
|
|
67
|
+
4. Rebuild and run check_theme again. Repeat until there are no failures.
|
|
68
|
+
5. ${DONE}`),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
server.registerPrompt(
|
|
72
|
+
'port-from-another-template-language',
|
|
73
|
+
{
|
|
74
|
+
title: 'Port a theme written in another template language',
|
|
75
|
+
description: 'Re-implement a theme written for another template language or storefront system on Theme ABI v2 and the kit: same design, the kit for every function. Translate, never copy third-party code you have no rights to.',
|
|
76
|
+
argsSchema: {
|
|
77
|
+
source: z.string().optional().describe('Where the source theme is (a folder in this workspace)'),
|
|
78
|
+
language: z.string().optional().describe('Its template language, if known'),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
({ source, language }) =>
|
|
82
|
+
user(`Port the theme ${source ? `in ${source} ` : ''}${language ? `(written in ${language}) ` : ''}to this Theme ABI v2 repository.
|
|
83
|
+
|
|
84
|
+
Treat the source as a design spec, not as code to keep. Only port a theme you have the rights to.
|
|
85
|
+
|
|
86
|
+
1. Inventory it: its templates and the page kinds they serve, its sections/partials and their settings, its global settings, its locale strings, its assets and CSS.
|
|
87
|
+
2. Map each part:
|
|
88
|
+
- page kinds → src/templates.ts route kinds + templates/<name>.json starter content (get_schema {kind: "content-document"});
|
|
89
|
+
- each section/partial → one defineSection (get_schema {kind: "section"}); its settings → field.* with the same defaults (get_schema {kind: "field-registry"});
|
|
90
|
+
- loops, filters, includes and template tags → React components and the kit's helpers (formatMoney, t(), <Image>, useCollection …);
|
|
91
|
+
- every storefront behaviour (cart, variant selection, search, filters, sort, pagination, localization, forms, consent, SEO tags) → the kit primitive for it (list_kit_components). Never port the old JavaScript for these;
|
|
92
|
+
- strings → locales/en.json and locales/th.json;
|
|
93
|
+
- CSS → src/styles/theme.css, without inline styles (the platform CSP forbids style attributes) and without text-transform.
|
|
94
|
+
3. Build one page kind at a time (home, collection, product, cart, search, page, blog/article, 404), screenshot each against the dev server and compare with the original design.
|
|
95
|
+
4. ${DONE}`),
|
|
96
|
+
)
|
|
97
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffold_section: a new kit-wired section in a theme laid out like the Blank starter (src/theme.tsx with
|
|
3
|
+
* defineTheme, src/sections/*.tsx, locales/en.json + th.json, tests/). Two steps, always: `plan` computes every file
|
|
4
|
+
* change and a digest of it; `write` applies exactly that plan, and refuses when the digest no longer matches
|
|
5
|
+
* (the theme changed in between, or the arguments differ).
|
|
6
|
+
*
|
|
7
|
+
* The generated code uses only @tanqory/theme-kit/headless, renders every fixed string through t() with en + th
|
|
8
|
+
* keys, keeps merchant text in settings, uses no inline style (the platform CSP forbids style attributes) and no
|
|
9
|
+
* text-transform, and gives links a 44 px minimum target.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { insideTheme } from './theme.mjs'
|
|
15
|
+
|
|
16
|
+
export const LAYOUTS = ['text', 'image-grid', 'media-text', 'product-grid']
|
|
17
|
+
|
|
18
|
+
export class ScaffoldError extends Error {
|
|
19
|
+
constructor(code, message, hint, extra = {}) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.code = code
|
|
22
|
+
this.hint = hint
|
|
23
|
+
Object.assign(this, extra)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const kebab = (s) =>
|
|
28
|
+
String(s)
|
|
29
|
+
.normalize('NFKD')
|
|
30
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
31
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, '')
|
|
35
|
+
.slice(0, 48)
|
|
36
|
+
.replace(/-+$/, '')
|
|
37
|
+
const camel = (k) => k.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase())
|
|
38
|
+
const pascal = (k) => camel(k).replace(/^./, (c) => c.toUpperCase())
|
|
39
|
+
const tsString = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, ' ')}'`
|
|
40
|
+
const comment = (s) => String(s).replace(/\*\//g, '* /').replace(/\s+/g, ' ').trim()
|
|
41
|
+
|
|
42
|
+
/** Deterministic layout choice from the name and purpose (no model call). */
|
|
43
|
+
export function inferLayout(name, purpose) {
|
|
44
|
+
const s = `${name} ${purpose}`.toLowerCase()
|
|
45
|
+
if (/\b(products?|collections?|best ?sellers?|catalog(ue)?|shop the)\b/.test(s)) return 'product-grid'
|
|
46
|
+
if (/\b(grid|gallery|lookbook|mosaic|portfolio|images|photos|pictures)\b/.test(s)) return 'image-grid'
|
|
47
|
+
if (/\b(image|photo|picture|banner|media|split)\b/.test(s)) return 'media-text'
|
|
48
|
+
return 'text'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function strings(layout, title, titleTh) {
|
|
52
|
+
const th = titleTh || title
|
|
53
|
+
const base = { label: [title, th] }
|
|
54
|
+
if (layout === 'image-grid') return { ...base, empty: ['Add images to this section in the editor.', 'เพิ่มรูปภาพให้ส่วนนี้ได้ในตัวแก้ไข'] }
|
|
55
|
+
if (layout === 'product-grid') return { ...base, empty: ['No products to show yet.', 'ยังไม่มีสินค้าที่จะแสดง'], viewAll: ['View all', 'ดูทั้งหมด'] }
|
|
56
|
+
return base
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sectionSource({ layout, type, title, purpose, ident, comp, cls, key, aiImport }) {
|
|
60
|
+
const head = `/**
|
|
61
|
+
* ${comment(title)}: ${comment(purpose)}
|
|
62
|
+
* Scaffolded by @tanqory/dev-mcp (layout: ${layout}). The markup and the CSS are yours; the kit supplies the settings,
|
|
63
|
+
* the data-tq-node wrapper and live edit. Fixed strings come from locales/en.json and locales/th.json (${key}.*).
|
|
64
|
+
*/
|
|
65
|
+
`
|
|
66
|
+
const sectionOpen = (extra = '') => ` <section className={${extra ? `\`${cls}${extra}\`` : `'${cls}'`}} aria-labelledby={settings.heading ? headingId : undefined} aria-label={settings.heading ? undefined : t('${key}.label')}>
|
|
67
|
+
{settings.heading ? (
|
|
68
|
+
<h2 id={headingId} className="${cls}__title">
|
|
69
|
+
{settings.heading}
|
|
70
|
+
</h2>
|
|
71
|
+
) : null}`
|
|
72
|
+
if (layout === 'image-grid') {
|
|
73
|
+
const block = `${type}-image`
|
|
74
|
+
return `${head}import { useId } from 'react'
|
|
75
|
+
import { Image, defineBlock, defineSection, field, useTranslate, type BlockInstance } from '@tanqory/theme-kit/headless'
|
|
76
|
+
|
|
77
|
+
const ${camel(block)} = defineBlock({
|
|
78
|
+
type: '${block}',
|
|
79
|
+
title: 'Image',
|
|
80
|
+
settings: {
|
|
81
|
+
image: field.resource('image', '', { label: 'Image' }),
|
|
82
|
+
caption: field.text('', { label: 'Caption' }),
|
|
83
|
+
link: field.url('', { label: 'Link' }),
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function ${comp}({ settings, blocks }: { settings: { heading: string; columns: number }; blocks: BlockInstance[] }): JSX.Element {
|
|
88
|
+
const t = useTranslate()
|
|
89
|
+
const headingId = useId()
|
|
90
|
+
const items = blocks.filter((b) => b.type === '${block}')
|
|
91
|
+
return (
|
|
92
|
+
${sectionOpen(` ${cls}--cols-\${settings.columns}`)}
|
|
93
|
+
{items.length ? (
|
|
94
|
+
<ul className="${cls}__grid">
|
|
95
|
+
{items.map((b, i) => {
|
|
96
|
+
const caption = String(b.settings.caption ?? '')
|
|
97
|
+
const link = String(b.settings.link ?? '')
|
|
98
|
+
const figure = (
|
|
99
|
+
<figure className="${cls}__figure">
|
|
100
|
+
<Image image={String(b.settings.image ?? '')} alt={caption} className="${cls}__img" sizes="(max-width: 720px) 50vw, 33vw" fallback={<span className="${cls}__img ${cls}__img--empty" />} />
|
|
101
|
+
{caption ? <figcaption className="${cls}__caption">{caption}</figcaption> : null}
|
|
102
|
+
</figure>
|
|
103
|
+
)
|
|
104
|
+
return (
|
|
105
|
+
<li key={i} className="${cls}__item">
|
|
106
|
+
{link ? (
|
|
107
|
+
<a className="${cls}__link" href={link}>
|
|
108
|
+
{figure}
|
|
109
|
+
</a>
|
|
110
|
+
) : (
|
|
111
|
+
figure
|
|
112
|
+
)}
|
|
113
|
+
</li>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</ul>
|
|
117
|
+
) : (
|
|
118
|
+
<p className="${cls}__empty">{t('${key}.empty')}</p>
|
|
119
|
+
)}
|
|
120
|
+
</section>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const ${ident} = defineSection({
|
|
125
|
+
type: '${type}',
|
|
126
|
+
title: ${tsString(title)},
|
|
127
|
+
description: ${tsString(purpose)},
|
|
128
|
+
category: 'editorial',
|
|
129
|
+
settings: {
|
|
130
|
+
heading: field.text(${tsString(title)}, { label: 'Heading' }),
|
|
131
|
+
columns: field.range(3, 2, 4, 1, { label: 'Columns' }),
|
|
132
|
+
},
|
|
133
|
+
blocks: [${camel(block)}],
|
|
134
|
+
presets: [{ name: ${tsString(title)}, settings: { heading: ${tsString(title)} }, blocks: [{ type: '${block}' }, { type: '${block}' }, { type: '${block}' }] }],
|
|
135
|
+
component: ${comp},
|
|
136
|
+
})
|
|
137
|
+
`
|
|
138
|
+
}
|
|
139
|
+
if (layout === 'product-grid') {
|
|
140
|
+
return `${head}import { useId } from 'react'
|
|
141
|
+
import { Image, ai, defineSection, field, formatMoney, useCollection, useTranslate } from '@tanqory/theme-kit/headless'
|
|
142
|
+
|
|
143
|
+
function ${comp}({ settings }: { settings: { heading: string; collection: string; count: number } }): JSX.Element {
|
|
144
|
+
const t = useTranslate()
|
|
145
|
+
const headingId = useId()
|
|
146
|
+
const c = useCollection(settings.collection)
|
|
147
|
+
const products = c.products.slice(0, settings.count)
|
|
148
|
+
return (
|
|
149
|
+
${sectionOpen()}
|
|
150
|
+
{products.length ? (
|
|
151
|
+
<ul className="${cls}__grid">
|
|
152
|
+
{products.map((p) => (
|
|
153
|
+
<li key={p.id} className="${cls}__item">
|
|
154
|
+
<a className="${cls}__link" href={p.url}>
|
|
155
|
+
<Image image={p.featuredImage} alt="" className="${cls}__img" sizes="(max-width: 720px) 50vw, 25vw" fallback={<span className="${cls}__img ${cls}__img--empty" />} />
|
|
156
|
+
<span className="${cls}__name">{p.title}</span>
|
|
157
|
+
<span className="${cls}__price">{formatMoney(p.priceRange.min)}</span>
|
|
158
|
+
</a>
|
|
159
|
+
</li>
|
|
160
|
+
))}
|
|
161
|
+
</ul>
|
|
162
|
+
) : (
|
|
163
|
+
<p className="${cls}__empty">{t('${key}.empty')}</p>
|
|
164
|
+
)}
|
|
165
|
+
{c.collection ? (
|
|
166
|
+
<a className="${cls}__all" href={c.collection.url}>
|
|
167
|
+
{t('${key}.viewAll')}
|
|
168
|
+
</a>
|
|
169
|
+
) : null}
|
|
170
|
+
</section>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const ${ident} = defineSection({
|
|
175
|
+
type: '${type}',
|
|
176
|
+
title: ${tsString(title)},
|
|
177
|
+
description: ${tsString(purpose)},
|
|
178
|
+
category: 'products',
|
|
179
|
+
settings: {
|
|
180
|
+
heading: field.text(${tsString(title)}, { label: 'Heading' }),
|
|
181
|
+
collection: field.resource('collection', 'all', { label: 'Collection' }),
|
|
182
|
+
count: field.range(8, 4, 16, 4, { label: 'Products to show' }),
|
|
183
|
+
},
|
|
184
|
+
presets: [{ name: ${tsString(title)}, settings: { heading: ${tsString(title)}, collection: 'all' } }],
|
|
185
|
+
ai: ai.productGrid('collection', { heading: 'heading' }),
|
|
186
|
+
component: ${comp},
|
|
187
|
+
})
|
|
188
|
+
`
|
|
189
|
+
}
|
|
190
|
+
if (layout === 'media-text') {
|
|
191
|
+
return `${head}import { useId } from 'react'
|
|
192
|
+
import { Image, defineSection, field, useTranslate } from '@tanqory/theme-kit/headless'
|
|
193
|
+
|
|
194
|
+
function ${comp}({ settings }: { settings: { heading: string; body: string; image: string; buttonLabel: string; buttonLink: string; imageFirst: boolean } }): JSX.Element {
|
|
195
|
+
const t = useTranslate()
|
|
196
|
+
const headingId = useId()
|
|
197
|
+
return (
|
|
198
|
+
<section className={\`${cls}\${settings.imageFirst ? '' : ' ${cls}--image-last'}\`} aria-labelledby={settings.heading ? headingId : undefined} aria-label={settings.heading ? undefined : t('${key}.label')}>
|
|
199
|
+
<div className="${cls}__media">
|
|
200
|
+
<Image image={settings.image} alt="" className="${cls}__img" sizes="(max-width: 720px) 100vw, 50vw" fallback={<span className="${cls}__img ${cls}__img--empty" />} />
|
|
201
|
+
</div>
|
|
202
|
+
<div className="${cls}__copy">
|
|
203
|
+
{settings.heading ? (
|
|
204
|
+
<h2 id={headingId} className="${cls}__title">
|
|
205
|
+
{settings.heading}
|
|
206
|
+
</h2>
|
|
207
|
+
) : null}
|
|
208
|
+
{settings.body ? <div className="${cls}__body" dangerouslySetInnerHTML={{ __html: settings.body }} /> : null}
|
|
209
|
+
{settings.buttonLabel && settings.buttonLink ? (
|
|
210
|
+
<a className="${cls}__button" href={settings.buttonLink}>
|
|
211
|
+
{settings.buttonLabel}
|
|
212
|
+
</a>
|
|
213
|
+
) : null}
|
|
214
|
+
</div>
|
|
215
|
+
</section>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const ${ident} = defineSection({
|
|
220
|
+
type: '${type}',
|
|
221
|
+
title: ${tsString(title)},
|
|
222
|
+
description: ${tsString(purpose)},
|
|
223
|
+
category: 'editorial',
|
|
224
|
+
settings: {
|
|
225
|
+
heading: field.text(${tsString(title)}, { label: 'Heading' }),
|
|
226
|
+
body: field.richtext('', { label: 'Text' }),
|
|
227
|
+
image: field.resource('image', '', { label: 'Image' }),
|
|
228
|
+
buttonLabel: field.text('', { label: 'Button label' }),
|
|
229
|
+
buttonLink: field.url('/collections/all', { label: 'Button link' }),
|
|
230
|
+
imageFirst: field.boolean(true, { label: 'Image first' }),
|
|
231
|
+
},
|
|
232
|
+
presets: [{ name: ${tsString(title)}, settings: { heading: ${tsString(title)} } }],
|
|
233
|
+
component: ${comp},
|
|
234
|
+
})
|
|
235
|
+
`
|
|
236
|
+
}
|
|
237
|
+
return `${head}import { useId } from 'react'
|
|
238
|
+
import { ${aiImport ? 'ai, ' : ''}defineSection, field, useTranslate } from '@tanqory/theme-kit/headless'
|
|
239
|
+
|
|
240
|
+
function ${comp}({ settings }: { settings: { heading: string; body: string } }): JSX.Element {
|
|
241
|
+
const t = useTranslate()
|
|
242
|
+
const headingId = useId()
|
|
243
|
+
return (
|
|
244
|
+
${sectionOpen()}
|
|
245
|
+
{settings.body ? <div className="${cls}__body" dangerouslySetInnerHTML={{ __html: settings.body }} /> : null}
|
|
246
|
+
</section>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const ${ident} = defineSection({
|
|
251
|
+
type: '${type}',
|
|
252
|
+
title: ${tsString(title)},
|
|
253
|
+
description: ${tsString(purpose)},
|
|
254
|
+
category: 'editorial',
|
|
255
|
+
settings: {
|
|
256
|
+
heading: field.text(${tsString(title)}, { label: 'Heading' }),
|
|
257
|
+
body: field.richtext('<p>${comment(purpose).replace(/[<>'\\]/g, '')}</p>', { label: 'Text' }),
|
|
258
|
+
},
|
|
259
|
+
presets: [{ name: ${tsString(title)}, settings: { heading: ${tsString(title)} } }],
|
|
260
|
+
ai: ai.richText({ heading: 'heading', body: 'body' }),
|
|
261
|
+
component: ${comp},
|
|
262
|
+
})
|
|
263
|
+
`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cssSource(layout, cls, type) {
|
|
267
|
+
const lines = [`/* ${type} (scaffolded by @tanqory/dev-mcp): restyle freely; keep 44 px link targets and no text-transform. */`, `.${cls} { padding: 48px var(--t-gutter, 16px); }`, `.${cls}__title { margin: 0 0 24px; }`]
|
|
268
|
+
if (layout === 'image-grid' || layout === 'product-grid')
|
|
269
|
+
lines.push(
|
|
270
|
+
`.${cls}__grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); list-style: none; margin: 0; padding: 0; }`,
|
|
271
|
+
layout === 'image-grid'
|
|
272
|
+
? `@media (min-width: 720px) { .${cls}--cols-3 .${cls}__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .${cls}--cols-4 .${cls}__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }`
|
|
273
|
+
: `@media (min-width: 720px) { .${cls}__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }`,
|
|
274
|
+
`.${cls}__figure { margin: 0; }`,
|
|
275
|
+
`.${cls}__img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 4; object-fit: cover; }`,
|
|
276
|
+
`.${cls}__img--empty { background: var(--t-panel, #f3f3f3); }`,
|
|
277
|
+
`.${cls}__link { display: block; min-height: 44px; color: inherit; text-decoration: none; }`,
|
|
278
|
+
`.${cls}__caption, .${cls}__name, .${cls}__price { display: block; margin-top: 8px; }`,
|
|
279
|
+
`.${cls}__all { display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; margin-top: 16px; }`,
|
|
280
|
+
)
|
|
281
|
+
if (layout === 'media-text')
|
|
282
|
+
lines.push(
|
|
283
|
+
`.${cls} { display: grid; gap: 24px; align-items: center; }`,
|
|
284
|
+
`@media (min-width: 720px) { .${cls} { grid-template-columns: 1fr 1fr; } .${cls}--image-last .${cls}__media { order: 2; } }`,
|
|
285
|
+
`.${cls}__img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }`,
|
|
286
|
+
`.${cls}__img--empty { background: var(--t-panel, #f3f3f3); }`,
|
|
287
|
+
`.${cls}__button { display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; padding: 0 16px; border: 1px solid currentColor; }`,
|
|
288
|
+
)
|
|
289
|
+
lines.push(`.${cls}__empty { margin: 0; }`)
|
|
290
|
+
return `\n${lines.join('\n')}\n`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function testSource({ type, ident, file, keys }) {
|
|
294
|
+
return `/** ${type}: scaffolded by @tanqory/dev-mcp. Extend it with what the section must guarantee. */
|
|
295
|
+
import { readFileSync } from 'node:fs'
|
|
296
|
+
import { join } from 'node:path'
|
|
297
|
+
import { fileURLToPath } from 'node:url'
|
|
298
|
+
import { describe, expect, it } from 'vitest'
|
|
299
|
+
import theme from '../../src/theme'
|
|
300
|
+
import { ${ident} } from '../../src/sections/${file}'
|
|
301
|
+
|
|
302
|
+
const root = fileURLToPath(new URL('../..', import.meta.url))
|
|
303
|
+
const locale = (lang: string) => JSON.parse(readFileSync(join(root, 'locales', \`\${lang}.json\`), 'utf8')) as Record<string, string>
|
|
304
|
+
|
|
305
|
+
describe('section ${type}', () => {
|
|
306
|
+
it('is declared and registered in the theme', () => {
|
|
307
|
+
expect(${ident}).toBeTruthy()
|
|
308
|
+
expect(theme.sections.map((s) => s.id)).toContain('${type}')
|
|
309
|
+
})
|
|
310
|
+
it('every fixed string it renders exists in en and th', () => {
|
|
311
|
+
for (const key of ${JSON.stringify(keys)}) {
|
|
312
|
+
expect(locale('en')[key], key).toBeTruthy()
|
|
313
|
+
expect(locale('th')[key], key).toBeTruthy()
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Add `ident` to defineTheme({ sections: [...] }) before `footer` (else at the end); add its import. */
|
|
321
|
+
function patchTheme(text, ident, file) {
|
|
322
|
+
const m = /(sections\s*:\s*\[)([^\]]*)(\])/.exec(text)
|
|
323
|
+
if (!m) return null
|
|
324
|
+
const items = m[2].split(',').map((s) => s.trim()).filter(Boolean)
|
|
325
|
+
if (items.includes(ident)) return null
|
|
326
|
+
const at = items.indexOf('footer')
|
|
327
|
+
at >= 0 ? items.splice(at, 0, ident) : items.push(ident)
|
|
328
|
+
let out = text.slice(0, m.index) + m[1] + items.join(', ') + m[3] + text.slice(m.index + m[0].length)
|
|
329
|
+
const importLine = `import { ${ident} } from './sections/${file}'`
|
|
330
|
+
const lines = out.split('\n')
|
|
331
|
+
let last = -1
|
|
332
|
+
let lastSection = -1
|
|
333
|
+
lines.forEach((l, i) => {
|
|
334
|
+
if (/^import\b/.test(l)) last = i
|
|
335
|
+
if (/^import\b.*from '\.\/sections\//.test(l)) lastSection = i
|
|
336
|
+
})
|
|
337
|
+
lines.splice((lastSection >= 0 ? lastSection : last) + 1, 0, importLine)
|
|
338
|
+
out = lines.join('\n')
|
|
339
|
+
return { text: out, importLine, sectionsLine: `sections: [${items.join(', ')}]` }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
|
|
343
|
+
const jsonText = (v) => `${JSON.stringify(v, null, 2)}\n`
|
|
344
|
+
|
|
345
|
+
/** Section types already declared anywhere under src/ (so a new one never collides). */
|
|
346
|
+
function declaredTypes(root) {
|
|
347
|
+
const types = new Set()
|
|
348
|
+
const walk = (d) => {
|
|
349
|
+
if (!existsSync(d)) return
|
|
350
|
+
for (const n of readdirSync(d, { withFileTypes: true })) {
|
|
351
|
+
if (n.name === 'generated' || n.name === 'node_modules') continue
|
|
352
|
+
const p = join(d, n.name)
|
|
353
|
+
if (n.isDirectory()) walk(p)
|
|
354
|
+
else if (/\.(tsx?|jsx?)$/.test(n.name)) for (const m of readFileSync(p, 'utf8').matchAll(/\btype:\s*'([a-z0-9-]+)'/g)) types.add(m[1])
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
walk(join(root, 'src'))
|
|
358
|
+
return types
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* @param {{ root: string|null, layout: string }} theme
|
|
363
|
+
* @param {{ name: string, purpose: string, layout?: string, template?: string, titleTh?: string }} input
|
|
364
|
+
*/
|
|
365
|
+
export function planSection(theme, input) {
|
|
366
|
+
if (!theme.root) throw new ScaffoldError('no_theme', 'No theme is open: the server was not started inside a theme folder.', 'Start the agent (and this server) in the theme root, the folder with tanqory.config.json.')
|
|
367
|
+
if (theme.layout !== 'kit-headless')
|
|
368
|
+
throw new ScaffoldError(
|
|
369
|
+
'unsupported_theme_layout',
|
|
370
|
+
theme.layout === 'nova-fork'
|
|
371
|
+
? 'This theme is a Nova fork (theme.config.ts); scaffold_section only knows the Blank layout (src/theme.tsx with defineTheme) today.'
|
|
372
|
+
: 'This theme has no src/theme.tsx with defineTheme(); scaffold_section only knows the Blank layout today.',
|
|
373
|
+
'Write the section by hand: get_kit_example {name: "defineSection"} shows a complete one, get_schema {kind: "section"} the declaration shape. Nothing was written.',
|
|
374
|
+
)
|
|
375
|
+
const root = theme.root
|
|
376
|
+
const title = String(input.name ?? '').trim()
|
|
377
|
+
const purpose = String(input.purpose ?? '').trim()
|
|
378
|
+
if (!title) throw new ScaffoldError('invalid_name', 'name is empty.', 'Pass a human name, e.g. "Lookbook".')
|
|
379
|
+
if (!purpose) throw new ScaffoldError('invalid_purpose', 'purpose is empty.', 'Say in one sentence what the section shows, e.g. "an image grid with a heading".')
|
|
380
|
+
const type = kebab(title)
|
|
381
|
+
if (!/^[a-z][a-z0-9-]*$/.test(type)) throw new ScaffoldError('invalid_name', `"${title}" does not give a usable section type (got "${type}").`, 'Use a name that starts with a Latin letter, e.g. "Lookbook".')
|
|
382
|
+
const layout = input.layout ?? inferLayout(title, purpose)
|
|
383
|
+
if (!LAYOUTS.includes(layout)) throw new ScaffoldError('invalid_layout', `layout "${layout}" is not one of ${LAYOUTS.join(', ')}.`, 'Omit it to infer one from the purpose.')
|
|
384
|
+
if (declaredTypes(root).has(type)) throw new ScaffoldError('section_exists', `A section or block of type "${type}" is already declared in src/.`, 'Choose another name, or edit the existing section.')
|
|
385
|
+
const file = type
|
|
386
|
+
const sectionPath = `src/sections/${file}.tsx`
|
|
387
|
+
const testPath = `tests/sections/${file}.test.ts`
|
|
388
|
+
for (const p of [sectionPath, testPath]) if (existsSync(join(root, p))) throw new ScaffoldError('file_exists', `${p} already exists.`, 'Choose another name; nothing was written.')
|
|
389
|
+
|
|
390
|
+
const ident = `${camel(type)}Section`
|
|
391
|
+
const comp = `${pascal(type)}Section`
|
|
392
|
+
const css = join(root, 'src', 'styles', 'theme.css')
|
|
393
|
+
const prefix = existsSync(css) && /\.t-[a-z]/.test(readFileSync(css, 'utf8')) ? 't-' : ''
|
|
394
|
+
const cls = `${prefix}${type}`
|
|
395
|
+
const key = `theme.${camel(type)}`
|
|
396
|
+
const str = strings(layout, title, input.titleTh?.trim())
|
|
397
|
+
const keys = Object.keys(str).map((k) => `${key}.${k}`)
|
|
398
|
+
|
|
399
|
+
const changes = []
|
|
400
|
+
changes.push({ path: sectionPath, action: 'create', content: sectionSource({ layout, type, title, purpose, ident, comp, cls, key, aiImport: layout === 'text' }), summary: `defineSection "${type}" (${layout})` })
|
|
401
|
+
|
|
402
|
+
const themeFile = join(root, 'src', 'theme.tsx')
|
|
403
|
+
const patched = patchTheme(readFileSync(themeFile, 'utf8'), ident, file)
|
|
404
|
+
if (!patched) throw new ScaffoldError('theme_not_patchable', 'src/theme.tsx has no `sections: [...]` list this tool can extend.', `Add \`import { ${ident} } from './sections/${file}'\` and put ${ident} in defineTheme({ sections }) yourself after writing the section.`)
|
|
405
|
+
changes.push({ path: 'src/theme.tsx', action: 'modify', content: patched.text, summary: `import ${ident}; ${patched.sectionsLine}` })
|
|
406
|
+
|
|
407
|
+
for (const [lang, idx] of [['en', 0], ['th', 1]]) {
|
|
408
|
+
const p = join(root, 'locales', `${lang}.json`)
|
|
409
|
+
if (!existsSync(p)) throw new ScaffoldError('locale_missing', `locales/${lang}.json does not exist.`, 'The theme needs locales/en.json and locales/th.json.')
|
|
410
|
+
const map = readJson(p)
|
|
411
|
+
const added = {}
|
|
412
|
+
for (const [k, v] of Object.entries(str)) if (!(`${key}.${k}` in map)) added[`${key}.${k}`] = v[idx]
|
|
413
|
+
changes.push({ path: `locales/${lang}.json`, action: 'modify', content: jsonText({ ...map, ...added }), summary: `add ${Object.keys(added).join(', ') || 'nothing (keys exist)'}`, added })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (existsSync(css)) changes.push({ path: 'src/styles/theme.css', action: 'modify', content: readFileSync(css, 'utf8').replace(/\n*$/, '\n') + cssSource(layout, cls, type), summary: `append .${cls} rules` })
|
|
417
|
+
|
|
418
|
+
changes.push({ path: testPath, action: 'create', content: testSource({ type, ident, file, keys }), summary: 'vitest: registered, en + th strings exist' })
|
|
419
|
+
|
|
420
|
+
if (input.template) {
|
|
421
|
+
const tname = String(input.template)
|
|
422
|
+
const tp = insideTheme(root, join('templates', `${tname}.json`))
|
|
423
|
+
if (!tp || !existsSync(tp)) throw new ScaffoldError('unknown_template', `templates/${tname}.json does not exist.`, `Pass one of: ${readdirSync(join(root, 'templates')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).join(', ')}; or omit template.`)
|
|
424
|
+
const doc = readJson(tp)
|
|
425
|
+
const main = doc.regions?.main
|
|
426
|
+
if (!main || !Array.isArray(main.sections)) throw new ScaffoldError('template_not_patchable', `templates/${tname}.json has no regions.main.sections list.`, 'Add the section to the template yourself.')
|
|
427
|
+
const ids = new Set(main.sections.map((s) => s.id))
|
|
428
|
+
let n = 1
|
|
429
|
+
while (ids.has(`${type}-${n}`)) n++
|
|
430
|
+
const id = `${type}-${n}`
|
|
431
|
+
const node = { id, type, settings: { heading: title } }
|
|
432
|
+
if (layout === 'image-grid') node.blocks = [1, 2, 3].map((i) => ({ id: `${id}-image-${i}`, type: `${type}-image` }))
|
|
433
|
+
const next = { ...doc, regions: { ...doc.regions, main: { ...main, sections: [...main.sections, node] } } }
|
|
434
|
+
changes.push({ path: `templates/${tname}.json`, action: 'modify', content: jsonText(next), summary: `append node "${id}" to regions.main` })
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const planDigest = `sha256:${createHash('sha256')
|
|
438
|
+
.update(JSON.stringify(changes.map((c) => [c.path, c.action, c.content])))
|
|
439
|
+
.digest('hex')}`
|
|
440
|
+
return {
|
|
441
|
+
type,
|
|
442
|
+
title,
|
|
443
|
+
layout,
|
|
444
|
+
localeKeys: keys,
|
|
445
|
+
translationTodo: input.titleTh ? [] : [`${key}.label (th): the Thai name equals the English one ("${title}"); translate it, or pass titleTh`],
|
|
446
|
+
changes,
|
|
447
|
+
planDigest,
|
|
448
|
+
nextSteps: [
|
|
449
|
+
'Restyle the markup and the appended CSS to your design.',
|
|
450
|
+
...(input.template ? [] : ['Place the section: add it in the editor, or call again with template: "index" to put it in the home page starter content.']),
|
|
451
|
+
'npm test, then screenshot the page with the section (desktop and mobile; the dev server rebuilds on save), then check_theme until it passes.',
|
|
452
|
+
],
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Apply a plan whose digest the caller confirmed. Returns the files written. */
|
|
457
|
+
export function writeSection(theme, input, planDigest) {
|
|
458
|
+
const plan = planSection(theme, input)
|
|
459
|
+
if (plan.planDigest !== planDigest)
|
|
460
|
+
throw new ScaffoldError('plan_changed', 'The plan no longer matches the digest you confirmed: the theme or the arguments changed since the plan was made. Nothing was written.', 'Call scaffold_section again without confirm, review the new plan, then confirm its planDigest.', { planDigest: plan.planDigest })
|
|
461
|
+
const written = []
|
|
462
|
+
for (const c of plan.changes) {
|
|
463
|
+
const abs = insideTheme(theme.root, c.path)
|
|
464
|
+
if (!abs) throw new ScaffoldError('path_outside_theme', `${c.path} is outside the theme.`, 'Nothing more was written.', { written })
|
|
465
|
+
if (c.action === 'create' && existsSync(abs)) throw new ScaffoldError('file_exists', `${c.path} appeared after planning.`, 'Nothing more was written.', { written })
|
|
466
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
467
|
+
writeFileSync(abs, c.content)
|
|
468
|
+
written.push({ path: c.path, action: c.action, bytes: Buffer.byteLength(c.content) })
|
|
469
|
+
}
|
|
470
|
+
return { ...plan, written }
|
|
471
|
+
}
|