@typeroll/mcp-server 0.45.80 → 0.45.82

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.
@@ -17,7 +17,7 @@ export function compactTools(catalog) {
17
17
  }),
18
18
  local('describe_tool', 'Read one tool’s complete description, effect and JSON input schema before calling it.', { name: z.string().max(100) }, async (args) => {
19
19
  const tool = byName.get(String(args.name));
20
- return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).meta({ additionalProperties: false }), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
20
+ return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).strict(), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
21
21
  }),
22
22
  ...['read', 'write', 'admin'].map(effect => ({
23
23
  name: `call_${effect}_tool`, effect,
@@ -28,7 +28,7 @@ export function compactTools(catalog) {
28
28
  if (!tool || tool.effect !== effect)
29
29
  return fail(new Error(`This endpoint accepts only ${effect} tools. Use describe_tool to check the required effect.`));
30
30
  try {
31
- return await tool.invoke(z.object(tool.schema).parse(args.arguments));
31
+ return await tool.invoke(z.object(tool.schema).strict().parse(args.arguments));
32
32
  }
33
33
  catch (error) {
34
34
  return fail(error);
package/dist/server.js CHANGED
@@ -57,7 +57,9 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
57
57
  function effectFor(name) {
58
58
  if (name === 'page_completeness' || name === 'resolve_page_url')
59
59
  return 'read';
60
- if (name === 'retry_deploy_verification'
60
+ if (name === 'guide_publishing_domain_change'
61
+ || name === 'onboard_publishing_domain_zone'
62
+ || name === 'retry_deploy_verification'
61
63
  || name === 'list_apps'
62
64
  || name === 'read_app'
63
65
  || name === 'update_app'
@@ -325,7 +327,7 @@ export function buildServer(options) {
325
327
  }
326
328
  for (const tool of options.toolMode === 'compact' ? compactTools(catalog) : catalog) {
327
329
  register(tool.name, {
328
- description: tool.description, inputSchema: z.object(tool.schema).meta({ additionalProperties: false }),
330
+ description: tool.description, inputSchema: z.object(tool.schema).strict(),
329
331
  annotations: { readOnlyHint: tool.effect === 'read', destructiveHint: tool.effect !== 'read', openWorldHint: true },
330
332
  }, tool.invoke);
331
333
  }
@@ -16,6 +16,25 @@
16
16
  import { z } from 'zod';
17
17
  import { ok, withErrorBoundary } from './helpers.js';
18
18
  export const domainTools = [
19
+ {
20
+ name: 'onboard_publishing_domain_zone', noSite: true,
21
+ description: 'Organization-admin DNS onboarding for a site owned by that organization. Create a zone in its Hosting Group account, start a Cloudflare DNS scan, review the discovered records against current public DNS and existing zone records, then apply a reviewed plan as DNS only. Nameservers are returned for the human registrar step; never changed automatically. A scan is incomplete: confirm the full old-provider inventory, especially MX/SPF/DKIM/DMARC, before applying. Existing conflicting records are never replaced. Only pending zones can import records; active zones use normal domain management.',
22
+ inputSchema: { site_id: z.string(), zone_name: z.string(), action: z.enum(['create_zone', 'scan_dns', 'review_dns', 'apply_dns']), plan_revision: z.string().optional(), confirm_inventory: z.boolean().optional() },
23
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('publishing/domain-onboarding', args))),
24
+ },
25
+ {
26
+ name: 'guide_publishing_domain_change',
27
+ description: 'Guide a site domain move. Start with check: returns hosting, zone, nameserver, certificate, permission, publishing, redirect capacity and candidate checks, current phase, next action and an explicit switch summary. Preview proposed hosts before save; confirm_rebuild is required if an existing candidate would be replaced. Prepare freezes the last published content, not current CMS edits. Adding redirect hosts preserves the content candidate. Approve requires explicit user approval of the switch summary and any validation interruption. Automatic DNS is journaled and rolled back after a failed check or the agreed deadline; later external edits require manual attention. Rollback requests restoration through the serialized publication chain. External DNS remains the caller’s responsibility.',
28
+ inputSchema: {
29
+ action: z.enum(['check', 'preview', 'save', 'prepare', 'approve', 'rollback']),
30
+ revision: z.string().optional(), candidate_id: z.string().optional(),
31
+ website_host: z.string().nullable().optional(), media_host: z.string().nullable().optional(), media_path_prefix: z.string().optional(),
32
+ redirect_hosts: z.array(z.string()).max(4).optional(), dns_mode: z.enum(['automatic', 'external']).optional(),
33
+ confirm_rebuild: z.boolean().optional(), accept_validation_window: z.boolean().optional(),
34
+ validation_timeout_minutes: z.number().int().min(5).max(45).optional(),
35
+ },
36
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'publishing/domain-guide', args))),
37
+ },
19
38
  {
20
39
  name: 'check_managed_site_migration',
21
40
  description: 'Verify migration of an existing static managed Pages site to connected Git publishing. Checks the original hosting account, project, domain and shared build engine. Returns a revision and previous live deployment; does not migrate or deploy.',
@@ -193,6 +212,7 @@ export const domainTools = [
193
212
  description: 'Approve switching website traffic to the verified frozen deployment. Read publishing domains first and review DNS requirements, certificate readiness and redirect_preparations. Existing traffic is preserved until validation succeeds. With automatic DNS, redirect hosts switch after the website host: Typeroll ensures their zone redirect rule, then changes their reviewed record to a proxied placeholder. The publication goes live only after every redirect host answers 301 to the website host with the same path and query. When preparation.validation_blocker.resolution is accept_validation_window, Cloudflare can validate the website host only after its one existing record points at Pages: approving with accept_validation_window: true lets Typeroll change that record, and the host does not answer until Cloudflare validates it (Cloudflare does not document how long; previous_records holds the rollback). Get the user\'s explicit go-ahead for that window and choose a quiet time. External DNS management remains the caller’s responsibility.',
194
213
  inputSchema: {
195
214
  revision: z.string(), candidate_id: z.string(),
215
+ validation_timeout_minutes: z.number().int().min(5).max(45).optional(),
196
216
  accept_validation_window: z.boolean().optional().describe('Accept the interruption until Cloudflare validates the website host after its single reviewed record changes. Only valid when preparation.validation_blocker.resolution is accept_validation_window.'),
197
217
  },
198
218
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'publishing/cutover', args))),
@@ -208,6 +228,7 @@ export const domainTools = [
208
228
  description: 'Save future website, redirect and media hosts before preparing a deployment. Normal trigger_deploy continues updating the current or temporary address, even while future hosts await validation. Shortly before changing DNS, explicitly call prepare_publishing_domain_change to prepare the last published snapshot for these hosts. A newer normal publication makes the older candidate outdated; prepare again before cutover. Requires the current revision from read_publishing_domains. This does not publish content or change traffic DNS. Choose external DNS when your agent manages Cloudflare independently. redirect_hosts (for example www.example.com when example.com is the website host) answer 301 to the website host, keeping path and query, through a Single Redirect rule in the host\'s Cloudflare zone; they are switched and verified with the website. The response includes host_checks: resolve its blockers before preparing.',
209
229
  inputSchema: {
210
230
  revision: z.string(), website_host: z.string().nullable(), media_host: z.string().nullable(),
231
+ confirm_rebuild: z.boolean().optional().describe('Confirm replacing a prepared content candidate after reviewing domain_rebuild_confirmation_required or the guide preview.'),
211
232
  media_path_prefix: z.string().optional().describe('Empty on a separate media host; /media when using the website host.'),
212
233
  redirect_hosts: z.array(z.string()).max(4).optional().describe('Hostnames that redirect permanently to website_host, such as www.example.com. Omit or send [] for none.'),
213
234
  dns_mode: z.enum(['automatic', 'external']),
@@ -40,7 +40,7 @@ export const settingsTools = [
40
40
  {
41
41
  name: 'update_site_settings',
42
42
  description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Accepts everything the portal Settings form does. Requires admin permission on the site, as in the portal. Only the fields you pass change. ' +
43
- 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
43
+ 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" or "patch" key. ' +
44
44
  'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
45
45
  'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
46
46
  'Unknown top-level keys return a 400 error listing the valid fields. ' +
@@ -118,6 +118,8 @@ export const settingsTools = [
118
118
  },
119
119
  handler: withErrorBoundary(async (args, { client, siteId }) => {
120
120
  const { version, ...body } = args;
121
+ if (!Object.keys(body).some(key => body[key] !== undefined))
122
+ throw new Error('No settings supplied. Pass at least one setting at the top level, for example {tagline: "New tagline"}; do not wrap fields in patch or settings.');
121
123
  const res = await client.patch(siteId, 'settings', body, v(version));
122
124
  return ok(res);
123
125
  }),
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.45.80';
11
+ export const VERSION = '0.45.82';
package/dist/workspace.js CHANGED
@@ -51,6 +51,8 @@ Public documentation: https://typeroll.com/docs/llms.txt
51
51
  ## Edit and verify
52
52
 
53
53
  - Read current Pages, Content types, templates, partials and relevant block schemas.
54
+ - Read pages/ notes before editing; follow workflows/page-notes.md for sources,
55
+ dated logs, external-link review and archiving. Keep internal notes out of public output.
54
56
  - Use native editable blocks; document necessary exceptions and design decisions.
55
57
  - Configure palette/font roles and design scales in site settings. Read workflows/design.md
56
58
  before styling blocks; run lint_design_tokens and document hardcoded exceptions.
@@ -115,7 +117,7 @@ export const workspaceTemplates = {
115
117
  'brief/content-model.md': '# Content model\n\n## Page types and routes\n\n## Fields and references\n\n## Shared templates and partials\n\n## Taxonomy and navigation\n\nUse the current CMS schema. This document describes decisions, not a second schema store.\n',
116
118
  'brief/rules.md': "# Owner preferences and project rules\n\n## Confirmed rules\n\nRecord each rule, its scope, who approved it and the date. Separate confirmed rules from proposals.\n\n## Writing and factual accuracy\n\nVoice, terminology, prohibited claims, source requirements and legal copy that must not be rewritten.\n\n## Design and editing\n\nBrand constraints, accessibility, editable content, standard/custom blocks and documented HTML exceptions.\n\n## Operations and approval\n\nWho may approve content, publication, DNS changes and messages? Credentials stay outside this repository.\n\n## Exceptions and superseded rules\n\nLink decisions.md; do not silently override a confirmed rule. Project notes do not grant permissions beyond the user's authorization or account access.\n",
117
119
  'brief/seo.md': "# SEO strategy\n\n## Business goals, audience, markets and languages\n\n## Search intent and topic coverage\n\nRecord research sources and dates. Distinguish evidence from assumptions; do not invent traffic or ranking forecasts.\n\n## Page roles and URL plan\n\nContent types, useful landing pages, taxonomy, internal links and routes to preserve or redirect. Avoid near-duplicate pages created only for keyword variants.\n\n## Indexing and canonical policy\n\nWhich pages should be indexable, public noindex, or private? Record deliberate canonical overrides and sitemap policy; actual values live in the CMS.\n\n## Editorial standards and structured data\n\nUnique useful copy, supported claims, descriptive titles and links, accessible image text, schema matching visible content. No fixed title length or word count is a hard SEO rule.\n\n## Measurement and review\n\nBaseline, analytics/consent decisions, measures of success and review dates. Never store credentials or private analytics exports here.\n",
118
- 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md and the relevant Page | tr-edit-site |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
120
+ 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md, pages/ notes and the relevant Page | tr-edit-site |\n| Page notes and review | workflows/page-notes.md, pages/README.md, pages/index.md | Read before edits; update logs afterward |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
119
121
  'decisions.md': '# Decisions\n\nRecord date, decision, reason, approved scope and alternatives when useful. For design-token exceptions include the source/block, fixed value, reason (e.g. white text on a photograph) and review criteria.\n',
120
122
  'sources/README.md': '# Sources\n\nStore research references and copy proposals. Record source URLs and dates.\nPrivate extracts belong in ignored private/ or an approved external store.\nDo not treat local drafts as current CMS content or commit media libraries.\n',
121
123
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.80",
3
+ "version": "0.45.82",
4
4
  "description": "Typeroll CMS MCP server \u2013 connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,11 @@ description: Edit or create Typeroll pages with a person’s usual AI assistant.
19
19
  content types, or `search_pages` for words in saved content. Ask the user to
20
20
  choose when several pages fit their description. A different host requires
21
21
  confirming a site switch and selecting a new context; never silently switch.
22
- 4. Read the page and relevant Page template, block types and reusable sections.
22
+ 4. When a project repository is available, read its pages/<page-id>.md note and
23
+ relevant decisions before editing. Follow workflows/page-notes.md: update the
24
+ dated log afterward, check external links during reviews, and archive notes
25
+ for confirmed removed Pages. Do not store private notes in public CMS fields.
26
+ Read the page and relevant Page template, block types and reusable sections.
23
27
  Prefer existing native blocks and shared styles. Editors may create custom
24
28
  block types and shared Page templates when needed. Read `tr-custom-blocks`
25
29
  or `tr-page-template` only when that work is relevant. Before changing a
@@ -339,3 +339,27 @@ placeholder settings for theme decoration, preserving real featured images and
339
339
  explicit `show_image: false`. Compare actual footer link height plus gap, wrapping,
340
340
  focus and column breakpoints; compact navigation density is an intentional option.
341
341
  Verify native preview and static output on the agreed viewport boundaries.
342
+
343
+ ## Domain migration and redirect capacity
344
+
345
+ Use `guide_publishing_domain_change` when advertised by this installation. Read
346
+ its checklist, preview settings impact before replacing a candidate, preserve
347
+ verified content when only redirect hosts change, and obtain explicit approval
348
+ for the traffic summary and any interruption. A saved future hostname is not live.
349
+ Organization-admin `onboard_publishing_domain_zone` can create a pending zone and
350
+ review a DNS scan; copy records DNS only and verify the full inventory (especially
351
+ mail and DKIM) before the human registrar step. Never infer completeness from a scan.
352
+
353
+ Core 0.2.107 checks Pages capacity during dry-run and final upload:
354
+ 2,000 static, 100 dynamic, 1,000 characters per line, with generated slash variants
355
+ and hostname aliases included. `_redirects` has a bounded 10 MiB transport envelope;
356
+ `_headers` keeps 128 KiB. Source preflight counts configured rules conservatively;
357
+ final output excludes redirects shadowing built pages. Remove obsolete shadowed
358
+ rules at source when close to capacity. Never split `_redirects` into multiple files.
359
+ After publication, run `verify_migration_urls` against the agreed full inventory.
360
+ Do not delete old provider redirect lists without separate explicit approval.
361
+
362
+ Record render/media reuse and actual preparation duration separately. A cache
363
+ format, renderer, indexing or content change may require one full baseline build;
364
+ a synthetic cache test does not prove a customer site's timing target. Media
365
+ transfer timeouts retry the same frozen job with checkpoints, not a new publication.
@@ -0,0 +1,40 @@
1
+ # Internal Page notes
2
+
3
+ Keep internal sources, decisions, comments and dated history in this repository,
4
+ not in public CMS fields. The CMS remains the source of current content; notes
5
+ are editorial context, never a second content copy. Keep this workspace private
6
+ if notes contain private information. Never put credentials or signed URLs here.
7
+ Do not include pages/ in static output, content imports or public source bundles.
8
+
9
+ One file per stable Page ID: pages/<encoded-page-id>.md. The helper percent-encodes
10
+ IDs that contain path characters. URLs and titles can change without renaming notes.
11
+ Use a separate workspace/Git branch for another Site or Version; the generated
12
+ facts are bound to portal, Organization, Site and Version and reject mismatches.
13
+
14
+ Each note has review, next_review (YYYY-MM or YYYY-MM-DD), last_checked and
15
+ responsible metadata, followed by Purpose, Sources (including date and whether an
16
+ owner statement), Publishing considerations, Open questions and Log. Use dated
17
+ change, decision, comment and check entries; keep the newest first. Site-wide
18
+ rules belong in decisions.md, referenced from the relevant notes.
19
+
20
+ Choose a review cadence for the project, for example quarterly, annual, event-led
21
+ or when source facts change. There is no default season or automatic review date.
22
+ Refreshing facts never changes last_checked or next_review. Set those only after
23
+ an actual review. index.md sorts scheduled reviews first, then unscheduled Pages.
24
+
25
+ The generated block contains saved CMS metadata and candidate links from the saved
26
+ body. It does not inspect template/partial links, JavaScript or final rendering;
27
+ query-bearing URLs are omitted to avoid copying credentials. Check the rendered
28
+ Page and every external link during review. Sources, historical import statistics
29
+ and their dates belong in the human sections, not invented by the script.
30
+
31
+ Run scripts/typeroll/page_notes.py --dry-run, inspect, then omit --dry-run to refresh.
32
+ --migration-inventory optionally includes recorded redirect sources. Run from the
33
+ workspace root with a privately injected TYPEROLL_API_KEY. No CMS writes occur.
34
+ Only the generated markers and index.md are refreshed; malformed markers fail
35
+ without overwriting notes. Reconcile existing notes manually before adopting the
36
+ new marker/scope format; do not replace an established project's editorial history.
37
+
38
+ Notes without a Page are reported, never deleted automatically. Confirm deletion
39
+ and any redirect, add a final log entry, then move the note to pages/archived/.
40
+ Do not archive just because an API call failed or access changed.
@@ -50,3 +50,10 @@ indexing have distinct effects but do not require repeated approval if covered.
50
50
  updates, future-host candidates and explicit traffic approval.
51
51
  - [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.
52
52
  - [ ] Review owner facts and dates against the chosen preserve/renew text policy.
53
+
54
+ ## Internal Page notes
55
+
56
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
57
+ read before edits, update afterward, check external links at each editorial review,
58
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
59
+ generated facts never counts as a completed review. Keep notes out of public output.
@@ -109,3 +109,10 @@ uploading or changing CMS content. Missing CMYK profiles, transparency, multiple
109
109
  frames and existing output files stop with an error. `--assume-srgb` is only for
110
110
  reviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.
111
111
  See workflows/source-media.md for archive verification and original preservation.
112
+
113
+ ## Internal Page notes
114
+
115
+ `page_notes.py --dry-run` previews local note creation/fact refresh and index updates.
116
+ Omit --dry-run to write local files; --migration-inventory adds recorded redirect
117
+ sources. It never writes to the CMS, marks a Page reviewed or deletes notes. Read
118
+ pages/README.md and workflows/page-notes.md before first use.
@@ -0,0 +1,144 @@
1
+ """Refresh repository-only Page facts; never edit CMS content or human notes.
2
+
3
+ Run from the workspace root. --dry-run previews local changes. Optional
4
+ --migration-inventory adds saved migration URL facts. Credentials use trlib.
5
+ """
6
+ import argparse
7
+ from datetime import date
8
+ import hashlib
9
+ from html.parser import HTMLParser
10
+ import json
11
+ from pathlib import Path
12
+ import re
13
+ from urllib.parse import quote, urlsplit
14
+ import trlib
15
+
16
+ START = '<!-- generated:start (page_notes.py; do not edit by hand) -->'
17
+ END = '<!-- generated:end -->'
18
+
19
+
20
+ def filename(page_id):
21
+ if not isinstance(page_id, str) or not page_id or page_id.lower() in ('readme', 'index', 'archived', '.', '..'):
22
+ raise ValueError('Page ID is empty or conflicts with a reserved notes path')
23
+ return quote(page_id, safe='-_') + '.md'
24
+
25
+
26
+ def cell(value):
27
+ return str(value if value is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('|', '&#124;').replace('\n', ' ').replace('\r', ' ')
28
+
29
+
30
+ class Links(HTMLParser):
31
+ def __init__(self):
32
+ super().__init__()
33
+ self.urls = set()
34
+
35
+ def handle_starttag(self, tag, attrs):
36
+ if tag == 'a':
37
+ self.urls.update(v for k, v in attrs if k == 'href' and v)
38
+
39
+
40
+ def body_links(page, blocks):
41
+ parser = Links()
42
+ parser.feed(page.get('html_content') or '')
43
+
44
+ def visit(value, key=''):
45
+ if isinstance(value, dict):
46
+ for k, v in value.items():
47
+ visit(v, k)
48
+ elif isinstance(value, list):
49
+ for v in value:
50
+ visit(v, key)
51
+ elif isinstance(value, str):
52
+ if key in ('href', 'link', 'url', 'button_url', 'link_url'):
53
+ parser.urls.add(value)
54
+ if '<a' in value.lower():
55
+ parser.feed(value)
56
+ for block in trlib.walk(blocks):
57
+ visit(block.get('data') or {})
58
+ # Do not persist signed preview links or query credentials in repository notes.
59
+ return sorted(u for u in parser.urls if not urlsplit(u).query and not urlsplit(u).username
60
+ and urlsplit(u).scheme in ('', 'http', 'https'))
61
+
62
+
63
+ def scope(config):
64
+ return hashlib.sha256(json.dumps([config[k] for k in ('portal', 'organization_id', 'site_id', 'version')]).encode()).hexdigest()
65
+
66
+
67
+ def refresh(root, config, pages, links, migration=(), dry_run=False):
68
+ """Read all inputs before writing. Preserve every byte outside owned markers."""
69
+ out = root / 'pages'
70
+ if out.is_symlink():
71
+ raise ValueError('The notes directory must not be a symlink')
72
+ names = [filename(p['id']) for p in pages]
73
+ if len({n.lower() for n in names}) != len(names):
74
+ raise ValueError('Page IDs collide on a case-insensitive filesystem')
75
+ identity = '<!-- scope: ' + scope(config) + ' -->'
76
+ planned, rows = {}, []
77
+ for p, name in zip(pages, names):
78
+ file = out / name
79
+ if file.is_symlink():
80
+ raise ValueError('A note must not be a symlink')
81
+ text = file.read_text() if file.exists() else (
82
+ '---\nreview: unset\nnext_review: \nlast_checked: \nresponsible: \n---\n\n# ' + cell(p['title']) + '\n\n'
83
+ + START + '\n' + identity + '\n' + END
84
+ + '\n\n## Purpose\n\nTODO\n\n## Sources\n\nTODO\n\n## Publishing considerations\n\nTODO\n\n'
85
+ '## Open questions\n\n- Set a review cadence and next review date.\n\n## Log\n\n- '
86
+ + date.today().isoformat() + ' · comment · Note created; editorial review has not been performed.\n')
87
+ if text.count(START) != 1 or text.count(END) != 1 or text.index(START) >= text.index(END):
88
+ raise ValueError('Missing or ambiguous generated markers in ' + name + '; reconcile manually')
89
+ old = text.split(START)[1].split(END)[0]
90
+ if identity not in old:
91
+ raise ValueError('Note scope differs from the selected Organization/Site/Version: ' + name)
92
+ facts = [START, identity, '', '| Fact | Value |', '| --- | --- |']
93
+ for label, value in [('Page ID', p['id']), ('URL', p.get('url')), ('Title', p['title']),
94
+ ('Status', p.get('status')), ('Content type', p.get('content_type')),
95
+ ('Template', p.get('template')), ('Last saved', p.get('date_updated'))]:
96
+ facts.append('| ' + label + ' | ' + cell(value) + ' |')
97
+ aliases = [m['path'] for m in migration if m.get('status') == 'redirected' and m.get('target', '').rstrip('/') == p.get('url', '').rstrip('/')]
98
+ if aliases:
99
+ facts.append('| Recorded old URLs | ' + cell(', '.join(sorted(aliases))) + ' |')
100
+ facts += ['', 'Saved body link candidates (not a complete rendered-link audit; query URLs omitted):', '']
101
+ facts += ['- ' + cell(u) for u in links.get(p['id'], [])] or ['- None found.']
102
+ facts += ['', END]
103
+ new = text[:text.index(START)] + '\n'.join(facts) + text[text.index(END) + len(END):]
104
+ if new != text:
105
+ planned[file] = new
106
+ elif not file.exists():
107
+ planned[file] = new
108
+ fm = dict(re.findall(r'^(review|next_review|last_checked|responsible):[ \t]*([^\n]*)', new.split('---', 2)[1], re.M)) if new.startswith('---\n') else {}
109
+ nxt = fm.get('next_review', '').strip()
110
+ if nxt and not re.fullmatch(r'\d{4}-\d{2}(?:-\d{2})?', nxt):
111
+ raise ValueError('Use YYYY-MM or YYYY-MM-DD for next_review in ' + name)
112
+ rows.append((nxt or '9999', name, '| ' + (nxt or 'Not scheduled') + ' | [' + cell(p['title']).replace('[', '&#91;').replace(']', '&#93;') + '](' + name + ') | ' + cell(fm.get('review', 'unset')) + ' | ' + cell(fm.get('last_checked', '')) + ' |'))
113
+ index = out / 'index.md'
114
+ if index.is_symlink():
115
+ raise ValueError('The notes index must not be a symlink')
116
+ index_text = '# Page notes index\n\nGenerated from local notes; dates are editorial decisions, not CMS save dates.\n\n| Next review | Page | Cadence | Last checked |\n| --- | --- | --- | --- |\n' + '\n'.join(r[2] for r in sorted(rows)) + '\n'
117
+ if not index.exists() or index.read_text() != index_text:
118
+ planned[index] = index_text
119
+ orphans = sorted(f.name for f in out.glob('*.md') if f.name not in {*names, 'README.md', 'index.md'})
120
+ if not dry_run:
121
+ out.mkdir(parents=True, exist_ok=True)
122
+ for file, text in planned.items():
123
+ file.write_text(text)
124
+ return {'changed': [str(f.relative_to(root)) for f in planned], 'notes_without_page': orphans, 'dry_run': dry_run}
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument('--dry-run', action='store_true')
130
+ parser.add_argument('--migration-inventory', action='store_true')
131
+ args = parser.parse_args()
132
+ root = Path.cwd()
133
+ if not (root / 'typeroll.json').is_file():
134
+ parser.error('Run from the workspace root containing typeroll.json')
135
+ config = trlib.workspace()
136
+ # list_pages(full=True) reads saved content, unlike read_page's draft overlay.
137
+ pages = list(trlib.all_pages(full=True))
138
+ links = {p['id']: body_links(p, p.get('blocks') or []) for p in pages}
139
+ migration = list(trlib.all_migration_urls()) if args.migration_inventory else []
140
+ print(json.dumps(refresh(root, config, pages, links, migration, args.dry_run), indent=2))
141
+
142
+
143
+ if __name__ == '__main__':
144
+ main()
@@ -155,10 +155,10 @@ def walk(blocks):
155
155
  yield from walk(slot)
156
156
 
157
157
 
158
- def all_pages():
158
+ def all_pages(full=False):
159
159
  cursor, seen = None, set()
160
160
  while True:
161
- args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
161
+ args = {'limit': 200, **({'full': True} if full else {}), **({'cursor': cursor} if cursor else {})}
162
162
  result = call('list_pages', args)
163
163
  yield from result['pages']
164
164
  cursor = result.get('next_cursor')
@@ -127,3 +127,10 @@ recurring-event wording only when a source supports recurrence. Flag uncertain
127
127
  facts for review or keep new Pages in draft; missing evidence does not prove that
128
128
  an event stopped. Link to the actual organiser when relevant. Upload approved
129
129
  photos with SHA-256 verification and review focal points at mobile/desktop widths.
130
+
131
+ ## Internal Page notes
132
+
133
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
134
+ read before edits, update afterward, check external links at each editorial review,
135
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
136
+ generated facts never counts as a completed review. Keep notes out of public output.
@@ -0,0 +1,20 @@
1
+ # Page notes and editorial review
2
+
3
+ 1. During migration or creation, create pages/<page-id>.md using pages/README.md.
4
+ Record sources, preservation choices, owner decisions and open questions.
5
+ 2. Before editing a Page, read its note and relevant decisions.md entries. Missing
6
+ notes mean missing context, not permission to invent facts. Read current CMS
7
+ content too; do not overwrite newer edits from repository snapshots.
8
+ 3. After an authorized edit, append a dated change/decision/comment entry, update
9
+ sources and open questions, and refresh generated facts with page_notes.py.
10
+ Saving or refreshing is not editorial review and must not advance last_checked.
11
+ 4. At each review, inspect the current rendered Page and follow every external
12
+ link, including template links. Verify destination and relevance, not just HTTP
13
+ status. Record redirects, failures and uncertain checks; propose corrections
14
+ instead of inventing replacements. Record actual review date and next review.
15
+ 5. For a confirmed removed Page, log deletion, replacement/redirect and archive
16
+ location, then move its note into pages/archived/. Keep its history in Git.
17
+
18
+ Use index.md as a review queue; no scheduler or mail is enabled by this routine.
19
+ Publication and DNS changes still require their normal authorization. These notes
20
+ are repository-only; no new MCP Page-notes fields or portal storage are implied.