seed-squarespace
v1.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.
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. Absolute or relative URLs both work, and 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.
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 shell globs work because the shell expands them first.
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).
-h, --helpRun seed-squarespace --help for the authoritative list as shipped.
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 any invalid image URLs seen across the whole run. Inputs are never merged: one input file yields one import batch, so you can load and verify them one at a time.
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.
