@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.
- package/dist/bundled-content.js +9 -6
- package/dist/compact-tools.js +2 -2
- package/dist/server.js +4 -2
- package/dist/tools/domain.js +21 -0
- package/dist/tools/settings.js +3 -1
- package/dist/version.js +1 -1
- package/dist/workspace.js +3 -1
- package/package.json +1 -1
- package/skills/tr-edit-site.md +5 -1
- package/skills/tr-migration-evidence.md +24 -0
- package/workspace-templates/pages/README.md +40 -0
- package/workspace-templates/qa/checklist.md +7 -0
- package/workspace-templates/scripts/README.md +7 -0
- package/workspace-templates/scripts/typeroll/page_notes.py +144 -0
- package/workspace-templates/scripts/typeroll/trlib.py +2 -2
- package/workspace-templates/workflows/migration.md +7 -0
- package/workspace-templates/workflows/page-notes.md +20 -0
package/dist/compact-tools.js
CHANGED
|
@@ -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).
|
|
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 === '
|
|
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).
|
|
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
|
}
|
package/dist/tools/domain.js
CHANGED
|
@@ -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']),
|
package/dist/tools/settings.js
CHANGED
|
@@ -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
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
package/skills/tr-edit-site.md
CHANGED
|
@@ -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.
|
|
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('&', '&').replace('<', '<').replace('>', '>').replace('|', '|').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('[', '[').replace(']', ']') + '](' + 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.
|