Metadata-Version: 2.4
Name: formwork-sp
Version: 0.13.0
Summary: Declarative SharePoint modern pages: declare, compile, preview, apply; and copy.
Author: Shaun Eccles-Smith
License: MIT
Requires-Python: >=3.15
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: dbml-sharepoint<0.27,>=0.23.0
Requires-Dist: jinja2>=3.1
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff>=0.17.0; extra == "dev"
Requires-Dist: pyrefly>=1.3.2; extra == "dev"
Dynamic: license-file

<h1>
<picture>
  <source media="(prefers-color-scheme: dark)"
    srcset="https://raw.githubusercontent.com/firmfooting/branding/main/lockups/formwork_dark.svg">
  <img alt="formwork, by firmfooting"
    src="https://raw.githubusercontent.com/firmfooting/branding/main/lockups/formwork.svg"
    height="56">
</picture>
</h1>

Declarative SharePoint modern pages: write the page as YAML, preview it offline,
and pour it in from the browser console.

[![CI](https://img.shields.io/github/actions/workflow/status/firmfooting/formwork/ci.yml?branch=main&label=CI)](https://github.com/firmfooting/formwork/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/formwork-sp?color=2A6B64)](https://pypi.org/project/formwork-sp/)
[![Python](https://img.shields.io/badge/python-3.15%2B-2A6B64)](https://github.com/firmfooting/formwork/blob/main/pyproject.toml)
[![Licence](https://img.shields.io/badge/licence-MIT-2A6B64)](https://github.com/firmfooting/formwork/blob/main/LICENSE)

formwork is for SharePoint site owners and M365 operators who build the same
modern pages again and again. You declare a page in YAML, compile it against
the site's own component catalogue, preview it offline, and pour it in from
the browser console. It can also copy an existing page from one site to
another.

The name is the trade: formwork is the mould you build once and reuse for many
identical pours. Build a page once (news, quick links, document library,
whatever your layout is), then reuse the mould across teams, sites, or tenants.

No admin consent, no app registration. The paste-ins run in your own browser
session over same-origin REST, so they can only read and write what you can
already read and write.

## At a glance

Authoring a page: discover the site once, then compile and apply as often as
you like.

```text
 target site                            your machine                        target site  
┌────────────┐ gen discover ┌─────────────────────────────────┐ gen apply  ┌────────────┐
│ DevTools   │ ──────────>  │ page.yaml ──compile──> payload  │ ────────>  │ DevTools   │
│ console    │  discovery   │     └─────preview───> HTML      │  paste-in  │ console    │
└────────────┘              └─────────────────────────────────┘            └────────────┘
```

Copying a page: extract it from the source site, rewrite its site-bound
values, apply it on the target.

```text
 source page                            your machine                        target site  
┌────────────┐ gen extract  ┌─────────────────────────────────┐ gen apply  ┌────────────┐
│ DevTools   │ ──────────>  │ bundle ──inspect──> report      │ ────────>  │ DevTools   │
│ console    │    bundle    │   └─process + mapping─> payload │  paste-in  │ console    │
└────────────┘              └─────────────────────────────────┘            └────────────┘
```

Each browser step is a paste-in: a script `formwork gen` prints, which you
run in the DevTools console of a modern page on the site. The paste-in does
the SharePoint work; discover and extract end by downloading their result.

## Contents

- [Install](#install)
- [Quickstart](#quickstart)
- [Commands](#commands)
- [The authoring workflow](#the-authoring-workflow)
- [Writing the spec](#writing-the-spec)
- [Multi-page](#multi-page)
- [Copying a page](#copying-a-page)
- [Reading a whole site](#reading-a-whole-site)
- [The canvas contract](#the-canvas-contract)
- [Measured SharePoint behaviour](#measured-sharepoint-behaviour)
- [The findings registry](#the-findings-registry)
- [Limits](#limits)
- [Contributing](#contributing)
- [Licence](#licence)

## Install

formwork needs Python 3.15 or later. It publishes to PyPI as `formwork-sp`,
because the name `formwork` there belongs to an unrelated project. The import
package and the `formwork` command keep the name `formwork`.

```sh
pipx install formwork-sp
```

`pip install formwork-sp` works too. Each release also attaches its wheel and
sdist to this repository's
[GitHub Releases](https://github.com/firmfooting/formwork/releases) page, if
you would rather pin a URL. Pinning a known release is safer than `latest`;
bump the version as releases land.

Either way you get the `formwork` command, listed under [Commands](#commands).
One caveat: `formwork gen findprobe` needs the findings registry,
`FINDINGS.md`, which lives in this repository and is not shipped in the
package. Run it from a checkout, or pass `--findings` with a copy. Everything
else runs anywhere.

## Quickstart

The shortest path from nothing to a poured page. Every paste-in must run from
a modern page of the site, one whose URL is under `.../SitePages/`.

1. Install: `pipx install formwork-sp`.

2. Discover the site. Print the discover paste-in, run it in the DevTools
   console on the target site, and keep the `formwork-discovery.json` it
   downloads:

   ```sh
   formwork gen discover > discover.js
   ```

3. Write `page.yaml`:

   ```yaml
   page: Team home
   sections:
     - type: two-thirds
       parts:
         - component: NewsWebPart
         - text: |
             ## Welcome

             Start here.
           column: 2
   ```

4. Check it offline, then compile it:

   ```sh
   formwork preview page.yaml --out preview.html
   formwork compile page.yaml formwork-discovery.json
   ```

5. Print the apply paste-in and run it in the console on the same site:

   ```sh
   formwork gen apply formwork-payload.json --name "Team home" > apply.js
   ```

   It creates the page, writes the canvas and checks that SharePoint stored
   exactly what was sent.

Each step is explained under [The authoring workflow](#the-authoring-workflow).

## Commands

| Command | What it does | Options |
| --- | --- | --- |
| `formwork gen discover` | Prints the discover paste-in, which downloads `formwork-discovery.json`. | none |
| `formwork gen apply PAYLOAD` | Prints the apply paste-in with the payload embedded. | `--name TITLE` (default: the payload's title), `--promoted-state 0` (site page, the default) or `1` (news post) |
| `formwork gen extract` | Prints the extract paste-in, which downloads `formwork-bundle.json`. | `--all` prints the paste-in that downloads every page as one `formwork-bundles.json` |
| `formwork gen inventory` | Prints the inventory paste-in, which downloads `formwork-inventory.json`. Read-only. | none |
| `formwork gen nav NAV_YAML` | Prints the paste-in that adds a `nav.yaml`'s quick launch groups and links to the site, then downloads `formwork-nav.json` (`added`, `kept`, `repointed`, `removed`, `skipped`, `missing`, `final`). A node whose title exists with another link is skipped, never repointed. | `--site URL` (required; the script refuses any other site), `--remove-unmanaged` (also delete top-level nodes the spec does not list) |
| `formwork gen site SITE_YAML` | Prints the paste-in that sets a site's logo, header and footer from a `site.yaml` (`logo`, `header: {layout, colour}`, `footer: {enabled, layout, colour}`; `logo: asset:<name>` needs `--assets`), then downloads `formwork-site.json`. The logo is linked, not copied; the script reads `SiteLogoUrl` back and fails with a clear message if SharePoint did not keep it. Header and footer values the spec omits are read and sent back unchanged. | `--site URL` (required; the script refuses any other site), `--assets FILE` |
| `formwork gen findprobe` | Prints the re-probe paste-in for the findings registry. | `--findings FILE` (default `FINDINGS.md`) |
| `formwork components DISCOVERY` | Lists the placeable components in a discovery document. | `--json` |
| `formwork compile SPEC DISCOVERY` | Compiles one spec to an apply payload. | `--out` (default `formwork-payload.json`), `--findings FILE`, `--findings-max-age DAYS` (default 90), `--vars FILE`, `--set NAME=VALUE`, `--assets FILE` |
| `formwork compile-pages SPECS DISCOVERY` | Compiles a directory or glob of specs: one payload each, plus `formwork-pages.json`. | `--out-dir` (default `.`), `--findings FILE`, `--findings-max-age DAYS`, `--vars FILE`, `--set NAME=VALUE`, `--assets FILE` |
| `formwork preview SPEC` | Renders a spec as standalone HTML. No SharePoint calls. | `--discovery FILE`, `--out` (default stdout), `--vars FILE`, `--set NAME=VALUE`, `--assets FILE` |
| `formwork assets check SPECS` | Fails when a spec names an `asset:` missing from the catalogue. Offline. | `--assets FILE` (required) |
| `formwork inspect BUNDLE` | Lists the site-bound values in an extracted page, or in every page of a bundles file. | `--json` |
| `formwork process BUNDLE` | Rewrites a bundle for the target site and emits an apply payload. | `--page NAME` (which page of a bundles file), `--mapping FILE`, `--page-name TITLE`, `--out` (default `formwork-payload.json`) |
| `formwork --version` | Prints the version. | none |

The `gen` commands print the paste-in to stdout; redirect it to a file or copy
it from the terminal.

## The authoring workflow

The five steps behind the [Quickstart](#quickstart).

### 1. Discover

Discover the site once. `formwork gen discover` prints a paste-in that
enumerates every placeable component via `GetClientSideWebParts` (73 on a
stock team site, 285 including hidden and extension components). It then:

- creates a scratch page;
- places one control per component across one-, two- and three-column
  sections;
- adds the text and styling probes described under
  [Measured SharePoint behaviour](#measured-sharepoint-behaviour);
- saves, and reads back what SharePoint persisted;
- recycles the scratch page;
- downloads `formwork-discovery.json`: the site's component catalogue plus
  the measurements the compiler relies on.

`formwork components formwork-discovery.json` lists what it found.

The paste-in also carries the web-part property, one-third layout and
list-binding probes under additive keys. The compiler reads them through the
catalogue for a part's `properties`, a section's columns and a part's `bind`.

For the list bindings it creates two containers of its own, the custom list
"Formwork Probe Source" and the document library "Formwork Probe Docs", and
recycles both after the scratch page. A refused create or recycle is recorded
in the document rather than failing the run, so check the console line if
either name already exists on the site.

### 2. Declare

Declare the page in `page.yaml`: sections, columns, parts, text, and the
emphasis of a part. See [Writing the spec](#writing-the-spec).

### 3. Compile

`formwork compile page.yaml formwork-discovery.json`:

- resolves every component against the live catalogue by alias or title;
- emits the section geometry (see [Sections and parts](#sections-and-parts)
  for the `sectionFactor` of each section type);
- applies property overrides;
- compiles text parts to text controls;
- writes `formwork-payload.json`.

It prints one line per placed part. An unknown or hidden component refuses to
compile: nothing is placed that the site did not declare placeable.

When `FINDINGS.md` is in the working directory (or named with `--findings`),
compile also checks the age of every measurement the spec relies on; see
[The findings registry](#the-findings-registry).

### 4. Preview

Preview at any point, catalogue or not: `formwork preview page.yaml --out
preview.html` renders the spec as a standalone HTML page. Sections come in
order, columns at their factors on a 12-column grid, text parts inline, and
other parts as titled placeholder cards. Pass `--discovery` to show the
catalogue's titles and descriptions.

No SharePoint calls, no network. The preview validates the spec exactly as
the compiler does, so a spec that previews will compile as far as the spec
itself is concerned. With `--discovery`, a hidden or extension component, or
a title more than one component carries, is refused as compile refuses it. A
name the catalogue lacks is shown as unresolved rather than refused.

The preview is the one place a spec's HTML runs in your browser without
SharePoint's sanitiser in front of it, so it carries its own guard. A
Content-Security-Policy blocks script, network requests, `<base>` and forms.
Text parts render through an allowlist of the editor's own elements (`p`,
`h1` to `h4`, lists, `strong`, `b`, `em`, `br`, `mark`, `span` and `a`) with
`style`, `class` and safe `href` attributes. Anything else is left out of
the preview and named on the part.

### 5. Apply

`formwork gen apply formwork-payload.json --name "Team home"` prints the
second paste-in with the payload embedded. Run it from the console on any
modern page of the target site. It creates the page through the `sitepages`
API, writes the canvas with an item MERGE under `If-Match`, then reads back
what SharePoint stored and compares it control by control. Three server
rewrites verify and are named in the log (`verified (normalised: ...)`): text
character re-encoding, same-host link stripping and the Image part's
server-filled fields (`page.canvas.server-normalisation`). Anything else, a
dropped link first, fails.

`--file-name Add-a-meeting.aspx` asks for the page at that leaf. When no page
holds it, the page is created there through `Files/AddTemplateFile` and needs
no rename; when one does (a replacement), the page is created at a random leaf
and the page holding the leaf is left untouched. The `[formwork] page {...}`
line says which: `placement` is `at-leaf`, `leaf-held` or `server-assigned` (no
`--file-name`), and `renameNeeded` is false only for `at-leaf`, so a caller
skips its move then (`page.page.create-at-leaf`,
[tests/fixtures/apply-leaf.json](tests/fixtures/apply-leaf.json)).

The page title is `--name` when you pass it. Otherwise it is the payload's
title: the spec's `page:`, or for a copy the bundle's title or
`process --page-name`. The canvas MERGE writes the title, and the script
prints the title SharePoint stored.

If the bytes differ the script throws, naming the new page's item id and URL.
The page exists with what SharePoint stored, and formwork does not recycle
it: keep it or recycle it yourself. Compile emits text HTML in the stored
spelling (`:` as `&#58;`, see [Text parts](#text-parts)), so that rewrite does
not trip the check.

### The provenance guard

The payload carries a `provenance` stamp written by `compile`. It holds:

- the formwork version;
- the SHA-256 of the discovery file's bytes;
- the discovery's web id, web URL and timestamp;
- the spec name and the compile time.

`compile` prints it on the line after "payload written". When the compile
used the template layer, the stamp also names the vars file and its SHA-256,
a page's own `vars:` file and its SHA-256, and the sorted `--set` key names,
never their values (see [Templated specs](#templated-specs)).

Before it creates anything, the apply script reads the web it runs on. It
refuses, listing the stamp's value beside the observed one, when:

- the web id or URL differs;
- the payload's formwork is newer than the script's own version;
- there is no stamp at all (a payload from before 0.5.0).

To apply a payload on a different web on purpose, set
`FORCE_SITE_MISMATCH = true` at the top of the script. Nothing overrides the
version check.

`--promoted-state 1` sends `PromotedState` inside the create body, where it
was measured persisting (beside the Article layout,
`page.promoted-state.create-is-effective`). The post-create MERGE the script
used before 0.5.0 read back 0 on the Home layout. The script prints the stored
value beside the verify result and warns if it differs.

## Writing the spec

```yaml
page: Team demo home
sections:
  - type: two-thirds
    parts:
      - component: NewsWebPart
        emphasis: 2
      - text: |
          ## Welcome

          The **team** page. See the [handbook](/sites/T/SitePages/Handbook.aspx).
        column: 2
  - type: one
    parts:
      - component: EventsWebPart
        displayTitle: What's on
        properties: {layoutId: "Compact"}
      - text: "<p>Raw <em>HTML</em> is fine too.</p>"
```

### Sections and parts

A section has a `type` and `parts`. The type sets the columns and the
`sectionFactor` each column is written with:

| `type` | columns | `sectionFactor` |
| --- | --- | --- |
| `one` | 1 | 12 |
| `two` | 2 | 6, 6 |
| `three` | 3 | 4, 4, 4 |
| `two-thirds` | 2 | 8, 4 |
| `one-third` | 2 | 4, 8 |

A part is either a component or a text block:

| key | on | meaning |
| --- | --- | --- |
| `component` | component part | The component, by alias or by catalogue title. |
| `properties` | component part | Property overrides, optional. |
| `displayTitle` | component part | The part's title, optional. |
| `emphasis` | component part | The per-control emphasis block; see [Emphasis](#emphasis). |
| `tile` | component part | Labels, links and images of Quick links, Button, Editorial card or Image; see [Tile parts](#tile-parts). |
| `bind` | component part | The list a list or library part shows; see [Binding lists](#binding-lists). |
| `text` | text part | Markdown or HTML; see [Text parts](#text-parts). |
| `format` | text part | `html` to treat the text as HTML. |
| `column` | any part | The column, 1-based. Defaults to the first column. |

### Text parts

Text is HTML when it starts with `<` or carries `format: html`. Otherwise it
is a small markdown subset:

- `#` to `####` headings, each a block of its own (a blank line after it);
- paragraphs;
- `-`/`*` and `1.` lists;
- `**bold**`, `*italic*`/`_italic_`;
- `[text](url)` links (http, https, mailto or server-relative). A bare
  relative target such as `Page.aspx` is refused: SharePoint stores it
  without its href.

Anything else (blockquotes, tables, code, images, rules, nested lists, raw
tags) is refused with the line number rather than guessed at. On both paths
these are refused: `<script>`, `<style>`, `<iframe>`, `<object>`, `<embed>`,
`<textarea>`, `<title>`, `<svg>`, `<meta>`, `<base>` and `<form>`, comments,
inline `on*=` handlers, `formaction`, `javascript:`/`data:`/`vbscript:` URLs
in any URL attribute (`href`, `src`, `srcset`, `action`, `xlink:href`,
`poster`, `cite` and the rest), `data-sp-` attributes and control characters.

HTML is checked the way a browser reads it. Attributes are split on
whitespace, `/` or a closing quote, and their values are decoded before the
URL check. Text content is never decoded, so a paragraph that describes
`href="javascript:..."` in escaped form is legal. A body that ends inside an
unterminated tag is refused, because the canvas markup that follows would
close it.

Text parts compile only against a discovery document whose text probes
persisted with at most the `:` to `&#58;` rewrite (measured 2026-09-06, see
[Measured SharePoint behaviour](#measured-sharepoint-behaviour)). A document
without that measurement, or whose samples differ in any other way, refuses
to compile text parts and says why.

Compile emits the inner HTML in that stored spelling, every `:` as `&#58;`
(in a style attribute, an absolute `href`, running text). So the payload
carries what SharePoint will store, and apply's byte-for-byte read-back
holds. The compiled part record keeps the HTML as you wrote it.

### Styled text

Text parts take inline styling as plain HTML. Measured 2026-09-06
(`styling.styleSamples` in
[tests/fixtures/discovery.styling.json](tests/fixtures/discovery.styling.json),
a live discover run on a stock team site): seven samples written into text
controls came back from SharePoint with no change other than the `:` to
`&#58;` rewrite it applies to every text control's HTML.

| sample | HTML the part carries | stored |
| --- | --- | --- |
| `color` | `<p><span style="color:#a4262c;">colour by style</span></p>` | yes, `:` stored as `&#58;` |
| `font-size` | `<p><span style="font-size:24px;">size by style</span></p>` | yes, `:` stored as `&#58;` |
| `background` | `<p><span style="background-color:#fff100;">background by style</span></p>` | yes, `:` stored as `&#58;` |
| `styled-link` | `<p><a href="https://example.com/" style="color:#0078d4;text-decoration:underline;">styled link</a></p>` | yes, `:` stored as `&#58;` |
| `mark` | `<p>Text with a <mark>marked</mark> word.</p>` | yes, byte-identical |
| `block-align` | `<p style="text-align:center;">centred paragraph</p>` | yes, `:` stored as `&#58;` |
| `rte-classes` | `<p><span class="fontColorRed">colour by class</span>, <span class="fontSizeLarge">size by class</span>, <span class="highlightColorYellow">highlight by class</span></p>` | yes, byte-identical |

"Stored" is the bytes SharePoint returned for `CanvasContent1`. Whether the
text web part renders each style is a browser question the probe does not
answer (`rendering` in `styling.unmeasured`). The markdown subset cannot
express any of this; write such a part as HTML. `tests/test_styling_evidence.py`
keeps this table equal to the fixture's sample list.

### Emphasis

A component part may carry `emphasis`, the per-control block SharePoint uses
for a section's background swatch:

```yaml
- component: NewsWebPart
  emphasis: 2               # shorthand for {zoneEmphasis: 2}
```

`zoneEmphasis` must be an integer from 1 to 4. Measured 2026-09-06
(`styling.sectionSamples` in the fixture above): a web-part control sent with
`{zoneEmphasis: 2}` and one sent with `{zoneEmphasis: 3, formworkProbe: "unknown
key"}` were stored byte-for-byte through the same item MERGE the apply paste-in
uses, and read back intact. 1 and 4 are the editor's other two swatches and are
accepted on that basis alone.

Nothing else is accepted:

- an unknown key inside the block refuses (the probe shows unknown keys echo
  back, which proves survival, not meaning);
- any other value refuses;
- `emphasis` on a text part refuses, because every text control the probe
  read back carried `{}`.

A section may carry `emphasis` as well. It writes the same block on every
control of the section and gives the section one zone id, derived from the page
title and section index so a rebuild writes the same id. Measured on the
sandbox 2026-10-21: a section sent this way through the item MERGE apply uses
renders its swatch (text controls included). That supersedes the 2026-09-06
reading that the swatch needs a SavePage-established section. A part's own
`emphasis` inside an emphasised section refuses, since the section decides it.

### Collapsible sections

```yaml
- type: one
  collapsible: {title: "Before you start", expanded: false}   # expanded defaults to true
  parts:
    - text: "..."
```

Every control of the section carries `zoneGroupMetadata` (`type` 1,
`isExpanded`, `showDividerLine`, `iconAlignment: left`, `displayName`) and the
section's zone id. Measured on the sandbox 2026-10-21 through the apply path's
item MERGE: the header renders with its chevron, an expanded section shows its
body, a collapsed one hides it, and clicking the header toggles it. The collapse
renders with a null zone id too, so the id is the live shape rather than a
requirement. A collapsible section may be a multi-column step.

Columns are written as live pages write them: `zoneIndex` is the section,
`sectionIndex` the column and `controlIndex` the position within the column.
Written the other way (every column at the section's own index) a two-column
section rendered its columns stacked.

### Tile parts

Quick links, Button, Editorial card and Image keep their words, links and
pictures in the web part's `serverProcessedContent`, which `properties:`
cannot reach. A component part's `tile:` key writes them, in the key
spellings real pages store. Each component takes one shape and nothing else:

```yaml
- component: QuickLinksWebPart
  tile:
    title: Find it                    # optional; without it the title is switched off
    items:                            # one or more
      - label: Rosters
        url: https://contoso.example/rosters
        image: /sites/team/SiteAssets/rosters.png   # optional
- component: ButtonWebPart
  tile: {label: Log a request, url: https://contoso.example/request}   # url optional
- component: EditorialCardWebPart
  tile:
    headline: Learning hub            # written as <h2>, text escaped
    description: Guides and tips.     # optional, written as <p>
    url: https://contoso.example/learn   # optional call-to-action link
    image: /sites/team/SiteAssets/card.png   # optional
- component: ImageWebPart
  tile: {image: /sites/team/SiteAssets/banner.png, alt: A banner}   # alt optional
```

An image is a path or URL written as given: a site-relative path is stored as
written, not made absolute (`page.image.relative-source-is-accepted`). The
file is not checked, so a path to a file that does not exist stores and shows
as a broken picture. `tile` and `properties` may not set the same property
(`items`, `isTitleEnabled`, `imageSourceType`, `altText`, `fileName`,
`image`); the refusal says collision.

Measured on a sandbox site: each tile part is stored exactly as sent and every
label, link and image source reads back equal (`page.quick-links.items-persist`,
`page.button.label-and-link-persist`, `page.editorial-card.fields-persist`,
`page.image.source-persists`). The searchable-text mirror elements a page
saved in the editor carries are neither written nor needed: none appears
after the merge or after publish (`page.quick-links.mirror-is-derived`).
Stored is not rendered: no probe has looked at the picture.

### Brand assets

The catalogue file (`assets.yaml`) maps an asset's `AssetName` (lowercase
letters, digits and hyphens) to its library file: absolute `url`, `library`,
`folder`, `file_name`, `size`, `sha256`, `etag`, `pushed_sha256`, `alt_text`,
`image_role`, `status` and `published` (the file has an approved version). A
spec writes `image: asset:<name>` (or `logo:`) wherever an image path is
accepted, and `compile`, `compile-pages` and `preview` resolve it with
`--assets assets.yaml` to the file's URL, percent-encoded once. The URL links
to the library; nothing is copied. Without `--assets`, or for a name the
catalogue lacks, compile refuses and names the reference. An Image web part
tile with no `alt` of its own takes the library's AltText; a Decorative image
keeps the empty alt, and a spec's own `alt` always wins.

`formwork assets check SPECS --assets FILE` exits 1 for a missing name, a
Retired asset, an asset with no published version, a file name that is not
`AssetName` plus its extension, or a name outside lowercase letters, digits and
hyphens.

Keeping the catalogue current is three paste-ins:

- `formwork gen assets-pull --site URL --library NAME [--assets FILE]` (read-only,
  refuses on any other web) downloads `formwork-assets.json`: every file with its
  metadata, approval state, sha256 and, when changed, its bytes. `NAME` is the
  library's URL name. Run it as someone who can edit, so pending items are seen.
- `formwork assets record BUNDLE --assets FILE --mirror DIR` folds a bundle into
  the catalogue and the mirror directory, and lists what was added, changed or
  removed (a removed asset's mirror copy stays), files with unique permissions
  and files with no AssetName.
- `formwork gen assets-push --assets FILE --map MAP --mirror DIR` uploads the files
  named in a reviewed mapping as pending items and downloads
  `formwork-assets-pushed.json` for `assets record`. The mapping is
  `<name>: {file, AssetType, AssetOwner, AltText, ReviewDate, ...}` using the
  library's column names (write `ReviewDate` as text); it is checked against the
  library's rules first. Push sets the metadata, checks the file in, and never
  approves. It skips a name already in the library unless the library file's
  sha256 equals the catalogue's `pushed_sha256`.

### Binding lists

A list or library part (`ListWebPart`; `properties: {isDocumentLibrary:
true}` picks the library entry) binds in one of two forms:

```yaml
- component: ListWebPart
  bind:                               # by title: resolved when the page is applied
    list: Team actions
    view: Open actions                # optional
- component: ListWebPart
  bind:                               # by id: written as given
    listId: 3ea65699-0f63-4592-ba66-28d1a8b68968
    listUrl: Lists/Team actions
    viewId: e150a99e-f3e3-4c81-811f-882c8036c862
```

The id form needs the lists to exist when you write the spec. The title form
does not: compile writes placeholder tokens and a `bindings` row into the
payload, and the apply paste-in reads the target site's lists and views
(GET only) before it creates anything. It matches `list` and `view` by title,
doubling apostrophes and percent-encoding the filter, so `Owner's & Docs`
works. It refuses, naming the title, with nothing created when the web has no
list or view with that title or more than one (`page.list-binding.title-resolves-to-guid`,
`page.list-binding.view-name-resolves-to-guid`,
`page.list-binding.missing-list-is-refused`). Mixing the two forms, `view`
without `list`, or an empty title refuses at compile.

Because the ids come from the site the page is applied to, the same payload
applies to a site whose lists have different ids.

### Access links

A link URL in a text part (markdown or HTML), a Quick links item, a Button or
an Editorial card can name an access page by what it is for, and the apply
paste-in works out the address on the site it runs against:

- `fw:list-permissions:<list title>` opens the list's permissions page;
- `fw:check-permissions:<list title>` opens its check permissions page;
- `fw:group-members:<group name>` opens the group's members page.

Compile writes a placeholder and a `links` row into the payload. Apply reads
the web's lists (by title) and site groups (by name) before it creates
anything, with the same quoting rules as a title bind, and refuses with
nothing created when there is no match or more than one. Any other `fw:` kind
refuses at compile. In markdown the name may hold spaces and runs to the
closing `)`, so a name with `)` needs an HTML link.

### Templated specs

A spec file is rendered as a Jinja2 template *before* the YAML parser sees
it, so one page per site (or per environment) can come from one spec.
Rendering is opt-in: it happens only when the command carries `--vars` or
`--set`, or the spec itself carries a top-level `vars:` key. With no flags
the spec's bytes reach the parser unchanged, so a spec holding literal
`{{ ... }}` text keeps compiling as data.

```yaml
vars: vars/finance.yaml      # this page's own vars file, relative to the spec
page: Finance {{ env }}
sections:
  - parts:
      - component: NewsWebPart
      - text: Managed by {{ team }}
```

```sh
formwork compile-pages pages/ formwork-discovery.json --out-dir build/ \
    --vars shared.yaml --set env=Training
```

- `--vars FILE` is a YAML mapping of template variables, read with
  `safe_load` only: a vars file is data, never code.
- `--set NAME=VALUE` names one variable and overrides the vars file; repeat
  it, and later pairs win. `compile`, `compile-pages` and `preview` all take
  both flags.
- A page's own `vars:` key, at column 0 and at most one per spec, names a
  vars file relative to the spec file; it outranks the shared `--vars`.
- Precedence, highest first: `--set` > the page's `vars:` file > the shared
  `--vars`. A page vars file naming a key an explicit `--set` also names is
  refused rather than silently losing the override.
- Missing variables fail loudly. `StrictUndefined` means a name the template
  uses but nobody supplies is a compile error naming the variable and the
  template line, never an empty string baked into a page.
- Values are substituted as text, not escaped: `&`, `<`, `>`, `"` and `'`
  reach the spec verbatim, so a URL with a query string or a title with an
  apostrophe is fine. `--set` refuses a value containing a newline (multi-line
  values belong in the vars file), and every `--set` value is a string.
- Refusals name the file, the position and the variable; a vars file's values
  are never echoed into a message or into `formwork-pages.json`.

When a compile used the template layer, its provenance stamp records the vars
file name and the SHA-256 of its bytes, a page's `ownVarsFile` and its
SHA-256, and the sorted `--set` key names (`setKeys`), never the values. So
two payloads built from the same spec with different `--set` values are not
stamp-identical.

### What compile refuses

Every refusal names the part or section and the reason, and each reason
cites its measurement. Nothing in a spec is silently dropped.

| key | where | why it is refused |
| --- | --- | --- |
| an unknown or hidden `component` | part | Nothing is placed that the site did not declare placeable. |
| markdown outside the subset, or a refused tag or URL | text part | See [Text parts](#text-parts). |
| `emphasis` other than `zoneEmphasis` 1 to 4 | part | See [Emphasis](#emphasis). |
| `emphasis` | text part | Every text control the probe read back carried `{}`. |
| `bind` | text part | The probe bound `ListWebPart` only. |
| `tile` | text part | Tiles are web part content. |
| `tile` | a component other than Quick links, Button, Editorial card or Image | Only those four are measured. |
| an unknown, missing or non-string key inside `tile`, or an empty `items` | part | Each component takes one measured shape. |
| `bind` with `list` and `listId`, `view` without `list`, or an empty title | part | The two forms do not mix and a view belongs to a list. |
| `theme` | page | A web-level setting, not a page field. |
| `background`, `spacing` | section | The canvas shape for those is unmeasured, so they are refused rather than guessed at. |
| `navigation` | page | Unmeasured. |
| any other key the level does not take | page, section, part | A typo such as `colum: 2` would otherwise be lost. The refusal lists the keys that level takes. |

A page-level `navigation` key is refused because adding a page to the site
navigation is a navigation-node write, not a page save, and no `FINDINGS.md`
row `page.navigation.*` records one. The refusal names the discover lane that
would measure it.

## Multi-page

A site is several pages declared together. `formwork compile-pages` takes a
directory (or a glob) of specs and one discovery document, compiles every
`*.yaml` against that one document, and writes one payload per spec plus a
manifest, `formwork-pages.json`, that carries the run's provenance header.

```yaml
# pages/home.yaml
page: Team home
sections:
  - type: two-thirds
    parts:
      - component: NewsWebPart
      - text: |
          ## Welcome

          See the [news](/sites/T/SitePages/Team-news.aspx).
        column: 2
```

```yaml
# pages/news.yaml
page: Team news
sections:
  - type: one
    parts:
      - component: NewsWebPart
        properties: {layoutId: "List"}
```

```sh
formwork compile-pages pages/ formwork-discovery.json --out-dir build/
```

```text
compiled 2 of 2 pages against formwork-discovery.json (web 7d1e9b5c-3a2f-4c8e-9b0a-2f6d4e8c1a35, sha256 4f0c9a3e7b21): manifest build/formwork-pages.json
  ok    home.yaml -> home.payload.json (Team home, 2 parts)
  ok    news.yaml -> news.payload.json (Team news, 1 part)
```

Each page compiles alone against the same catalogue. One bad spec fails with
its own reason, the rest still build, the command exits 1 and the manifest
records every result.

The manifest header, `compiledWith`, names the formwork version, the SHA-256
of the discovery bytes, the web id and URL and the discovery's own timestamp
once for the run. Each page entry carries the spec, the title, the payload
name, the part count and any staleness warnings from `FINDINGS.md` (the same
ones `compile` prints, per page on stderr). Each payload also carries that
header plus its own spec name and the run's compile time as `provenance`: the
stamp the apply script checks (see [The provenance guard](#the-provenance-guard)).

What multi-page deliberately does not do:

- It writes one payload per spec, not one combined artefact. `formwork gen
  apply` takes one payload and creates one page, so each payload is applied
  in turn; two specs with the same stem refuse before anything is written.
- There is no link resolution. A text part may link to another page of the
  set (the href above), and the href is emitted as written. Which file name
  a created page is given is a page-state question: on the Home layout apply
  creates with, SharePoint ignores a `FileName` sent at create and assigns
  its own eight-character name (`page.page-state.filename-slug`, under
  [Measured SharePoint behaviour](#measured-sharepoint-behaviour)). The name
  is not known at compile time, so rewriting a link would be a guess.
- There is no transaction and no cross-page ordering. Pages compile in name
  order and are applied one paste-in at a time; nothing sequences them, and
  a page that fails to apply leaves the others as they are.
- `navigation` is refused at parse, per page, as in
  [What compile refuses](#what-compile-refuses): the write is unmeasured.

## Copying a page

The second workflow moves an existing page between sites.

1. **Extract.** `formwork gen extract` prints a paste-in. Run it from the
   console on the source page. It fetches the page item and the web and site
   identity and downloads `formwork-bundle.json`: page fields, the raw canvas
   markup (`CanvasContent1`) and source identity. A page in a Site Pages
   sub-folder is found by its full path and keeps its folder in the bundle's
   `pagePath`.

2. **Inspect.** `formwork inspect formwork-bundle.json` lists every
   site-bound value in the page's canvas, web part by web part: `baseUrl`
   links, `siteId`/`webId` properties, list ids and urls, searchable plain
   texts, and, detected but never rewritten, other `link` entries and
   `image` sources. Each is addressed by the web part's `instanceId`. The
   bundle carries no separate web-part list; the canvas is the source.

3. **Process.** Rewrite what your mapping resolves and report the rest:

   ```sh
   formwork process formwork-bundle.json \
     --mapping mapping.json --page-name "Team home" --out payload.json
   ```

   Unresolved values are never guessed. They are left exactly as extracted
   and reported, so a partial mapping degrades to "as extracted", not to a
   broken page.

4. **Apply.** `formwork gen apply payload.json --name "Team home"`, as in
   the authoring workflow. A process payload's stamp names the formwork
   version and the bundle but no discovery web (a copy is bound by its
   mapping), so the apply script runs its version check and prints that the
   site check does not apply.

### The mapping file

```json
{
  "baseUrl": "https://tenant.sharepoint.com/sites/target",
  "siteId": "00000000-0000-0000-0000-000000000000",
  "webId": "00000000-0000-0000-0000-000000000000",
  "lists": {
    "Document library": {
      "id": "00000000-0000-0000-0000-000000000000",
      "url": "/sites/target/Shared Documents",
      "webRelativeUrl": "Shared Documents",
      "viewId": "00000000-0000-0000-0000-000000000000"
    }
  },
  "textOverrides": {"Documents": "Team documents"}
}
```

`lists` is keyed by source web part title. `textOverrides` is keyed by the
exact source text. Everything you omit stays as extracted and is reported as
unresolved. Any console session on the target site can read
`/_api/web?$select=Id,Title,Url` and `/_api/site?$select=Id,Url` for the ids.

Links follow the site. Add `sourceUrl` (the source site url) next to
`baseUrl` (the target) and `process` rewrites every value under the source
site so the copy points at the target:

- `href` and `src` values in text web parts, server-relative
  (`/sites/source/SitePages/X.aspx`) or absolute;
- a web part's `links` entries other than `baseUrl`, such as a Quick links
  item's `items[n].sourceItem.url`;
- `imageSources` (image web part and Quick links images).

Links to other sites, sibling paths such as `/sites/sourcery` and external
urls stay as extracted. Without `sourceUrl` the `link` and `image` values are
only detected and reported. `pages` renames page files in the rewritten
links, for pages whose file name changes on apply:

```json
{"sourceUrl": "https://tenant.sharepoint.com/sites/source",
 "pages": {"Team-home.aspx": "Team-home-1.aspx"}}
```

Process re-serialises only the web parts whose values actually changed; a
mapping that restates an extracted value leaves that web part's bytes alone.

### What is copied

| copied | not copied |
| --- | --- |
| section structure, column widths | the content behind the web parts (news posts, list items, documents) |
| web part choices and properties: everything in the canvas | description and banner |
| the page title (the bundle's, or `--page-name`) | layout type: apply creates every page with the Home layout |
| | promoted state: set it with `gen apply --promoted-state`, not from the bundle |
| | page permissions, analytics, comments, version history |

The bundle records the source page's layout, promoted state, banner and
description among its page fields, but the payload carries only the title and
the canvas.

A News web part on the target shows the target's news, and a Document library
web part needs its `lists` mapping to point at a library that exists there.
Images referenced as `imageSources` stay pointed at the source site unless
the mapping sets `sourceUrl` (above).

## Reading a whole site

Two read-only paste-ins capture a site in one paste each. Both send GET
requests only, and run from any modern page under `.../SitePages/`.

**Inventory.** `formwork gen inventory` prints a paste-in that downloads
`formwork-inventory.json` (schema `formwork.inventory/v1`): the web's title,
URL and template; every non-hidden list with its base template, item count
and non-hidden writable columns; the quick launch and top navigation with
their children; the site groups, by title; and the Site Pages items with
their file name, title and modified date.

**Extract all.** `formwork gen extract --all` prints a paste-in that downloads
`formwork-bundles.json` (schema `formwork.bundles/v1`): one
`formwork.bundle/v1` entry per `.aspx` page in Site Pages, each with the same
fields as a single extract. `inspect` and `process` accept either file. For
a bundles file, `inspect` reports every page, and `process` needs
`--page Home.aspx` to pick one (a file of one page needs no flag):

```sh
formwork process formwork-bundles.json --page Home.aspx --out payload.json
```

## The canvas contract

A modern page's layout lives in `CanvasContent1` as HTML-encoded canvas
markup. Each control is a `div` whose `data-sp-controldata` and
`data-sp-webpartdata` attributes carry entity-escaped JSON; a text control
carries its HTML in a `data-sp-rte` child instead of web-part data.

formwork parses this markup and addresses each web part by its `instanceId`,
the per-control GUID. (The web part's `id` is the component type and repeats
when a page places the same part twice.)

Untouched controls stay byte-exact. A control is re-escaped only when a value
on it actually changed, in SharePoint's own escaping style (`&#123;`,
`&quot;`, `&#58;`), which is not what generic HTML escapers produce. That
holds on both paths: process rewrites the extracted canvas in place, control
by control, and compile builds its controls from the same model. Text HTML is
emitted with `:` as `&#58;`, the stored spelling. So what process or compile
emits is what apply's read-back compares against.

## Measured SharePoint behaviour

Every claim formwork makes about SharePoint was measured on the shauntestazure
sandbox; nothing here is inferred from documentation. Dates are measurement
dates, and the fixtures named are the evidence. [FINDINGS.md](FINDINGS.md)
holds the same claims as data, one row each; see
[The findings registry](#the-findings-registry).

### 2026-09-05: a live-captured team-site home page

Evidence: `tests/fixtures/collabhome.*`.

- The canvas is entity-escaped JSON inside HTML attributes, in the style
  above. The parser's round-trip of that page is byte-exact, including a
  re-render after every control is marked dirty.

### 2026-09-06: transport and page creation

Evidence: the v0.2.0 discover and apply runs.

- An unprefixed `/_api/...` from a page under `/sites/<name>/` resolves to the
  tenant root web, so every paste-in derives the site prefix from
  `location.pathname` and refuses to run anywhere but a page under
  `/SitePages/`.
- `contextinfo` is POST-only; GET is refused with 405. The digest it returns
  is reused for the lifetime it states (`FormDigestTimeoutSeconds`, less a
  minute), not fetched again for every write. A write refused with 403 while
  carrying the reused digest is sent once more with a fresh one.
- Site Pages is a document library: a list-item POST into it is refused and
  `Files/add` of an `.aspx` is 403. Pages are created through
  `/_api/sitepages/pages` and then written with an item MERGE under the real
  etag.
- MERGE stores `CanvasContent1` as sent for web-part controls: discover placed
  73 components and read 73 back byte-exact.

### Transport facts ported from dbml-sharepoint

Partials read 2026-09-06; the error-body finding is a live finding of
2026-07-24.

- A throttled browser session is not answered with 429: SharePoint redirects
  it to `/_layouts/15/throttle.htm`, which arrives as 406 because the calls
  ask for JSON. The paste-ins match the final URL, hold every request behind
  one gate, and retry honouring `Retry-After`, in seconds or as an HTTP date.
  A single wait is capped at five minutes, and the warning says when the
  server asked for more. After eight retries, the error says the request
  was throttled.
- The server's reason lives at `error.message.value` in the error body; every
  non-OK response is reported with it next to the operation and status.
- The `contextinfo` response is parsed step by step; the blind
  `.d.GetContextWebInformation.FormDigestValue` chain turned a server refusal
  into a TypeError.
- Apostrophes in OData string literals are doubled, and URI-encoded when used
  in a path segment.

### 2026-09-06: text and styling

Evidence: `tests/fixtures/discovery.styling.json`,
`tests/fixtures/savepage-section-emphasis.json`.

- Text controls (`controlType` 4, `editorType` CKEditor) sent through the
  item MERGE are stored byte-for-byte except that `:` in the inner HTML is
  rewritten as `&#58;`. Nine of nine samples: the two probe controls and the
  seven styled samples in the table above. All nine carried `emphasis: {}`
  both ways.
- Five one-control sections were stored byte-identical: `zoneEmphasis` 2,
  `zoneEmphasis` 3 with an unknown key echoed back, collapsible
  `zoneGroupMetadata`, full-width `sectionFactor` 0, vertical `layoutIndex` 2.
  Stored is not rendered.
- The page model refuses the HTML canvas: `SavePageAsDraft` with
  `CanvasContent1` set to the markup answered HTTP 500, "Unexpected character
  encountered while parsing value: <". The page model's `SavePage` takes a
  JSON array of controls whose positions carry `zoneId` GUIDs; the editor's
  own body for an emphasised page is the second fixture.
- Section emphasis takes effect once a section is established through
  SavePage. The item-merged probe page is recorded as "emphasis dropped", the
  editor-authored page keeps `zoneEmphasis` 3 on every control, and a control
  merged into a SavePage-established section persisted `zoneEmphasis` 3.
- Unmeasured, and therefore refused or undocumented rather than guessed:
  `theme` (a web-level setting; no page save can set it),
  `section-background` (canvas shape unknown), `section-spacing` (no known
  key), `rendering` (the probe reads persisted bytes only), the rules for
  generating `zoneId` GUIDs, vertical and collapsible sections through the
  SavePage path, and section background images.

### 2026-09-06: page state and identity

Evidence: `tests/fixtures/discovery.pagestate.json#pageState`, from the lane
`formwork gen discover` and `formwork gen findprobe` share
(`_probe_pagestate.js.j2`). The run is dated 2026-09-06 in UTC, the
registry's convention; it was the morning of 2026-09-07 in Australia.

Each sample is on a scratch page of the lane's own, requested and persisted
by page id, and recycled before the download. The results, as `FINDINGS.md`
records them:

| question | result | check-id |
| --- | --- | --- |
| An explicit `FileName` at create, and one carrying spaces and capitals, on the Home layout | Ignored both times; the server assigns an eight-character name. Whether Article honours `FileName` is unmeasured. | `page.page-state.filename-slug` |
| `Description` and `BannerImageUrl` (as an `SP.FieldUrlValue`) sent by the item MERGE | The banner persisted; `Description` returned 204 and stayed null. | `page.page-state.description-banner` |
| The `Article` layout at create | Persisted. | `page.page-state.layout-article` |
| `PromotedState` 1 at create, and by the item MERGE apply made before 0.5.0 | 1 at create (beside Article); 0 after the MERGE on a Home-layout draft. Whether the write path or the layout decides is not separated. | `page.page-state.promoted-state`, `page.promoted-state.create-is-effective` |
| A fresh draft's `OData__UIVersionString` and `CheckoutUserId`, then after `checkoutpage` and `publish` | 0.1 and checked out to its creator; `checkoutpage` 200 with no change; `publish` 200, version 1.0, checkout cleared. | `page.page-state.publish-flow` |
| `HasUniqueRoleAssignments` on every page created | False: creating a page does not break inheritance. | `page.page-state.permission-inheritance` |

The lane also asks for the item's moderation status, which the item read did not
return, and writes the banner a second way, through the page model's
`SavePageAsDraft`; the registry has no row for either. `compile` relies on none
of these rows, so it neither warns nor refuses on them, and `compile-pages` does
no link resolution.

Unmeasured by the lane, and named as such in the document: `navigation`
(a navigation-node write, not a page save; the DSL refuses the key),
`permission-break` (inheritance is read on every page, never broken),
`banner-json` (only the two banner writes above are attempted) and
`rendering`.

### The editor round trip

Measured 2026-10-20 (`page.editor.roundtrip`,
[tests/fixtures/editor-roundtrip.json](tests/fixtures/editor-roundtrip.json)):
a compiled page was applied, saved back through the page model's `SavePage`
with its controls re-serialised from the stored canvas and nothing edited, and
published. Every control's web part data and text came back equal as data. The
save renumbered nothing, rewrote no `dataVersion` and dropped no property. It
stored the integer positions and the `isFromSectionTemplate` and
`addedFromPersistedData` keys that the re-serialiser had already put in the
request (the request is not what the server would synthesise), rewrote the
markup around each web part, and wrote a searchable-text mirror for a bound
list's title. The stored canvas of the save that sent a `pageSettingsSlice`
control carries one; the request bodies were not captured, so that is the
observable output only. Publish changed no byte. Two things stay unmeasured:
the browser editor itself (the save was REST, so the editor's own serialiser
was not observed) and whether the server rewrites a float position, since a
script cannot send `1000.0`. This is a manual step: no findprobe leg drives
it.

## The findings registry

[FINDINGS.md](FINDINGS.md) is the table of those claims, one row per
measured claim. Each row has:

| column | content |
| --- | --- |
| check-id | `page.<scope>.<question>` |
| claim | the sentence the code relies on |
| measured | the date of the live run |
| result | the compact result |
| evidence | a pointer into a fixture, `tests/fixtures/<file>#<dotted.key>` |
| re-probe | the command that re-derives it |

It is data. `src/formwork/findings.py` parses it on load and refuses a
malformed row. `tests/test_findings.py` checks that every evidence pointer
resolves, every date matches the run that produced the fixture, and the table
is byte-identical to its own canonical rendering. The hand-measured
section-emphasis mechanism is an ordinary row there; the pin survives because
the re-probe re-derives it.

Two commands read it.

### compile warns on old evidence

`formwork compile` warns (never refuses) when the newest row a spec relies
on is older than `--findings-max-age` (default 90 days).

| spec element | relies on |
| --- | --- |
| a component part | the component-merge row |
| a part with `properties` | its own `page.properties.<alias>` row, or the newest properties row when there is none for that alias |
| a part with `bind` by id | the list-binding row |
| a part with `bind` by title | the list-binding row and the three `page.list-binding.*` rows |
| a part with `tile` | the rows for its component: Quick links two, Button one, Editorial card one, Image two |
| a part with `emphasis` | the control-merge row |
| a text part | the colon-rewrite row, and the styled-text row when its HTML carries a `style`, a `class` or a `<mark>` |
| a section | the row for its factors |

Each warning names the check-id, the parts or sections that rely on it, its
age and the re-probe command. A relied-on claim with no row at all is warned
about the same way. Evidence ages, it does not vanish.

Without a registry in the working directory compile is silent and unchanged;
an explicit `--findings` that does not exist is an error.

### findprobe re-runs the measurements

`formwork gen findprobe` prints the re-probe paste-in. It carries the
discover probe's own measurement legs (shared as template partials, so the
two scripts carry the same bytes) plus a SavePage leg and a tile leg. The
SavePage leg establishes two emphasised sections on a second scratch page and
item-merges a third control into one of them. The tile leg authors the four
tile parts through the same code a spec's `tile:` key compiles through, binds
two parts by title through the apply paste-in's own resolver (one list title
carries an apostrophe and an ampersand), reads the stored canvas back and
publishes it.

It recycles everything it created and prints a verdict per row: "same" or
"DIFFERS" against the result column, with the discover-lane rows listed as
not re-run here. It downloads `formwork-findprobe.json` with the evidence
under discover's keys.

A DIFFERS row is the cue to re-measure, fold the capture into the fixtures
and add a dated row; the old row stays.

## Limits

Each of these is described in full above; this is the list in one place.

- **Stored is not rendered.** Every measurement reads back persisted bytes.
  Whether a style, a swatch, a layout or a promoted state renders as such is
  a browser question no probe answers yet (`rendering`).
- **Section emphasis does not show on applied pages.** Apply writes by item
  MERGE, and emphasis takes effect only on sections established through
  SavePage. Section-level `emphasis:` refuses; part-level emphasis is stored
  but the colour is not promised. See [Emphasis](#emphasis).
- **Unmeasured keys are refused.** `theme`, section `background` and
  `spacing`, and page `navigation`. See
  [What compile refuses](#what-compile-refuses).
- **No link resolution and no transaction** across a multi-page set. See
  [Multi-page](#multi-page).
- **A copy moves the canvas, not the content.** Images and other `link`
  values are report-only and stay pointed at the source.
- **Every applied page has the Home layout**, and a copy keeps only the
  title and the canvas. See [What is copied](#what-is-copied).
- **The paste-ins run in your browser session.** They can only do what your
  account can already do, and must run from a modern page under
  `.../SitePages/`. They find the Site Pages library by that URL
  (`web/GetList`), not by its display title, which is translated on
  non-English sites. The live measurements so far come from one English
  sandbox site; see the transport epic (#34).
- **`formwork gen findprobe` needs a copy of `FINDINGS.md`.** See
  [Install](#install).

## Contributing

Development setup, the tests and gates CI runs, how the code and templates
are laid out, the golden paste-ins, the fixtures and the release process are
in [CONTRIBUTING.md](CONTRIBUTING.md). Commits on `main` follow conventional
commits, which release-please reads.

## Licence

MIT. See [LICENSE](LICENSE).

---

<sub>Part of <a href="https://github.com/firmfooting">firmfooting</a>: safe,
plain tooling for M365 and SharePoint operators. Not affiliated with or endorsed
by Microsoft.</sub>
