@tideworthy/tidysites
v0.1.42
Published
Pull a Tidysites website to files, edit, validate against its content model, push it back, screenshot the result.
Readme
tidysites
A Tidysites website as files. Pull every record and page to a folder, edit them (by hand, with an editor, or with an assistant), check them against the website's content model, push the changes back in one write, and look at the result. The website is the remote: pull, edit, push.
npx -y @tideworthy/tidysites@latest init <site_id> --host https://app.tideworthy.com --key <key>The connector prints that command, with the key filled in, from its
connect_tidysites_cli tool. The key reaches the workspace's websites and
nothing else; it lives in .tidysites/key, which is kept out of git.
Commands
| | |
|---|---|
| tidysites init <site_id> --host <url> --key <key> | connect this folder to a website and pull it |
| tidysites pull [--collections pages,services,menus] | the website's content to files; files for entries the website no longer has are removed |
| tidysites status | what changed here since the pull |
| tidysites validate | every file against the content model, with the file and the field in each message |
| tidysites push [--prune] [--force] [--dry] [--only pages] | validate, then write the created and updated entries back (with --prune, delete the entries and menus whose files are gone), then pull. Validate names an SVG on a picture field that takes none (only logo and icon fields, row media and gallery pictures take SVG) with the field and the types it takes, before anything is sent |
| tidysites shot <path or url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--first-screen] [--live] [--bands [px]] | a screenshot of a page of the preview site, the live site, or any address, saved under .tidysites/shots/; none of the width flags means desktop and mobile; a port compares all four. The page is walked to the bottom and back and every picture loaded before the capture, so lazy pictures are in it. The platform hands a tall page over in 1600px bands when its image service can cut it; a long page (a 11,000px desktop capture, a phone capture at twice the pixels) often comes whole. --bands cuts every capture that came whole into <name>-band-01.jpg, … (1600px of the picture each, or --bands 1200), top to bottom, at every width, beside the whole file (a page shorter than that is written as <name>-band-01.jpg too, so a script finds a band file either way); the cut is made in the file's own blocks, so the bands are the capture's pixels. A fixed or sticky bar is in the one capture once: before it the platform makes sticky elements static and fixed ones absolute, so a header sits at the top of the page and a bar fixed to the bottom of the screen at the foot of the first screen (on a phone, 844px or 1688 pixels down, the top of band 02); it is not repeated in the other bands. --no-fixed cannot hide it: the screenshot service takes no option for it. --menu opens every navigation menu (burgers, <details>, hover dropdowns) before the capture; --open a,b clicks those selectors. --hover a,b cannot move a real pointer or focus: it adds the classes hover, is-hover, open, is-open and active to those elements and shows the lists under them, so a panel opened only by :hover or :focus-within stays closed unless its rule also names one of those classes (.nav-item.is-hover .mega) |
| tidysites measure <path or url> [--mobile] [--tablet] [--desktop] [--wide] [--all] [--full] | the page as the browser lays it out: every heading, paragraph, button, picture and section box with its computed type (size, line height, weight, family, colour) and box (position, size, padding, margin, background), saved as JSON under .tidysites/measures/ and printed. Measure the old page and the new one at the same width and style to the numbers. The same --menu, --open and --hover as shot, so a dropdown's open type is measured |
| tidysites speed <path or url> [--mobile\|--desktop] [--live] [--against <url>] | PageSpeed Insights (Google's Lighthouse) for a page of the preview or live site, or any address: scores, lab and field metrics, the largest-paint element and the opportunities with the files they name, saved under .tidysites/speed/. --against <url> tests the old page in the same call for a side-by-side. Run it on the live site before calling a build done; what the content cannot fix goes in the report |
| tidysites redirect [add <old path> <new path> \| rm <old path>] | the website's redirects: an old address that must keep working after a port goes here; they ship with the next publish |
| tidysites pages | every address the website renders |
| tidysites page <path> [--html] [--out file] | the rendered page, section by section, with the key each section carries; --html prints the page's HTML to stdout and nothing else (page / --html \| grep tidy-template), --out file saves it there and prints the outline |
| tidysites schema [type or collection] | the section types and layouts, or one type's or collection's fields |
| tidysites schema pending | the fields the website's template expects that its content model does not have yet; pull and push print one line when there are any. A fresh site that has not been pushed to answers "Not yet reported" unless the platform's pull names the fields; a pull that carries the website's template version prints it (Pulled … (template 0.47.0)) |
| tidysites gallery [type] | a screenshot of every section type and layout in this website's theme, or the first screen of one type |
| tidysites fetch <url> [--out dir] | a page of the site being ported: cleaned HTML, its outline (headings, navigation, images, phones, fonts, an Adobe Fonts kit and the families it serves, the stylesheet's own variables and colours) and its stylesheets, inline style blocks included |
| tidysites rm <collection>/<slug> ... | delete entry files with their sidecars (services/* for every entry of a collection), or a menu (menus/probe); push --prune then removes them from the website, and a pull before that does not bring them back |
| tidysites skill [--out <dir>] | the Working with Tidysites skill (skill/SKILL.md in this package, the same document the connector serves as get_tidysite_skill) saved into the project as a Claude Code skill, .claude/skills/tidysites/SKILL.md unless --out says where; run it again after an update |
| tidysites publish --yes | the live site becomes a copy of the preview site. A website that is live on the client's own domain is never published from the command line: the website refuses and says so; the person publishes it (Publish now on the Website tab), or an assistant with them does through the connector after they confirm |
A section keeps its type with its key: to replace an intro with a row, give the row its own _key
(validate says so; the website would only say "internal error"). After a push the pushed files take the
website's version (a $media becomes the stored image, _version appears); every other file stays as it is.
A push that would overwrite an entry changed on the website since the pull
is refused with the list; pull again (your files are overwritten, so copy
what you need first) or push --force.
validate (and so every push) also reports a section _key used twice in one entry's list and a new menu
whose name the website refuses (it takes a lowercase letter, then lowercase letters, digits and underscores:
footer_patients, not footer-patients). A push the website stops part way through names the entry it
stopped at; the entries before it are stored and pulled at once, so the next push, once that entry is fixed,
sends only what is left (no --force). After a push, tidysites schema pending says whether the website's
content model has every field its template expects.
The folder
<collection>/<slug>.json { "id", "slug", "status", "updated_at", "data": { ... } }
<collection>/<slug>.css data.custom_css as a file (for rows: data.css); a custom_css written in the JSON again (a build
script writing whole entries) goes with the push, the JSON's when the two differ (validate says
so), and the pull after the push moves it into the .css
rows/<slug>.html data.html as a file
menus/<name>.json a menu as a tree: { "label", "items": [{ "label", "url", "children": [...] }] }
.tidysites/ config.json, key, snapshot.json (the last pull), schema.json (the content model), shots/, source/A new entry is a new <collection>/<slug>.json with at least "data"; the
slug is the file name, the status is published unless the file says
draft. Delete a file (tidysites rm services/old-one, or services/* for all) and push --prune removes the entry. A field taken
out of a file is cleared on the website at the next push. id and
updated_at are the website's; leave them as pulled. A pull never overwrites
a file you changed and have not pushed; it keeps the file and the difference
shows in status.
Styling: a row's .css file is scoped to that row (every rule is prefixed with the row's own selector), so it cannot style the rest of the page; rules for the whole page, the sections and the theme's classes go in business/business.css. Order: theme CSS, then business/business.css, then the page's CSS, then each row's CSS; the row prefix adds the weight of two classes, so a row rule beats business.css on the same element. To let business.css win (a phone-width rule over a header row's layout), put the row rule in @layer row { ... }: unlayered CSS beats layered CSS whatever the weight. Row media takes SVG (cleaned on upload), so a map or icon needs no PNG.
Images: an image field, a hero's image or a Columns image slot takes
{ "$media": { "url": "https://...", "alt": "what it shows" } } (or { "$media": { "file": "assets/x.png", "alt": "..." } } for a picture that exists only as a file in the folder, say an illustration drawn from the old page's inline SVG; the push carries its bytes; a file field, such as a row's media list or the Business font files, takes a video (mp4, webm, up to 25 MB, the most one file of a published site may weigh), a font (woff2) or a pdf the same way) and the
push fetches the file into the website's library; a file the library already
holds (the same bytes) is reused, not added again. An image already in the
library keeps the value the pull gave it.
A script that writes its files again can keep writing $media: the folder
remembers every picture it pushed (.tidysites/media.json, a file by its
bytes, an address by itself) with the value the website stored, so a
picture already in the library is neither sent nor fetched again, and an
entry that differs only by it is not listed by status or pushed. A file
whose bytes change, or a new address, goes up again; a new alt rides on
the stored picture.
status and push compare a file with the website's copy the way the
website counts a push unchanged: a yes/no written true is the stored 1, a
number written "2,140" is 2140, a date "2026-10-28" is the stored day,
markdown numbered by hand reads as the pull's "1." list, an empty list is
nothing written, and a section written without _version takes the one the
website holds. What status lists is what a push sends.
A picture in custom HTML is written as a plain <img src="{{ s.image.url }}" alt="{{ s.image.alt }}">: a stored picture gets its resized variants (srcset, and a sizes hint when none is written) on the way out, so a phone downloads a phone-sized file; write your own sizes when the picture is narrower than half the screen on a laptop, and your own srcset to take over entirely. Pictures in the first section load at once and the first of them asks to go first; pictures below load when they are needed, unless the markup says loading="eager".
Every form carries a hidden trap field a scraper fills and a person never sees. A Forms record or a Request form section can add Cloudflare Turnstile through its Spam protection field; it is off unless chosen, and when chosen its script loads only as the form comes into view or is touched.
References: a record names another by its id (what the pull shows), or by
"$ref:<slug>" for one created in the same push.
Rich text (a section's copy, a post's content) is markdown.
Custom HTML (a row's .html file, a Columns HTML slot) reads the records:
{{ business.phone }}, {{ page.title }}, {{#each services as s}}…{{ s.url }}…{{/each}},
{{#if page.collection == "services"}}…{{/if}}, and the drop-ins {{> menu "primary"}},
{{> logo}}, {{> phone}}, {{> address}}, {{> hours}}, {{> social}}, {{> year}},
{{> button "Label" "/url" style=outline}}, {{> form "Name"}}. A link to #form:<Name>
opens that form in a dialog. So a custom header is written once against page.* and
business.* and fits every page; frame/frame.json names it as header_row, or a page
names its own in header_row.
Building a site from these files
tidysites schemafor the section types,tidysites galleryto see them in this theme.- Records first:
business/business.json(the company's name, tagline, phone, hours, brand),locations/,services/,people/,reviews/,faqs/. frame/frame.jsonfor the top bar, the call bar, the footer headings, the wording;menus/primary.jsonandmenus/footer.jsonfor the menus (an item withchildrenis a dropdown).- Pages as sections:
pages/<slug>.json, each a list indata.sections. Columns is the section for a band with no record behind it. - The look: brand colours and fonts on the Business record,
business/business.cssfor what the tokens cannot say. The theme's own rules are written as.ts.ts-accentand.ts.ts-ink .ts-prose a, so a rule of yours needs the same weight (.ts.ts-accent …) to win. tidysites validate,tidysites push,tidysites shot / --all, and look at every width: a design often shows a different header above 1440px.
The website's own instructions
A website can carry standing instructions from the partner on how it is done. pull (and init) write them into AGENTS.md in the folder, with a CLAUDE.md that imports it (@AGENTS.md), and keep their version in .tidysites/snapshot.json. Both land as a marked block (<!-- tidysites:instructions -->), so a file you already keep under either name stays yours: the block is appended to it, replaced in place on every pull, and taken out when the website has no instructions. Read them before changing anything. push, redirect, and publish send that version with the change; when the website's instructions changed since the last pull, they stop and say to pull again. A website with no instructions gets no files, and ours are taken away on the pull that finds them gone (CLAUDE.md only while it is still the one line we wrote).
The skill
skill/SKILL.md is the Working with Tidysites skill: how a site is put together, where every setting lives, the order to port or build, and this command line's part in it. It is the one copy. The connector serves it as get_tidysite_skill, the platform app imports it from this package, and tidysites skill writes it into a project. A change to a command here, to the content model, or to where a setting lives on the template edits skill/SKILL.md in the same pull request, and ships with the next cli-v* tag.
