DEV Community

labyrinthlab
labyrinthlab

Posted on

Exporting a Solo site: fixing fonts, image paths and forms by hand

Solo's Shutdown FAQ says the export ZIP holds "an HTML export of your website and a CSV file with links to your image assets, as well as your contact form and newsletter submissions". It also says: "Image source files are not included in the ZIP."

You can't just upload that ZIP to a static host. This post covers four problem areas: images, fonts and styles, page links, and forms. For each one, you get the code our Solo export tool uses (or what it doesn't do) and the manual steps.

A caveat first: we haven't seen a real Solo export. Solo doesn't document the file names or folder layout. Our tests run on a synthetic fixture, fixtures/solo-export-example-bakery-with-assets.zip, built by scripts/build_synthetic_export.py. It assumes a layout that matches what Solo's FAQ says the ZIP contains. The code is written to cope with a different wrapper folder.

The fixture

solo-export-example-bakery-with-assets.zip
├── website/
│   ├── index.html
│   └── about/index.html
├── image_links.csv
├── contact_form_submissions.csv
└── newsletter_submissions.csv
Enter fullscreen mode Exit fullscreen mode

index.html contains the kinds of references the code has to handle:

<link rel="icon" href="/favicon/favicon.ico">
<link rel="stylesheet" href="/_next/static/css/x.css">
<script src="https://soloist.ai/_next/static/chunks/main.js"></script>
...
.hero{background-image:url(https://images.unsplash.com/photo-1542653285-7f095a1183e0?crop=entropy&amp;cs=srgb&amp;fm=jpg&amp;q=60&amp;w=1600)}
...
<a href="/example-bakery/about">About</a> · <a href="/about">About</a>
<img src="https://images.unsplash.com/photo-...?...&amp;w=1080" srcset="...w=400 400w, ...w=1080 1080w, ...w=2400 2400w">
<script id="__NEXT_DATA__" type="application/json">{... "url": "https://images.unsplash.com/photo-...?crop=entropy\u0026cs=srgb\u0026fm=jpg\u0026q=85" ...}</script>
Enter fullscreen mode Exit fullscreen mode

Image URLs show up in four places: src, srcset, inline CSS url(), and JSON. The JSON copy escapes & as \u0026.

1. Image paths

What the code does

lib/localize.ts scans every text file (html, htm, css, js, mjs, cjs, json, csv, txt, xml, svg) with one regex that tolerates both escapes:

export const IMAGE_HOSTS = new Set(["cdn.soloist.ai", "images.unsplash.com"]);

/** Matches http(s) URLs, including HTML `&amp;` and JSON `\\u0026`. */
export const URL_RE = /https?:\/\/(?:[^\s"'<>()\\,]|\\u0026)+/g;

export function normalizeUrl(raw: string): string {
  return raw.replace(/&amp;/g, "&").replace(/\\u0026/g, "&");
}
Enter fullscreen mode Exit fullscreen mode

Then it collapses size variants so each image is downloaded once:

if (host === "cdn.soloist.ai") {
  const m = x.pathname.match(/^(.*)_(\d+)x(\d+)(\.\w+)$/);
  if (m) {
    const fetchUrl = `https://${host}${m[1]}_2048x2048${m[4]}`;
    return { key: host + m[1], fetchUrl, original: url, fallbackUrl: url !== fetchUrl ? url : undefined };
  }
}
if (host === "images.unsplash.com") {
  return {
    key: host + x.pathname,
    fetchUrl: `https://${host}${x.pathname}?ixlib=rb-4.0.3&fm=jpg&q=85&w=2400&fit=max`,
    original: url,
    fallbackUrl: url,
  };
}
Enter fullscreen mode Exit fullscreen mode

A cdn.soloist.ai URL ending in _NNNxNNN maps to its _2048x2048 variant. Unsplash URLs are keyed by pathname, so all the w=400, w=1080 and w=2400 variants count as one image. If the upgraded URL fails, downloadOne() in lib/fetch-images.ts retries with the original URL.

Each file is saved as images/<sha1-prefix>.<ext>, with the extension taken from Content-Type. Then every match in every text file gets rewritten to a path relative to that file:

export function posixRelative(fromFile: string, toFile: string): string {
  // ...
  const rel = [...Array.from({ length: fromParts.length - i }, () => ".."), ...toParts.slice(i)].join("/");
  return rel || normalizedTo;
}
Enter fullscreen mode Exit fullscreen mode

A root page gets images/abc.jpg. about/index.html gets ../images/abc.jpg.

In the browser, cdn.soloist.ai images can't be fetched directly. Our README notes they "have no CORS headers", so the tool routes them through a same-origin /api/img proxy. That proxy allows only cdn.soloist.ai over HTTPS and only passes image/* responses.

By hand

CORS only applies inside browsers, so a script or curl can download the images directly.

  1. Grep every text file for cdn.soloist.ai and images.unsplash.com URLs. Decode &amp; and \u0026 before you dedupe.
  2. Dedupe on path, ignoring size suffix and query string. Download the largest variant.
  3. Replace every occurrence, including srcset entries, CSS url() and __NEXT_DATA__, with a relative path. Calculate the path from each file's own folder, not from the site root.

2. Fonts and styles

What the code does

The exported HTML links stylesheets under /_next/. lib/assets.ts only fetches static, non-JS files from that one prefix:

const JS_PATH = /\.(?:js|mjs|cjs)(?:$|[?#])/i;
const ALLOWED_STATIC_EXT =
  /\.(?:css|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|ico|avif)(?:$|[?#])/i;

export function isAllowedSoloStaticAsset(url: string): boolean {
  const u = new URL(url);
  if (u.protocol !== "https:") return false;
  if (u.hostname !== SOLO_ASSET_HOST) return false; // "soloist.ai"
  if (!u.pathname.startsWith(SOLO_PATH_PREFIX)) return false; // "/_next/"
  if (JS_PATH.test(u.pathname)) return false;
  return ALLOWED_STATIC_EXT.test(u.pathname);
}
Enter fullscreen mode Exit fullscreen mode

captureSoloAssets() starts from each <link rel="stylesheet"> (or rel="preload" as="style"). It reads @import and url() from every CSS file it downloads and queues those references up to depth 4. Each file is saved under assets/ at its original path, so https://soloist.ai/_next/static/media/bakery.woff2 becomes assets/_next/static/media/bakery.woff2. CSS url() values are then rewritten relative to the saved CSS file. Our test asserts the result:

const css = new TextDecoder().decode(result.files["assets/_next/static/css/x.css"]);
assert.match(css, /url\(["']?\.\.\/media\/bakery\.woff2/);
assert.doesNotMatch(css, /https:\/\/soloist\.ai\/_next\/static\/media\/bakery/);
Enter fullscreen mode Exit fullscreen mode

fetchAssetWithRetry() retries once on a 502 or a network error. Anything still missing gets one more sweep after a delay (ASSET_SWEEP_DELAY_MS = 2000). If it's still missing after that, it's listed as skipped in the report.

By hand

  1. Download each /_next/static/css/*.css the pages link.
  2. grep -o 'url([^)]*)' and @import in each file. Download those too, and repeat for any CSS they pull in.
  3. Keep the _next/static/... folder structure under one assets/ folder. Most relative url(../media/x.woff2) references then resolve without edits. Rewrite any absolute https://soloist.ai/_next/... references.
  4. Point the <link href> in each page at the local copy, relative to that page.

3. Scripts and page links

stripSoloNextScripts() removes <script src> tags and <link rel="preload" as="script"> tags pointing at soloist.ai. It leaves __NEXT_DATA__ alone, because that tag has no src. Once scripts are removed, the report says: "Interactive parts that relied on Solo's scripts may not work."

Links in the fixture use both /<handle>/about and /about. rootAbsoluteToRelative() maps both onto files in the flattened ZIP. These are the cases from tests/assets.test.ts:

rootAbsoluteToRelative("/example-bakery/about", "index.html", files)          // "about/index.html"
rootAbsoluteToRelative("/about", "index.html", files)                         // "about/index.html"
rootAbsoluteToRelative("/example-bakery/#menu-1", "index.html", files)        // "#menu-1"
rootAbsoluteToRelative("/example-bakery/#menu-1", "about/index.html", files)  // "../index.html#menu-1"
rootAbsoluteToRelative("/favicon/favicon.ico", "index.html", files)           // unchanged
Enter fullscreen mode Exit fullscreen mode

The first path segment only counts as a handle if it isn't a known asset root (favicon, img, images, assets, static, _next, css, js, fonts), doesn't contain a dot, and doesn't match a file or folder in the ZIP.

By hand: delete the Solo <script> tags and script preloads. Then, for every root-absolute href, drop the handle segment and rewrite the rest to a relative path.

4. Forms

Our tool doesn't convert Solo forms. Our static-host guide says it plainly: "The tool strips Solo scripts, and a static host cannot send the form."

The code does handle the submissions data. lib/process-zip.ts treats every .csv (and manifest.csv / report.txt) as private and splits it into a second ZIP:

function isPrivateDataPath(path: string): boolean {
  if (path === "_rescue-data" || path.startsWith("_rescue-data/")) return true;
  const base = path.split("/").pop() ?? path;
  if (PRIVATE_DATA_BASENAMES.has(base)) return true;
  if (base.endsWith(".csv")) return true;
  return base.endsWith("_submissions.csv");
}
Enter fullscreen mode Exit fullscreen mode

countFormSubmissions() in lib/solo/gaps.ts counts non-empty data rows in contact_form_submissions.csv and newsletter_submissions.csv. Its quote-aware CSV splitter keeps a multi-line message as one row.

By hand:

  1. Move every CSV out of the folder you deploy. Those files hold your visitors' names and emails.
  2. Replace the form markup with a mailto: link, or point it at a form backend you control. Static files can't receive a POST.

Output

From the README, the Example Bakery fixture with styles produces:

website-ready-to-host.zip
  index.html
  images/<hash>.jpg
  assets/_next/static/css/x.css
  assets/_next/static/media/...

private-form-data-do-not-upload.zip
  image_links.csv
  contact_form_submissions.csv
  newsletter_submissions.csv
  manifest.csv
  report.txt
Enter fullscreen mode Exit fullscreen mode

Host only the first ZIP. If you'd rather not do this by hand, the free tool runs in the browser: https://site-export-rescue.pages.dev/?utm_source=devto&utm_medium=article&utm_campaign=solo-export-fixes


Written with AI assistance from our own source code and test exports, and checked against them.

Top comments (0)