@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.
Files changed (42) hide show
  1. package/dist/bundled-content.js +29 -8
  2. package/dist/server.js +1 -1
  3. package/dist/tools/deploy.js +2 -1
  4. package/dist/tools/domain.js +3 -3
  5. package/dist/tools/media.js +1 -1
  6. package/dist/tools/migration.js +4 -2
  7. package/dist/tools/pages.js +1 -1
  8. package/dist/tools/preview.js +1 -1
  9. package/dist/tools/settings.js +9 -2
  10. package/dist/tools/working-copy.js +1 -1
  11. package/dist/version.js +1 -1
  12. package/dist/workspace.js +7 -6
  13. package/package.json +2 -2
  14. package/skills/tr-brand.md +43 -7
  15. package/skills/tr-header-footer.md +95 -41
  16. package/skills/tr-migrate-wp.md +77 -6
  17. package/skills/tr-migration-evidence.md +3 -1
  18. package/skills/tr-new-site.md +42 -8
  19. package/skills/tr-starter-design.md +36 -0
  20. package/workspace-templates/qa/checklist.md +52 -0
  21. package/workspace-templates/qa/typeroll-platform-feedback.md +11 -0
  22. package/workspace-templates/scripts/README.md +111 -0
  23. package/workspace-templates/scripts/typeroll/browser.cjs +50 -0
  24. package/workspace-templates/scripts/typeroll/call.py +12 -0
  25. package/workspace-templates/scripts/typeroll/capture_text.cjs +17 -0
  26. package/workspace-templates/scripts/typeroll/card-stacking-check.cjs +27 -0
  27. package/workspace-templates/scripts/typeroll/check_assets.py +72 -0
  28. package/workspace-templates/scripts/typeroll/check_unsaved.py +13 -0
  29. package/workspace-templates/scripts/typeroll/check_url_parity.py +74 -0
  30. package/workspace-templates/scripts/typeroll/color_tokens.py +81 -0
  31. package/workspace-templates/scripts/typeroll/compare_text.py +59 -0
  32. package/workspace-templates/scripts/typeroll/contact_sheet.py +20 -0
  33. package/workspace-templates/scripts/typeroll/layout_trace.cjs +33 -0
  34. package/workspace-templates/scripts/typeroll/prepare_image.py +64 -0
  35. package/workspace-templates/scripts/typeroll/shoot_hosted.cjs +26 -0
  36. package/workspace-templates/scripts/typeroll/shootframe.cjs +16 -0
  37. package/workspace-templates/scripts/typeroll/sweep_blocks.example.py +23 -0
  38. package/workspace-templates/scripts/typeroll/trlib.py +212 -0
  39. package/workspace-templates/workflows/design.md +65 -0
  40. package/workspace-templates/workflows/migration.md +94 -0
  41. package/workspace-templates/workflows/new-site.md +7 -0
  42. package/workspace-templates/workflows/source-media.md +50 -0
@@ -5,6 +5,42 @@ description: Use when the user wants to create a new Typeroll site from scratch,
5
5
 
6
6
  # Bootstrap a new Typeroll site
7
7
 
8
+ ## Site tokens before block overrides
9
+
10
+ Core 0.2.106 / template capabilities 0.54.0 adds palette names in all colour
11
+ fields and named typography/spacing steps. Check the connected capability
12
+ version; on older renderers use `var(--color-surface)` rather than a bare name.
13
+
14
+ Set colours and font families in site settings, then use named styles and
15
+ palette names in blocks. Do not write hex, rgb or font-family literals in block
16
+ content. Prefer `primary`, `secondary`, `accent`, `background`, `surface`, `text`,
17
+ `text_light`, and `on_primary`/`on_secondary`/`on_accent` for text on brand colours.
18
+ Use `surface_hover` for a darker interactive surface (derived automatically
19
+ unless explicitly configured). Existing semantic roles should be reused; a
20
+ new visual shade is not automatically a new semantic role. For a derived shade
21
+ use `color-mix(in srgb, var(--color-surface) 92%, var(--color-primary))`.
22
+ For an additional role not in the native palette, define a named site style
23
+ or a site CSS variable derived from existing palette variables, and document it.
24
+
25
+ Choose `heading`, `body` or `mono` for fonts. Font families belong only in the
26
+ site's `fonts` settings. Use type-size steps `xs`, `sm`, `md`, `lg`, `xl`, `2xl`,
27
+ `3xl`; spacing `none`, `xs`, `sm`, `md`, `lg`, `xl`; line height `tight`, `snug`,
28
+ `normal`, `relaxed`, `loose`. Presentation fields accept responsive maps of these
29
+ names, for example `font_size_px: {mobile: "md", desktop: "lg"}`. Read the field
30
+ schema: counts, widths and breakpoints are not spacing steps. Pixel values are
31
+ explicit exceptions, not the default authoring method. Configure scales in
32
+ `design_scales`, then reuse them in role styles and blocks.
33
+
34
+ Record deliberate exceptions in `decisions.md`, with source/block, reason and
35
+ review criteria (for example white text on a photograph). Run `lint_design_tokens`
36
+ before launch and after major design changes. It warns, never blocks launch.
37
+ Use the starter's `scripts/typeroll/color_tokens.py` for an exact-match dry run;
38
+ review the plan before applying it in a dedicated Version. Equal hex values can
39
+ mean different roles: never automatically choose between background and text.
40
+ Verify a primary/surface change in a test Version across header, footer, cards,
41
+ menus and buttons, then restore the test settings. Do not republish automatically.
42
+
43
+
8
44
  > **The buffer model (draft writes).** Every content write in this recipe
9
45
  > (pages, blocks and partials) lands in an unsaved per-doc
10
46
  > DRAFT — deploys and plain previews only see SAVED content. For recipe-style
@@ -165,7 +201,7 @@ it there.
165
201
  ### 3. Header + footer partials
166
202
 
167
203
  **Start from the native preset — don't hand-roll navigation.** Read
168
- `tr-header-footer` and use its `template/site_logo` + `core/navigation`
204
+ `tr-header-footer` and use its `template/site_logo` + `core/navigation_menu` with `core/navigation_links` slots
169
205
  composition. The server-rendered landmark, current-page state, no-JS links,
170
206
  mobile disclosure, focus treatment, and responsive behavior are Core
171
207
  contracts rather than tenant CSS/JavaScript.
@@ -202,7 +238,7 @@ on a block *inside* the section instead):
202
238
 
203
239
  ```json
204
240
  [
205
- { "type": "core/section", "data": { "background": "#ffffff", "padding_y": "lg" }, "children": [
241
+ { "type": "core/section", "data": { "background": "surface", "padding_y": "lg" }, "children": [
206
242
  { "type": "core/media_card", "data": {
207
243
  "image": "MEDIA_URL", "image_alt": "…", "image_side": "right",
208
244
  "heading": "Huvudrubriken", "heading_level": "h2",
@@ -269,12 +305,10 @@ passing the whole tree inline (`block={ type: 'core/columns', slots:
269
305
  slot_index=0|1`. Requires template_capabilities_version ≥ 0.15.2 — on
270
306
  older sites use `core/grid` (children flow into columns) instead.
271
307
 
272
- Partial last rows (template_capabilities_version ≥ 0.16.5): when N equal
273
- cards don't divide by the grid's column count (5 cards, 3 cols), set
274
- `last_row: 'center'` on the `core/grid` — the orphan row auto-centers.
275
- NEVER invent a "wide" variant of one peer card to fill the hole, and
276
- don't hand-roll 6-column CSS tricks; both distort content to patch
277
- layout. On older sites, pick a column count that divides N.
308
+ Partial last rows: keep `last_row: 'start'` on `core/grid`. Incomplete rows
309
+ stay left-aligned, preserving the reading edge. Center only after an explicit
310
+ owner design request, never as an automatic fix. Do not widen one peer card
311
+ or invent six-column CSS to fill a gap.
278
312
 
279
313
  Icons (template_capabilities_version ≥ 0.16.0): `type: 'icon'` fields on
280
314
  `core/icon`, `core/icon_box`, and `core/step_card` render inline SVG when
@@ -5,6 +5,42 @@ description: Reference for the default design new Typeroll sites start with —
5
5
 
6
6
  # Starter design system
7
7
 
8
+ ## Site tokens before block overrides
9
+
10
+ Core 0.2.106 / template capabilities 0.54.0 adds palette names in all colour
11
+ fields and named typography/spacing steps. Check the connected capability
12
+ version; on older renderers use `var(--color-surface)` rather than a bare name.
13
+
14
+ Set colours and font families in site settings, then use named styles and
15
+ palette names in blocks. Do not write hex, rgb or font-family literals in block
16
+ content. Prefer `primary`, `secondary`, `accent`, `background`, `surface`, `text`,
17
+ `text_light`, and `on_primary`/`on_secondary`/`on_accent` for text on brand colours.
18
+ Use `surface_hover` for a darker interactive surface (derived automatically
19
+ unless explicitly configured). Existing semantic roles should be reused; a
20
+ new visual shade is not automatically a new semantic role. For a derived shade
21
+ use `color-mix(in srgb, var(--color-surface) 92%, var(--color-primary))`.
22
+ For an additional role not in the native palette, define a named site style
23
+ or a site CSS variable derived from existing palette variables, and document it.
24
+
25
+ Choose `heading`, `body` or `mono` for fonts. Font families belong only in the
26
+ site's `fonts` settings. Use type-size steps `xs`, `sm`, `md`, `lg`, `xl`, `2xl`,
27
+ `3xl`; spacing `none`, `xs`, `sm`, `md`, `lg`, `xl`; line height `tight`, `snug`,
28
+ `normal`, `relaxed`, `loose`. Presentation fields accept responsive maps of these
29
+ names, for example `font_size_px: {mobile: "md", desktop: "lg"}`. Read the field
30
+ schema: counts, widths and breakpoints are not spacing steps. Pixel values are
31
+ explicit exceptions, not the default authoring method. Configure scales in
32
+ `design_scales`, then reuse them in role styles and blocks.
33
+
34
+ Record deliberate exceptions in `decisions.md`, with source/block, reason and
35
+ review criteria (for example white text on a photograph). Run `lint_design_tokens`
36
+ before launch and after major design changes. It warns, never blocks launch.
37
+ Use the starter's `scripts/typeroll/color_tokens.py` for an exact-match dry run;
38
+ review the plan before applying it in a dedicated Version. Equal hex values can
39
+ mean different roles: never automatically choose between background and text.
40
+ Verify a primary/surface change in a test Version across header, footer, cards,
41
+ menus and buttons, then restore the test settings. Do not republish automatically.
42
+
43
+
8
44
  A reference for the *default* design that ships with new Typeroll sites.
9
45
  The customer may have replaced it — always inspect the actual header partial
10
46
  (`read_partial partial_id="header"`) and an existing page before assuming any
@@ -0,0 +1,52 @@
1
+ # Verification
2
+
3
+ - Confirm Organization, Site, Version and deployed/source identity.
4
+ - Run `python3 scripts/typeroll/check_unsaved.py` before trigger_deploy. Review
5
+ and explicitly save authorized Pages and partials; templates save immediately.
6
+ - Compare preserved text with capture_text.cjs + compare_text.py and an explicit
7
+ selector. Check intentional additions, source comments and images separately.
8
+ - Inspect desktop/mobile previews and actual hosted output around breakpoints.
9
+ Use shootframe.cjs / shoot_hosted.cjs and inspect full screenshots, not only tiles.
10
+ - Exercise menus, keyboard focus, current links, forms and enabled app states.
11
+ Use card-stacking-check.cjs for whole-card excerpt/media/whitespace targets.
12
+ - Check actual contrast on light/dark/photo backgrounds, image subjects and
13
+ sharpness, footer balance, left-aligned last rows and heading hierarchy.
14
+ - Check home title/suffix, metadata, canonical/indexing policy and JSON-LD.
15
+ - Run check_url_parity.py and check_assets.py on the deployed candidate; compare
16
+ final targets with the approved URL map and flatten accidental redirect chains.
17
+ - Treat 403/network/DNS failures as unverified. Do not claim search-bot access
18
+ from a user-agent string or turn off customer protection without approval.
19
+ - Record tested pages, viewports, revision, results and unresolved issues per QA
20
+ round. Record product defects in qa/typeroll-platform-feedback.md.
21
+ - Keep screenshots and private runtime reports in ignored qa/reports/.
22
+ - Check publishing readiness and migration launch report. Passing technical
23
+ validation does not prove visual/editorial completeness or grant launch approval.
24
+
25
+ Commands/dependencies/limits: scripts/README.md. Design: workflows/design.md.
26
+ Follow existing owner authorization; test publication, traffic cutover and
27
+ indexing have distinct effects but do not require repeated approval if covered.
28
+
29
+
30
+ - [ ] Check heading role spacing, slot gap and responsive vertical padding in the browser.
31
+ - [ ] Review global/current navigation colors against the real parent background.
32
+ - [ ] Check intrinsic-width image and full-image hero framing at breakpoint boundaries.
33
+ - [ ] Test click-to-load video with keyboard; no provider request before activation.
34
+ - [ ] Review matched drafts, unpublished targets and redirect chains before launch.
35
+ - [ ] Explicitly save working copies, or obtain approval to publish saved content only.
36
+
37
+ ## Design tokens (Core 0.2.106+)
38
+
39
+ - [ ] Run `lint_design_tokens` before launch and after major design changes.
40
+ - [ ] Review hardcoded colours, fonts and sizes; record justified exceptions in decisions.md.
41
+ - [ ] Dry-run colour migration; do not infer semantic roles from equal hex values.
42
+ - [ ] Change primary and surface in a test Version: header, footer, cards, menus and buttons follow without block edits at mobile/tablet/desktop widths. Restore the test settings.
43
+ - [ ] Report actual versus simulated before/after counts separately; do not claim a migration was applied from a dry run.
44
+
45
+ - [ ] Before source retirement, review workflows/source-media.md: unused originals,
46
+ print files, private archive manifest, verified copies and a restore test.
47
+ - [ ] Review ICC-converted derivatives visually; preserve originals separately.
48
+ - [ ] Verify Analytics configuration and actual beacon/cookie behavior after build.
49
+ - [ ] Read domain publication intent before deployment; distinguish test-host
50
+ updates, future-host candidates and explicit traffic approval.
51
+ - [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.
52
+ - [ ] Review owner facts and dates against the chosen preserve/renew text policy.
@@ -0,0 +1,11 @@
1
+ # Platform feedback
2
+
3
+ Record site-specific observations here, without credentials or private exports.
4
+
5
+ | ID | Priority | Surface / version | Observation | Evidence | Workaround | Status |
6
+ | --- | --- | --- | --- | --- | --- | --- |
7
+
8
+ For each finding record expected versus actual behavior, minimal reproduction,
9
+ Site/Version, page/block locator, relevant schema, tested widths, source revision
10
+ and whether it blocks launch. Distinguish bugs, capabilities, instructions and
11
+ project choices. Link upstream fixes; verify deployed output before closing them.
@@ -0,0 +1,111 @@
1
+ # Repeatable site QA
2
+
3
+ These optional tools supplement MCP and the platform's publication validator.
4
+ They do not publish, change DNS, approve a launch or replace visual review.
5
+ Python tools use the standard library except contact_sheet.py (Pillow). Browser
6
+ tools require Node.js 22+, Playwright and its Chromium browser. From scripts/typeroll:
7
+
8
+ ```sh
9
+ npm install --no-save --package-lock=false playwright
10
+ npx playwright install chromium
11
+ ```
12
+
13
+ Run the commands below from the **workspace root**. Inject TYPEROLL_API_KEY through
14
+ your private credential manager. Never put it in a file or command argument.
15
+ trlib.py reads portal, organization_id, site_id and version from typeroll.json,
16
+ checks the connected Site/Organization, discovers the live tool schema, and
17
+ passes the Version where supported. It refuses unversioned operations on a
18
+ non-main binding; use the explicit interactive API workflow for organization or
19
+ shared media operations instead. It is a small hosted MCP helper for API keys,
20
+ not a replacement OAuth client or a new general CRUD CLI. REST is also supported
21
+ by Typeroll if your integration already uses it.
22
+
23
+ ## Before publication
24
+
25
+ ```sh
26
+ python3 scripts/typeroll/check_unsaved.py
27
+ ```
28
+
29
+ Exit 1 lists unsaved Pages **and partials**. Review those changes; save only the
30
+ work authorized for this publication, then run again. Templates save immediately.
31
+ A working-copy preview is not proof of saved output. Never auto-save other work.
32
+
33
+ ## After an authorized publication
34
+
35
+ ```sh
36
+ python3 scripts/typeroll/check_url_parity.py https://YOUR-TEST-HOST
37
+ python3 scripts/typeroll/check_assets.py https://YOUR-TEST-HOST
38
+ node scripts/typeroll/shoot_hosted.cjs https://YOUR-TEST-HOST qa/reports/hosted /,/about/ 390 768 1023 1024 1280
39
+ node scripts/typeroll/card-stacking-check.cjs https://YOUR-TEST-HOST/listing/ 390
40
+ ```
41
+
42
+ URL parity reads every migration inventory page and observed spelling, skips
43
+ explicit exclusions unless --show-excluded, keeps origins/query parameters,
44
+ detects loops and flags chains beyond a single redirect (one initial slash
45
+ normalization is allowed). This does **not** prove the final destination is the
46
+ editorially correct page: compare with the approved URL map too. No inventory
47
+ is a failure, not a passing zero-row check. Asset QA uses resolved routes,
48
+ checks published/unlisted Pages, relative URLs, srcset and downloads, uses four
49
+ concurrent HEAD requests and falls back to GET when HEAD is unsupported. CSS
50
+ backgrounds, authenticated assets and dynamically loaded resources need browser QA.
51
+ Neither tool spoofs a search crawler. A 403/DNS/network failure is unverified;
52
+ inspect access policy rather than treating it as a broken migration or a pass.
53
+
54
+ ## Other helpers
55
+
56
+ - `call.py TOOL '{"argument":"value"}'`: raw MCP result; use `-` to read JSON
57
+ from stdin. Mutating tools require the same owner authorization as interactive
58
+ calls. Network failures are not automatically retried for mutations.
59
+ - `trlib.upload(path, content_type, alt)`: direct signed upload and mandatory
60
+ finalize SHA-256 verification. No image bytes pass through the model or Git.
61
+ - `sweep_blocks.example.py`: dry-run grid normalization across Pages, templates
62
+ and partials. Review before --apply; refuses to commit existing unsaved work.
63
+ It is not transactional: a failure can leave a partial draft; inspect before retry.
64
+ - `shootframe.cjs OUT-PREFIX SIGNED-PREVIEW-URL [widths...]`: selects only the
65
+ Typeroll preview iframe, reports fonts/hosts/overflow. Signed URLs are secrets;
66
+ pass privately, never paste into committed reports or shell history. Direct-frame
67
+ capture checks rendering only; test Extension storage/navigation/handoffs in
68
+ the original wrapper because those features depend on its parent bridge.
69
+ - `layout_trace.cjs URL [width] [selector]`: computed spacing of ancestors and
70
+ next section. Works with a direct page or Typeroll preview.
71
+ - `contact_sheet.py OUT-PREFIX COLS TILE-WIDTH PNG...`: Pillow contact sheets.
72
+ It crops tall screenshots; inspect full screenshots too.
73
+ - `capture_text.cjs URL qa/reports/text.json 'main .article-content'` followed by
74
+ `compare_text.py sources/private/page.html qa/reports/text.json --source-title 'Title'`:
75
+ word/punctuation diff. A saved single WP REST record (.json) is another source
76
+ adapter. Select exactly the preserved region, including its template-owned
77
+ title once. Deliberate shared additions need separate acceptance. Comments,
78
+ scripts and styles are excluded from source text. It never edits content.
79
+
80
+ Browser reports exit nonzero for missing images, overflow, unexpected H1 count,
81
+ failed requests or incomplete scrolling. They keep third-party failures visible.
82
+ Network checks retry only a bounded read. All reports, screenshots and source
83
+ exports stay under ignored qa/reports/ or sources/private/. Do not commit signed
84
+ URLs, personal data or customer assets. Adapt scripts deliberately and run tests
85
+ before applying broad changes. The workspace updater preserves your edits.
86
+
87
+ ## Palette migration (Core 0.2.106 / MCP 0.45.80+)
88
+
89
+ `color_tokens.py --plan /tmp/design-token-plan.json` runs the read-only
90
+ `lint_design_tokens` audit and lists unambiguous direct block hex replacements.
91
+ Review the plan and remove deliberate exceptions. `--apply /tmp/design-token-plan.json`
92
+ requires the same site/palette and a dedicated non-main Version without existing
93
+ working copies. Pause other editing in that Version during the run: ordinary
94
+ field writes do not provide a site-wide transaction. The script refuses stale
95
+ values, never retries uncertain writes, and never publishes. On an interruption,
96
+ inspect working copies before saving or resuming. Templates save immediately.
97
+
98
+ Ambiguous colours, nested/responsive colour maps and custom CSS are reported for
99
+ manual review rather than rewritten. API updates through the normal block tools
100
+ can resolve these after choosing the semantic role. Run lint again and compare
101
+ header, footer, navigation, cards and buttons at all breakpoints. Keep generated
102
+ plans outside Git; they are version-specific review artifacts.
103
+
104
+ ## Local image preparation
105
+
106
+ `prepare_image.py SOURCE OUTPUT.jpg` requires Pillow with ImageCms. It converts
107
+ through the original ICC profile to sRGB and applies orientation, without
108
+ uploading or changing CMS content. Missing CMYK profiles, transparency, multiple
109
+ frames and existing output files stop with an error. `--assume-srgb` is only for
110
+ reviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.
111
+ See workflows/source-media.md for archive verification and original preservation.
@@ -0,0 +1,50 @@
1
+ // Isolated QA browser only. Never persist storage state, cookies or signed URLs.
2
+ const { chromium } = require('playwright');
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+ async function open(page, url) {
6
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
7
+ // Select only Typeroll's own preview, never an arbitrary ad/video iframe.
8
+ const frame = page.locator('iframe[title="Preview"], iframe[title="Site preview"]');
9
+ if (await frame.count() === 1) {
10
+ await page.waitForFunction(() => document.querySelector('iframe[title="Preview"], iframe[title="Site preview"]')?.getAttribute('src'), null, { timeout: 15000 });
11
+ const src = await frame.getAttribute('src');
12
+ if (!src) throw Error('Preview frame has no source');
13
+ const target = new URL(src, page.url());
14
+ if (target.origin !== new URL(page.url()).origin) throw Error('Unexpected preview frame origin');
15
+ await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: 60000 });
16
+ }
17
+ await page.evaluate(() => document.fonts.ready);
18
+ }
19
+ async function settle(page) {
20
+ // Bounded for infinite-scroll pages; report coverage rather than hanging.
21
+ for (let y = 0; y < 30000; y += 600) {
22
+ await page.evaluate(y => scrollTo(0, y), y);
23
+ await page.waitForTimeout(100);
24
+ if (y + 900 >= await page.evaluate(() => document.documentElement.scrollHeight)) break;
25
+ }
26
+ await page.evaluate(() => scrollTo(0, 0));
27
+ await page.waitForTimeout(300);
28
+ }
29
+ async function inspect(page) {
30
+ return page.evaluate(() => ({
31
+ overflow: document.documentElement.scrollWidth > innerWidth,
32
+ h1: document.querySelectorAll('h1').length,
33
+ title: document.title,
34
+ robots: document.querySelector('meta[name="robots"]')?.content ?? null,
35
+ fonts: { body: getComputedStyle(document.body).fontFamily, heading: getComputedStyle(document.querySelector('h1') || document.body).fontFamily },
36
+ incompleteImages: [...document.images].filter(i => !i.complete || !i.naturalWidth).map(i => new URL(i.currentSrc || i.src, location.href).pathname),
37
+ scrollCoverageComplete: document.documentElement.scrollHeight <= 30000,
38
+ }));
39
+ }
40
+ async function screenshot(page, file) {
41
+ await fs.mkdir(path.dirname(file), { recursive: true });
42
+ await page.screenshot({ path: file, fullPage: true, animations: 'disabled' });
43
+ }
44
+ function widths(args) {
45
+ const values = (args.length ? args : ['390', '768', '1023', '1024', '1280']).map(Number);
46
+ if (values.some(w => !Number.isInteger(w) || w < 240 || w > 4000)) throw Error('Invalid viewport width');
47
+ return values;
48
+ }
49
+ function fail() { console.error('QA failed; inspect the target privately. URLs and credentials are withheld.'); process.exitCode = 2; }
50
+ module.exports = { chromium, open, settle, inspect, screenshot, widths, fail };
@@ -0,0 +1,12 @@
1
+ """One MCP call; output can contain private data. Do not commit it."""
2
+ import argparse
3
+ import json
4
+ import sys
5
+ import trlib
6
+
7
+ parser = argparse.ArgumentParser(description=__doc__)
8
+ parser.add_argument('tool')
9
+ parser.add_argument('arguments', nargs='?', default='{}', help='JSON or - for stdin')
10
+ args = parser.parse_args()
11
+ value = json.loads(sys.stdin.read() if args.arguments == '-' else args.arguments)
12
+ print(json.dumps(trlib.call(args.tool, value), ensure_ascii=False, indent=2))
@@ -0,0 +1,17 @@
1
+ // Explicit selector avoids silently excluding the wrong column/sidebar.
2
+ const qa = require('./browser.cjs');
3
+ const fs = require('node:fs/promises');
4
+ const [url, out, selector = 'main'] = process.argv.slice(2);
5
+ (async () => {
6
+ const browser = await qa.chromium.launch();
7
+ try {
8
+ const page = await browser.newPage();
9
+ await qa.open(page, url); await qa.settle(page);
10
+ const target = page.locator(selector);
11
+ if (await target.count() !== 1) throw Error('Select exactly one content region');
12
+ const value = await target.evaluate(el => ({ text: el.innerText,
13
+ images: [...el.querySelectorAll('img')].map(i => ({ alt: i.alt, path: new URL(i.currentSrc || i.src, location.href).pathname })) }));
14
+ await fs.mkdir(require('node:path').dirname(out), { recursive: true });
15
+ await fs.writeFile(out, JSON.stringify(value, null, 2));
16
+ } finally { await browser.close(); }
17
+ })().catch(qa.fail);
@@ -0,0 +1,27 @@
1
+ // Check every whole-card target, including excerpt/media/meta, without navigating.
2
+ const qa = require('./browser.cjs');
3
+ const [url, width = '390'] = process.argv.slice(2);
4
+ (async () => {
5
+ const browser = await qa.chromium.launch();
6
+ try {
7
+ const page = await browser.newPage({ viewport: { width: qa.widths([width])[0], height: 900 } });
8
+ await qa.open(page, url);
9
+ const cards = page.locator('[data-block="post_card"][data-whole="true"]');
10
+ const report = [];
11
+ for (let index = 0; index < await cards.count(); index++) {
12
+ const card = cards.nth(index);
13
+ await card.scrollIntoViewIfNeeded();
14
+ report.push(await card.evaluate(card => {
15
+ const link = card.querySelector('.block-postcard-title > a');
16
+ const targets = [card, ...card.querySelectorAll('.block-postcard-excerpt,.block-postcard-meta,.block-postcard-media')];
17
+ const hits = targets.filter(el => el.getBoundingClientRect().height > 0).map(el => {
18
+ const r = el.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
19
+ return { part: el.className || 'card', inViewport: y >= 0 && y < innerHeight, linked: document.elementFromPoint(x, y)?.closest('a') === link };
20
+ });
21
+ return { block: card.dataset.blockId || card.dataset.bid || null, title: link?.textContent, hits };
22
+ }));
23
+ }
24
+ console.log(JSON.stringify(report, null, 2));
25
+ if (!report.length || report.some(r => r.hits.some(h => !h.inViewport || !h.linked))) process.exitCode = 1;
26
+ } finally { await browser.close(); }
27
+ })().catch(qa.fail);
@@ -0,0 +1,72 @@
1
+ """Check published page assets, including relative URLs and srcset. CSS backgrounds need visual QA."""
2
+ import argparse
3
+ import concurrent.futures
4
+ from html.parser import HTMLParser
5
+ import json
6
+ import urllib.parse
7
+ import trlib
8
+ from check_url_parity import origin, fetch, probe
9
+
10
+
11
+ class Assets(HTMLParser):
12
+ def __init__(self, base):
13
+ super().__init__(convert_charrefs=True)
14
+ self.base, self.urls = base, set()
15
+
16
+ def handle_starttag(self, tag, attrs):
17
+ data = dict(attrs)
18
+ values = []
19
+ if tag in ('img', 'source', 'video', 'audio', 'script'):
20
+ values.extend(data[k] for k in ('src', 'poster') if data.get(k))
21
+ if tag == 'link' and data.get('rel') == 'stylesheet':
22
+ values.append(data.get('href', ''))
23
+ href = data.get('href', '')
24
+ if tag == 'a' and ('download' in data or urllib.parse.urlsplit(href).path.lower().endswith(('.pdf', '.jpg', '.jpeg', '.png', '.webp', '.avif', '.svg', '.zip'))):
25
+ values.append(href)
26
+ if data.get('srcset') and not data['srcset'].strip().startswith('data:'):
27
+ values.extend(item.strip().split()[0] for item in data['srcset'].split(',') if item.strip())
28
+ for value in values:
29
+ url = urllib.parse.urljoin(self.base, value)
30
+ if urllib.parse.urlsplit(url).scheme in ('http', 'https'):
31
+ self.urls.add(url)
32
+
33
+
34
+ def check(url):
35
+ status, location, _ = fetch(url, 'HEAD')
36
+ # HEAD is not supported everywhere; redirects must resolve too.
37
+ if status in (405, 501) or location:
38
+ status = probe(url).get('status', 0)
39
+ return {'url': url, 'status': status, 'ok': status == 200}
40
+
41
+
42
+ def main():
43
+ parser = argparse.ArgumentParser(description=__doc__)
44
+ parser.add_argument('origin', type=origin)
45
+ args = parser.parse_args()
46
+ pages = [p for p in trlib.all_pages() if p.get('status') in ('published', 'unlisted')]
47
+ errors, assets = [], set()
48
+ for page in pages:
49
+ if not page.get('url'):
50
+ errors.append({'page_id': page['id'], 'error': 'missing_resolved_route'})
51
+ continue
52
+ route = urllib.parse.urlsplit(page['url'])
53
+ url = args.origin + (route.path or '/') + ('?' + route.query if route.query else '')
54
+ evidence = probe(url, expected_page=True)
55
+ if not evidence.get('ok'):
56
+ errors.append({'page_id': page['id'], **evidence})
57
+ continue
58
+ status, _, body = fetch(evidence['final_url'])
59
+ if status != 200:
60
+ errors.append({'page_id': page['id'], 'status': status})
61
+ continue
62
+ parser = Assets(evidence['final_url'])
63
+ parser.feed(body.decode('utf8', 'replace'))
64
+ assets.update(parser.urls)
65
+ with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
66
+ results = list(pool.map(check, sorted(assets)))
67
+ print(json.dumps({'pages': len(pages), 'page_errors': errors, 'assets': results}, indent=2))
68
+ return 1 if not pages or errors or any(not item['ok'] for item in results) else 0
69
+
70
+
71
+ if __name__ == '__main__':
72
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ """Pre-deploy gate: list every Page and partial with an unsaved working copy (Page templates save immediately).
2
+ Deploys build saved content only, and previews with include_working_copy hide the difference.
3
+ usage: python3 scripts/typeroll/check_unsaved.py (exit 1 when something is unsaved)"""
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
+ bad = [t for t in trlib.containers() if t["kind"] != "template"
9
+ and trlib.call("read_working_copy", {"site_id": SITE, "target": t}).get("working_copy")]
10
+ for t in bad:
11
+ print("unsaved:", t["kind"], t["id"])
12
+ print("unsaved total:", len(bad))
13
+ sys.exit(1 if bad else 0)
@@ -0,0 +1,74 @@
1
+ """Read-only URL continuity check on an explicitly supplied deployed origin."""
2
+ import argparse
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ import trlib
8
+
9
+ UA = 'Typeroll-Starter-QA/1.0 (+https://typeroll.com/docs/)'
10
+
11
+
12
+ def fetch(url, method='GET'):
13
+ request = urllib.request.Request(url, method=method, headers={'User-Agent': UA})
14
+ for attempt in range(2):
15
+ try:
16
+ with urllib.request.build_opener(trlib.NoRedirect).open(request, timeout=30) as response:
17
+ return response.status, response.headers.get('Location'), response.read() if method == 'GET' else b''
18
+ except urllib.error.HTTPError as error:
19
+ if error.code in (502, 503, 504) and attempt == 0:
20
+ continue
21
+ return error.code, error.headers.get('Location'), b''
22
+ except (OSError, TimeoutError):
23
+ if attempt:
24
+ return 0, None, b''
25
+
26
+
27
+ def origin(value):
28
+ parsed = urllib.parse.urlsplit(value)
29
+ if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
30
+ raise ValueError('Supply a plain deployed HTTP(S) origin')
31
+ return value.rstrip('/')
32
+
33
+
34
+ def probe(url, expected_page=False):
35
+ initial, seen, hops = url, set(), []
36
+ for _ in range(6):
37
+ if url in seen:
38
+ return {'error': 'redirect_loop', 'hops': hops}
39
+ seen.add(url)
40
+ status, location, _ = fetch(url)
41
+ if status not in (301, 302, 303, 307, 308) or not location:
42
+ slash = bool(hops) and urllib.parse.urlsplit(initial)._replace(path=urllib.parse.urlsplit(initial).path.rstrip('/')) == urllib.parse.urlsplit(hops[0])._replace(path=urllib.parse.urlsplit(hops[0]).path.rstrip('/'))
43
+ maximum = 1 if expected_page else 2 if slash else 1
44
+ acceptable_page_hop = not expected_page or not hops or (len(hops) == 1 and slash)
45
+ ok = status == 200 and len(hops) <= maximum and acceptable_page_hop
46
+ return {'ok': ok, 'status': status, 'hops': hops, 'final_url': url}
47
+ url = urllib.parse.urljoin(url, location)
48
+ if urllib.parse.urlsplit(url).scheme not in ('http', 'https'):
49
+ return {'error': 'unsupported_redirect_scheme', 'hops': hops}
50
+ hops.append(url)
51
+ return {'error': 'too_many_redirects', 'hops': hops}
52
+
53
+
54
+ def main():
55
+ parser = argparse.ArgumentParser(description=__doc__)
56
+ parser.add_argument('origin', type=origin)
57
+ parser.add_argument('--show-excluded', action='store_true')
58
+ args = parser.parse_args()
59
+ report = []
60
+ for row in trlib.all_migration_urls():
61
+ if row.get('status') == 'excluded' and not args.show_excluded:
62
+ continue
63
+ for path in sorted(set([row['path']] + (row.get('observed_paths') or []))):
64
+ # Inventory spellings must be paths, not a silent change of tested host.
65
+ if not path.startswith('/') or path.startswith('//'):
66
+ raise ValueError('Inventory contains a non-local path')
67
+ url = args.origin + urllib.parse.quote(path, safe='/%?=&:+')
68
+ report.append({'path': path, **probe(url, row.get('status') == 'migrated')})
69
+ print(json.dumps(report, indent=2))
70
+ return 1 if not report or any(not row.get('ok') for row in report) else 0
71
+
72
+
73
+ if __name__ == '__main__':
74
+ raise SystemExit(main())