@typeroll/mcp-server 0.45.76 → 0.45.79
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/AGENTS.md +8 -2
- package/README.md +18 -0
- package/dist/bundled-content.js +35 -10
- package/dist/init.js +1 -1
- package/dist/tools/deploy.js +2 -1
- package/dist/tools/media.js +1 -1
- package/dist/tools/migration.js +4 -2
- package/dist/tools/pages.js +1 -1
- package/dist/tools/preview.js +1 -1
- package/dist/tools/working-copy.js +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.js +34 -10
- package/package.json +3 -2
- package/skills/tr-header-footer.md +95 -41
- package/skills/tr-import-url.md +6 -6
- package/skills/tr-migrate-astro.md +3 -3
- package/skills/tr-migrate-multisite.md +4 -4
- package/skills/tr-migrate-wp.md +28 -12
- package/skills/tr-migration-evidence.md +35 -23
- package/skills/tr-new-site.md +5 -7
- package/workspace-templates/brief/interview.md +18 -0
- package/workspace-templates/brief/migration.md +42 -0
- package/workspace-templates/qa/checklist.md +35 -0
- package/workspace-templates/qa/typeroll-platform-feedback.md +11 -0
- package/workspace-templates/scripts/README.md +85 -0
- package/workspace-templates/scripts/typeroll/browser.cjs +50 -0
- package/workspace-templates/scripts/typeroll/call.py +12 -0
- package/workspace-templates/scripts/typeroll/capture_text.cjs +17 -0
- package/workspace-templates/scripts/typeroll/card-stacking-check.cjs +27 -0
- package/workspace-templates/scripts/typeroll/check_assets.py +72 -0
- package/workspace-templates/scripts/typeroll/check_unsaved.py +13 -0
- package/workspace-templates/scripts/typeroll/check_url_parity.py +74 -0
- package/workspace-templates/scripts/typeroll/compare_text.py +59 -0
- package/workspace-templates/scripts/typeroll/contact_sheet.py +20 -0
- package/workspace-templates/scripts/typeroll/layout_trace.cjs +33 -0
- package/workspace-templates/scripts/typeroll/shoot_hosted.cjs +26 -0
- package/workspace-templates/scripts/typeroll/shootframe.cjs +16 -0
- package/workspace-templates/scripts/typeroll/sweep_blocks.example.py +23 -0
- package/workspace-templates/scripts/typeroll/trlib.py +212 -0
- package/workspace-templates/workflows/design.md +30 -0
- package/workspace-templates/workflows/interview.md +72 -0
- package/workspace-templates/workflows/migration.md +84 -0
- package/workspace-templates/workflows/new-site.md +17 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Compare saved source HTML (or one WP REST record) to a captured target.
|
|
2
|
+
Template titles are included exactly once by explicit source title/target selector.
|
|
3
|
+
This reports differences, never rewrites content or approves a migration.
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Text(HTMLParser):
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__(convert_charrefs=True)
|
|
16
|
+
self.words, self.skip = [], 0
|
|
17
|
+
def handle_starttag(self, tag, attrs):
|
|
18
|
+
if tag in ('script', 'style'):
|
|
19
|
+
self.skip += 1
|
|
20
|
+
elif tag in ('p', 'div', 'li', 'br', 'h1', 'h2', 'h3', 'h4'):
|
|
21
|
+
self.words.append(' ')
|
|
22
|
+
def handle_endtag(self, tag):
|
|
23
|
+
if tag in ('script', 'style'):
|
|
24
|
+
self.skip = max(0, self.skip - 1)
|
|
25
|
+
elif tag in ('p', 'div', 'li', 'h1', 'h2', 'h3', 'h4'):
|
|
26
|
+
self.words.append(' ')
|
|
27
|
+
def handle_data(self, value):
|
|
28
|
+
if not self.skip:
|
|
29
|
+
self.words.append(value)
|
|
30
|
+
# HTMLParser excludes comments. Do not strip tags with a regexp.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tokens(value):
|
|
34
|
+
parser = Text()
|
|
35
|
+
parser.feed(value)
|
|
36
|
+
return re.findall(r'\w+|[^\w\s]', ''.join(parser.words))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
args = argparse.ArgumentParser(description=__doc__)
|
|
41
|
+
args.add_argument('source', help='HTML fragment or one saved WP REST record (.json)')
|
|
42
|
+
args.add_argument('target', help='JSON from capture_text.cjs')
|
|
43
|
+
args.add_argument('--source-title', default='', help='For an HTML source whose title is template-owned')
|
|
44
|
+
opts = args.parse_args()
|
|
45
|
+
source = Path(opts.source).read_text()
|
|
46
|
+
if opts.source.endswith('.json'):
|
|
47
|
+
record = json.loads(source)
|
|
48
|
+
source = record['title']['rendered'] + ' ' + record['content']['rendered']
|
|
49
|
+
else:
|
|
50
|
+
source = opts.source_title + ' ' + source
|
|
51
|
+
target = json.loads(Path(opts.target).read_text())
|
|
52
|
+
before, after = tokens(source), re.findall(r'\w+|[^\w\s]', target['text'])
|
|
53
|
+
changes = [line for line in difflib.ndiff(before, after) if line.startswith(('+ ', '- '))]
|
|
54
|
+
print(json.dumps({'source_tokens': len(before), 'target_tokens': len(after), 'changes': changes}, ensure_ascii=False, indent=2))
|
|
55
|
+
return int(bool(changes))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == '__main__':
|
|
59
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Tile screenshots into contact sheets for fast visual review of many pages.
|
|
2
|
+
usage: python3 contact_sheet.py <out-prefix> <cols> <tile-width> <png> [<png> ...]
|
|
3
|
+
Each tile shows the top of a screenshot (height = 3.2 x width) labelled with its file name."""
|
|
4
|
+
import sys, os
|
|
5
|
+
from PIL import Image, ImageDraw
|
|
6
|
+
out, cols, tw, files = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4:]
|
|
7
|
+
th = int(tw * 3.2)
|
|
8
|
+
per = cols * 2
|
|
9
|
+
for n, start in enumerate(range(0, len(files), per)):
|
|
10
|
+
chunk = files[start:start + per]
|
|
11
|
+
sheet = Image.new("RGB", (cols * tw, 2 * (th + 20)), "white")
|
|
12
|
+
d = ImageDraw.Draw(sheet)
|
|
13
|
+
for i, f in enumerate(chunk):
|
|
14
|
+
im = Image.open(f).convert("RGB")
|
|
15
|
+
im = im.resize((tw, int(im.height * tw / im.width))).crop((0, 0, tw, th))
|
|
16
|
+
x, y = (i % cols) * tw, (i // cols) * (th + 20)
|
|
17
|
+
sheet.paste(im, (x, y + 20))
|
|
18
|
+
d.text((x + 3, y + 3), os.path.basename(f)[:40], fill="red")
|
|
19
|
+
sheet.save(f"{out}-{n}.png")
|
|
20
|
+
print(f"{out}-{n}.png")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Explain vertical spacing: computed margin/padding/gap of every ancestor of a selector and of the next
|
|
2
|
+
// section down to its first heading. Works on a signed preview link (reads the inner frame) or a public URL.
|
|
3
|
+
// usage: node layout_trace.js <url> [width=390] [selector=h1]
|
|
4
|
+
const qa = require('./browser.cjs');
|
|
5
|
+
const { chromium } = qa;
|
|
6
|
+
(async () => {
|
|
7
|
+
const [,, url, width = '390', sel = 'h1'] = process.argv;
|
|
8
|
+
const b = await chromium.launch();
|
|
9
|
+
const p = await b.newPage({viewport: {width: +width, height: 900}});
|
|
10
|
+
await qa.open(p, url);
|
|
11
|
+
const f = p.mainFrame();
|
|
12
|
+
try { console.log(await f.evaluate((sel) => {
|
|
13
|
+
const out = [];
|
|
14
|
+
const show = (lab, el) => {
|
|
15
|
+
if (!el) return;
|
|
16
|
+
const c = getComputedStyle(el), r = el.getBoundingClientRect();
|
|
17
|
+
out.push(`${lab} <${el.tagName.toLowerCase()} ${el.getAttribute('data-block') || String(el.className).slice(0, 40)}> top=${Math.round(r.top)} h=${Math.round(r.height)} margin=${c.marginTop}/${c.marginBottom} padding=${c.paddingTop}/${c.paddingBottom} gap=${c.rowGap} radius=${c.borderRadius}`);
|
|
18
|
+
};
|
|
19
|
+
const target = document.querySelector(sel);
|
|
20
|
+
if (!target) throw Error('Selector not found');
|
|
21
|
+
const sec = target.closest('[data-block="section"]') || target;
|
|
22
|
+
const chain = []; for (let a = target; a && a !== document.body; a = a.parentElement) chain.push(a);
|
|
23
|
+
chain.reverse().forEach((x, i) => show('ancestor' + i, x));
|
|
24
|
+
for (let s = sec.nextElementSibling; s; s = s.nextElementSibling) {
|
|
25
|
+
show('next', s);
|
|
26
|
+
const h = s.querySelector('h1,h2,h3');
|
|
27
|
+
if (h) { const ch = []; for (let q = h; q && q !== s; q = q.parentElement) ch.push(q); ch.reverse().forEach((x, i) => show('inside' + i, x)); break; }
|
|
28
|
+
}
|
|
29
|
+
show('header', document.querySelector('header'));
|
|
30
|
+
return out.join('\n');
|
|
31
|
+
}, sel));
|
|
32
|
+
} finally { await b.close(); }
|
|
33
|
+
})().catch(qa.fail);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const qa = require('./browser.cjs');
|
|
2
|
+
const [origin, out, paths, ...args] = process.argv.slice(2);
|
|
3
|
+
(async () => {
|
|
4
|
+
const browser = await qa.chromium.launch();
|
|
5
|
+
const report = [];
|
|
6
|
+
try {
|
|
7
|
+
for (const route of paths.split(',')) {
|
|
8
|
+
const url = new URL(route, origin);
|
|
9
|
+
if (url.origin !== new URL(origin).origin) throw Error('Paths must stay on the selected origin');
|
|
10
|
+
for (const width of qa.widths(args)) {
|
|
11
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
12
|
+
const failed = [];
|
|
13
|
+
page.on('requestfailed', r => failed.push({ host: new URL(r.url()).hostname, kind: 'network' }));
|
|
14
|
+
page.on('response', r => { if (r.status() >= 400) failed.push({ host: new URL(r.url()).hostname, status: r.status() }); });
|
|
15
|
+
await qa.open(page, url.href); await qa.settle(page);
|
|
16
|
+
const result = { path: route, width, ...await qa.inspect(page), failed };
|
|
17
|
+
const name = require('node:crypto').createHash('sha256').update(route).digest('hex').slice(0, 12);
|
|
18
|
+
await qa.screenshot(page, `${out}/${name}-${width}.png`);
|
|
19
|
+
report.push(result);
|
|
20
|
+
await page.close();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
console.log(JSON.stringify(report, null, 2));
|
|
24
|
+
if (report.some(r => r.overflow || r.incompleteImages.length || r.h1 !== 1 || r.failed.length || !r.scrollCoverageComplete)) process.exitCode = 1;
|
|
25
|
+
} finally { await browser.close(); }
|
|
26
|
+
})().catch(qa.fail);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const qa = require('./browser.cjs');
|
|
2
|
+
const [out, url, ...args] = process.argv.slice(2);
|
|
3
|
+
(async () => {
|
|
4
|
+
const browser = await qa.chromium.launch();
|
|
5
|
+
try {
|
|
6
|
+
for (const width of qa.widths(args)) {
|
|
7
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
8
|
+
const hosts = new Set();
|
|
9
|
+
page.on('request', r => { try { hosts.add(new URL(r.url()).hostname); } catch {} });
|
|
10
|
+
await qa.open(page, url); await qa.settle(page);
|
|
11
|
+
await qa.screenshot(page, `${out}-${width}.png`);
|
|
12
|
+
console.log(JSON.stringify({ width, ...await qa.inspect(page), hosts: [...hosts] }));
|
|
13
|
+
await page.close();
|
|
14
|
+
}
|
|
15
|
+
} finally { await browser.close(); }
|
|
16
|
+
})().catch(qa.fail);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pattern: change one kind of block on every Page, Page template and partial, then save.
|
|
2
|
+
Example: set last_row "start" on every core/grid that centers its last row. Dry run lists; --apply writes.
|
|
3
|
+
Copy and edit MATCH/PATCH for other sweeps. Templates save immediately; Pages and partials must be committed. Refuses existing unsaved work."""
|
|
4
|
+
import os, sys
|
|
5
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
6
|
+
import trlib
|
|
7
|
+
SITE = trlib.site_id()
|
|
8
|
+
MATCH = lambda b: b.get("type") == "core/grid" and (b.get("data") or {}).get("last_row") == "center"
|
|
9
|
+
PATCH = {"last_row": "start"}
|
|
10
|
+
apply = "--apply" in sys.argv
|
|
11
|
+
for tgt in trlib.containers():
|
|
12
|
+
tree = trlib.call("get_page_blocks", {"site_id": SITE, "target": tgt})
|
|
13
|
+
hits = [b for b in trlib.walk(tree.get("blocks", tree) if isinstance(tree, dict) else tree) if MATCH(b)]
|
|
14
|
+
if not hits:
|
|
15
|
+
continue
|
|
16
|
+
print(tgt["kind"], tgt["id"], len(hits))
|
|
17
|
+
if apply:
|
|
18
|
+
if tgt['kind'] != 'template' and trlib.call('read_working_copy', {'target': tgt}).get('working_copy'):
|
|
19
|
+
raise RuntimeError('Unsaved work exists; review it before this sweep')
|
|
20
|
+
for b in hits:
|
|
21
|
+
trlib.call("update_block", {"site_id": SITE, "target": tgt, "block_id": b["id"], "data": PATCH})
|
|
22
|
+
if tgt["kind"] in ("page", "partial"):
|
|
23
|
+
trlib.call("commit_working_copy", {"site_id": SITE, "target": tgt})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Small, version-bound MCP helper. Credentials are injected, never persisted.
|
|
2
|
+
No automatic retries for mutations. Run from the workspace root.
|
|
3
|
+
"""
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
14
|
+
def redirect_request(self, *args, **kwargs):
|
|
15
|
+
return None # Never forward Authorization or signed PUTs to another URL.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def workspace():
|
|
19
|
+
for directory in (Path.cwd(), *Path.cwd().parents):
|
|
20
|
+
file = directory / 'typeroll.json'
|
|
21
|
+
if file.is_file():
|
|
22
|
+
value = json.loads(file.read_text())
|
|
23
|
+
if not all(value.get(k) for k in ('portal', 'organization_id', 'site_id', 'version')):
|
|
24
|
+
raise ValueError('Complete portal, organization_id, site_id and version in typeroll.json first')
|
|
25
|
+
origin = urllib.parse.urlsplit(value['portal'])
|
|
26
|
+
if origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):
|
|
27
|
+
raise ValueError('portal must be a plain origin')
|
|
28
|
+
if origin.scheme != 'https' and not (origin.scheme == 'http' and origin.hostname in ('localhost', '127.0.0.1', '::1')):
|
|
29
|
+
raise ValueError('portal requires HTTPS (HTTP is allowed only on loopback)')
|
|
30
|
+
return value
|
|
31
|
+
raise ValueError('Run from a workspace containing typeroll.json')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Client:
|
|
35
|
+
def __init__(self, config=None):
|
|
36
|
+
self.config = config or workspace()
|
|
37
|
+
self.url = self.config['portal'].rstrip('/') + '/api/mcp?tools=full'
|
|
38
|
+
self.session = None
|
|
39
|
+
self.sequence = 0
|
|
40
|
+
self.tools = None
|
|
41
|
+
self.verified = False
|
|
42
|
+
self.opener = urllib.request.build_opener(NoRedirect)
|
|
43
|
+
|
|
44
|
+
def rpc(self, method, params=None, notify=False):
|
|
45
|
+
self.sequence += 1
|
|
46
|
+
body = {'jsonrpc': '2.0', 'method': method}
|
|
47
|
+
if params is not None:
|
|
48
|
+
body['params'] = params
|
|
49
|
+
if not notify:
|
|
50
|
+
body['id'] = self.sequence
|
|
51
|
+
key = os.environ.get('TYPEROLL_API_KEY')
|
|
52
|
+
if not key:
|
|
53
|
+
raise ValueError('Inject TYPEROLL_API_KEY privately before running')
|
|
54
|
+
headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',
|
|
55
|
+
'Accept': 'application/json, text/event-stream'}
|
|
56
|
+
if self.session:
|
|
57
|
+
headers['Mcp-Session-Id'] = self.session
|
|
58
|
+
request = urllib.request.Request(self.url, data=json.dumps(body).encode(), headers=headers)
|
|
59
|
+
try:
|
|
60
|
+
with self.opener.open(request, timeout=180) as response:
|
|
61
|
+
self.session = response.headers.get('Mcp-Session-Id') or self.session
|
|
62
|
+
raw = response.read().decode()
|
|
63
|
+
except urllib.error.HTTPError as error:
|
|
64
|
+
raise RuntimeError(f'MCP HTTP {error.code}; no mutation was retried') from None
|
|
65
|
+
if notify:
|
|
66
|
+
return None
|
|
67
|
+
result = decode_rpc(raw, self.sequence)
|
|
68
|
+
if 'error' in result:
|
|
69
|
+
raise RuntimeError(f"MCP RPC error {result['error'].get('code')}")
|
|
70
|
+
return result['result']
|
|
71
|
+
|
|
72
|
+
def initialize(self):
|
|
73
|
+
self.rpc('initialize', {'protocolVersion': '2025-03-26', 'capabilities': {},
|
|
74
|
+
'clientInfo': {'name': 'typeroll-workspace', 'version': '1'}})
|
|
75
|
+
self.rpc('notifications/initialized', notify=True)
|
|
76
|
+
self.tools = {}
|
|
77
|
+
cursor = None
|
|
78
|
+
seen = set()
|
|
79
|
+
while True:
|
|
80
|
+
result = self.rpc('tools/list', {'cursor': cursor} if cursor else {})
|
|
81
|
+
self.tools.update({t['name']: t for t in result['tools']})
|
|
82
|
+
cursor = result.get('nextCursor')
|
|
83
|
+
if not cursor:
|
|
84
|
+
break
|
|
85
|
+
if cursor in seen:
|
|
86
|
+
raise RuntimeError('tools/list repeated its cursor')
|
|
87
|
+
seen.add(cursor)
|
|
88
|
+
|
|
89
|
+
def call(self, tool, args=None):
|
|
90
|
+
if self.tools is None:
|
|
91
|
+
self.initialize()
|
|
92
|
+
if tool not in self.tools:
|
|
93
|
+
raise ValueError(f'Tool unavailable: {tool}')
|
|
94
|
+
if not self.verified:
|
|
95
|
+
info = self.invoke('get_site', {})
|
|
96
|
+
if info.get('organization_id') != self.config['organization_id'] or info.get('id') != self.config['site_id']:
|
|
97
|
+
raise ValueError('Connected Organization/Site does not match typeroll.json; nothing was written')
|
|
98
|
+
self.verified = True
|
|
99
|
+
return self.invoke(tool, args or {})
|
|
100
|
+
|
|
101
|
+
def invoke(self, tool, args):
|
|
102
|
+
props = self.tools[tool]['inputSchema'].get('properties', {})
|
|
103
|
+
data = dict(args)
|
|
104
|
+
for key, wanted in [('site_id', self.config['site_id']), ('version', self.config['version'])]:
|
|
105
|
+
if key in data and data[key] != wanted:
|
|
106
|
+
raise ValueError(f'{key} differs from typeroll.json; change the binding explicitly')
|
|
107
|
+
if key in props:
|
|
108
|
+
data[key] = wanted
|
|
109
|
+
if 'version' not in props and self.config['version'] != 'main' and tool != 'get_site':
|
|
110
|
+
raise ValueError(f'{tool} has no version parameter; refusing an implicit main-version operation')
|
|
111
|
+
result = self.rpc('tools/call', {'name': tool, 'arguments': data})
|
|
112
|
+
if result.get('isError'):
|
|
113
|
+
raise RuntimeError(f'{tool} failed; inspect through your authenticated MCP client (no retry)')
|
|
114
|
+
if 'structuredContent' in result:
|
|
115
|
+
return result['structuredContent']
|
|
116
|
+
text = ''.join(c.get('text', '') for c in result.get('content', []) if c.get('type') == 'text')
|
|
117
|
+
return json.loads(text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def decode_rpc(raw, request_id):
|
|
121
|
+
if raw.lstrip().startswith('{'):
|
|
122
|
+
value = json.loads(raw)
|
|
123
|
+
if value.get('id') != request_id:
|
|
124
|
+
raise ValueError('MCP response did not contain the matching request ID')
|
|
125
|
+
return value
|
|
126
|
+
# Streamable HTTP may include notifications, multiline data, and keepalives.
|
|
127
|
+
for event in raw.replace('\r\n', '\n').split('\n\n'):
|
|
128
|
+
data = '\n'.join(line[5:].lstrip() for line in event.splitlines() if line.startswith('data:'))
|
|
129
|
+
if data:
|
|
130
|
+
value = json.loads(data)
|
|
131
|
+
if value.get('id') == request_id:
|
|
132
|
+
return value
|
|
133
|
+
raise ValueError('MCP response did not contain the matching request ID')
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_client = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def call(tool, args=None):
|
|
140
|
+
global _client
|
|
141
|
+
if _client is None:
|
|
142
|
+
_client = Client()
|
|
143
|
+
return _client.call(tool, args)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def site_id():
|
|
147
|
+
return workspace()['site_id']
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def walk(blocks):
|
|
151
|
+
for block in blocks or []:
|
|
152
|
+
yield block
|
|
153
|
+
yield from walk(block.get('children'))
|
|
154
|
+
for slot in block.get('slots') or []:
|
|
155
|
+
yield from walk(slot)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def all_pages():
|
|
159
|
+
cursor, seen = None, set()
|
|
160
|
+
while True:
|
|
161
|
+
args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
|
|
162
|
+
result = call('list_pages', args)
|
|
163
|
+
yield from result['pages']
|
|
164
|
+
cursor = result.get('next_cursor')
|
|
165
|
+
if not cursor:
|
|
166
|
+
return
|
|
167
|
+
if cursor in seen:
|
|
168
|
+
raise RuntimeError('list_pages repeated its cursor')
|
|
169
|
+
seen.add(cursor)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def all_migration_urls():
|
|
173
|
+
offset = 0
|
|
174
|
+
while True:
|
|
175
|
+
result = call('list_migration_urls', {'limit': 1000, 'offset': offset})
|
|
176
|
+
rows = result['urls']
|
|
177
|
+
yield from rows
|
|
178
|
+
offset += len(rows)
|
|
179
|
+
if not rows or offset >= result.get('total_matching', float('inf')):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def containers():
|
|
184
|
+
for page in all_pages():
|
|
185
|
+
yield {'kind': 'page', 'id': page['id']}
|
|
186
|
+
for kind, tool, key in [('template', 'list_page_templates', 'templates'), ('partial', 'list_partials', 'partials')]:
|
|
187
|
+
result = call(tool)
|
|
188
|
+
rows = result if isinstance(result, list) else result.get(key, result.get('page_templates'))
|
|
189
|
+
if not isinstance(rows, list):
|
|
190
|
+
raise ValueError(f'Unexpected response from {tool}')
|
|
191
|
+
for row in rows:
|
|
192
|
+
yield {'kind': kind, 'id': row['id']}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def upload(file, content_type, alt=''):
|
|
196
|
+
content = Path(file).read_bytes()
|
|
197
|
+
sha = hashlib.sha256(content).hexdigest()
|
|
198
|
+
mint = call('create_upload_url', {'filename': Path(file).name, 'content_type': content_type,
|
|
199
|
+
'size': len(content), 'alt_text': alt})
|
|
200
|
+
if urllib.parse.urlsplit(mint['upload_url']).scheme != 'https':
|
|
201
|
+
raise ValueError('Signed upload must use HTTPS')
|
|
202
|
+
request = urllib.request.Request(mint['upload_url'], data=content, method='PUT', headers={'Content-Type': content_type})
|
|
203
|
+
try:
|
|
204
|
+
with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response:
|
|
205
|
+
response.read()
|
|
206
|
+
except Exception:
|
|
207
|
+
raise RuntimeError('Signed upload failed; URL withheld; no automatic retry') from None
|
|
208
|
+
result = call('finalize_media', {'media_id': mint['media_id'], 'expected_sha256': sha})
|
|
209
|
+
evidence = result.get('result', result)
|
|
210
|
+
if evidence.get('sha256') != sha:
|
|
211
|
+
raise ValueError('Finalize did not confirm the source SHA-256; do not reference this asset')
|
|
212
|
+
return {'media_id': mint['media_id'], 'cdn_url': mint['cdn_url'], 'sha256': sha}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Layout defaults and owner decisions
|
|
2
|
+
|
|
3
|
+
Use current native composition starters and block schemas. These defaults are
|
|
4
|
+
starting points, not permission to overwrite an approved design.
|
|
5
|
+
|
|
6
|
+
- Keep incomplete grid rows left-aligned (`last_row: start`). Never enlarge one
|
|
7
|
+
peer card just to fill the gap. Center only on an explicit owner request.
|
|
8
|
+
- Prefer `core/navigation_menu` with `core/navigation_links` desktop/mobile slots
|
|
9
|
+
for a header drawer (below 1024 px). Read tr-header-footer; test 1023/1024 px.
|
|
10
|
+
- Separate dense footer about text from the link grid. Give it a readable width
|
|
11
|
+
and a larger gap; inspect 390/1024/1280/1536 px. Equal columns need equal density.
|
|
12
|
+
- Sections own gutters. Do not erase side padding with `padding: 3rem 0` when
|
|
13
|
+
only vertical spacing is intended. Wrap column-slot contents in a flow
|
|
14
|
+
container with article rhythm when they need vertical block spacing.
|
|
15
|
+
- Give cards one clear destination. Use whole_card_link without action_label
|
|
16
|
+
or download actions. Check excerpt, image, whitespace and keyboard activation.
|
|
17
|
+
- Do not fade body text with opacity. Verify actual text/background contrast,
|
|
18
|
+
current links on light/dark navigation, and all states.
|
|
19
|
+
- Preserve image subjects across widths. Choose contain/original sizing when
|
|
20
|
+
cropping or upscaling is not intended. Hero crop, text placement, plates,
|
|
21
|
+
corner treatment and category-page composition are project decisions in
|
|
22
|
+
brief/brand.md, not a universal design.
|
|
23
|
+
- After migration, check heading hierarchy, unchanged preserved text, HTML
|
|
24
|
+
comments, links with ../, and the home SEO title/suffix. Shared headings and
|
|
25
|
+
titles must not be duplicated in body content.
|
|
26
|
+
|
|
27
|
+
Record deliberate departures in decisions.md with scope and owner approval.
|
|
28
|
+
Use a QA round per review: method, pages/viewports, expected differences, results,
|
|
29
|
+
unverified items and remaining defects. Record platform findings in
|
|
30
|
+
qa/typeroll-platform-feedback.md; do not bury them in workaround CSS.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Guided website interview
|
|
2
|
+
|
|
3
|
+
## When to start or resume
|
|
4
|
+
|
|
5
|
+
Use this routine when the owner asks for guidance or a material decision is missing
|
|
6
|
+
from the brief. Read the existing briefs, decisions and brief/interview.md first.
|
|
7
|
+
Do not restart an interview or ask questions already answered. If the brief is
|
|
8
|
+
complete, summarize it and continue with the relevant routine. This is an agent
|
|
9
|
+
conversation, not a portal wizard or a new API workflow.
|
|
10
|
+
|
|
11
|
+
## How to conduct it
|
|
12
|
+
|
|
13
|
+
- Use the owner's language. Ask one short question at a time; offer concrete
|
|
14
|
+
choices and allow a free answer or "not sure". Explain technical choices plainly.
|
|
15
|
+
- Reuse explicit instructions already given. Distinguish confirmed answers,
|
|
16
|
+
proposals and unknowns. Never treat silence as approval.
|
|
17
|
+
- If an answer is unknown, suggest an option with its consequences. Research and
|
|
18
|
+
reversible prototypes may continue; do not perform dependent CMS writes until
|
|
19
|
+
the necessary decision is confirmed.
|
|
20
|
+
- Save each answer and its source/date in brief/interview.md, and the approved
|
|
21
|
+
decision in the relevant brief. Keep the next question and unresolved items so
|
|
22
|
+
another session can resume. Do not store credentials, personal contact lists
|
|
23
|
+
or raw private transcripts here. No MCP connection is needed to plan locally.
|
|
24
|
+
- Reopen only affected questions when the owner changes direction. Record the
|
|
25
|
+
change in decisions.md; do not silently replace confirmed rules.
|
|
26
|
+
|
|
27
|
+
## Interview stages
|
|
28
|
+
|
|
29
|
+
1. **Goal and scope** — New website or migration? Purpose, audience, language,
|
|
30
|
+
desired visitor actions, source URL and what is in/out of scope.
|
|
31
|
+
Save to brief/site.md and brief/migration.md when relevant.
|
|
32
|
+
2. **Target and access** — Existing or new Typeroll Site? Discover accessible
|
|
33
|
+
Organizations/Sites through MCP and confirm the exact target and Version before
|
|
34
|
+
writes. Explain any missing storage or access when it becomes relevant; never
|
|
35
|
+
ask for a secret in chat. Save non-secret IDs to typeroll.json only when known.
|
|
36
|
+
3. **Text decision (migration)** — Preserve existing wording or renew it?
|
|
37
|
+
Preserve covers headings, body copy, CTA labels, captions and other authored
|
|
38
|
+
text. Record SEO titles/descriptions, alt text, legal text, translation and any
|
|
39
|
+
page-specific exceptions explicitly. Renewal needs an agreed editorial brief;
|
|
40
|
+
it is not permission to invent facts. Save text_intent in brief/migration.md.
|
|
41
|
+
4. **Design and image decision (migration)** — Independently preserve or renew
|
|
42
|
+
layout, typography, colors and imagery? Offer all four text/design combinations.
|
|
43
|
+
For renewal, ask which existing assets must remain and which may be replaced
|
|
44
|
+
or generated. Clarify cropping, logos, credits and image rights. Optimization
|
|
45
|
+
alone must not change the motif. Save design_intent and asset exceptions in
|
|
46
|
+
brief/migration.md, and the direction in brief/brand.md. A fresh design never
|
|
47
|
+
grants permission to rewrite preserved text.
|
|
48
|
+
5. **Content and behavior** — Page types, shared templates, navigation, forms,
|
|
49
|
+
downloads, integrations and who will edit them. Identify missing capabilities
|
|
50
|
+
before scaling up. Save to brief/content-model.md. New websites skip migration
|
|
51
|
+
preservation choices and establish their brand/content brief here.
|
|
52
|
+
6. **SEO and continuity** — Markets, search intent, useful topics and existing
|
|
53
|
+
valuable URLs. Record evidence separately from hypotheses in brief/seo.md.
|
|
54
|
+
Confirm metadata policy; text preservation cannot be bypassed as "SEO cleanup".
|
|
55
|
+
Every old public URL must remain or redirect meaningfully regardless of the
|
|
56
|
+
two migration choices. Retiring a URL requires a separate agreed exception.
|
|
57
|
+
7. **Owner rules and review** — Non-negotiable copy/design constraints, editing
|
|
58
|
+
roles and who approves prototypes, publication, DNS or messages. Save confirmed
|
|
59
|
+
rules to brief/rules.md. Keep technical access separate from user authorization.
|
|
60
|
+
8. **Recap and next action** — Show the scope, both migration decisions (if any),
|
|
61
|
+
image exceptions, acceptance references, open questions and the next concrete
|
|
62
|
+
step. Ask the owner to correct/confirm the brief before dependent writes.
|
|
63
|
+
Reuse existing approval if it already covers that exact step. Brief approval
|
|
64
|
+
alone does not authorize publication, domain cutover or mail.
|
|
65
|
+
|
|
66
|
+
## Completion
|
|
67
|
+
|
|
68
|
+
Mark the interview ready for the agreed next step only when its blocking questions
|
|
69
|
+
are answered; list deferred decisions explicitly. Continue with workflows/new-site.md
|
|
70
|
+
or workflows/migration.md and load only the relevant MCP recipe. Confirm one
|
|
71
|
+
prototype per distinct layout before bulk work. Review preserved dimensions
|
|
72
|
+
against the source and renewed dimensions against the approved brief separately.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Migration kickoff
|
|
2
|
+
|
|
3
|
+
If guidance is needed, start or resume workflows/interview.md. Otherwise fill in
|
|
4
|
+
this prompt from the confirmed brief. The two preservation choices are independent.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
Migrate [source URL] to Typeroll, Organization [ID/name], Site [ID/name], Version [ID].
|
|
8
|
+
Text: [preserve existing wording / renew against an agreed editorial brief].
|
|
9
|
+
Design and images: [preserve / renew against an agreed design brief].
|
|
10
|
+
Read AGENTS.md and use workflows/interview.md for missing decisions.
|
|
11
|
+
Read tr-migration-evidence, then tr-migrate-wp (or the appropriate source recipe).
|
|
12
|
+
Confirm the target and import readiness before writes. Inventory URLs, content,
|
|
13
|
+
media, templates and integrations; record both choices and exceptions in brief/migration.md.
|
|
14
|
+
Build and verify one prototype per distinct layout before bulk reconstruction.
|
|
15
|
+
Use standard blocks first, editable custom types next, isolated HTML only for
|
|
16
|
+
justified exceptions. Rebuild imported HTML with block tools; a mode switch does not convert it.
|
|
17
|
+
Keep preserved text unchanged even when the design is new. Record which images
|
|
18
|
+
may change. Preserve every old public URL or redirect to a relevant destination.
|
|
19
|
+
Protect existing CMS edits. Review text and design/images against their own
|
|
20
|
+
acceptance references, plus mobile/desktop behavior, downloads, forms and URL coverage.
|
|
21
|
+
Report unverified items separately. Do not publish, switch DNS or send messages
|
|
22
|
+
without explicit approval.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The managed importer stages cleaned HTML, media and shared references in Review.
|
|
26
|
+
The external agent can then reconstruct those Pages; no manual editor conversion
|
|
27
|
+
is required. An agent can also build block Pages directly from source material.
|
|
28
|
+
Neither path includes an automatic HTML-to-blocks converter. Read and preserve the
|
|
29
|
+
full source before switching modes, then save/read back and preview the rebuilt
|
|
30
|
+
Page on the selected Version. Never publish an empty intermediate block Page.
|
|
31
|
+
|
|
32
|
+
Track import, block reconstruction, separate text/design review, candidate
|
|
33
|
+
publication and domain cutover as separate milestones. Import completion is not
|
|
34
|
+
launch approval. All four text/design combinations use the same block hierarchy
|
|
35
|
+
and URL continuity contract. Planning choices are not migration API fields.
|
|
36
|
+
|
|
37
|
+
## Repeatable reconstruction and acceptance
|
|
38
|
+
|
|
39
|
+
Read workflows/design.md and scripts/README.md. First check whether public
|
|
40
|
+
WordPress REST + HTML covers the source; use tr-migrate-wp's access decision
|
|
41
|
+
table before asking for a helper plugin. Inventory both Pages and posts plus
|
|
42
|
+
internal links and known old slugs. A sitemap alone is not complete.
|
|
43
|
+
|
|
44
|
+
Discard HTML comments before extracting text, resolve relative links against
|
|
45
|
+
the source, and keep an explicit old URL map. Do not redirect deleted content
|
|
46
|
+
to the home page merely to get a 200. Preserve/renew text and design separately.
|
|
47
|
+
Use capture_text.cjs + compare_text.py for preserved text with explicit selectors.
|
|
48
|
+
|
|
49
|
+
Before every authorized test deployment run check_unsaved.py (Pages and partials).
|
|
50
|
+
Deploy saved content to the agreed protected test host, then run check_url_parity.py,
|
|
51
|
+
check_assets.py and shoot_hosted.cjs. Check whole-card targets with
|
|
52
|
+
card-stacking-check.cjs and inspect actual desktop/mobile screenshots. Record a
|
|
53
|
+
QA round and platform findings. Review on the owner's phone when possible.
|
|
54
|
+
Domain preparation/cutover and production indexing follow their own explicit
|
|
55
|
+
approval scope and the platform's current guided flow; do not bypass readiness.
|
|
56
|
+
Do not request a second approval for a step the owner has already authorized.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Native controls and publication checks
|
|
60
|
+
|
|
61
|
+
On Core 0.2.103 / MCP 0.45.79 or later, discover the actual block schemas first.
|
|
62
|
+
Use column `content_gap_px`, responsive section `padding_y`, heading-role
|
|
63
|
+
`space_before`/`space_after`, hero focal/fit/text-position fields and image
|
|
64
|
+
`never_upscale` instead of one-off CSS. Responsive block keys start at `mobile`,
|
|
65
|
+
not `base`. For vertical-only spacing, do not reset horizontal padding with CSS
|
|
66
|
+
shorthand. Optional `click_to_load` videos need an explicit poster if desired;
|
|
67
|
+
choose a first-party poster to avoid early provider requests.
|
|
68
|
+
|
|
69
|
+
`import_sitemap` can include public WordPress REST (`include_wordpress: true`)
|
|
70
|
+
and a bounded crawl (`crawl_pages: 10`). Record truncation and access warnings.
|
|
71
|
+
A `matched_draft` URL is accounted for in the CMS but not ready for launch.
|
|
72
|
+
Resolve `unpublished_targets`, redirect loops/chains and actual deployed parity.
|
|
73
|
+
|
|
74
|
+
A deploy with working copies returns `unsaved_content`; review the targets.
|
|
75
|
+
Save explicitly or obtain approval to publish saved content only using
|
|
76
|
+
`allow_unsaved: true`. Never silently save somebody else's work.
|
|
77
|
+
|
|
78
|
+
Keep the bound Organization/Site/Version explicit. A site API key cannot switch
|
|
79
|
+
to another Organization: use an authorized OAuth connection or a separate key.
|
|
80
|
+
Read the schema for target envelopes and pagination; do not guess aliases.
|
|
81
|
+
Page/partial block writes update working copies; check `saved` and use
|
|
82
|
+
`commit_working_copy` with `target: {kind: "page"|"partial", id: "..."}`.
|
|
83
|
+
Page templates save immediately and do not have working copies. REST read envelopes and
|
|
84
|
+
MCP tool text are different layers; `call.py` returns the decoded tool result.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# New website kickoff
|
|
2
|
+
|
|
3
|
+
Use this prompt only when the brief is already clear. Otherwise start or resume
|
|
4
|
+
workflows/interview.md; the owner does not need to fill in every document alone.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
Create [website] for [audience and purpose] in Typeroll Organization [ID/name].
|
|
8
|
+
Read AGENTS.md, confirm the Site and Version, and use workflows/interview.md for
|
|
9
|
+
missing decisions. Record confirmed goals, brand, content model, owner rules and
|
|
10
|
+
SEO strategy in brief/. Read tr-new-site and relevant tool schemas on demand.
|
|
11
|
+
Build representative desktop/mobile layouts with editable blocks and shared templates.
|
|
12
|
+
Share previews, record accepted decisions and keep unresolved items explicit.
|
|
13
|
+
Do not publish, change DNS or send messages without explicit approval.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The CMS owns current content and settings. The repository owns the brief, rules,
|
|
17
|
+
decisions and evidence. Load recipes when needed, not the whole manual.
|