npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

seed-squarespace

v2.0.0

Published

Generate WordPress WXR files that reliably import into Squarespace — bulk-load structured data as a Squarespace blog

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 --help

Or 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, and sqs-html-content wrapper <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 attachment item, gives that attachment a post_parent pointing back at its post (unparented attachments orphan on import), and links the two with a _thumbnail_id so 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_id values come from a single monotonic counter shared by posts and attachments, so the post_parent and _thumbnail_id references 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|content
Downtown 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&hellip;</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 as INVALID_URL_FORMAT in batch_errors.log and 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) or NETWORK_ERROR (<code>) (offline, DNS, refused). Each file’s summary line counts the images dropped. Under --no-validate the 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 as Lat:<value> and Lon:<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.txt from 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, --help

seed-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 quoted

A 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 exits 2. 1.0.0 ignored it.
  • A --chunk-size that is not a whole number (--chunk-size abc) now exits 2. 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 exited 0.
  • 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-url containing &, < 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: --verify and --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_*.xml and batch_errors.log back in as inputs (each line becomes a SKIPPED malformed row, exit 1). Keep output and input directories apart.
  • wp:attachment_url is written as given: surrounding spaces are kept, and a ]]> or control character in an image URL is not neutralized (--verify catches the ]]>).
  • --help lags this README: its heading is the old “Squarespace XML Batch Converter”, it shows -o and --chunk-size values as optional, its --verify list is incomplete, and it does not list exit codes. This README is the reference.
  • Argument edge cases: --creator -h prints help instead of an error, -o -x creates a directory named -x, and --chunk-size 0 means no chunking.
  • Wording: counts are not pluralized (“1 records”, “1 issue(s)”), a trailing slash on -o prints as out//, and batch_errors.log timestamps are UTC time of day with no date.
  • Titles are adjusted: the first letter is upper-cased (iPhone Fund becomes IPhone Fund) and straight quotes are stripped from both ends.
  • Two records with the same title and body get the same <guid> (the wp:post_id values 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 engines floor 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-date and date-only values, every record on the same date gets the same timestamp, which loses the input order within that day.
  • --verify checks 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 100 exists.

  • 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_id postmeta 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.

License

MIT