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
- Minimal required core — two required fields, both constitutive: a site has a name
and a location. description and handle are optional.
- Additive evolution —
site@1 is new; the page.md changes are convention text, not
schema, so nothing bumps and no records need migrating.
- 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.
- Queryable fields are top-level scalars —
handle is the field generators filter on
and it is a top-level scalar.
- 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.
- Well-known association labels are part of the type — the
site label is specified
here with the same authority as a field.
- 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.ts — SITE 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
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.mdanticipates it:Path derivation walks
parentIdancestors and stops at the first non-pageancestor, soinserting 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. Twoaboutpages under two sitesare 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? }, andtypeIdis stack-global./articles/on thepersonal site and
/writing/on the professional site both select everyarticle@1in thestack. The same applies to
photo,bookmark, andpostlistings.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.tagcannot carry thisThe obvious workaround is a
site:personal/site:professionaltag selected viacollection.tag. It is a dead end rather than merely inelegant:tagis 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
travelwould needtagto carry both the site and thetopic. Site-as-tag and tag-as-topic collide in one field.
Why
parentIdcannot carry it eitherParenting 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.
parentIdwould say the site owns thearticle.
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@1exists to prevent.Intended writer:
@haverstack/eleventy(see #205), which needs it to build the twosites described above from one stack.
Schema
Read-compat core:
{ title, baseUrl }.Semantics
titlestring<title>suffixes, bylines.baseUrlstringdescriptiontexthandlestringpersonal,professional. Lets a build config name a site without embedding an opaque record ID. Uniqueness is a writer obligation, as withmenu.handle; a generator resolving a handle to zero or several sites fails loudly.baseUrlis required because the canonical-URL machinery below is the main thing therecord 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
parentIdnames asite@1record is a root page of that site. Pathderivation is unchanged: walk
parentIdancestors, stop at the first non-page, and asiteancestor is simply one such stop.Membership: the
sitecross-type conventionAn
article,photo,bookmark, orpostcarrying this association is published on thatsite. Multi-valued: two associations means the record appears on both sites, which is the
cross-posting case
parentIdcould not express.This is proposed as a cross-type convention alongside
location,embed,seriesandauthor, and structurally it is the same shape aslocation: a label that points from anyrecord 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'tmet, 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.mdputs hierarchy inparentIdandmembership in a query.
Collection scoping is derived, not stored
A collection root with a
siteancestor selects only members carrying that site'smembership association. The rule is a convention on
page@1, not a new field oncollection.Storing the site on the collection would denormalize a fact the tree already carries — the
same objection
page.mdraises 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
collectionitself 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:
Single-site stacks are unaffected
Both rules degrade to current behavior when no
siterecord exists: a page with nositeancestor is at the site root, and a collection root with no
siteancestor selects memberswithout regard to membership. Single-site setups still need no container, exactly as
page.mdsays today.Canonical URLs
article.urlis stamped with the canonical location at first publish. A record publishedon 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
baseUrlprefixes the storedurlis the canonical one, and a site building a record whose
urlpoints elsewhere renders<link rel="canonical">at it instead of claiming it. First publish wins, matching theconvention
article.mdalready documents. If order-independent control is ever wanted, asite-canonicallabel variant marks it without an association carrying data — but that isnot proposed here.
Prior art
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.
motivating case: the personal store is primary and the sites are things published from it.
membership is expressed as filesystem location, which is precisely the generator-local,
non-portable encoding this proposal moves into records.
rel=canonical/ POSSE — one canonical location per work, other copiespointing back at it, which is the rule adopted above.
Evaluation against the design rules
and a location.
descriptionandhandleare optional.site@1is new; thepage.mdchanges are convention text, notschema, so nothing bumps and no records need migrating.
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 fieldon the article.
handleis the field generators filter onand it is a top-level scalar.
parentId, and the association points and marks without carrying data. No bare IDstrings in content.
sitelabel is specifiedhere with the same authority as a field.
Scope
docs/commons/site.md— new type file, with changelog.docs/commons/page.md— replace the "site container is app territory" convention withthe container's definition; add the collection-scoping convention; note both degrade for
single-site.
docs/commons/README.md— namespace list, initial-set table,sitecross-type convention.packages/commons/src/index.ts—SITEexport, mirroring the fenced block above.No change to
Stack, toRecordFilter, or to any adapter:parentId, relationshipassociations and
Filter.relatedToalready carry all of it.Relationship to other issues
falls in the ordering.
shape that RFC reshapes, and one that would be written across every record in the import.
It strengthens the case for landing RFC: discriminated union for RelationshipAssociation targets #16 before the import rather than after.
unlistedpermission tier — reachable by URI, absent from feeds and listings (split from #15) #202 — orthogonal.unlistedgoverns whether a record appears in its own site'slistings; membership governs which site's listings it is a candidate for.