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.
- formwork_sp-0.7.0/LICENSE +21 -0
- formwork_sp-0.7.0/PKG-INFO +633 -0
- formwork_sp-0.7.0/README.md +615 -0
- formwork_sp-0.7.0/pyproject.toml +68 -0
- formwork_sp-0.7.0/setup.cfg +4 -0
- formwork_sp-0.7.0/src/formwork/__init__.py +7 -0
- formwork_sp-0.7.0/src/formwork/bundle.py +91 -0
- formwork_sp-0.7.0/src/formwork/canvas.py +288 -0
- formwork_sp-0.7.0/src/formwork/catalogue.py +708 -0
- formwork_sp-0.7.0/src/formwork/cli.py +532 -0
- formwork_sp-0.7.0/src/formwork/dsl.py +792 -0
- formwork_sp-0.7.0/src/formwork/findings.py +412 -0
- formwork_sp-0.7.0/src/formwork/generator.py +101 -0
- formwork_sp-0.7.0/src/formwork/multipage.py +419 -0
- formwork_sp-0.7.0/src/formwork/preview.py +100 -0
- formwork_sp-0.7.0/src/formwork/provenance.py +217 -0
- formwork_sp-0.7.0/src/formwork/refs.py +282 -0
- formwork_sp-0.7.0/src/formwork/sections.py +152 -0
- formwork_sp-0.7.0/src/formwork/spec_templates.py +149 -0
- formwork_sp-0.7.0/src/formwork/templates/_prelude.js.j2 +227 -0
- formwork_sp-0.7.0/src/formwork/templates/_probe_legs.js.j2 +598 -0
- formwork_sp-0.7.0/src/formwork/templates/_probe_pagestate.js.j2 +263 -0
- formwork_sp-0.7.0/src/formwork/templates/_probe_setup.js.j2 +71 -0
- formwork_sp-0.7.0/src/formwork/templates/apply.js.j2 +208 -0
- formwork_sp-0.7.0/src/formwork/templates/discover.js.j2 +182 -0
- formwork_sp-0.7.0/src/formwork/templates/extract.js.j2 +59 -0
- formwork_sp-0.7.0/src/formwork/templates/findprobe.js.j2 +423 -0
- formwork_sp-0.7.0/src/formwork/templates/preview.html.j2 +60 -0
- formwork_sp-0.7.0/src/formwork/templating.py +53 -0
- formwork_sp-0.7.0/src/formwork/text.py +263 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/PKG-INFO +633 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/SOURCES.txt +54 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/dependency_links.txt +1 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/entry_points.txt +2 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/requires.txt +8 -0
- formwork_sp-0.7.0/src/formwork_sp.egg-info/top_level.txt +1 -0
- formwork_sp-0.7.0/tests/test_apply_guard.py +442 -0
- formwork_sp-0.7.0/tests/test_bundle.py +69 -0
- formwork_sp-0.7.0/tests/test_canvas.py +251 -0
- formwork_sp-0.7.0/tests/test_catalogue.py +701 -0
- formwork_sp-0.7.0/tests/test_cli.py +160 -0
- formwork_sp-0.7.0/tests/test_cli_compile.py +121 -0
- formwork_sp-0.7.0/tests/test_dsl.py +1105 -0
- formwork_sp-0.7.0/tests/test_findings.py +792 -0
- formwork_sp-0.7.0/tests/test_generator.py +778 -0
- formwork_sp-0.7.0/tests/test_mirror_reporting.py +86 -0
- formwork_sp-0.7.0/tests/test_multipage.py +500 -0
- formwork_sp-0.7.0/tests/test_preview.py +240 -0
- formwork_sp-0.7.0/tests/test_provenance.py +242 -0
- formwork_sp-0.7.0/tests/test_refs.py +445 -0
- formwork_sp-0.7.0/tests/test_sections.py +411 -0
- formwork_sp-0.7.0/tests/test_spec_templates.py +425 -0
- formwork_sp-0.7.0/tests/test_styling_evidence.py +217 -0
- formwork_sp-0.7.0/tests/test_text.py +210 -0
- formwork_sp-0.7.0/tests/test_version.py +53 -0
- 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 `:` 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 `:` (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
|
+
`:` 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 `:` |
|
|
177
|
+
| `font-size` | `<p><span style="font-size:24px;">size by style</span></p>` | yes, `:` stored as `:` |
|
|
178
|
+
| `background` | `<p><span style="background-color:#fff100;">background by style</span></p>` | yes, `:` stored as `:` |
|
|
179
|
+
| `styled-link` | `<p><a href="https://example.com/" style="color:#0078d4;text-decoration:underline;">styled link</a></p>` | yes, `:` stored as `:` |
|
|
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 `:` |
|
|
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 (`{`, `"`, `:`), 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
|
+
`:`, 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 `:`. 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.
|