@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.
Files changed (43) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +18 -0
  3. package/dist/bundled-content.js +35 -10
  4. package/dist/init.js +1 -1
  5. package/dist/tools/deploy.js +2 -1
  6. package/dist/tools/media.js +1 -1
  7. package/dist/tools/migration.js +4 -2
  8. package/dist/tools/pages.js +1 -1
  9. package/dist/tools/preview.js +1 -1
  10. package/dist/tools/working-copy.js +1 -1
  11. package/dist/version.js +1 -1
  12. package/dist/workspace.js +34 -10
  13. package/package.json +3 -2
  14. package/skills/tr-header-footer.md +95 -41
  15. package/skills/tr-import-url.md +6 -6
  16. package/skills/tr-migrate-astro.md +3 -3
  17. package/skills/tr-migrate-multisite.md +4 -4
  18. package/skills/tr-migrate-wp.md +28 -12
  19. package/skills/tr-migration-evidence.md +35 -23
  20. package/skills/tr-new-site.md +5 -7
  21. package/workspace-templates/brief/interview.md +18 -0
  22. package/workspace-templates/brief/migration.md +42 -0
  23. package/workspace-templates/qa/checklist.md +35 -0
  24. package/workspace-templates/qa/typeroll-platform-feedback.md +11 -0
  25. package/workspace-templates/scripts/README.md +85 -0
  26. package/workspace-templates/scripts/typeroll/browser.cjs +50 -0
  27. package/workspace-templates/scripts/typeroll/call.py +12 -0
  28. package/workspace-templates/scripts/typeroll/capture_text.cjs +17 -0
  29. package/workspace-templates/scripts/typeroll/card-stacking-check.cjs +27 -0
  30. package/workspace-templates/scripts/typeroll/check_assets.py +72 -0
  31. package/workspace-templates/scripts/typeroll/check_unsaved.py +13 -0
  32. package/workspace-templates/scripts/typeroll/check_url_parity.py +74 -0
  33. package/workspace-templates/scripts/typeroll/compare_text.py +59 -0
  34. package/workspace-templates/scripts/typeroll/contact_sheet.py +20 -0
  35. package/workspace-templates/scripts/typeroll/layout_trace.cjs +33 -0
  36. package/workspace-templates/scripts/typeroll/shoot_hosted.cjs +26 -0
  37. package/workspace-templates/scripts/typeroll/shootframe.cjs +16 -0
  38. package/workspace-templates/scripts/typeroll/sweep_blocks.example.py +23 -0
  39. package/workspace-templates/scripts/typeroll/trlib.py +212 -0
  40. package/workspace-templates/workflows/design.md +30 -0
  41. package/workspace-templates/workflows/interview.md +72 -0
  42. package/workspace-templates/workflows/migration.md +84 -0
  43. package/workspace-templates/workflows/new-site.md +17 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tr-migration-evidence
3
- description: Choose preserve or renew intent, map standard and custom blocks, preserve old URLs, and verify content, design and behavior before migration acceptance.
3
+ description: Choose text and design/image preservation independently, map standard and custom blocks, preserve old URLs, and verify content, design and behavior before migration acceptance.
4
4
  ---
5
5
 
6
6
  # Migration evidence contract
@@ -12,22 +12,33 @@ AI assistant has no migration tools: it does not import, convert or verify sites
12
12
  Use this shared contract with WordPress, Astro, URL and multisite recipes.
13
13
  ## Choose the migration intent before writes
14
14
 
15
- Record `preserve` or `renew` in the workspace's `brief/migration.md` (or the
16
- project's equivalent brief), with scope, source URLs and the user's decision.
17
- These are planning labels, not fields accepted by the migration API. Ask when
18
- the intent is unclear; do not infer redesign permission from “migrate”.
15
+ Record two independent choices in `brief/migration.md` (or the project's
16
+ corresponding brief): `text_intent` and `design_intent`, each `preserve` or `renew`.
17
+ Design includes images. These are planning labels, not migration API parameters.
18
+ Use the starter's `workflows/interview.md` when guidance or decisions are missing;
19
+ resume existing answers. Do not infer either choice from a vague "migrate" or
20
+ "renew", or silently apply an old single-choice brief to both dimensions.
19
21
 
20
- | Intent | Source material | Acceptance reference |
22
+ | Text | Design/images | Acceptance |
21
23
  | --- | --- | --- |
22
- | Preserve: change publishing platform | Keep text, images, structure, visual identity and behavior as closely as practical. | The original site. Record deliberate deviations and their agreed benefit. |
23
- | Renew: improve design and content | Use source text, images, structure and graphic design as raw material. Agree what to retain, rewrite, combine or replace. | The agreed brief, content plan and approved prototypes, not visual similarity to the old site. |
24
+ | Preserve | Preserve | Compare wording and appearance separately with the source. |
25
+ | Preserve | Renew | Keep wording; compare appearance with approved new prototypes. |
26
+ | Renew | Preserve | Review copy against the editorial brief; keep source appearance, allowing documented layout adjustments for new text length. |
27
+ | Renew | Renew | Review copy and appearance against their separately approved briefs. |
24
28
 
25
- Both paths preserve facts, ownership, required business behavior and historical
26
- URL continuity. Renewal does not authorize invented claims, removal of useful
27
- content or an unapproved domain switch. Do not use a partly migrated target as
28
- an accidental design reference. Protect existing target edits in either path.
29
+ Preserved text includes headings, body copy, CTA labels and captions. Record
30
+ metadata, alt text, legal text and translation rules explicitly; SEO is not an
31
+ exception permitting silent rewrites. With renewed design, list required assets
32
+ and which images may be replaced or generated, including cropping, credits and
33
+ rights. Format/size optimization must not change a preserved image's motif.
29
34
 
30
- ## Use editable blocks in both paths
35
+ Record source URLs, dates, approved scope, per-Page exceptions and acceptance
36
+ references separately. Both choices preserve facts, ownership, required behavior
37
+ and historical URL continuity. Renewing design does not authorize text rewrites;
38
+ renewing text does not authorize new imagery. Neither authorizes invented claims,
39
+ unreviewed content removal or domain cutover. Protect existing target edits.
40
+
41
+ ## Use editable blocks for all four combinations
31
42
 
32
43
  1. Inspect standard block schemas, composition starters, named styles and Page
33
44
  templates. Prefer these when they fit.
@@ -60,10 +71,10 @@ still needs a platform fix, not a disguised HTML/CSS workaround.
60
71
 
61
72
  The managed WordPress importer deterministically stores cleaned HTML and shared
62
73
  references, without AI rewriting. That is a source-preserving intermediate step.
63
- The external agent rebuilds it into reviewed blocks/templates for either intent;
74
+ The external agent rebuilds it into reviewed blocks/templates for all four combinations;
64
75
  `set_page_mode` does not perform conversion. Keep the complete source, map every
65
- in-scope content element, and do not silently truncate long pages. In preserve
66
- mode retain wording; in renew mode track approved rewrites and source facts.
76
+ in-scope content element, and do not silently truncate long pages. When text_intent is preserve retain wording; when it is renew track approved
77
+ rewrites and source facts, independently of design_intent.
67
78
  An import finishing does not mean design, block reconstruction or launch is done.
68
79
 
69
80
  ## Old URLs must continue to work
@@ -86,10 +97,10 @@ Do not switch traffic until these checks pass and cutover is authorized.
86
97
 
87
98
  ## Gate 1: source baseline, before conversion
88
99
 
89
- Record the chosen intent, authoritative source URL/date, scope, relevant Core version, active
100
+ Record both chosen intents, authoritative source URL/date, scope, relevant Core version, active
90
101
  Page templates and overrides. Inventory home, category/archive, long/short
91
102
  articles, downloads, empty related lists and each distinct business surface.
92
- For preserve, capture source and target geometry; for renew, compare the target
103
+ For design_intent=preserve, capture source and target geometry; for design_intent=renew, compare the target
93
104
  with the approved design while retaining source coverage evidence. Capture
94
105
  screenshots and computed geometry at matching actual `innerWidth`
95
106
  values (320/390/768/1024/1280/1536 where relevant, plus both sides of actual
@@ -249,14 +260,15 @@ pre-cutover scope honestly when DNS or integrations remain out of scope.
249
260
 
250
261
  The initial route census must combine source sitemaps, internal links, archive
251
262
  pagination, CMS inventory and known utility routes. Include homepage, archives,
252
- 404/search behavior and routes outside the sitemap where present. In preserve mode, do not invent
253
- features absent from the source. In renew mode, include additions in the agreed brief. Assign every route a recipe, template override,
263
+ 404/search behavior and routes outside the sitemap where present. Do not invent features absent from the source; include additions only in the agreed brief. Assign every route a recipe, template override,
254
264
  composition or documented exception. Inventory the whole shell: content outside
255
265
  `main`, header, footer, service navigation and archive heroes are not optional.
256
266
 
257
- Derive required evidence from that census. For preserve, pair every in-scope source/target route
258
- at mobile and desktop; for renew, compare every target route to its approved
259
- prototype and account for the source content/URL mapping; add each unique component, breakpoint and interaction
267
+ Derive required evidence from that census. For design_intent=preserve, pair every
268
+ in-scope source/target route at mobile and desktop; for design_intent=renew, compare
269
+ every target route to its approved prototype. Independently compare preserved text
270
+ to the source and renewed text to its editorial brief; account for the source
271
+ content/URL mapping in every combination; add each unique component, breakpoint and interaction
260
272
  state. Inspect page-specific media, lists, tables, embeds, CTAs, anchors and related
261
273
  content. One representative article cannot attest every article's content.
262
274
 
@@ -165,7 +165,7 @@ it there.
165
165
  ### 3. Header + footer partials
166
166
 
167
167
  **Start from the native preset — don't hand-roll navigation.** Read
168
- `tr-header-footer` and use its `template/site_logo` + `core/navigation`
168
+ `tr-header-footer` and use its `template/site_logo` + `core/navigation_menu` with `core/navigation_links` slots
169
169
  composition. The server-rendered landmark, current-page state, no-JS links,
170
170
  mobile disclosure, focus treatment, and responsive behavior are Core
171
171
  contracts rather than tenant CSS/JavaScript.
@@ -269,12 +269,10 @@ passing the whole tree inline (`block={ type: 'core/columns', slots:
269
269
  slot_index=0|1`. Requires template_capabilities_version ≥ 0.15.2 — on
270
270
  older sites use `core/grid` (children flow into columns) instead.
271
271
 
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.
272
+ Partial last rows: keep `last_row: 'start'` on `core/grid`. Incomplete rows
273
+ stay left-aligned, preserving the reading edge. Center only after an explicit
274
+ owner design request, never as an automatic fix. Do not widen one peer card
275
+ or invent six-column CSS to fill a gap.
278
276
 
279
277
  Icons (template_capabilities_version ≥ 0.16.0): `type: 'icon'` fields on
280
278
  `core/icon`, `core/icon_box`, and `core/step_card` render inline SVG when
@@ -0,0 +1,18 @@
1
+ # Interview progress
2
+
3
+ Status: not started
4
+ Next question: goal and scope
5
+
6
+ ## Confirmed answers
7
+
8
+ | Topic | Answer | Source/date | Brief location |
9
+ | --- | --- | --- | --- |
10
+
11
+ ## Proposals and unknowns
12
+
13
+ Record what is not yet decided, whether it blocks the next step and who can decide.
14
+
15
+ ## Recap and approved next step
16
+
17
+ Record the owner's confirmation, exact scope and date. Do not infer deployment,
18
+ DNS or message approval from a completed brief. No secrets or raw private transcripts.
@@ -0,0 +1,42 @@
1
+ # Migration brief (when applicable)
2
+
3
+ ## Independent choices
4
+
5
+ - text_intent: undecided (preserve or renew)
6
+ - design_intent: undecided (preserve or renew; includes images)
7
+
8
+ These are planning labels, not API parameters. Choose each separately. If guidance
9
+ is needed, use workflows/interview.md. A single "renew" must not silently authorize
10
+ both. Expand an older single-choice brief with the owner before dependent writes.
11
+
12
+ ## Source URLs, date and agreed scope
13
+
14
+ ## Text policy and exceptions
15
+
16
+ Preserve wording, headings, CTAs and captions when text_intent is preserve. Record
17
+ SEO metadata/alt text, legal copy, translation and page-specific exceptions explicitly.
18
+ For renewal, record the approved editorial brief and facts that must remain true.
19
+
20
+ ## Design and image policy and exceptions
21
+
22
+ Record layout/brand references, required logos/assets, permitted replacements or
23
+ generation, cropping, rights and credits. Design renewal alone never permits text
24
+ rewriting. Preserve image motifs unless a replacement is agreed; format/size
25
+ optimization does not mean changing the image's content.
26
+
27
+ ## Acceptance references
28
+
29
+ Text: original source for preserve; approved editorial brief for renew.
30
+ Design/images: original source for preserve; approved design/prototypes for renew.
31
+ Record these separately per layout or page when exceptions apply.
32
+
33
+ ## Standard blocks, custom types and HTML exceptions
34
+
35
+ ## Old URL to preserved path or relevant redirect
36
+
37
+ Both choices retain URL continuity, source facts and required business behavior.
38
+
39
+ ## Prototype and verification evidence
40
+
41
+ Record text, design/images, links and behavior results separately. Keep private
42
+ extracts in sources/private/ and screenshots in qa/reports/.
@@ -0,0 +1,35 @@
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.
@@ -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,85 @@
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.
@@ -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())