docs
/
Building on Appmint

HTML to live pages

The production pipeline — author a site as plain HTML you can open from disk, then deploy it to page records with one command, safely and repeatably.

This is the workflow most real sites here are built with, and it is worth stating plainly because it sounds too simple to be the recommended one:

Write the site as ordinary HTML and CSS in a folder. Open it in a browser from disk. When it looks right, run a script that turns it into page records.

No framework, no bundler, no dev server. You get a site you can work on offline, review as files, keep in git, and deploy in seconds.

Build and deploy a whole site walks through this once, end to end. This page is the production version: the parts that matter at twenty-five pages rather than three, and the failures that only show up at that size.

The source folder

site/
  index.html
  pricing.html
  contact.html
  contact.js          ← page behaviour, optional, one per page
  styles.css          ← one stylesheet, linked by every page
  page-ids.json       ← name → record id

Every .html file is a complete document that works when double-clicked. That property is the whole point — it is what lets you build and review without a deploy.

What the deploy script does

Four transforms, then a write.

1. Inline the stylesheet

There is no static asset host for your CSS. A relative <link> resolves against the route and 404s.

n = len(re.findall(r'<link rel="stylesheet" href="styles\.css"\s*/?>', html))
assert n == 1, f"{name}: expected 1 styles.css link, found {n}"
html = re.sub(r'<link rel="stylesheet" href="styles\.css"\s*/?>',
              lambda m: "<style>\n" + css + "\n</style>", html, count=1)

Match both <link …> and <link … />. A pattern handling one spelling silently fails to substitute on pages using the other, and you deploy a page with no styling — a wall of unstyled text that looks like a catastrophic failure and is a one-character regex bug.

The assertion is what makes this a failed deploy instead of a live incident. I have shipped this exact fault to production.

External stylesheets — fonts and the like — stay as links. Only your own relative paths break.

Locally you link pricing.html. Deployed, the route is /pricing.

def rewrite(m):
    page, tail = m.group(1), m.group(2) or ''      # tail = #fragment or ?query
    return 'href="' + ('/' if page == 'index' else '/' + page) + tail + '"'

html = re.sub(r'href="([a-z0-9\-]+)\.html([#?][^"]*)?"', rewrite, html)

leftover = re.findall(r'href="(?!https?:)[^"]*\.html[^"]*"', html)
assert not leftover, f"{name}: unrewritten links {sorted(set(leftover))[:6]}"

The fragment group is not optional. A pattern requiring the closing quote straight after .html misses index.html#pricing, index.html#modules and every other anchored link — which is to say, the entire navigation, on every page. They deploy verbatim and 404 for every visitor.

I shipped this too. Thirty-five links across twenty-five pages. The leftover assertion catches the whole class in one line; run it every deploy.

3. Extract metadata

title = re.search(r'<title>(.*?)</title>', html, re.S).group(1).strip()
md = re.search(r'<meta name="description" content="(.*?)"', html, re.S)
desc = md.group(1) if md else ""

4. Attach the page's JavaScript

If a .js file sits beside the HTML, it goes into the record's top-level style:

body = {
    "sk": sk,
    "version": rec.get("version", 0),
    "data.html": html,
    "data.title": title,
    "data.description": desc,
}

js_path = f"{SRC}/{name}.js"
if os.path.exists(js_path):
    body["style"] = {"javascript": open(js_path).read()}

style is a sibling of data, not a key inside it. data.style.javascript is read by nothing. See Where your code goes.

While you are authoring, a plain <script src="contact.js"> in the HTML makes the page work from disk; the deploy strips it and sends the file up properly. You get local behaviour and a correct deploy from one source.

The write

s, rec = req("GET", f"/repository/get/page/{sk}", token=t)
assert s == 200

body = { ... }
if name == "index":
    body.update({"data.screens": [], "data.sections": [], "data.content": ""})

req("POST", f"/repository/update-partial/page/{sk}", body, token=t)

data.screens overrides data.html. A page ever saved in the visual editor carries screens, and your data.html edits will deploy successfully and change nothing on screen — every call returns 201, nothing errors, and you will re-deploy four times before suspecting the record.

Clear the other shapes on any page you are taking ownership of.

Always send the version from the get you just did. If someone else wrote in between, the call is rejected rather than silently overwriting them.

The id map

{ "index": "67dbad05b7820b761a55d7e7", "pricing": "6a63d834784850f15a522db2" }

Keep page-ids.json in git beside the source. It is the only link between a file and its record, and without it you are matching records by name and creating duplicates when you guess wrong.

Creating a page that does not exist yet:

s, d = req("PUT", "/repository/create", {
    "datatype": "page",
    "name": name,
    "isNew": True,
    "data": {"name": name, "slug": name, "html": html,
             "title": title, "description": desc},
}, token=t)
ids[name] = d["sk"]
json.dump(ids, open("page-ids.json", "w"), indent=2)

isNew: True is required — without it the create fails with a message about "the new property", which is not a field called new.

Write the id back to the map immediately, in the same breath as the create. A create that succeeds while the map is unsaved leaves an orphan record, and the next run makes a second page with the same name.

Deploying one page

python3 deploy.py contact          # one
python3 deploy.py                  # all of them

Per-page deploys are what make this workflow pleasant. Change a line, push one page, look at it.

Verifying

A 201 means the record changed. It says nothing about what a visitor sees.

Status codes are not a validity check

An unknown path returns 200 with the site's fallback body. curl -w '%{http_code}' returns 200 for every URL you can invent, including /does-not-exist-xyz.

Check the <h1>:

def check(route):
    html = fetch(route)
    m = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S)
    return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip() if m else "(no h1)"

A route rendering the generic site name where you expected a page title is a 404 wearing a 200.

targets = set()
for name in ids:
    route = "/" if name == "index" else "/" + name
    html = fetch(route)
    assert not re.findall(r'href="(?!https?:)[^"]*\.html[^"]*"', html), \
        f"{route}: unrewritten .html links are live"
    targets |= {l.split("#")[0].split("?")[0]
                for l in re.findall(r'href="(/[^"]*)"', html)}

for t_ in sorted(targets):
    print(t_, check(t_))

This is the check that would have caught both regex bugs above, before anyone else saw them.

The served HTML contains your markup more than once — the rendered DOM plus the framework's serialised payload, where < appears as <. A naive grep counts each element two or three times. Match the un-escaped copy, or read the DOM in a browser, before concluding you have duplicates.

Look at it

Render one page and look. Curl greps are especially misleading here: the serialised payload embeds the whole document, so a title grep succeeds on a page whose body is blank.

At minimum — nav styled (catches a failed CSS inline), footer present, and any page script actually ran.

Shared chrome

Every page carrying its own copy of the nav and footer is fine at five pages and a liability at twenty-five: a nav change is twenty-five edits, and one will be missed.

Two options.

Keep the duplication and generate it. Hold the nav and footer in one partial and have the deploy script splice them in. The source files stay standalone; the chrome has one definition.

Use a wrapper page. The platform supports a page whose content wraps others, which is also how built-in routes — checkout, account, search — and Site Features routes — Blog, Forms, Content Player — get your site's chrome rather than a bare default: the page you pick for a feature in Site Features is its template, and its header and footer wrap what the feature renders. If your site has any of those, you want this regardless.

Things that bite at scale

A helper that writes files after a loop. If the loop asserts on a later item, the write never happens and the earlier edits are silently lost. Write each file as you finish it.

Deploying a page you have not read. find returns a partial projection. Fetch with get before editing, or fields you never saw get dropped.

Nested objects in update-partial. {"data": {...}} replaces all of data. Dot-paths change one key.

Assuming the CDN has caught up. Custom domains lag the platform host by a cache cycle. Verify on <site>-<org>.site.appmint.app first; if the custom domain still shows the old page a minute later, that is a cache, not your deploy.

Icon links in your <head>. Stripped by the renderer — favicons come from the site record. See Set a logo and favicon.

Checklist

  • Every source file opens correctly from disk
  • CSS inline asserted on the link count
  • Links rewritten including fragments, with a leftover assertion
  • Page JS in top-level style.javascript
  • data.screens / sections / content cleared where they existed
  • page-ids.json committed, written back on every create
  • isNew: True on creates
  • Every route verified by <h1>, not status code
  • At least one page rendered and looked at

Next: The gotcha index — every trap on this page and the rest, listed by what you see.