Skip to main content

formwork, by firmfooting

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

CI PyPI Python Licence

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.

 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.

 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

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.

pipx install formwork-sp

pip install formwork-sp works too. Each release also attaches its wheel and sdist to this repository's GitHub 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. 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:

    formwork gen discover > discover.js
    
  3. Write page.yaml:

    page: Team home
    sections:
      - type: two-thirds
        parts:
          - component: NewsWebPart
          - text: |
              ## Welcome
    
              Start here.
            column: 2
    
  4. Check it offline, then compile it:

    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:

    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.

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.

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;
  • 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.

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 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.

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).

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), 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).

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

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.
tile component part Labels, links and images of Quick links, Button, Editorial card or Image; see Tile parts.
bind component part The list a list or library part shows; see Binding lists.
text text part Markdown or HTML; see 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). 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, 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:

- 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

- 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:

- 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:

- 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.

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.

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 }}
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.
emphasis other than zoneEmphasis 1 to 4 part See 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.

# 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
# pages/news.yaml
page: Team news
sections:
  - type: one
    parts:
      - component: NewsWebPart
        properties: {layoutId: "List"}
formwork compile-pages pages/ formwork-discovery.json --out-dir build/
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).

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). 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: 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:

    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

{
  "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:

{"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):

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 holds the same claims as data, one row each; see 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): 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 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.
  • Unmeasured keys are refused. theme, section background and spacing, and page navigation. See What compile refuses.
  • No link resolution and no transaction across a multi-page set. See 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.
  • 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.

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. Commits on main follow conventional commits, which release-please reads.

Licence

MIT. See LICENSE.


Part of firmfooting: safe, plain tooling for M365 and SharePoint operators. Not affiliated with or endorsed by Microsoft.

Metadata

Release files for formwork-sp 0.13.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for formwork-sp 0.13.0
File Size Uploaded Provenance
formwork_sp-0.13.0.tar.gz 328.7 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for formwork-sp 0.13.0
File Interpreter ABI Platform
formwork_sp-0.13.0-py3-none-any.whl Python 3 none any Details

Total release size: 508.7 kB

Release files / formwork_sp-0.13.0.tar.gz

Download URL formwork_sp-0.13.0.tar.gz
Size 328.7 kB
Tags Source
SHA-256 checksum
How to use checksums
5417af4d783c5a1c7235dae6c96acc5bdaef17bd83b35d3dd4b83b3e02a530fc
BLAKE2b-256 checksum
How to use checksums
4afe3b57ee0ac03e9220b5c7194894f3ea3bc2d29d296e66a3e6f1d3028b0b63
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / formwork_sp-0.13.0-py3-none-any.whl

Download URL formwork_sp-0.13.0-py3-none-any.whl
Size 180.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1fb96d2c6ff3597fbc826b566a13d509f003afaaeb0a55dd40309470d84b5a26
BLAKE2b-256 checksum
How to use checksums
573de0e699d06e399be54847975db5f1d36249d752516072a833dfa602bbe4d4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Security for formwork-sp 0.13.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for formwork_sp-0.13.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page