formwork-sp 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. formwork_sp-0.7.0/LICENSE +21 -0
  2. formwork_sp-0.7.0/PKG-INFO +633 -0
  3. formwork_sp-0.7.0/README.md +615 -0
  4. formwork_sp-0.7.0/pyproject.toml +68 -0
  5. formwork_sp-0.7.0/setup.cfg +4 -0
  6. formwork_sp-0.7.0/src/formwork/__init__.py +7 -0
  7. formwork_sp-0.7.0/src/formwork/bundle.py +91 -0
  8. formwork_sp-0.7.0/src/formwork/canvas.py +288 -0
  9. formwork_sp-0.7.0/src/formwork/catalogue.py +708 -0
  10. formwork_sp-0.7.0/src/formwork/cli.py +532 -0
  11. formwork_sp-0.7.0/src/formwork/dsl.py +792 -0
  12. formwork_sp-0.7.0/src/formwork/findings.py +412 -0
  13. formwork_sp-0.7.0/src/formwork/generator.py +101 -0
  14. formwork_sp-0.7.0/src/formwork/multipage.py +419 -0
  15. formwork_sp-0.7.0/src/formwork/preview.py +100 -0
  16. formwork_sp-0.7.0/src/formwork/provenance.py +217 -0
  17. formwork_sp-0.7.0/src/formwork/refs.py +282 -0
  18. formwork_sp-0.7.0/src/formwork/sections.py +152 -0
  19. formwork_sp-0.7.0/src/formwork/spec_templates.py +149 -0
  20. formwork_sp-0.7.0/src/formwork/templates/_prelude.js.j2 +227 -0
  21. formwork_sp-0.7.0/src/formwork/templates/_probe_legs.js.j2 +598 -0
  22. formwork_sp-0.7.0/src/formwork/templates/_probe_pagestate.js.j2 +263 -0
  23. formwork_sp-0.7.0/src/formwork/templates/_probe_setup.js.j2 +71 -0
  24. formwork_sp-0.7.0/src/formwork/templates/apply.js.j2 +208 -0
  25. formwork_sp-0.7.0/src/formwork/templates/discover.js.j2 +182 -0
  26. formwork_sp-0.7.0/src/formwork/templates/extract.js.j2 +59 -0
  27. formwork_sp-0.7.0/src/formwork/templates/findprobe.js.j2 +423 -0
  28. formwork_sp-0.7.0/src/formwork/templates/preview.html.j2 +60 -0
  29. formwork_sp-0.7.0/src/formwork/templating.py +53 -0
  30. formwork_sp-0.7.0/src/formwork/text.py +263 -0
  31. formwork_sp-0.7.0/src/formwork_sp.egg-info/PKG-INFO +633 -0
  32. formwork_sp-0.7.0/src/formwork_sp.egg-info/SOURCES.txt +54 -0
  33. formwork_sp-0.7.0/src/formwork_sp.egg-info/dependency_links.txt +1 -0
  34. formwork_sp-0.7.0/src/formwork_sp.egg-info/entry_points.txt +2 -0
  35. formwork_sp-0.7.0/src/formwork_sp.egg-info/requires.txt +8 -0
  36. formwork_sp-0.7.0/src/formwork_sp.egg-info/top_level.txt +1 -0
  37. formwork_sp-0.7.0/tests/test_apply_guard.py +442 -0
  38. formwork_sp-0.7.0/tests/test_bundle.py +69 -0
  39. formwork_sp-0.7.0/tests/test_canvas.py +251 -0
  40. formwork_sp-0.7.0/tests/test_catalogue.py +701 -0
  41. formwork_sp-0.7.0/tests/test_cli.py +160 -0
  42. formwork_sp-0.7.0/tests/test_cli_compile.py +121 -0
  43. formwork_sp-0.7.0/tests/test_dsl.py +1105 -0
  44. formwork_sp-0.7.0/tests/test_findings.py +792 -0
  45. formwork_sp-0.7.0/tests/test_generator.py +778 -0
  46. formwork_sp-0.7.0/tests/test_mirror_reporting.py +86 -0
  47. formwork_sp-0.7.0/tests/test_multipage.py +500 -0
  48. formwork_sp-0.7.0/tests/test_preview.py +240 -0
  49. formwork_sp-0.7.0/tests/test_provenance.py +242 -0
  50. formwork_sp-0.7.0/tests/test_refs.py +445 -0
  51. formwork_sp-0.7.0/tests/test_sections.py +411 -0
  52. formwork_sp-0.7.0/tests/test_spec_templates.py +425 -0
  53. formwork_sp-0.7.0/tests/test_styling_evidence.py +217 -0
  54. formwork_sp-0.7.0/tests/test_text.py +210 -0
  55. formwork_sp-0.7.0/tests/test_version.py +53 -0
  56. formwork_sp-0.7.0/tests/test_yaml_position_honesty.py +51 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 firmfooting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,633 @@
1
+ Metadata-Version: 2.4
2
+ Name: formwork-sp
3
+ Version: 0.7.0
4
+ Summary: Declarative SharePoint modern pages: declare, compile, preview, apply; and copy.
5
+ Author: Shaun Eccles-Smith
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: jinja2>=3.1
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: mypy>=1.10; extra == "dev"
15
+ Requires-Dist: ruff>=0.5; extra == "dev"
16
+ Requires-Dist: types-PyYAML; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # formwork
20
+
21
+ Declarative SharePoint modern pages. Write the page as YAML, compile it against
22
+ the site's own component catalogue, preview it offline, and pour it in from
23
+ the browser console. Or copy an existing page from one site to another.
24
+
25
+ The name is the trade: formwork is the mould you build once and reuse for many
26
+ identical pours. Build a page once — news, quick links, document library,
27
+ whatever your layout is — then reuse the mould across teams, sites, or tenants.
28
+
29
+ No admin consent, no app registration. The paste-ins run in your own browser
30
+ session over same-origin REST, so they can only read and write what you can
31
+ already read and write.
32
+
33
+ ## The authoring workflow
34
+
35
+ ```
36
+ target site your laptop target site
37
+ ┌─────────────┐ gen discover ┌─────────────────────────────┐ gen apply ┌─────────────┐
38
+ │ DevTools │ ─────────────> │ page.yaml ──compile──> payload │ ─────────> │ DevTools │
39
+ │ console │ catalogue │ └─preview─> html │ paste-in │ console │
40
+ └─────────────┘ └─────────────────────────────┘ └─────────────┘
41
+ ```
42
+
43
+ 1. **Discover** the site once. `formwork gen discover` prints a paste-in that
44
+ enumerates every placeable component via `GetClientSideWebParts` (73 on a
45
+ stock team site, 285 including hidden and extension components), creates a
46
+ scratch page, places one control per component across one-, two- and
47
+ three-column sections, adds the text and styling probes described under
48
+ [Measured SharePoint behaviour](#measured-sharepoint-behaviour), saves,
49
+ reads back what SharePoint persisted, recycles the scratch page and
50
+ downloads `formwork-discovery.json`: the site's component catalogue plus
51
+ the measurements the compiler relies on. `formwork components
52
+ formwork-discovery.json` lists what it found.
53
+
54
+ The paste-in also carries the web-part property, one-third layout and
55
+ list-binding probes under additive keys; the compiler reads them through
56
+ the catalogue for a part's `properties`, a section's columns and a
57
+ part's `bind`. For the list bindings it creates two containers of its
58
+ own, the custom list "Formwork Probe Source" and the document library
59
+ "Formwork Probe Docs", and recycles both after the scratch page; a
60
+ refused create or recycle is recorded in the document rather than
61
+ failing the run, so check the console line if either name already exists
62
+ on the site.
63
+
64
+ 2. **Declare** the page in `page.yaml`. Sections, columns, parts, text, and
65
+ the emphasis of a part; see [Writing the spec](#writing-the-spec).
66
+
67
+ 3. **Compile** it: `formwork compile page.yaml formwork-discovery.json`
68
+ resolves every component against the live catalogue by alias or title,
69
+ emits the section geometry (`sectionFactor` 8/4 for two-thirds, 12 for
70
+ one, 4/4/4 for three), applies property overrides, compiles text parts to
71
+ text controls and writes `formwork-payload.json`. It prints one line per
72
+ placed part. An unknown or hidden component refuses to compile: nothing is
73
+ placed that the site did not declare placeable. When `FINDINGS.md` is in
74
+ the working directory (or named with `--findings`), compile also checks
75
+ the age of every measurement the spec relies on; see
76
+ [The findings registry](#the-findings-registry).
77
+
78
+ 4. **Preview** it at any point, catalogue or not: `formwork preview page.yaml
79
+ --out preview.html` renders the spec as a standalone HTML page, sections
80
+ in order, columns at their factors on a 12-column grid, text parts inline,
81
+ other parts as titled placeholder cards. Pass `--discovery` to show the
82
+ catalogue's titles and descriptions. No SharePoint calls, no network. The
83
+ preview validates the spec exactly as the compiler does, so a spec that
84
+ previews will compile as far as the spec itself is concerned.
85
+
86
+ 5. **Apply** it: `formwork gen apply formwork-payload.json --name "Team home"`
87
+ prints the second paste-in with the payload embedded. Run it from the
88
+ console on any page of the target site. It creates the page through the
89
+ `sitepages` API, writes the canvas with an item MERGE under `If-Match`,
90
+ then reads back what SharePoint stored and compares it byte for byte.
91
+ If the bytes differ the script throws, naming the new page's item id and
92
+ URL: the page exists with what SharePoint stored, and Formwork does not
93
+ recycle it. Keep it or recycle it yourself. Compile emits text HTML in
94
+ the stored spelling (`:` as `:`, see "Text parts"), so that rewrite
95
+ does not trip the check.
96
+
97
+ **Provenance guard.** The payload carries a `provenance` stamp written by
98
+ `compile`: the formwork version, the SHA-256 of the discovery file's
99
+ bytes, the discovery's web id, web URL and timestamp, the spec name and
100
+ the compile time (`compile` prints it on the line after "payload
101
+ written"). When the compile used the template layer, the stamp also names
102
+ the vars file and its SHA-256, a page's own `vars:` file and its SHA-256,
103
+ and the sorted `--set` key names — never their values (see "Templated
104
+ specs"). Before it creates anything, the apply script reads the web it
105
+ runs on and refuses, listing the stamp's value beside the observed one,
106
+ when the web id or URL differs, when the payload's formwork is newer than
107
+ the script's own version, or when there is no stamp at all (a payload from
108
+ before 0.5.0). To apply a payload on a different web on purpose, set
109
+ `FORCE_SITE_MISMATCH = true` at the top of the script; nothing overrides
110
+ the version check. `--promoted-state 1` sends `PromotedState` inside the
111
+ create body, where it was measured persisting (beside the Article layout,
112
+ `page.promoted-state.create-is-effective`); the post-create MERGE the
113
+ script used before 0.5.0 read back 0 on the Home layout. The script prints
114
+ the stored value beside the byte-exact result and warns if it differs.
115
+
116
+ ## Writing the spec
117
+
118
+ ```yaml
119
+ page: Team demo home
120
+ sections:
121
+ - type: two-thirds
122
+ parts:
123
+ - component: NewsWebPart
124
+ emphasis: 2
125
+ - text: |
126
+ ## Welcome
127
+ The **team** page. See the [handbook](/sites/T/SitePages/Handbook.aspx).
128
+ column: 2
129
+ - type: one
130
+ parts:
131
+ - component: EventsWebPart
132
+ displayTitle: What's on
133
+ properties: {layoutId: "Compact"}
134
+ - text: "<p>Raw <em>HTML</em> is fine too.</p>"
135
+ ```
136
+
137
+ ### Sections and parts
138
+
139
+ A section has a `type` (`one`, `two`, `three`, `two-thirds`, `one-third`)
140
+ and `parts`. A part names either a `component`, by alias or by catalogue
141
+ title, with optional `properties` and `displayTitle`, or a `text` block.
142
+ `column` places the part (1-based); it defaults to the first column.
143
+
144
+ ### Text parts
145
+
146
+ Text is HTML when it starts with `<` or carries `format: html`, otherwise a
147
+ small markdown subset: `#` to `####` headings, paragraphs, `-`/`*` and `1.`
148
+ lists, `**bold**`, `*italic*`/`_italic_`, and `[text](url)` links (http,
149
+ https, mailto or relative). Anything else — blockquotes, tables, code,
150
+ images, rules, nested lists, raw tags — is refused with the line number
151
+ rather than guessed at. On both paths `<script>`, `<style>`, `<iframe>`,
152
+ `<textarea>`, `<svg>`, comments, inline `on*=` handlers,
153
+ `javascript:`/`data:`/`vbscript:` URLs, `data-sp-` attributes and control
154
+ characters are refused.
155
+
156
+ Text parts compile only against a discovery document whose text probes
157
+ persisted with at most the `:` to `&#58;` rewrite (measured 2026-09-06, see
158
+ below). A document without that measurement, or whose samples differ in any
159
+ other way, refuses to compile text parts and says why. Compile emits the
160
+ inner HTML in that stored spelling, every `:` as `&#58;` (in a style
161
+ attribute, an absolute `href`, running text), so the payload carries what
162
+ SharePoint will store and apply's byte-for-byte read-back holds. The
163
+ compiled part record keeps the HTML as you wrote it.
164
+
165
+ ### Styled text
166
+
167
+ Text parts take inline styling as plain HTML. Measured 2026-09-06
168
+ (`styling.styleSamples` in
169
+ [tests/fixtures/discovery.styling.json](tests/fixtures/discovery.styling.json),
170
+ a live discover run on a stock team site): seven samples written into text
171
+ controls came back from SharePoint with no change other than the `:` to
172
+ `&#58;` rewrite it applies to every text control's HTML.
173
+
174
+ | sample | HTML the part carries | stored |
175
+ | --- | --- | --- |
176
+ | `color` | `<p><span style="color:#a4262c;">colour by style</span></p>` | yes, `:` stored as `&#58;` |
177
+ | `font-size` | `<p><span style="font-size:24px;">size by style</span></p>` | yes, `:` stored as `&#58;` |
178
+ | `background` | `<p><span style="background-color:#fff100;">background by style</span></p>` | yes, `:` stored as `&#58;` |
179
+ | `styled-link` | `<p><a href="https://example.com/" style="color:#0078d4;text-decoration:underline;">styled link</a></p>` | yes, `:` stored as `&#58;` |
180
+ | `mark` | `<p>Text with a <mark>marked</mark> word.</p>` | yes, byte-identical |
181
+ | `block-align` | `<p style="text-align:center;">centred paragraph</p>` | yes, `:` stored as `&#58;` |
182
+ | `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 |
183
+
184
+ "Stored" is the bytes SharePoint returned for `CanvasContent1`. Whether the
185
+ text web part renders each style is a browser question the probe does not
186
+ answer (`rendering` in `styling.unmeasured`). The markdown subset cannot
187
+ express any of this; write such a part as HTML. `tests/test_styling_evidence.py`
188
+ keeps this table equal to the fixture's sample list.
189
+
190
+ ### Emphasis
191
+
192
+ A component part may carry `emphasis`, the per-control block SharePoint uses
193
+ for a section's background swatch:
194
+
195
+ ```yaml
196
+ - component: NewsWebPart
197
+ emphasis: 2 # shorthand for {zoneEmphasis: 2}
198
+ ```
199
+
200
+ `zoneEmphasis` must be an integer from 1 to 4. Measured 2026-09-06
201
+ (`styling.sectionSamples` in the fixture above): a web-part control sent with
202
+ `{zoneEmphasis: 2}` and one sent with `{zoneEmphasis: 3, formworkProbe: "unknown key"}`
203
+ were stored byte-for-byte through the same item MERGE the apply paste-in
204
+ uses, and read back intact. 1 and 4 are the editor's other two swatches and
205
+ are accepted on that basis alone. Nothing else is: an unknown key inside the
206
+ block refuses (the probe shows unknown keys echo back, which proves survival,
207
+ not meaning), any other value refuses, and `emphasis` on a text part refuses
208
+ because every text control the probe read back carried `{}`.
209
+
210
+ What the measurement does not say is that the swatch shows on a page created
211
+ by apply. The mechanism probe (`styling.sectionEmphasisMechanism`, 2026-09-06)
212
+ found that section emphasis takes effect once the section is established
213
+ through the page model's `/_api/sitepages/pages(<id>)/SavePage`, whose body is
214
+ a JSON array of controls carrying `zoneId` GUIDs, not the HTML canvas; the
215
+ probe page, which was only ever item-merged, is recorded there as "emphasis
216
+ dropped", while on a SavePage-established page a newly merged control with
217
+ `zoneEmphasis` 3 persisted. The apply paste-in does not call SavePage, so a
218
+ section-level `emphasis:` key refuses and names that limitation. Put the key
219
+ on the parts, and expect the stored bytes rather than the colour until that
220
+ path is measured.
221
+
222
+ ### Templated specs
223
+
224
+ A spec file is rendered as a Jinja2 template *before* the YAML parser sees
225
+ it, so one page per site (or per environment) can come from one spec.
226
+ Rendering is opt-in: it happens only when the command carries `--vars` or
227
+ `--set`, or the spec itself carries a top-level `vars:` key. With no flags
228
+ the spec's bytes reach the parser unchanged, so a spec holding literal
229
+ `{{ ... }}` text keeps compiling as data.
230
+
231
+ ```yaml
232
+ vars: vars/finance.yaml # this page's own vars file, relative to the spec
233
+ page: Finance {{ env }}
234
+ sections:
235
+ - parts:
236
+ - component: NewsWebPart
237
+ - text: Managed by {{ team }}
238
+ ```
239
+
240
+ ```
241
+ formwork compile-pages pages/ formwork-discovery.json --out-dir build/ \
242
+ --vars shared.yaml --set env=Training
243
+ ```
244
+
245
+ - `--vars FILE` is a YAML mapping of template variables, read with
246
+ `safe_load` only — a vars file is data, never code.
247
+ - `--set NAME=VALUE` names one variable and overrides the vars file; repeat
248
+ it, and later pairs win. `compile`, `compile-pages` and `preview` all take
249
+ both flags.
250
+ - A page's own `vars:` key, at column 0 and at most one per spec, names a
251
+ vars file relative to the spec file; it outranks the shared `--vars`.
252
+ - Precedence, highest first: `--set` > the page's `vars:` file > the shared
253
+ `--vars`. A page vars file naming a key an explicit `--set` also names is
254
+ refused rather than silently losing the override.
255
+ - Missing variables fail loudly. `StrictUndefined` means a name the template
256
+ uses but nobody supplies is a compile error naming the variable and the
257
+ template line, never an empty string baked into a page.
258
+ - Values are substituted as text, not escaped: `&`, `<`, `>`, `"` and `'`
259
+ reach the spec verbatim, so a URL with a query string or a title with an
260
+ apostrophe is fine. `--set` refuses a value containing a newline (multi-line
261
+ values belong in the vars file), and every `--set` value is a string.
262
+ - Refusals name the file, the position and the variable; a vars file's values
263
+ are never echoed into a message or into `formwork-pages.json`.
264
+
265
+ When a compile used the template layer its provenance stamp records the vars
266
+ file name and the SHA-256 of its bytes, a page's `ownVarsFile` and its
267
+ SHA-256, and the sorted `--set` key names (`setKeys`) — never the values — so
268
+ two payloads built from the same spec with different `--set` values are not
269
+ stamp-identical.
270
+
271
+ ### What compile refuses
272
+
273
+ Every refusal names the part or section and the reason, and each reason
274
+ cites its measurement. Beyond the unknown-component, text and emphasis
275
+ refusals above, the spec may not carry `theme` (a web-level setting, not a
276
+ page field) or a section `background` or `spacing`: the canvas shape for
277
+ those is unmeasured, so they are refused rather than guessed at. A page-level
278
+ `navigation` key is refused as unmeasured: adding a page to the site
279
+ navigation is a navigation-node write, not a page save, and no `FINDINGS.md`
280
+ row `page.navigation.*` records one; the refusal names the discover lane that
281
+ would measure it. Nothing in a spec is silently dropped.
282
+
283
+ ## Multi-page
284
+
285
+ A site is several pages declared together. `formwork compile-pages` takes a
286
+ directory (or a glob) of specs and one discovery document, compiles every
287
+ `*.yaml` against that one document, and writes one payload per spec plus a
288
+ manifest, `formwork-pages.json`, that carries the run's provenance header.
289
+
290
+ ```yaml
291
+ # pages/home.yaml
292
+ page: Team home
293
+ sections:
294
+ - type: two-thirds
295
+ parts:
296
+ - component: NewsWebPart
297
+ - text: |
298
+ ## Welcome
299
+
300
+ See the [news](/sites/T/SitePages/Team-news.aspx).
301
+ column: 2
302
+ ```
303
+
304
+ ```yaml
305
+ # pages/news.yaml
306
+ page: Team news
307
+ sections:
308
+ - type: one
309
+ parts:
310
+ - component: NewsWebPart
311
+ properties: {layoutId: "List"}
312
+ ```
313
+
314
+ ```
315
+ formwork compile-pages pages/ formwork-discovery.json --out-dir build/
316
+ ```
317
+
318
+ ```text
319
+ compiled 2 of 2 pages against formwork-discovery.json (web 7d1e9b5c-3a2f-4c8e-9b0a-2f6d4e8c1a35, sha256 4f0c9a3e7b21): manifest build/formwork-pages.json
320
+ ok home.yaml -> home.payload.json (Team home, 2 parts)
321
+ ok news.yaml -> news.payload.json (Team news, 1 part)
322
+ ```
323
+
324
+ Each page compiles alone against the same catalogue: one bad spec fails with
325
+ its own reason, the rest still build, the command exits 1 and the manifest
326
+ records every result. The header, `compiledWith`, names the formwork version,
327
+ the SHA-256 of the discovery bytes, the web id and URL and the discovery's
328
+ own timestamp once for the run; each page entry carries the spec, the title,
329
+ the payload name, the part count and any staleness warnings from
330
+ `FINDINGS.md` (the same ones `compile` prints, per page on stderr). Each
331
+ payload also carries that header plus its own spec name and the run's compile
332
+ time as `provenance`: the stamp the apply script checks (see step 5 of the
333
+ authoring workflow).
334
+
335
+ What multi-page deliberately does not do:
336
+
337
+ - It writes one payload per spec, not one combined artefact. `formwork gen
338
+ apply` takes one payload and creates one page, so each payload is applied
339
+ in turn; two specs with the same stem refuse before anything is written.
340
+ - There is no link resolution. A text part may link to another page of the
341
+ set (the href above), and the href is emitted as written. Which file name
342
+ a created page is given is a page-state question the discover lane
343
+ measures (`pageState`, under "Measured SharePoint behaviour"); until a
344
+ registry row records it, rewriting a link would be a guess.
345
+ - There is no transaction and no cross-page ordering. Pages compile in name
346
+ order and are applied one paste-in at a time; nothing sequences them, and
347
+ a page that fails to apply leaves the others as they are.
348
+ - `navigation` is refused at parse, per page, as in "What compile refuses":
349
+ the write is unmeasured.
350
+
351
+ ## Copying a page
352
+
353
+ The second workflow moves an existing page between sites.
354
+
355
+ 1. **Extract** — `formwork gen extract` prints a paste-in. Run it from the
356
+ console on the source page. It fetches the page item and the web and site
357
+ identity and downloads `formwork-bundle.json`: page fields, the raw canvas
358
+ markup (`CanvasContent1`) and source identity.
359
+
360
+ 2. **Inspect** — `formwork inspect formwork-bundle.json` lists every
361
+ site-bound value in the page's canvas, web part by web part: `baseUrl`
362
+ links, `siteId`/`webId` properties, list ids and urls, searchable plain
363
+ texts, and, detected but never rewritten, other `link` entries and
364
+ `image` sources. Each is addressed by the web part's `instanceId`. The
365
+ bundle carries no separate web-part list; the canvas is the source.
366
+
367
+ 3. **Process** — rewrite what your mapping resolves and report the rest:
368
+
369
+ ```
370
+ formwork process formwork-bundle.json \
371
+ --mapping mapping.json --page-name "Team home" --out payload.json
372
+ ```
373
+
374
+ Unresolved values are never guessed. They are left exactly as extracted
375
+ and reported, so a partial mapping degrades to "as extracted", not to a
376
+ broken page.
377
+
378
+ 4. **Apply** — `formwork gen apply payload.json --name "Team home"`, as in
379
+ the authoring workflow. A process payload's stamp names the formwork
380
+ version and the bundle but no discovery web (a copy is bound by its
381
+ mapping), so the apply script runs its version check and prints that the
382
+ site check does not apply.
383
+
384
+ Mapping file shape:
385
+
386
+ ```json
387
+ {
388
+ "baseUrl": "https://tenant.sharepoint.com/sites/target",
389
+ "siteId": "00000000-0000-0000-0000-000000000000",
390
+ "webId": "00000000-0000-0000-0000-000000000000",
391
+ "lists": {
392
+ "Document library": {
393
+ "id": "00000000-0000-0000-0000-000000000000",
394
+ "url": "/sites/target/Shared Documents",
395
+ "webRelativeUrl": "Shared Documents",
396
+ "viewId": "00000000-0000-0000-0000-000000000000"
397
+ }
398
+ },
399
+ "textOverrides": {"Documents": "Team documents"}
400
+ }
401
+ ```
402
+
403
+ `lists` is keyed by source web part title. `textOverrides` is keyed by the
404
+ exact source text. Everything you omit stays as extracted and is reported as
405
+ unresolved. Any console session on the target site can read
406
+ `/_api/web?$select=Id,Title,Url` and `/_api/site?$select=Id,Url` for the ids.
407
+
408
+ Two kinds are report-only: `link` (a web part's `links` entries other than
409
+ `baseUrl`, such as a Quick links item's `items[n].sourceItem.url`) and
410
+ `image` (`imageSources`). Inspect and process list them; nothing rewrites
411
+ them, because no mapping key for them has been measured: such a value may
412
+ be external, page-relative or list-bound, and what a target site wants there
413
+ is not known. They stay as extracted.
414
+
415
+ Process re-serialises only the web parts whose values actually changed; a
416
+ mapping that restates an extracted value leaves that web part's bytes alone.
417
+
418
+ Copied: page title, description, layout type, promoted state, section
419
+ structure, column widths, web part choices and properties — everything in the
420
+ canvas. Not copied: the content behind the web parts (news posts, list items,
421
+ documents; a News web part on the target shows the target's news, and a
422
+ Document library web part needs its `lists` mapping to point at a library
423
+ that exists there), page permissions, analytics, comments, version history.
424
+ Images referenced as `imageSources` stay pointed at the source site; they are
425
+ report-only (above), with no mapping key yet.
426
+
427
+ ## The canvas contract
428
+
429
+ A modern page's layout lives in `CanvasContent1` as HTML-encoded canvas
430
+ markup. Each control is a `div` whose `data-sp-controldata` and
431
+ `data-sp-webpartdata` attributes carry entity-escaped JSON; a text control
432
+ carries its HTML in a `data-sp-rte` child instead of web-part data. Formwork
433
+ parses this markup and addresses each web part by its `instanceId`, the
434
+ per-control GUID (the web part's `id` is the component type and repeats when
435
+ a page places the same part twice). Untouched controls stay byte-exact; a
436
+ control is re-escaped only when a value on it actually changed, in
437
+ SharePoint's own escaping style (`&#123;`, `&quot;`, `&#58;`), which is not
438
+ what generic HTML escapers produce. That holds on both paths: process
439
+ rewrites the extracted canvas in place, control by control, and compile
440
+ builds its controls from the same model. Text HTML is emitted with `:` as
441
+ `&#58;`, the stored spelling. So what process or compile emits is what
442
+ apply's read-back compares against.
443
+
444
+ ## Measured SharePoint behaviour
445
+
446
+ Every claim formwork makes about SharePoint was measured on the shauntestazure
447
+ sandbox; nothing here is inferred from documentation. Dates are measurement
448
+ dates, and the fixtures named are the evidence.
449
+
450
+ **2026-09-05, a live-captured team-site home page** (`tests/fixtures/collabhome.*`)
451
+
452
+ - The canvas is entity-escaped JSON inside HTML attributes, in the style
453
+ above. The parser's round-trip of that page is byte-exact, including a
454
+ re-render after every control is marked dirty.
455
+
456
+ **2026-09-06, transport and page creation** (the v0.2.0 discover and apply runs)
457
+
458
+ - An unprefixed `/_api/...` from a page under `/sites/<name>/` resolves to the
459
+ tenant root web, so every paste-in derives the site prefix from
460
+ `location.pathname` and refuses to run off a `/SitePages/` page.
461
+ - `contextinfo` is POST-only; GET is refused with 405.
462
+ - Site Pages is a document library: a list-item POST into it is refused and
463
+ `Files/add` of an `.aspx` is 403. Pages are created through
464
+ `/_api/sitepages/pages` and then written with an item MERGE under the real
465
+ etag.
466
+ - MERGE stores `CanvasContent1` as sent for web-part controls: discover placed
467
+ 73 components and read 73 back byte-exact.
468
+
469
+ **Transport facts ported from dbml-sharepoint** (partials read 2026-09-06; the
470
+ error-body finding is a live finding of 2026-07-24)
471
+
472
+ - A throttled browser session is not answered with 429: SharePoint redirects
473
+ it to `/_layouts/15/throttle.htm`, which arrives as 406 because the calls
474
+ ask for JSON. The paste-ins match the final URL, hold every request behind
475
+ one gate, and retry honouring `Retry-After`.
476
+ - The server's reason lives at `error.message.value` in the error body; every
477
+ non-OK response is reported with it next to the operation and status.
478
+ - The `contextinfo` response is parsed step by step; the blind
479
+ `.d.GetContextWebInformation.FormDigestValue` chain turned a server refusal
480
+ into a TypeError.
481
+ - Apostrophes in OData string literals are doubled, and URI-encoded when used
482
+ in a path segment.
483
+
484
+ **2026-09-06, text and styling** (`tests/fixtures/discovery.styling.json`,
485
+ `tests/fixtures/savepage-section-emphasis.json`)
486
+
487
+ - Text controls (`controlType` 4, `editorType` CKEditor) sent through the
488
+ item MERGE are stored byte-for-byte except that `:` in the inner HTML is
489
+ rewritten as `&#58;`. Nine of nine samples: the two probe controls and the
490
+ seven styled samples in the table above. All nine carried `emphasis: {}`
491
+ both ways.
492
+ - Five one-control sections were stored byte-identical: `zoneEmphasis` 2,
493
+ `zoneEmphasis` 3 with an unknown key echoed back, collapsible
494
+ `zoneGroupMetadata`, full-width `sectionFactor` 0, vertical `layoutIndex` 2.
495
+ Stored is not rendered.
496
+ - The page model refuses the HTML canvas: `SavePageAsDraft` with
497
+ `CanvasContent1` set to the markup answered HTTP 500, "Unexpected character
498
+ encountered while parsing value: <". The page model's `SavePage` takes a
499
+ JSON array of controls whose positions carry `zoneId` GUIDs; the editor's
500
+ own body for an emphasised page is the second fixture.
501
+ - Section emphasis takes effect once a section is established through
502
+ SavePage; the item-merged probe page is recorded as "emphasis dropped", the
503
+ editor-authored page keeps `zoneEmphasis` 3 on every control, and a control
504
+ merged into a SavePage-established section persisted `zoneEmphasis` 3.
505
+ - Unmeasured, and therefore refused or undocumented rather than guessed:
506
+ `theme` (a web-level setting; no page save can set it),
507
+ `section-background` (canvas shape unknown), `section-spacing` (no known
508
+ key), `rendering` (the probe reads persisted bytes only), the rules for
509
+ generating `zoneId` GUIDs, vertical and collapsible sections through the
510
+ SavePage path, and section background images.
511
+
512
+ **2026-09-07, page state and identity** (`_probe_pagestate.js.j2`, the lane
513
+ `formwork gen discover` and `formwork gen findprobe` share)
514
+
515
+ - Measured by the lane, not yet recorded: what SharePoint does with an
516
+ explicit `FileName` at create, and with one carrying spaces and capitals;
517
+ whether `Description` and `BannerImageUrl` survive the item MERGE (the
518
+ banner as an `SP.FieldUrlValue`) and the page model's `SavePageAsDraft`;
519
+ whether the `Article` layout and `PromotedState` 1 read back as requested
520
+ at create and after the MERGE apply makes; a fresh draft's
521
+ `OData__UIVersionString`, `CheckoutUserId` and moderation status, and the
522
+ same after `checkoutpage` and `publish`; and `HasUniqueRoleAssignments` on
523
+ every page created. Each is a sample under the document's `pageState` key,
524
+ requested and persisted by page id, on a scratch page of the lane's own
525
+ that is recycled before the download.
526
+ - The `FINDINGS.md` rows for these claims follow the first live run: a row
527
+ cites a fixture, and none exists yet. Until then `compile` neither warns
528
+ nor refuses on them, and `compile-pages` does no link resolution.
529
+ - Unmeasured by the lane, and named as such in the document: `navigation`
530
+ (a navigation-node write, not a page save; the DSL refuses the key),
531
+ `permission-break` (inheritance is read on every page, never broken),
532
+ `banner-json` (only the two banner writes above are attempted) and
533
+ `rendering`.
534
+
535
+ ## The findings registry
536
+
537
+ [FINDINGS.md](FINDINGS.md) is the table of those claims, one row per
538
+ measured claim: a check-id (`page.<scope>.<question>`), the claim, the
539
+ measured date, the compact result, an evidence pointer into a fixture
540
+ (`tests/fixtures/<file>#<dotted.key>`) and the re-probe command that
541
+ re-derives it. It is data: `src/formwork/findings.py` parses it on load and
542
+ refuses a malformed row, and `tests/test_findings.py` checks that every
543
+ evidence pointer resolves, every date matches the run that produced the
544
+ fixture, and the table is byte-identical to its own canonical rendering.
545
+ The hand-measured section-emphasis mechanism is an ordinary row there; the
546
+ pin survives because the re-probe re-derives it.
547
+
548
+ Two commands read it.
549
+
550
+ - `formwork compile` warns (never refuses) when the newest row a spec relies
551
+ on is older than `--findings-max-age` (default 90 days). A part relies on
552
+ the component-merge row, its own `page.properties.<alias>` row when it
553
+ sets `properties` (or the newest properties row when there is none for
554
+ that alias), the list-binding row when it has `bind`, and the
555
+ control-merge row when it has `emphasis`; a text part relies on the colon
556
+ rewrite and, when its HTML carries a `style`, a `class` or a `<mark>`, on
557
+ the styled-text row; a section relies on the row for its factors. Each
558
+ warning names the check-id, the parts or sections that rely on it, its age
559
+ and the re-probe command; a relied-on claim with no row at all is warned
560
+ about the same way. Evidence ages, it does not vanish. Without a
561
+ registry in the working directory compile is silent and unchanged; an
562
+ explicit `--findings` that does not exist is an error.
563
+ - `formwork gen findprobe` prints the re-probe paste-in: the discover
564
+ probe's own measurement legs (shared as template partials, so the two
565
+ scripts carry the same bytes) plus a SavePage leg that establishes two
566
+ emphasised sections on a second scratch page and item-merges a third
567
+ control into one of them. It recycles everything it created, prints a
568
+ verdict per row ("same" or "DIFFERS" against the result column, the
569
+ discover-lane rows listed as not re-run here) and downloads
570
+ `formwork-findprobe.json` with the evidence under discover's keys. A
571
+ DIFFERS row is the cue to re-measure, fold the capture into the fixtures
572
+ and add a dated row; the old row stays.
573
+
574
+ ## Install
575
+
576
+ The package publishes to PyPI as `formwork-sp` (`formwork` was taken there;
577
+ the import package and the `formwork` command are unchanged). Releases are
578
+ cut by release-please: conventional commits on `main` maintain a release PR,
579
+ and merging it tags the version, attaches the wheel + sdist to a GitHub
580
+ release, and publishes to PyPI by trusted publishing. Install from PyPI:
581
+
582
+ ```
583
+ pipx install formwork-sp
584
+ ```
585
+
586
+ or `pip install formwork-sp`. Release wheels also land on this repository's
587
+ GitHub Releases page if you would rather pin a URL (pinning to a known
588
+ release is safer than `latest`; bump the version as releases land).
589
+
590
+ Both give you the `formwork` command: `compile`, `compile-pages`, `preview`,
591
+ `process`, `inspect`, `components`, and the `gen` paste-in generators. One
592
+ caveat: `formwork gen findprobe` reads the findings registry from
593
+ `FINDINGS.md` in the working directory, so it only runs from a formwork
594
+ repository checkout — everything else runs anywhere.
595
+
596
+ ## Development
597
+
598
+ ```
599
+ uv sync
600
+ uv run pytest
601
+ uv run ruff check .
602
+ uv run mypy src
603
+ ```
604
+
605
+ `uv sync --locked` is what CI runs; relock with `uv lock` whenever you touch
606
+ `pyproject.toml`, or CI's `--locked` check fails the PR. (A plain venv +
607
+ `pip install -e '.[dev]'` also works locally, but uv is what the gates run.)
608
+
609
+ The paste-ins and the preview are Jinja templates under
610
+ `src/formwork/templates/` (`jinja2` is the one runtime dependency besides
611
+ PyYAML). The four scripts share one prelude partial, `_prelude.js.j2`, which
612
+ carries the measured transport facts with their citations and is the only
613
+ place the display layer names the Site Pages list; discover and findprobe
614
+ also share the probe setup, the measurement legs and the page-state lane
615
+ (`_probe_setup.js.j2`, `_probe_legs.js.j2`, `_probe_pagestate.js.j2`); each
616
+ script's template holds its own phase logic. `compile-pages` is
617
+ `src/formwork/multipage.py`, a loop over `dsl.compile_page` with the manifest
618
+ around it. Generated paste-ins are gated with `node --check` and compared byte for byte
619
+ with the goldens under `tests/fixtures/expected/` (extract, discover, apply,
620
+ apply with an awkward payload, and findprobe with the repository's
621
+ `FINDINGS.md`); after a deliberate template change, regenerate them with
622
+ `.venv/bin/python tests/test_generator.py` and review the diff like code.
623
+
624
+ The fixtures under `tests/fixtures/` are live captures (data already
625
+ anonymous and sandbox-bound) and are the ground truth for the canvas parser,
626
+ the text-part gate and the styling claims above; `tests/test_styling_evidence.py`
627
+ reads them next to this README. The version is written in `pyproject.toml`
628
+ and `src/formwork/__init__.py` and pinned equal, with the changelog's first
629
+ entry, by `tests/test_version.py`.
630
+
631
+ ## Licence
632
+
633
+ MIT.