seed-squarespace
v2.0.0
Published
Generate WordPress WXR files that reliably import into Squarespace — bulk-load structured data as a Squarespace blog
Maintainers
Readme
seed-squarespace
Turn a spreadsheet into a browsable, filterable directory that lives natively inside a Squarespace site — no iframe, no embedded third-party widget, no monthly SaaS bill, and every record a real page Google can index. The catch is that Squarespace has no CSV import for blogs, and its one bulk door in — the WordPress importer — is a picky doorman: hand it a stock WordPress export and it turns you away. This tool emits the narrow subset of the WordPress WXR format that the doorman actually waves through, so you can smuggle a whole structured dataset in as an ordinary-looking blog.
That is the trick in one line: a Squarespace blog is a serviceable pseudo-database. Each row of your data becomes a post; the post's categories and tags become the facets your visitors filter on; the post body becomes the detail page. Squarespace does the hosting, the search indexing, and the responsive rendering. You supply the data.
Proven in a 14,752-record production import that built a public grant-directory site — a searchable, year-and-category-filterable listing of grant recipients, served as a native Squarespace collection.
Zero npm dependencies, one file, Node 14 or newer, CLI only.
Hardened: 2026-10-30 · 0f51f1c · gate CLOSED WITH FOLLOW-UPS
Quick start
npx seed-squarespace posts.txt # writes ./xml_posts.xml
npx seed-squarespace posts.txt -o out/ # writes out/xml_posts.xml
npx seed-squarespace 2024.txt 2025.txt -o out/ # one WXR file per input
npx seed-squarespace --helpOr install it globally with npm install -g seed-squarespace, then run seed-squarespace ....
Then, in Squarespace: Settings → Website → Import & Export → Import → WordPress, and upload the generated .xml. Each file you upload becomes its own new blog collection. (The import mechanics have sharp edges; the appendix at the end of this document is the field guide.)
What it does
Squarespace has no built-in CSV or spreadsheet import for blog content, and no WordPress export of its own. The WordPress importer is the only supported path for bulk-loading posts — but a WXR file produced by an actual WordPress site carries far more than the importer needs, and the extra structure is exactly what makes it fail here. This tool writes the minimal, well-formed WXR 1.2 subset that imports cleanly, and handles the parts a hand-written export usually gets wrong:
Squarespace HTML cleanup — strips the leftover
sqs-block,sqs-block-content, andsqs-html-contentwrapper<div>s, and rewrites tags to the XHTML self-closing form (<br />,<hr />,<img ... />) that the importer requires.Featured-image attachments — for each post's image URL it emits a matching
attachmentitem, gives that attachment apost_parentpointing back at its post (unparented attachments orphan on import), and links the two with a_thumbnail_idso the image lands as the post's featured image. It can HEAD-check each URL first so a dead link never produces a stranded attachment.Deterministic, collision-free IDs — post GUIDs are an MD5 of title plus content, so re-running the same input yields the same GUIDs;
wp:post_idvalues come from a single monotonic counter shared by posts and attachments, so thepost_parentand_thumbnail_idreferences always resolve and never collide across a run.Sequential publish dates — the publish date is what a Squarespace blog sorts on, so unless you opt into the source dates the tool stamps each record with a synthetic timestamp one second after the last, in input order, preserving your intended ordering even when the source dates are missing or unreliable.
Chunking — split a large dataset into WXR files of N records each, because the importer's reliability falls off sharply on large single files (see the appendix for the number that works).
Input format
One record per line, pipe-delimited (|), nine fields:
title|slug|date|image-url|category|tags|extra-category-1|extra-category-2|contentDowntown Arts Fund|downtown-arts-fund|2024-03-14|https://example.com/photo.jpg|Grants|nonprofit,downtown|Lat:40.7128|Lon:-74.0060|<p>Full HTML body for the detail page…</p>Field by field:
title,slug— the post title and its URL slug. Slugs are deduplicated per input file, so repeated titles do not collide.date— used as the publish date only under--use-raw-date; otherwise ignored in favor of the synthetic sequential timestamp described above.image-url— the featured image. May be empty. Use an absolute URL: with image validation on (the default), anything that does not parse as one, such as a relative path or plain text, is logged asINVALID_URL_FORMATinbatch_errors.logand the post is written without an image. The same goes for a URL that fails the HEAD check, logged with the reason:HTTP_<status>(a 404, or a host that refuses HEAD requests),TIMEOUT(no answer within two seconds) orNETWORK_ERROR (<code>)(offline, DNS, refused). Each file’s summary line counts the images dropped. Under--no-validatethe value is used as given. Query strings (for example Squarespace's own...image.png?format=1500w) are handled correctly when detecting the image type.category,tags— comma-separated. These become the facets your directory filters on, so this is where the "database" structure actually lives.extra-category-1,extra-category-2— two optional extra category fields. Leave them empty if unused, or put any string in them and each non-empty one is emitted as its own category. The production job used them to carry geodata asLat:<value>andLon:<value>, which is a convention, not a requirement — any label works.content— the post body as HTML. This becomes the detail page.
Notes:
A valid record is exactly nine pipe-delimited fields. Any line that is not is treated as a malformed or misaligned row (typically an upstream parse failure that split one record into fragments), skipped with a console warning, and counted in the run summary rather than silently imported as a junk post. A nine-field row with an empty title is skipped and counted the same way.
Because the delimiter is
|, a literal pipe inside your content or any other field will split the record and trip the nine-field guard. Neutralize pipes in the source data (the production pipeline rewrote them to/) before this tool sees them.Blank lines and lines beginning with
#are skipped.With no input argument, the tool reads
posts.txtfrom the current directory. Inputs may be individual files or directories (every non-hidden file in a directory is processed), and globs work: the shell expands them first, and a quoted glob the shell left alone is matched by the tool.
Options
--output-dir, -o <path> Directory to write XML files and the error log into
(default: current directory).
--chunk-size <num> Split output into files of at most <num> records each
(default: one file per input). Keep this at 100 or
below for reliable imports (see appendix).
--no-images Skip image and attachment handling entirely.
--no-validate Skip the HEAD-request check that an image URL resolves
before attaching it (default: validates).
--use-raw-date Use the date column (field 3) as each post's publish
date. Default: assign sequential synthetic dates one
second apart, in input order, across the whole run.
--format-record Append one extra duplicate of the last post in each
output file, titled "Format This Record" with a
non-indexed slug and a fresh attachment ID — a scratch
post for checking theme styling after import without
touching real content.
--creator <name> dc:creator value for every post (default: admin).
--site-url <url> Base URL for <link> and wp:base_site_url /
wp:base_blog_url (default: http://example.com — the
importer never dereferences this, it is only required
WXR metadata).
--verify After writing each output file, re-read it and scan
for XML corruption. Prints a per-file pass/fail
report and exits non-zero if any file fails.
--verify-only [inputs...] Skip generation; verify existing file(s)
instead. Accepts files, directories, or a glob.
-h, --helpseed-squarespace --help prints the same list in brief (it lags this README in places; see Known limits).
Verification
A WXR file can be well-formed enough to open in a text editor and still fail on import, because the corruption that breaks it is invisible at a glance — a stray control character, or a bare & where the importer expects an entity reference. --verify catches this right after generation: it re-reads each file it just wrote and checks for the things that silently corrupt an import — illegal XML control characters, an unescaped & outside a CDATA section, a literal ]]> outside CDATA, a CDATA section left unclosed, and an <item> that never closes — printing a pass/fail report per file and exiting non-zero if anything fails. It also fails a file that is empty, has no <rss> root, has a mismatched or unclosed tag outside CDATA (what a truncated file from an interrupted run looks like), has content after the closing </rss>, or has a _thumbnail_id that names no wp:post_id in the same file (each file imports on its own, so that featured image would be lost). A --verify run that wrote no file at all says so and exits 1 rather than reporting a pass.
seed-squarespace posts.txt --verify # generate, then verify what was written
seed-squarespace --verify-only out/xml_posts.xml # verify an existing file, no generation
seed-squarespace --verify-only out/ # verify every .xml file in a directory
seed-squarespace --verify-only "out/*.xml" # glob — shell-expanded, or matched internally if quotedA failure reports the offending line and a short excerpt of context:
✗ FAIL out/xml_posts.xml — 1 issue(s):
[Line 12] Unescaped '&' found outside CDATA
Context: ...le.com?a=1&b=2</link...This started life as a standalone QA script run by hand after a batch; it is now a first-class step in the tool itself, so a bad file never makes it as far as the Squarespace importer.
Output
For each input file the tool writes <output-dir>/xml_<name>.xml, or xml_import_<year>.xml when the input filename contains a four-digit year, or ..._part1.xml, ..._part2.xml, and so on when --chunk-size is set. A batch_errors.log in the same directory records every image URL dropped across the whole run, with the reason. Inputs are never merged: one input file yields one import batch, so you can load and verify them one at a time. Two inputs that would write the same file (grants-2024-a.txt and grants-2024-b.txt both map to xml_import_2024.xml, as do a/posts.txt and b/posts.txt) are refused before anything is written, exit 2, naming both; rename one. Each chunk file is self-contained: an image shared by posts in different chunks gets its own attachment in each file.
Exit codes
The exit code says how the run ended, so a script or pipeline can tell a bad flag from a half-failed batch.
| Code | Meaning | When |
|--:|---|---|
| 0 | OK | Every row was processed and, with --verify or --verify-only, every file passed. --help also exits 0. |
| 1 | Partial | The run finished, but something it already reports as a failure happened: a malformed row was skipped (SKIPPED malformed row, which includes an empty title), --verify / --verify-only found a problem in a file, or --verify had no file to check. The output files are still written. An image URL dropped and logged in batch_errors.log (INVALID_URL_FORMAT, HTTP_<status>, TIMEOUT, NETWORK_ERROR) is a warning: it does not by itself make the exit code 1. |
| 2 | Usage | An unknown option, an option missing its value, a --chunk-size that is not a whole number, an input path that does not exist or is not a file (a directory is fine; a subdirectory inside it is not), no input files found, two inputs that would write the same output file, an --output-dir that is a file, or --verify-only with nothing to verify. Nothing is written. |
| 3 | Could not complete | An environment or internal failure: an output file or directory that cannot be written, an input or --verify-only file that cannot be read, or any uncaught exception. A single error: <message> line goes to stderr. |
A usage error prints error: <message> and a pointer to --help on stderr. A reader that closes the output early (seed-squarespace … | head) no longer stops the run: it finishes quietly and writes every file.
Upgrading from 1.0.0
2.0.0 is a major version because a script that worked with 1.0.0 can now stop with an error. A clean run (distinct input names, every row accepted, every image URL reachable, no image shared across chunks) writes the same files and prints the same output as 1.0.0; the test suite checks this against the commit 1.0.0 was published from. What changed (the full list is in CHANGELOG.md):
- A missing input now refuses the whole run (exit
2, nothing written). 1.0.0 skipped it and processed the rest. - An unknown or misspelled option (
--no-imges) now exits2. 1.0.0 ignored it. - A
--chunk-sizethat is not a whole number (--chunk-size abc) now exits2. 1.0.0 treated it as “no chunking”. - Two inputs that map to the same output file now exit
2. 1.0.0 let the second overwrite the first. - A skipped malformed row, now including a row with an empty title (which 1.0.0 dropped without a word), makes the exit code
1. 1.0.0 exited0. - With
--chunk-size, an image shared across chunks now gets an attachment in each file. 1.0.0 wrote it only into the first file, so posts in later files lost their featured image. - An input filename or
--site-urlcontaining&,<or>is now escaped. 1.0.0 wrote it raw, which made the file invalid XML. - An image URL that fails the HEAD check is now logged with its reason and counted. 1.0.0 dropped it silently.
- New:
--verifyand--verify-only, quoted-glob expansion for inputs, and the exit codes above.
Known limits
Found in the 2.0.0 release gate and left as they are for now:
- Re-running with the output directory inside an input directory reads the tool’s own
xml_*.xmlandbatch_errors.logback in as inputs (each line becomes aSKIPPED malformed row, exit1). Keep output and input directories apart. wp:attachment_urlis written as given: surrounding spaces are kept, and a]]>or control character in an image URL is not neutralized (--verifycatches the]]>).--helplags this README: its heading is the old “Squarespace XML Batch Converter”, it shows-oand--chunk-sizevalues as optional, its--verifylist is incomplete, and it does not list exit codes. This README is the reference.- Argument edge cases:
--creator -hprints help instead of an error,-o -xcreates a directory named-x, and--chunk-size 0means no chunking. - Wording: counts are not pluralized (“1 records”, “1 issue(s)”), a trailing slash on
-oprints asout//, andbatch_errors.logtimestamps are UTC time of day with no date. - Titles are adjusted: the first letter is upper-cased (
iPhone FundbecomesIPhone Fund) and straight quotes are stripped from both ends. - Two records with the same title and body get the same
<guid>(thewp:post_idvalues stay unique). How the importer treats a repeated GUID is untested. - Lines starting with
#are skipped as comments without being counted, including a real title such as#1 Fan Club. A byte-order mark before a first-line#comment makes it a malformed row instead. - The
enginesfloor of Node 14 is from a reading of the code, not a run on Node 14. The test suite needs Node 20.1 or newer. - With
--use-raw-dateand date-only values, every record on the same date gets the same timestamp, which loses the input order within that day. --verifychecks structure line by line, without a full XML parser: a comment that spans lines, or a>inside an attribute value, can confuse it.
Appendix: how Squarespace's WordPress importer actually behaves
Everything below was reverse-engineered over the course of the 14,752-record production import — the behaviors that are not documented anywhere and that determine whether a run succeeds. This is the reason the tool emits what it emits.
On the import itself:
One hundred records per file is the reliable ceiling. Imports at that size almost never fail; larger files fail often enough that it is not worth attempting. This is why
--chunk-size 100exists.Squarespace auto-retries a failed import up to three times on its own. When the panel reads "Failed" but also says it is retrying, that is transient — wait it out. Budget up to about five minutes per file rather than the roughly eighty seconds a clean import takes. Do not cancel and do not re-import while a retry is pending: cancelling mid-retry leaves a partial duplicate collection behind that you then have to hunt down and delete.
A cooldown of twenty to thirty seconds between imports measurably lowers the failure rate. Back-to-back imports raise the transient-interruption rate; a short pause between them settles it.
Imports run server-side. The browser tab can disconnect, the extension's service worker can sleep, you can navigate away — and the import keeps going and completes on the server. Never assume a disconnect failed the import; reconnect and re-check the state before acting.
Each WXR file becomes its own new collection, and the channel
<title>element is what names that collection. There is no destination mapping and no merge; the per-file-collection structure is the model, not a limitation to work around.
On categories and content:
Category names are hard-capped at twenty-five characters at creation, and the truncation is permanent — a name that arrives longer is cut and cannot be recovered by editing later. Keep your facet labels within that budget.
Commas cannot survive inside a category name. The production job encoded them as a double underscore (
__) in the source and let the tool translate that back on the way to the slug.The
_thumbnail_idpostmeta must not be CDATA-wrapped, while the other postmeta (such as_wp_attached_file) must be. Get this backwards and the featured-image linkage silently breaks. The tool already emits each correctly.Attachments need a
post_parent, or they orphan on import. Every attachment this tool writes is parented to its post.Tags in the markup must use XHTML self-closing form.
<br>and<img ...>in their HTML4 form can break the import;<br />and<img ... />are required. The HTML cleanup pass enforces this.The publish date is the blog's sort order. This is why the tool defaults to synthetic sequential timestamps: it gives you deterministic control over the order records appear in, independent of whatever dates the source data carried.
Curly typographic punctuation survives the import intact. Smart quotes, apostrophes, and dashes in titles and bodies come through faithfully, so there is no need to flatten them to ASCII before importing (an earlier version of the tool did, and it was undoing deliberate typography).
The one warning that matters most, after import:
- Opening an imported post in the Squarespace admin editor converts it to Fluid Engine and irreversibly rewrites its markup. The importer stores each body as flat HTML in a single html block, which is what makes the detail pages render correctly and the collection sort and filter correctly. The moment you open such a post in the UI editor, Squarespace re-wraps it as a Fluid Engine layout and the click-through formatting breaks — and there is no undo. Treat imported records as read-only in the UI. If you must edit them programmatically after import, do it through the content API, which round-trips the record without triggering the conversion; do not click into them.
Limitations
Posts only. This targets the blog/collection model; it does not create pages, products, events, or any other Squarespace content type.
The importer is officially a one-time migration path. Squarespace supports it for moving off WordPress, not as an ongoing sync, and treating it as a repeatable pipeline means living within that framing.
Blog-as-database suits mostly-static datasets. Re-importing adds new records; it does not reconcile against what is already there. There is no update-in-place or delete-by-key on re-import, so a dataset that changes constantly is a poor fit. A directory that is published and then only occasionally amended is the sweet spot.
