Skip to content

Commons: site@1 and the site cross-type convention — several sites from one personal stack #206

Description

@cuibonobo

Motivation

A stack is a personal data store, and one person's publishing rarely maps to one website.
The motivating case is two: a personal site (hobbies, photos, art) and a professional one
(closer to a resume). Both carry pages and both carry articles, drawn from the same stack.

The page tree already survives this. page.md anticipates it:

The site container is app territory — An app managing multiple sites parents each
site's root pages to its own site record; single-site setups need no container at all.

Path derivation walks parentId ancestors and stops at the first non-page ancestor, so
inserting a container as the parent of each site's root pages changes no rule — the walk
just terminates on a named record instead of on null. Two about pages under two sites
are two records at two paths, and they don't collide, because collision is per-path and
the paths are in different trees.

Collections do not survive it. A listing root selects members with
collection: { typeId, tag?, order? }, and typeId is stack-global. /articles/ on the
personal site and /writing/ on the professional site both select every article@1 in the
stack. The same applies to photo, bookmark, and post listings.

So the gap is not the tree. It is that nothing in the commons says which records a site
publishes
, and the field that looks like it could is already spoken for.

Why collection.tag cannot carry this

The obvious workaround is a site:personal / site:professional tag selected via
collection.tag. It is a dead end rather than merely inelegant: tag is a single string,
so spending it on site membership means a multi-site stack can never have a tag archive.
A professional-site archive of travel would need tag to carry both the site and the
topic. Site-as-tag and tag-as-topic collide in one field.

Why parentId cannot carry it either

Parenting articles to a site record is single-valued, so an article published on both sites
is unrepresentable — and it inverts the ownership the stack is for. The article is the
owner's; the sites are things that publish it. parentId would say the site owns the
article.

Why this belongs in the commons and not in a generator

"Site container is app territory" is right for single-site, where the container is absent
and root pages are the site. It stops being right at multi-site, because there the
container becomes load-bearing structure — it is the thing that makes a page tree a site,
and the thing that says which articles a site publishes. A second generator reading the
same stack cannot derive either. Leaving it to app territory reintroduces, one level up,
exactly the generator lock-in page@1 exists to prevent.

Intended writer: @haverstack/eleventy (see #205), which needs it to build the two
sites described above from one stack.


Schema

await stack.defineType('org.haverstack/site@1', 'Site', {
  title: { kind: 'string', required: true },
  baseUrl: { kind: 'string', required: true },
  description: { kind: 'text' },
  handle: { kind: 'string' },
});

Read-compat core: { title, baseUrl }.

Semantics

Field Kind Required Meaning
title string yes The site's name. Feed titles, <title> suffixes, bylines.
baseUrl string yes Origin and optional path prefix the site is published at, no trailing slash. What makes cross-site canonical links and absolute feed URLs derivable.
description text no Feed and metadata description. Absent means the generator omits it rather than inventing one.
handle string no Stable lookup key — personal, professional. Lets a build config name a site without embedding an opaque record ID. Uniqueness is a writer obligation, as with menu.handle; a generator resolving a handle to zero or several sites fails loudly.

baseUrl is required because the canonical-URL machinery below is the main thing the
record exists for, and because a site is a thing published at a location — without one
there is a page tree but not yet a site.

Structure: root pages are parented to the site

A page whose parentId names a site@1 record is a root page of that site. Path
derivation is unchanged: walk parentId ancestors, stop at the first non-page, and a
site ancestor is simply one such stop.

Membership: the site cross-type convention

{ kind: 'relationship', label: 'site', recordId: <site record id> }

An article, photo, bookmark, or post carrying this association is published on that
site. Multi-valued: two associations means the record appears on both sites, which is the
cross-posting case parentId could not express.

This is proposed as a cross-type convention alongside location, embed, series and
author, and structurally it is the same shape as location: a label that points from any
record at a record of one commons type. Per the README that carries a higher bar than a
field, since it is proposed for every record in every stack. The narrower alternative —
specifying the label only in site.md, per design rule 6 — is available if the bar isn't
met, and nothing else in this proposal depends on which way it goes.

Pages use parentId, other members use the association, and the split is principled.
A page's relationship to its site is containment: it determines the page's path, and a
page lives at exactly one path, so a single-valued native field is correct. An article's
relationship to a site is publication: the article exists independently of any site and
may be published on several. Same reason page.md puts hierarchy in parentId and
membership in a query.

Collection scoping is derived, not stored

A collection root with a site ancestor selects only members carrying that site's
membership association.
The rule is a convention on page@1, not a new field on
collection.

Storing the site on the collection would denormalize a fact the tree already carries — the
same objection page.md raises when it rejects full-path slugs in favour of deriving them,
and it would make a listing root under one site able to claim another site's members. It
belongs in the commons rather than in a generator's config for the reason collection
itself does: it is an interop rule, and an interop rule that lives in one generator dies
there.

Resolved, a scoped collection is one indexed query, composing filters that all exist today:

{
  typeId: 'org.haverstack/article@1',
  tags: ['travel'],                                  // topic — still free
  relatedTo: { recordId: siteId, label: 'site' },    // site
}

Single-site stacks are unaffected

Both rules degrade to current behavior when no site record exists: a page with no site
ancestor is at the site root, and a collection root with no site ancestor selects members
without regard to membership. Single-site setups still need no container, exactly as
page.md says today.

Canonical URLs

article.url is stamped with the canonical location at first publish. A record published
on two sites has two locations and one field, which is a conflict only multi-site produces.

The resolution needs no new mechanism: whichever site's baseUrl prefixes the stored url
is the canonical one, and a site building a record whose url points elsewhere renders
<link rel="canonical"> at it instead of claiming it. First publish wins, matching the
convention article.md already documents. If order-independent control is ever wanted, a
site-canonical label variant marks it without an association carrying data — but that is
not proposed here.


Prior art

  • WordPress Multisite — one install and one shared user table behind many sites. The
    useful contrast is that it partitions content tables per site, so a post belongs to
    exactly one site and cross-posting means duplication. The association here is the
    multi-valued version of the same idea.
  • Micro.blog — one account, several blogs, one shared media library. Closest to the
    motivating case: the personal store is primary and the sites are things published from it.
  • Hugo / Eleventy multi-site — separate config plus separate content directories. Site
    membership is expressed as filesystem location, which is precisely the generator-local,
    non-portable encoding this proposal moves into records.
  • IndieWeb rel=canonical / POSSE — one canonical location per work, other copies
    pointing back at it, which is the rule adopted above.

Evaluation against the design rules

  1. Minimal required core — two required fields, both constitutive: a site has a name
    and a location. description and handle are optional.
  2. Additive evolutionsite@1 is new; the page.md changes are convention text, not
    schema, so nothing bumps and no records need migrating.
  3. Properties, not perspectives — worth stating precisely, since a site can sound like a
    perspective. The record is a thing in the world with a title and a URL, so its fields
    are properties. The membership is the perspective-shaped half, and it is expressed as
    an association exactly as this rule directs — never as a sites: string[] content field
    on the article.
  4. Queryable fields are top-level scalarshandle is the field generators filter on
    and it is a top-level scalar.
  5. Use the native machinery — membership is a relationship association, hierarchy is
    parentId, and the association points and marks without carrying data. No bare ID
    strings in content.
  6. Well-known association labels are part of the type — the site label is specified
    here with the same authority as a field.
  7. String vocabularies over booleans-in-waiting — none introduced.

Scope

  • docs/commons/site.md — new type file, with changelog.
  • docs/commons/page.md — replace the "site container is app territory" convention with
    the container's definition; add the collection-scoping convention; note both degrade for
    single-site.
  • docs/commons/README.md — namespace list, initial-set table, site cross-type convention.
  • packages/commons/src/index.tsSITE export, mirroring the fenced block above.

No change to Stack, to RecordFilter, or to any adapter: parentId, relationship
associations and Filter.relatedTo already carry all of it.

Relationship to other issues

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions