Differentiator

Templates that think in graphs

Every other static CMS does "related posts" via tag overlap. We do it via embedding similarity, multi-hop traversal, and link prediction — and the cognitive cost is a one-liner.

The pitch in 90 seconds

StaticOwl's substrate is a graph engine (InvariantDB), not a flat key-value store. Most flat-CMS shops have a "related items" widget that's WHERE tag = 'foo'. We expose six Liquid tags that wrap engine procedures customers couldn't reach before: similarity over content embeddings, multi-hop traversal without writing Cypher, BM25 + vector search, COUNT with engine pushdown, link-prediction recommendations, and pageRank-driven trending.

Each tag is a one-liner. None requires the author to know Cypher. The cost over flat-tag-matching is the same syntax surface; the value is qualitatively better.

The six tags

{% similar %} — related items via embeddings

{% similar to:"{{ id }}" limit:5 as match %}
  <a href="{{ match.url }}">{{ match.title }}</a>
  <span>{{ match.score | times: 100 | round }}% match</span>
{% else %}
  No related items.
{% endsimilar %}

Wraps db.similar(id, k): WL-style structural fingerprints on every content node, deterministic and ML-free. score is cosine similarity 0–1. The embedding captures structure (which other nodes does this connect to?), not just text overlap — a CSS Grid post that shares outbound MENTIONS edges with another post is "similar" to it even if the wording differs. Flat tag-match can't do that at any cost.

{% traverse %} — multi-hop, no Cypher

{% traverse from:"{{ id }}" via:"-[:HAS_LESSON]->-[:NEXT]->" type:"lesson" as l %}
  <a href="{{ l.url }}">{{ l.title }}</a>
{% endtraverse %}

The via: is a Cypher path fragment with a strict allow-list (uppercase letters, digits, underscore, colon, brackets, dash, greater-than). The tag splices it into a MATCH (root {id: $from})<via>(n) against the engine. Two-hop, three-hop, anything writable in path-grammar. No Cypher knowledge required for the author; no engine endpoint exposed for arbitrary injection.

{% search %} — build-time semantic search

{% search "how to deploy" in:"doc" limit:5 as r %}
  <li><a href="{{ r.url }}">{{ r.title }}</a></li>
{% endsearch %}

BM25 + optional vector rerank when configured. Falls back to CONTAINS when the engine procedure isn't reachable, so templates still render usefully on cold-start. The first positional arg is the quoted query — interpolated against the rendering context, so {% search "{{ category }} tutorials" %} works.

{% count %} — engine-pushdown aggregate

{% count type:"lesson" where:"parentId:{{ id }}" as n %}
<p>{{ n }} lessons in this course</p>

Single-shot — no iteration block. The as binding becomes a number in surrounding scope. One indexed MATCH … RETURN count(n) on the engine, with CSR pushdown when available. When the hook isn't wired yet, n is 0 — the template doesn't crash.

{% recommend %} — algorithm-driven "what next"

{% recommend after:"{{ id }}" type:"lesson" algo:"jaccard" limit:3 as next %}
  <a href="{{ next.url }}">{{ next.title }}</a>
  <small>{{ next.reason }}</small>
{% endrecommend %}

Wraps db.linkPrediction.predict. Algorithm whitelist: jaccard (default), adamicAdar, commonNeighbors, preferentialAttachment. Returns row.reason carrying the engine's explain string — your "Up next" widget can show why each pick was chosen, which most flat-CMS recommenders can't do because they're tag-match by construction.

{% trending %} — pageRank with a window

{% trending type:"post" window:"7d" limit:5 as t %}
  <li><a href="{{ t.url }}">{{ t.title }}</a></li>
{% endtrending %}

PageRank over the recency-weighted edge subgraph. window: accepts Nm/Nh/Nd/Nw (e.g. 7d, 24h). When the engine procedure isn't available, the tag falls back to a recency-ordered list — templates render usefully on cold-start instead of being empty.

Why this is the differentiator

Visible failure mode

If db.similar errors (most common cause on bulk-imported sites: the nodes haven't been embedded yet), the tag silently returns zero rows. We surface this as a structured transformError in the build response so the author sees the gap end-to-end:

{
  "transformErrors": [
    {
      "contentId": "lesson:cs101-intro",
      "transform": "similar",
      "error": "{% similar to:\"lesson:cs101-intro\" %} failed: . If this is a freshly-imported site, run db.materializeEmbeddings on the content type (or POST /api/maintenance/embeddings/materialize?type=X) — bulk import does NOT auto-embed."
    }
  ]
}

The companion repair endpoint is one curl call:

curl -X POST "https://app.staticowl.com/api/maintenance/embeddings/materialize?type=lesson" \
  -H "Authorization: Bearer so_…" \
  -H "X-Site-Id: site:my-site"

Idempotent. Safe to re-run after every bulk import.

Where to start