@typeroll/mcp-server 0.45.77 → 0.45.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundled-content.js +29 -8
- package/dist/server.js +1 -1
- package/dist/tools/deploy.js +2 -1
- package/dist/tools/domain.js +3 -3
- 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/settings.js +9 -2
- package/dist/tools/working-copy.js +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.js +7 -6
- package/package.json +2 -2
- package/skills/tr-brand.md +43 -7
- package/skills/tr-header-footer.md +95 -41
- package/skills/tr-migrate-wp.md +77 -6
- package/skills/tr-migration-evidence.md +3 -1
- package/skills/tr-new-site.md +42 -8
- package/skills/tr-starter-design.md +36 -0
- package/workspace-templates/qa/checklist.md +52 -0
- package/workspace-templates/qa/typeroll-platform-feedback.md +11 -0
- package/workspace-templates/scripts/README.md +111 -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/color_tokens.py +81 -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/prepare_image.py +64 -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 +65 -0
- package/workspace-templates/workflows/migration.md +94 -0
- package/workspace-templates/workflows/new-site.md +7 -0
- package/workspace-templates/workflows/source-media.md +50 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Review exact colour-to-token replacements; never publish or edit CSS automatically.
|
|
2
|
+
Dry run: python3 scripts/typeroll/color_tokens.py --plan /tmp/design-token-plan.json
|
|
3
|
+
Apply: python3 scripts/typeroll/color_tokens.py --apply /tmp/design-token-plan.json
|
|
4
|
+
Apply only on a dedicated non-main Version, with no other editors in that Version.
|
|
5
|
+
The plan contains paths and colour values, not page bodies. Keep it out of Git.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import trlib
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def digest(value):
|
|
15
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def snapshot():
|
|
19
|
+
config = trlib.workspace()
|
|
20
|
+
report = trlib.call('lint_design_tokens')
|
|
21
|
+
if (report['site_id'], report['organization_id'], report['version_id']) != (
|
|
22
|
+
config['site_id'], config['organization_id'], config['version']):
|
|
23
|
+
raise ValueError('Audit scope does not match typeroll.json')
|
|
24
|
+
settings = trlib.call('read_site_settings')['settings']
|
|
25
|
+
return {'scope': {k: config[k] for k in ('portal', 'organization_id', 'site_id', 'version')},
|
|
26
|
+
'palette_hash': digest(settings.get('colors', {})),
|
|
27
|
+
'counts_before': report['counts'],
|
|
28
|
+
'replacements': report['exact_replacements']}, report
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_plan(plan):
|
|
32
|
+
current, report = snapshot()
|
|
33
|
+
if plan['scope'] != current['scope'] or plan['palette_hash'] != current['palette_hash']:
|
|
34
|
+
raise ValueError('Site, Version or palette changed. Run a new dry run; nothing was written.')
|
|
35
|
+
if current['scope']['version'] == 'main':
|
|
36
|
+
raise ValueError('Use a dedicated test Version, review it, then promote normally. main is not supported.')
|
|
37
|
+
available = {digest(row) for row in current['replacements']}
|
|
38
|
+
if any(digest(row) not in available for row in plan['replacements']):
|
|
39
|
+
raise ValueError('A replacement is stale or was edited. Run a new dry run; nothing was written.')
|
|
40
|
+
targets = {(r['target']['kind'], r['target']['id']) for r in plan['replacements']}
|
|
41
|
+
for kind, id_ in targets:
|
|
42
|
+
if kind != 'template' and trlib.call('read_working_copy', {'target': {'kind': kind, 'id': id_}}).get('working_copy'):
|
|
43
|
+
raise ValueError(f'{kind}:{id_} has an unsaved draft; nothing was written.')
|
|
44
|
+
applied = 0
|
|
45
|
+
# Ordinary field patches preserve unrelated data. No retries after uncertain writes.
|
|
46
|
+
# There is no site-wide lock: stop concurrent editing while this command runs.
|
|
47
|
+
for row in plan['replacements']:
|
|
48
|
+
tree = trlib.call('get_page_blocks', {'target': row['target']})
|
|
49
|
+
block = next((b for b in trlib.walk(tree['blocks']) if b['id'] == row['block_id']), None)
|
|
50
|
+
if block is None or block.get('data', {}).get(row['field']) != row['before']:
|
|
51
|
+
raise ValueError(f"Source changed after {applied} replacements. Inspect drafts and run a new dry run.")
|
|
52
|
+
trlib.call('update_block', {'target': row['target'], 'block_id': row['block_id'], 'data': {row['field']: row['after']}})
|
|
53
|
+
applied += 1
|
|
54
|
+
for kind, id_ in targets:
|
|
55
|
+
if kind != 'template':
|
|
56
|
+
trlib.call('commit_working_copy', {'target': {'kind': kind, 'id': id_}})
|
|
57
|
+
after = trlib.call('lint_design_tokens')
|
|
58
|
+
print(json.dumps({'applied': applied, 'before': plan['counts_before'], 'after': after['counts'],
|
|
59
|
+
'published': False, 'next': 'Review preview at all breakpoints before promotion.'}, indent=2))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
64
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
65
|
+
mode.add_argument('--plan', type=Path)
|
|
66
|
+
mode.add_argument('--apply', type=Path)
|
|
67
|
+
args = parser.parse_args()
|
|
68
|
+
if args.apply:
|
|
69
|
+
apply_plan(json.loads(args.apply.read_text()))
|
|
70
|
+
else:
|
|
71
|
+
plan, report = snapshot()
|
|
72
|
+
args.plan.write_text(json.dumps(plan, indent=2) + '\n')
|
|
73
|
+
args.plan.chmod(0o600)
|
|
74
|
+
print(json.dumps({'counts': report['counts'], 'proposed_replacements': len(plan['replacements']),
|
|
75
|
+
'simulated_color_count_after': report['counts']['hardcoded_color'] - len(plan['replacements']),
|
|
76
|
+
'written': False, 'plan': str(args.plan),
|
|
77
|
+
'note': 'Review and remove deliberate exceptions from the plan. Ambiguous roles, nested values and CSS require manual review.'}, indent=2))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == '__main__':
|
|
81
|
+
main()
|
|
@@ -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,64 @@
|
|
|
1
|
+
"""Convert a reviewed local original to an sRGB JPEG; never upload or change CMS content.
|
|
2
|
+
Requires Pillow with ImageCms. Preserve the untouched original in the source archive.
|
|
3
|
+
CMYK needs its actual embedded or supplied ICC profile. Never guess a CMYK profile.
|
|
4
|
+
Unprofiled RGB/gray requires --assume-srgb after review. Output must not exist.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def prepare(source, output, source_profile=None, assume_srgb=False, quality=95):
|
|
14
|
+
from PIL import Image, ImageCms, ImageOps
|
|
15
|
+
source, output = Path(source), Path(output)
|
|
16
|
+
if output.exists() or source.resolve() == output.resolve():
|
|
17
|
+
raise ValueError('Output already exists; choose a new derivative path.')
|
|
18
|
+
if output.suffix.lower() not in ('.jpg', '.jpeg'):
|
|
19
|
+
raise ValueError('Choose a .jpg or .jpeg output.')
|
|
20
|
+
if not 1 <= quality <= 100:
|
|
21
|
+
raise ValueError('JPEG quality must be from 1 to 100.')
|
|
22
|
+
with Image.open(source) as original:
|
|
23
|
+
if getattr(original, 'n_frames', 1) != 1:
|
|
24
|
+
raise ValueError('Multi-page/animated originals require an explicit frame decision.')
|
|
25
|
+
if 'A' in original.getbands() or 'transparency' in original.info:
|
|
26
|
+
raise ValueError('Transparent originals need a reviewed background or a PNG workflow.')
|
|
27
|
+
profile_bytes = Path(source_profile).read_bytes() if source_profile else original.info.get('icc_profile')
|
|
28
|
+
image = ImageOps.exif_transpose(original)
|
|
29
|
+
target = ImageCms.ImageCmsProfile(ImageCms.createProfile('sRGB'))
|
|
30
|
+
if profile_bytes:
|
|
31
|
+
profile = ImageCms.ImageCmsProfile(io.BytesIO(profile_bytes))
|
|
32
|
+
converted = ImageCms.profileToProfile(image, profile, target, outputMode='RGB')
|
|
33
|
+
elif image.mode in ('RGB', 'L') and assume_srgb:
|
|
34
|
+
converted = image.convert('RGB')
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError('Missing source ICC profile. CMYK cannot be safely guessed; supply the correct --source-profile. For reviewed unprofiled RGB/gray only, use --assume-srgb.')
|
|
37
|
+
# No source EXIF/GPS/caption metadata is copied into the public derivative.
|
|
38
|
+
with output.open('xb') as destination:
|
|
39
|
+
converted.save(destination, format='JPEG', quality=quality, subsampling=0, icc_profile=target.tobytes())
|
|
40
|
+
with Image.open(output) as check:
|
|
41
|
+
check.load()
|
|
42
|
+
if check.mode != 'RGB' or not check.info.get('icc_profile'):
|
|
43
|
+
raise ValueError('Output verification failed; do not upload the derivative.')
|
|
44
|
+
dimensions = list(check.size)
|
|
45
|
+
return {'source_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),
|
|
46
|
+
'output_sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
|
|
47
|
+
'width': dimensions[0], 'height': dimensions[1], 'colour_space': 'sRGB',
|
|
48
|
+
'source_profile': 'supplied' if source_profile else 'embedded' if profile_bytes else 'assumed sRGB',
|
|
49
|
+
'uploaded': False}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
54
|
+
parser.add_argument('source', type=Path)
|
|
55
|
+
parser.add_argument('output', type=Path)
|
|
56
|
+
parser.add_argument('--source-profile', type=Path)
|
|
57
|
+
parser.add_argument('--assume-srgb', action='store_true')
|
|
58
|
+
parser.add_argument('--quality', type=int, default=95)
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
print(json.dumps(prepare(args.source, args.output, args.source_profile, args.assume_srgb, args.quality), indent=2))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == '__main__':
|
|
64
|
+
main()
|
|
@@ -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,65 @@
|
|
|
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.
|
|
31
|
+
|
|
32
|
+
## Site tokens before block overrides
|
|
33
|
+
|
|
34
|
+
Core 0.2.106 / template capabilities 0.54.0 adds palette names in all colour
|
|
35
|
+
fields and named typography/spacing steps. Check the connected capability
|
|
36
|
+
version; on older renderers use `var(--color-surface)` rather than a bare name.
|
|
37
|
+
|
|
38
|
+
Set colours and font families in site settings, then use named styles and
|
|
39
|
+
palette names in blocks. Do not write hex, rgb or font-family literals in block
|
|
40
|
+
content. Prefer `primary`, `secondary`, `accent`, `background`, `surface`, `text`,
|
|
41
|
+
`text_light`, and `on_primary`/`on_secondary`/`on_accent` for text on brand colours.
|
|
42
|
+
Use `surface_hover` for a darker interactive surface (derived automatically
|
|
43
|
+
unless explicitly configured). Existing semantic roles should be reused; a
|
|
44
|
+
new visual shade is not automatically a new semantic role. For a derived shade
|
|
45
|
+
use `color-mix(in srgb, var(--color-surface) 92%, var(--color-primary))`.
|
|
46
|
+
For an additional role not in the native palette, define a named site style
|
|
47
|
+
or a site CSS variable derived from existing palette variables, and document it.
|
|
48
|
+
|
|
49
|
+
Choose `heading`, `body` or `mono` for fonts. Font families belong only in the
|
|
50
|
+
site's `fonts` settings. Use type-size steps `xs`, `sm`, `md`, `lg`, `xl`, `2xl`,
|
|
51
|
+
`3xl`; spacing `none`, `xs`, `sm`, `md`, `lg`, `xl`; line height `tight`, `snug`,
|
|
52
|
+
`normal`, `relaxed`, `loose`. Presentation fields accept responsive maps of these
|
|
53
|
+
names, for example `font_size_px: {mobile: "md", desktop: "lg"}`. Read the field
|
|
54
|
+
schema: counts, widths and breakpoints are not spacing steps. Pixel values are
|
|
55
|
+
explicit exceptions, not the default authoring method. Configure scales in
|
|
56
|
+
`design_scales`, then reuse them in role styles and blocks.
|
|
57
|
+
|
|
58
|
+
Record deliberate exceptions in `decisions.md`, with source/block, reason and
|
|
59
|
+
review criteria (for example white text on a photograph). Run `lint_design_tokens`
|
|
60
|
+
before launch and after major design changes. It warns, never blocks launch.
|
|
61
|
+
Use the starter's `scripts/typeroll/color_tokens.py` for an exact-match dry run;
|
|
62
|
+
review the plan before applying it in a dedicated Version. Equal hex values can
|
|
63
|
+
mean different roles: never automatically choose between background and text.
|
|
64
|
+
Verify a primary/surface change in a test Version across header, footer, cards,
|
|
65
|
+
menus and buttons, then restore the test settings. Do not republish automatically.
|