Declarative SharePoint modern pages: write the page as YAML, preview it offline, and pour it in from the browser console.
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
- Quickstart
- Commands
- The authoring workflow
- Writing the spec
- Multi-page
- Copying a page
- Reading a whole site
- The canvas contract
- Measured SharePoint behaviour
- The findings registry
- Limits
- Contributing
- 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.
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/.
-
Install:
pipx install formwork-sp. -
Discover the site. Print the discover paste-in, run it in the DevTools console on the target site, and keep the
formwork-discovery.jsonit downloads:formwork gen discover > discover.js
-
Write
page.yaml:page: Team home sections: - type: two-thirds parts: - component: NewsWebPart - text: | ## Welcome Start here. column: 2
-
Check it offline, then compile it:
formwork preview page.yaml --out preview.html formwork compile page.yaml formwork-discovery.json
-
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
sectionFactorof 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 :, 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;
-/*and1.lists;**bold**,*italic*/_italic_;[text](url)links (http, https, mailto or server-relative). A bare relative target such asPage.aspxis 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 : 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 :
(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
: 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 : |
font-size |
<p><span style="font-size:24px;">size by style</span></p> |
yes, : stored as : |
background |
<p><span style="background-color:#fff100;">background by style</span></p> |
yes, : stored as : |
styled-link |
<p><a href="https://example.com/" style="color:#0078d4;text-decoration:underline;">styled link</a></p> |
yes, : stored as : |
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 : |
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;
emphasison 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) downloadsformwork-assets.json: every file with its metadata, approval state, sha256 and, when changed, its bytes.NAMEis the library's URL name. Run it as someone who can edit, so pending items are seen.formwork assets record BUNDLE --assets FILE --mirror DIRfolds 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 DIRuploads the files named in a reviewed mapping as pending items and downloadsformwork-assets-pushed.jsonforassets record. The mapping is<name>: {file, AssetType, AssetOwner, AltText, ReviewDate, ...}using the library's column names (writeReviewDateas 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'spushed_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.
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.
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 FILEis a YAML mapping of template variables, read withsafe_loadonly: a vars file is data, never code.--set NAME=VALUEnames one variable and overrides the vars file; repeat it, and later pairs win.compile,compile-pagesandpreviewall 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'svars:file > the shared--vars. A page vars file naming a key an explicit--setalso names is refused rather than silently losing the override. - Missing variables fail loudly.
StrictUndefinedmeans 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.--setrefuses a value containing a newline (multi-line values belong in the vars file), and every--setvalue 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 applytakes 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
FileNamesent 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.
navigationis 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.
-
Extract.
formwork gen extractprints a paste-in. Run it from the console on the source page. It fetches the page item and the web and site identity and downloadsformwork-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'spagePath. -
Inspect.
formwork inspect formwork-bundle.jsonlists every site-bound value in the page's canvas, web part by web part:baseUrllinks,siteId/webIdproperties, list ids and urls, searchable plain texts, and, detected but never rewritten, otherlinkentries andimagesources. Each is addressed by the web part'sinstanceId. The bundle carries no separate web-part list; the canvas is the source. -
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.
-
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:
hrefandsrcvalues in text web parts, server-relative (/sites/source/SitePages/X.aspx) or absolute;- a web part's
linksentries other thanbaseUrl, such as a Quick links item'sitems[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 ({,
", :), 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 :, 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 fromlocation.pathnameand refuses to run anywhere but a page under/SitePages/. contextinfois 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/addof an.aspxis 403. Pages are created through/_api/sitepages/pagesand then written with an item MERGE under the real etag. - MERGE stores
CanvasContent1as 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 honouringRetry-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.valuein the error body; every non-OK response is reported with it next to the operation and status. - The
contextinforesponse is parsed step by step; the blind.d.GetContextWebInformation.FormDigestValuechain 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 (
controlType4,editorTypeCKEditor) sent through the item MERGE are stored byte-for-byte except that:in the inner HTML is rewritten as:. Nine of nine samples: the two probe controls and the seven styled samples in the table above. All nine carriedemphasis: {}both ways. - Five one-control sections were stored byte-identical:
zoneEmphasis2,zoneEmphasis3 with an unknown key echoed back, collapsiblezoneGroupMetadata, full-widthsectionFactor0, verticallayoutIndex2. Stored is not rendered. - The page model refuses the HTML canvas:
SavePageAsDraftwithCanvasContent1set to the markup answered HTTP 500, "Unexpected character encountered while parsing value: <". The page model'sSavePagetakes a JSON array of controls whose positions carryzoneIdGUIDs; 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
zoneEmphasis3 on every control, and a control merged into a SavePage-established section persistedzoneEmphasis3. - 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 generatingzoneIdGUIDs, 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, sectionbackgroundandspacing, and pagenavigation. 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
linkvalues 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 findprobeneeds a copy ofFINDINGS.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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| formwork_sp-0.13.0.tar.gz | 328.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.