@port60/template-kit 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/bin/cli.mjs +26 -4
- package/package.json +1 -1
- package/src/commands/content.mjs +42 -0
- package/src/commands/create.mjs +10 -7
- package/src/commands/dev.mjs +134 -8
- package/src/commands/model.mjs +34 -0
- package/src/commands/packageCmd.mjs +3 -3
- package/src/commands/refresh.mjs +48 -0
- package/src/commands/upgrade.mjs +44 -0
- package/src/commands/validate.mjs +2 -3
- package/src/lib/agentsMd.mjs +78 -21
- package/src/vendor/contract/v1/behaviours.json +129 -0
- package/src/vendor/contract/v1/content-bounds.json +3 -3
- package/src/vendor/contract/v1/content-model.json +193 -0
- package/src/vendor/contract/v1/context.json +1985 -141
- package/src/vendor/contract/v1/dialect.json +3 -3
- package/src/vendor/contract/v1/fonts.json +1 -1
- package/src/vendor/contract/v1/imagery.json +4 -4
- package/src/vendor/contract/v1/islands.json +256 -110
- package/src/vendor/contract/v1/layout.json +25 -0
- package/src/vendor/contract/v1/manifest.schema.json +60 -14
- package/src/vendor/contract/v1/sections.json +330 -61
- package/src/vendor/contract/v1/tokens.json +1 -1
- package/src/vendor/contract/v1.lock.json +1 -1
- package/src/vendor/engine/content-footprint.mjs +79 -0
- package/src/vendor/validator/behaviors-runtime.js +2 -0
- package/src/vendor/validator/fixture-art.mjs +109 -0
- package/src/vendor/validator/model-reference.mjs +92 -0
- package/src/vendor/validator/platform-base.css +4250 -0
- package/src/vendor/validator/preview.mjs +497 -23
- package/src/vendor/validator/site-context.mjs +97 -0
- package/src/vendor/validator/validate.mjs +214 -14
- package/starter/assets/theme.css +54 -8
- package/starter/layout.liquid +15 -16
- package/starter/manifest.json +10 -5
- package/starter/sections/campaigns.liquid +23 -0
- package/starter/sections/homeHero.liquid +26 -11
- package/starter/sections/people.liquid +1 -1
- package/starter/sections/values.liquid +6 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// CONTENT FOOTPRINTS (content model v1 — docs/template-content-model.md): which site.* paths a
|
|
2
|
+
// template touches, decidable at publish because the dialect is closed. Lives in the ENGINE so
|
|
3
|
+
// the production loader computes the same footprint the validator stamps — the render path
|
|
4
|
+
// fetches precisely what a template reads, and the two can never disagree.
|
|
5
|
+
import contentModel from '../contract/v1/content-model.json' with { type: 'json' };
|
|
6
|
+
|
|
7
|
+
export { contentModel };
|
|
8
|
+
|
|
9
|
+
// ── Extraction ─────────────────────────────────────────────────────────
|
|
10
|
+
// The dialect is closed, so the site paths a template reads are decidable from its sources: every
|
|
11
|
+
// reference is a literal `site.…` chain (dynamic indexing is refused below, and aliasing the tree
|
|
12
|
+
// itself is refused so a chain can never hide behind a variable). The footprint is collection-
|
|
13
|
+
// granular — `content.events` — because item fields ride the collection fetch.
|
|
14
|
+
|
|
15
|
+
const CHAIN = /\bsite((?:\.[A-Za-z_][A-Za-z0-9_-]*|\[\s*'[^']*'\s*\]|\[\s*"[^"]*"\s*\])+)/g;
|
|
16
|
+
const DYNAMIC_INDEX = /\bsite(?:\.[A-Za-z_][A-Za-z0-9_-]*|\[\s*(?:'[^']*'|"[^"]*")\s*\])*\[\s*(?!\s*['"])[^\]]/;
|
|
17
|
+
const BARE_SITE = /\bsite\s*(?:\}\}|\|)|(?:\bassign\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*|\bfor\s+[A-Za-z_][A-Za-z0-9_]*\s+in\s+)site\s*(?:%\}|\.\s*%\})/;
|
|
18
|
+
|
|
19
|
+
function segmentsOf(chain) {
|
|
20
|
+
const segs = [];
|
|
21
|
+
const re = /\.([A-Za-z_][A-Za-z0-9_-]*)|\[\s*'([^']*)'\s*\]|\[\s*"([^"]*)"\s*\]/g;
|
|
22
|
+
let m;
|
|
23
|
+
while ((m = re.exec(chain)) !== null) segs.push(m[1] ?? m[2] ?? m[3]);
|
|
24
|
+
return segs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the content footprint from the artifact's liquid sources.
|
|
29
|
+
* Returns { footprint: string[], minModelVersion: string|null, errors: string[] } — footprint
|
|
30
|
+
* entries are 'brand' | 'nav' | 'socials' | 'locale' | 'content.<collection>'.
|
|
31
|
+
*/
|
|
32
|
+
export function extractContentFootprint(files) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const touched = new Set();
|
|
35
|
+
const siblings = new Set(contentModel.siblings.keys);
|
|
36
|
+
const collections = contentModel.collections;
|
|
37
|
+
|
|
38
|
+
for (const [path, source] of Object.entries(files)) {
|
|
39
|
+
if (!path.endsWith('.liquid')) continue;
|
|
40
|
+
if (DYNAMIC_INDEX.test(source)) {
|
|
41
|
+
errors.push(`${path}: dynamic indexing into site.* is refused — the content footprint must be decidable at publish. Read a named collection instead.`);
|
|
42
|
+
}
|
|
43
|
+
if (BARE_SITE.test(source)) {
|
|
44
|
+
errors.push(`${path}: aliasing or outputting the bare site tree is refused — reference a named path (site.brand, site.content.<collection>) so the footprint stays decidable.`);
|
|
45
|
+
}
|
|
46
|
+
let m;
|
|
47
|
+
CHAIN.lastIndex = 0;
|
|
48
|
+
while ((m = CHAIN.exec(source)) !== null) {
|
|
49
|
+
const segs = segmentsOf(m[1]);
|
|
50
|
+
if (segs.length === 0) continue;
|
|
51
|
+
const head = segs[0];
|
|
52
|
+
if (siblings.has(head)) {
|
|
53
|
+
touched.add(head);
|
|
54
|
+
} else if (head === 'content') {
|
|
55
|
+
if (segs.length < 2) {
|
|
56
|
+
errors.push(`${path}: references site.content without a collection — name the collection (the footprint must be decidable).`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const collection = segs[1];
|
|
60
|
+
if (!collections[collection]) {
|
|
61
|
+
errors.push(`${path}: site.content.${collection} is not in content model ${contentModel.version} — see contract/v1/content-model.json for the collections that exist.`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
touched.add(`content.${collection}`);
|
|
65
|
+
} else {
|
|
66
|
+
errors.push(`${path}: site.${head} is not part of the content model — site carries brand, nav, socials, locale and content.*.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const versions = [...touched]
|
|
72
|
+
.filter((t) => t.startsWith('content.'))
|
|
73
|
+
.map((t) => collections[t.slice('content.'.length)].since);
|
|
74
|
+
const minModelVersion = versions.length
|
|
75
|
+
? versions.sort((a, b) => Number(b.split('.')[1] ?? 0) - Number(a.split('.')[1] ?? 0))[0]
|
|
76
|
+
: null;
|
|
77
|
+
return { footprint: [...touched].sort(), minModelVersion, errors };
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// GENERATED by scripts/build-behaviors-runtime.mjs — do not edit by hand.
|
|
2
|
+
"use strict";var p60Behaviors=(()=>{var L=Object.defineProperty;var w=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var M=Object.prototype.hasOwnProperty;var k=(r,i)=>{for(var t in i)L(r,t,{get:i[t],enumerable:!0})},S=(r,i,t,n)=>{if(i&&typeof i=="object"||typeof i=="function")for(let e of x(i))!M.call(r,e)&&e!==t&&L(r,e,{get:()=>i[e],enumerable:!(n=w(i,e))||n.enumerable});return r};var T=r=>S(L({},"__esModule",{value:!0}),r);var R={};k(R,{initBehaviors:()=>K});var A=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches;function v(r,i){let t=`data-p60-bound-${i}`;return r.hasAttribute(t)?!0:(r.setAttribute(t,""),!1)}function E(r,i){if(r.length===0)return;if(A()||!("IntersectionObserver"in window)){r.forEach(i);return}let t=new IntersectionObserver(n=>{for(let e of n)e.isIntersecting&&(t.unobserve(e.target),i(e.target))},{rootMargin:"0px 0px -10% 0px",threshold:.15});r.forEach(n=>t.observe(n))}function H(r){let i=Array.from(r.querySelectorAll("[data-p60-reveal-group]")).filter(n=>!v(n,"reveal-group"));for(let n of i)Array.from(n.children).forEach((e,o)=>{e.style.setProperty("--p60-reveal-index",String(o)),e.classList.add("p60-reveal-child")});E(i,n=>n.classList.add("is-revealed"));let t=Array.from(r.querySelectorAll("[data-p60-reveal]")).filter(n=>!v(n,"reveal"));E(t,n=>n.classList.add("is-revealed"))}function q(r){let i=Array.from(r.querySelectorAll("[data-p60-count]")).filter(t=>!v(t,"count"));E(i,t=>{t.classList.add("is-revealed");let n=Number.parseInt(t.getAttribute("data-p60-count")??"",10);if(!Number.isFinite(n)||A())return;let e=Math.min(Number.parseInt(t.getAttribute("data-p60-count-duration")??"1200",10)||1200,4e3),o=new Intl.NumberFormat(document.documentElement.lang||void 0),d=performance.now(),s=a=>{let c=Math.min((a-d)/e,1),u=1-Math.pow(1-c,3);t.textContent=o.format(Math.round(n*u)),c<1&&requestAnimationFrame(s)};requestAnimationFrame(s)})}function N(r){let i=Array.from(r.querySelectorAll("[data-p60-progress]")).filter(t=>!v(t,"progress"));if(!A()){for(let t of i){let n=t.style.width||getComputedStyle(t).width;t.style.transition="none",t.style.width="0px",t.offsetWidth,t.style.transition="width 1.1s cubic-bezier(0.22, 1, 0.36, 1)",t.dataset.p60ProgressTarget=n}E(i,t=>{t.classList.add("is-revealed"),t.style.width=t.dataset.p60ProgressTarget??""})}}function P(r){let i=Array.from(r.querySelectorAll("[data-p60-countdown]")).filter(t=>!v(t,"countdown"));for(let t of i){let n=Date.parse(t.getAttribute("data-p60-until")??"");if(!Number.isFinite(n))continue;let e=t.getAttribute("data-p60-countdown")||"dhm",o=e==="dhms",d=()=>{let a=n-Date.now();if(a<=0){t.classList.add("is-elapsed");let l=t.getAttribute("data-p60-elapsed-text");l&&(t.textContent=l),window.clearInterval(s);return}let c=Math.floor(a/1e3),u=Math.floor(c/86400),f=Math.floor(c%86400/3600),g=Math.floor(c%3600/60),m=c%60,h=[];e.includes("d")&&u>0&&h.push(`${u}d`),h.push(`${f}h`,`${g}m`),o&&h.push(`${m}s`),t.textContent=h.join(" ")},s=window.setInterval(d,o?1e3:15e3);d()}}function C(r){let i=Array.from(r.querySelectorAll("[data-p60-accordion]")).filter(t=>!v(t,"accordion"));for(let t of i){let n=t.getAttribute("data-p60-accordion")!=="multi",e=Array.from(t.querySelectorAll("details"));for(let o of e){o.addEventListener("toggle",()=>{if(o.classList.toggle("is-open",o.open),o.open&&n)for(let a of e)a!==o&&a.open&&(a.open=!1)}),o.open&&o.classList.add("is-open");let s=o.querySelector("summary")?.nextElementSibling;s&&!A()&&o.addEventListener("toggle",()=>{if(!o.open)return;let a=s.scrollHeight;s.style.overflow="hidden",s.animate([{height:"0px",opacity:"0"},{height:`${a}px`,opacity:"1"}],{duration:260,easing:"ease-out"}).addEventListener("finish",()=>{s.style.overflow=""})})}}}function I(r){let i=Array.from(r.querySelectorAll("[data-p60-carousel]")).filter(t=>!v(t,"carousel"));for(let t of i){let n=Array.from(t.querySelectorAll("[data-p60-slide]"));if(n.length<2)continue;let e=Math.max(0,n.findIndex(l=>l.classList.contains("is-active"))),o=!1,d=[],s=document.createElement("nav");s.className="p60-carousel-dots",s.setAttribute("aria-label","Slides"),n.forEach((l,y)=>{let p=document.createElement("button");p.type="button",p.className="p60-carousel-dot",p.setAttribute("aria-label",`Go to slide ${y+1}`),p.addEventListener("click",()=>a(y,!0)),d.push(p),s.appendChild(p)}),t.appendChild(s),t.setAttribute("aria-roledescription","carousel"),t.hasAttribute("tabindex")||t.setAttribute("tabindex","0");let a=(l,y=!1)=>{e=(l+n.length)%n.length,n.forEach((p,b)=>{p.classList.toggle("is-active",b===e),b===e?(p.removeAttribute("aria-hidden"),p.removeAttribute("inert")):(p.setAttribute("aria-hidden","true"),p.setAttribute("inert",""))}),d.forEach((p,b)=>{p.classList.toggle("p60-carousel-dot--active",b===e),p.setAttribute("aria-current",b===e?"true":"false")}),t.style.setProperty("--p60-carousel-index",String(e)),y&&h()};t.querySelectorAll("[data-p60-carousel-prev]").forEach(l=>l.addEventListener("click",()=>a(e-1,!0))),t.querySelectorAll("[data-p60-carousel-next]").forEach(l=>l.addEventListener("click",()=>a(e+1,!0))),t.addEventListener("keydown",l=>{l.key==="ArrowLeft"&&a(e-1,!0),l.key==="ArrowRight"&&a(e+1,!0)});let c=0,u=0;t.addEventListener("pointerdown",l=>{c=l.clientX,u=l.clientY}),t.addEventListener("pointerup",l=>{let y=l.clientX-c,p=l.clientY-u;Math.abs(y)>40&&Math.abs(y)>Math.abs(p)&&a(e+(y<0?1:-1),!0)}),t.addEventListener("mouseenter",()=>{o=!0}),t.addEventListener("mouseleave",()=>{o=!1}),t.addEventListener("focusin",()=>{o=!0}),t.addEventListener("focusout",()=>{o=!1});let f,g=t.getAttribute("data-p60-carousel")==="auto"&&!A(),m=Math.max(Number.parseInt(t.getAttribute("data-p60-interval")??"6000",10)||6e3,3e3),h=()=>{f!==void 0&&window.clearInterval(f),g&&(f=window.setInterval(()=>{!o&&!document.hidden&&a(e+1)},m))};a(e),h()}}function F(r){let i=Array.from(r.querySelectorAll("[data-p60-sticky-header]")).filter(t=>!v(t,"sticky-header"));for(let t of i){let n=t.getAttribute("data-p60-sticky-header")==="auto-hide",e=window.scrollY,o=!1,d=()=>{let s=window.scrollY;t.classList.toggle("is-condensed",s>24),n&&(s>160&&s>e?t.classList.add("is-hidden"):t.classList.remove("is-hidden")),e=s,o=!1};window.addEventListener("scroll",()=>{o||(o=!0,requestAnimationFrame(d))},{passive:!0}),d()}}function D(r){let i=Array.from(r.querySelectorAll("[data-p60-sticky-cta]")).filter(t=>!v(t,"sticky-cta"));for(let t of i){let n=Number.parseInt(t.getAttribute("data-p60-sticky-cta")??"480",10)||480,e=!1,o=()=>{t.classList.toggle("is-stuck",window.scrollY>n),e=!1};window.addEventListener("scroll",()=>{e||(e=!0,requestAnimationFrame(o))},{passive:!0}),o()}}function Y(r){let i=Array.from(r.querySelectorAll("[data-p60-lightbox]")).filter(t=>!v(t,"lightbox"));for(let t of i){let n=Array.from(t.querySelectorAll("a[data-p60-lightbox-item]"));if(n.length===0)continue;let e=null,o=0,d=null,s=()=>{e?.remove(),e=null,t.classList.remove("is-open"),document.removeEventListener("keydown",c),d?.focus()},a=f=>{o=(f+n.length)%n.length;let g=n[o];if(!e)return;let m=e.querySelector(".p60-lightbox-img"),h=e.querySelector(".p60-lightbox-caption");m&&(m.src=g.href);let l=g.getAttribute("data-p60-caption")??g.querySelector("img")?.alt??"";h&&(h.textContent=l),m&&(m.alt=l)},c=f=>{if(f.key==="Escape"&&s(),f.key==="ArrowLeft"&&a(o-1),f.key==="ArrowRight"&&a(o+1),f.key==="Tab"&&e){let g=Array.from(e.querySelectorAll("button")),m=g.indexOf(document.activeElement),h=f.shiftKey?m<=0?g.length-1:m-1:m>=g.length-1?0:m+1;g[h]?.focus(),f.preventDefault()}},u=(f,g)=>{d=g,e=document.createElement("div"),e.className="p60-lightbox",e.setAttribute("role","dialog"),e.setAttribute("aria-modal","true"),A()&&(e.style.transition="none"),e.innerHTML='<figure><img class="p60-lightbox-img" alt=""><figcaption class="p60-lightbox-caption"></figcaption></figure>';let m=(l,y,p)=>{let b=document.createElement("button");return b.type="button",b.className=l,b.setAttribute("aria-label",y),b.addEventListener("click",p),e.appendChild(b),b};n.length>1&&(m("p60-lightbox-prev","Previous image",()=>a(o-1)),m("p60-lightbox-next","Next image",()=>a(o+1)));let h=m("p60-lightbox-close","Close",s);e.addEventListener("click",l=>{l.target===e&&s()}),document.body.appendChild(e),t.classList.add("is-open"),document.addEventListener("keydown",c),a(f),h.focus()};n.forEach((f,g)=>f.addEventListener("click",m=>{m.preventDefault(),u(g,f)}))}}function $(r){let i=Array.from(r.querySelectorAll("[data-p60-tabs]")).filter(t=>!v(t,"tabs"));for(let t of i){let n=Array.from(t.querySelectorAll("[data-p60-tab]")),e=Array.from(t.querySelectorAll("[data-p60-panel]"));if(n.length===0||e.length===0)continue;let o=s=>e.find(a=>a.getAttribute("data-p60-panel")===s.getAttribute("data-p60-tab"));n[0].closest("[data-p60-tabs] > *")?.setAttribute("role","tablist");let d=(s,a=!1)=>{for(let c of n){let u=c===s;c.classList.toggle("is-active",u),c.setAttribute("aria-selected",u?"true":"false"),c.setAttribute("tabindex",u?"0":"-1")}for(let c of e){let u=c===o(s);c.classList.toggle("is-active",u),c.hidden=!u}a&&s.focus()};n.forEach((s,a)=>{s.setAttribute("role","tab"),s.addEventListener("click",c=>{c.preventDefault(),d(s)}),s.addEventListener("keydown",c=>{let u=c.key;u==="ArrowLeft"&&d(n[(a-1+n.length)%n.length],!0),u==="ArrowRight"&&d(n[(a+1)%n.length],!0),u==="Home"&&d(n[0],!0),u==="End"&&d(n[n.length-1],!0)})}),e.forEach(s=>s.setAttribute("role","tabpanel")),d(n.find(s=>s.classList.contains("is-active"))??n[0])}}function K(r=document){document.documentElement.classList.add("p60-js"),H(r),q(r),N(r),P(r),C(r),I(r),F(r),D(r),Y(r),$(r)}return T(R);})();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// FIXTURE ART — the studio's sealed imagery. Fixtures reference imagery as `p60fixture:<kind>/<seed>`
|
|
2
|
+
// and this module resolves every reference before a render: by default to a deterministic inline-SVG
|
|
3
|
+
// scene (a data: URI, so the network-dead studio CSP renders it), or — when the caller passes an
|
|
4
|
+
// `imageBase` — to `<imageBase>/<kind>-<seed>.jpg`, the dev-richer half of the dev-richer /
|
|
5
|
+
// studio-sealed split (the same shape as the behaviour runtime: the kit's dev server may opt into
|
|
6
|
+
// the one platform CDN host; the studio document never fetches anything).
|
|
7
|
+
//
|
|
8
|
+
// The art is deliberately scenery, not labels: layered gradients, a horizon, soft shapes — enough
|
|
9
|
+
// composition that cards, carousels and banners read like a real site rather than a wireframe.
|
|
10
|
+
// Deterministic by seed so goldens stay byte-identical run to run.
|
|
11
|
+
|
|
12
|
+
const PALETTES = {
|
|
13
|
+
hero: [['#27413a', '#0f2b22', '#c9a961'], ['#1f3550', '#0c1a2c', '#d08c4a'], ['#3c2f42', '#191423', '#c96f6f']],
|
|
14
|
+
event: [['#2c2440', '#141024', '#e0a458'], ['#402438', '#1f0f1c', '#d97f6a'], ['#1f3345', '#0d1a26', '#7fb3d9']],
|
|
15
|
+
article: [['#39404a', '#20242b', '#a9b6c4'], ['#414036', '#24231c', '#c4b58e']],
|
|
16
|
+
campaign: [['#59332a', '#2b1712', '#e8a15f'], ['#2a4759', '#122430', '#7fc4b0']],
|
|
17
|
+
cause: [['#5c3040', '#2c1420', '#e0937f'], ['#33504a', '#152824', '#a5c98e']],
|
|
18
|
+
service: [['#2f4d3e', '#132a1f', '#9fc490'], ['#31495c', '#14222e', '#8fb8c9']],
|
|
19
|
+
people: [['#4d4238', '#292019', '#d3a97c'], ['#3d4550', '#1f242b', '#b0a3c4']],
|
|
20
|
+
course: [['#2d4a55', '#112530', '#83c1b0'], ['#4a3c2d', '#251d11', '#c1a583']],
|
|
21
|
+
media: [['#26243a', '#100e1e', '#8f7fd9'], ['#3a2430', '#1e0f18', '#d97fae']],
|
|
22
|
+
volunteer: [['#3e4a2d', '#1e2511', '#b7c183'], ['#2d3f4a', '#111e25', '#83a9c1']],
|
|
23
|
+
impact: [['#44503c', '#232b1e', '#c2cf9f'], ['#503c46', '#2b1e25', '#cf9fb4']]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SIZES = { wide: [160, 90], photo: [150, 100], portrait: [96, 120] };
|
|
27
|
+
|
|
28
|
+
// A tiny deterministic PRNG seeded from the reference string — good enough for composition,
|
|
29
|
+
// stable across runs and platforms.
|
|
30
|
+
function rng(seedText) {
|
|
31
|
+
let h = 2166136261;
|
|
32
|
+
for (let i = 0; i < seedText.length; i += 1) {
|
|
33
|
+
h ^= seedText.charCodeAt(i);
|
|
34
|
+
h = Math.imul(h, 16777619);
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
h ^= h << 13; h ^= h >>> 17; h ^= h << 5;
|
|
38
|
+
return ((h >>> 0) % 1000) / 1000;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scene(kind, seed, aspect) {
|
|
43
|
+
const [w, hgt] = SIZES[aspect] ?? SIZES.wide;
|
|
44
|
+
const random = rng(`${kind}/${seed}`);
|
|
45
|
+
const set = PALETTES[kind] ?? PALETTES.hero;
|
|
46
|
+
const [top, bottom, accent] = set[Math.floor(random() * set.length)];
|
|
47
|
+
|
|
48
|
+
// Horizon line + two hill paths, varied by seed.
|
|
49
|
+
const horizon = hgt * (0.52 + random() * 0.2);
|
|
50
|
+
const hill = (base, amp, fill, opacity) => {
|
|
51
|
+
const y1 = base - amp * random();
|
|
52
|
+
const y2 = base - amp * random();
|
|
53
|
+
const y3 = base - amp * random();
|
|
54
|
+
return `<path d='M0 ${base.toFixed(1)} C ${w * 0.2} ${y1.toFixed(1)}, ${w * 0.4} ${y2.toFixed(1)}, ${w * 0.6} ${y3.toFixed(1)} S ${w} ${y1.toFixed(1)}, ${w} ${base.toFixed(1)} L ${w} ${hgt} L 0 ${hgt} Z' fill='${fill}' opacity='${opacity}'/>`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// A sun/moon disc and a small cluster of soft foreground shapes.
|
|
58
|
+
const discX = w * (0.15 + random() * 0.7);
|
|
59
|
+
const discY = horizon * (0.35 + random() * 0.4);
|
|
60
|
+
const discR = 6 + random() * (w / 14);
|
|
61
|
+
const clusters = Array.from({ length: 3 }, () => {
|
|
62
|
+
const cx = w * random();
|
|
63
|
+
const cy = horizon + (hgt - horizon) * random();
|
|
64
|
+
const r = 2 + random() * 6;
|
|
65
|
+
return `<circle cx='${cx.toFixed(1)}' cy='${cy.toFixed(1)}' r='${r.toFixed(1)}' fill='${accent}' opacity='${(0.12 + random() * 0.2).toFixed(2)}'/>`;
|
|
66
|
+
}).join('');
|
|
67
|
+
|
|
68
|
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${hgt}'>` +
|
|
69
|
+
`<defs><linearGradient id='s' x1='0' y1='0' x2='0' y2='1'>` +
|
|
70
|
+
`<stop offset='0' stop-color='${top}'/><stop offset='1' stop-color='${bottom}'/>` +
|
|
71
|
+
`</linearGradient></defs>` +
|
|
72
|
+
`<rect width='${w}' height='${hgt}' fill='url(#s)'/>` +
|
|
73
|
+
`<circle cx='${discX.toFixed(1)}' cy='${discY.toFixed(1)}' r='${discR.toFixed(1)}' fill='${accent}' opacity='.55'/>` +
|
|
74
|
+
hill(horizon, hgt * 0.22, bottom, '.85') +
|
|
75
|
+
hill(hgt * 0.82, hgt * 0.14, top, '.9') +
|
|
76
|
+
clusters +
|
|
77
|
+
`<rect width='${w}' height='${hgt}' fill='${bottom}' opacity='.08'/>` +
|
|
78
|
+
`</svg>`;
|
|
79
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const REF = /^p60fixture:([a-z]+)\/([a-z0-9-]+)(?:\/(wide|photo|portrait))?$/;
|
|
83
|
+
|
|
84
|
+
/** Resolve one `p60fixture:` reference, or return the value untouched. */
|
|
85
|
+
export function resolveFixtureImage(value, options = {}) {
|
|
86
|
+
if (typeof value !== 'string') return value;
|
|
87
|
+
const m = REF.exec(value);
|
|
88
|
+
if (!m) return value;
|
|
89
|
+
const [, kind, seed, aspect] = m;
|
|
90
|
+
if (options.imageBase) {
|
|
91
|
+
return `${String(options.imageBase).replace(/\/$/, '')}/${kind}-${seed}.jpg`;
|
|
92
|
+
}
|
|
93
|
+
return scene(kind, seed, aspect ?? 'wide');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Deep-resolve every `p60fixture:` reference in a fixture tree. Returns a resolved copy; the
|
|
98
|
+
* input is never mutated (renders with different options must not contaminate each other).
|
|
99
|
+
*/
|
|
100
|
+
export function resolveFixtureArt(value, options = {}) {
|
|
101
|
+
if (typeof value === 'string') return resolveFixtureImage(value, options);
|
|
102
|
+
if (Array.isArray(value)) return value.map((v) => resolveFixtureArt(v, options));
|
|
103
|
+
if (value && typeof value === 'object') {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const [k, v] of Object.entries(value)) out[k] = resolveFixtureArt(v, options);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// THE MODEL, VISIBLE (content model v1): a self-contained reference page the kit's dev server
|
|
2
|
+
// serves at /model, the registry's shape beside the LIVE example data the preview is rendering
|
|
3
|
+
// right now (preview-content.json overlay included), so "what can I read?" is answered where the
|
|
4
|
+
// developer already lives. Network-dead like every preview document.
|
|
5
|
+
import { contentModel } from '../engine/content-footprint.mjs';
|
|
6
|
+
|
|
7
|
+
const escapeHtml = (s) =>
|
|
8
|
+
String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
9
|
+
|
|
10
|
+
function fieldRows(item) {
|
|
11
|
+
return Object.entries(item)
|
|
12
|
+
.map(([name, spec]) => {
|
|
13
|
+
const notes = [];
|
|
14
|
+
if (spec.nullable) notes.push('nullable, always branch');
|
|
15
|
+
if (spec.enumOpen) {
|
|
16
|
+
notes.push(spec.enumOpen.length
|
|
17
|
+
? `open enum: ${spec.enumOpen.join(', ')} today, more will arrive`
|
|
18
|
+
: 'open enum');
|
|
19
|
+
}
|
|
20
|
+
if (spec.max) notes.push(`max ${spec.max} chars`);
|
|
21
|
+
return `<tr><td><code>${escapeHtml(name)}</code></td><td>${escapeHtml(spec.type)}</td>` +
|
|
22
|
+
`<td>${escapeHtml(notes.join(' · '))}</td><td>${escapeHtml(spec.since)}</td></tr>`;
|
|
23
|
+
})
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function exampleOf(site, name) {
|
|
28
|
+
const items = site?.content?.[name];
|
|
29
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
30
|
+
return JSON.stringify(items[0], null, 2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The /model page: registry + the live example values of THIS preview's site tree. */
|
|
34
|
+
export function renderModelReferenceHtml(site) {
|
|
35
|
+
const sectionsHtml = Object.entries(contentModel.collections)
|
|
36
|
+
.map(([name, model]) => {
|
|
37
|
+
const example = exampleOf(site, name);
|
|
38
|
+
return `<section id="${escapeHtml(name)}">
|
|
39
|
+
<h2><code>site.content.${escapeHtml(name)}</code></h2>
|
|
40
|
+
<p class="meta">bounded at <strong>${model.cap}</strong>${model.moreHref ? ` · more at <code>${escapeHtml(model.moreHref)}</code>` : ''} · since ${escapeHtml(model.since)}</p>
|
|
41
|
+
<p>${escapeHtml(model.description)}</p>
|
|
42
|
+
<table><thead><tr><th>Field</th><th>Type</th><th>Notes</th><th>Since</th></tr></thead>
|
|
43
|
+
<tbody>${fieldRows(model.item)}</tbody></table>
|
|
44
|
+
${example ? `<details><summary>Live example (item 0 of what this preview is rendering)</summary><pre>${escapeHtml(example)}</pre></details>` : '<p class="meta">No items in the current preview data.</p>'}
|
|
45
|
+
</section>`;
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
|
|
49
|
+
const toc = Object.keys(contentModel.collections)
|
|
50
|
+
.map((name) => `<a href="#${escapeHtml(name)}">${escapeHtml(name)}</a>`)
|
|
51
|
+
.join(' · ');
|
|
52
|
+
|
|
53
|
+
return `<!doctype html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head>
|
|
56
|
+
<meta charset="utf-8">
|
|
57
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
59
|
+
<title>site.content, the content model</title>
|
|
60
|
+
<style>
|
|
61
|
+
body { font: 15px/1.6 system-ui, sans-serif; margin: 0 auto; max-width: 56rem; padding: 24px 20px 64px; color: #172029; background: #fbfbf9; }
|
|
62
|
+
h1 { margin: 0 0 4px; } h2 { margin: 40px 0 2px; font-size: 20px; }
|
|
63
|
+
.meta { color: #5a6672; font-size: 13px; margin: 2px 0 8px; }
|
|
64
|
+
.lede { color: #3c4854; max-width: 46rem; }
|
|
65
|
+
nav.toc { font-size: 13px; margin: 14px 0 6px; color: #5a6672; }
|
|
66
|
+
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
|
|
67
|
+
th, td { text-align: left; border-bottom: 1px solid #e3e3dc; padding: 5px 10px 5px 0; vertical-align: top; }
|
|
68
|
+
th { font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: #5a6672; }
|
|
69
|
+
code { background: #eeeee6; border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
|
70
|
+
pre { background: #10161c; color: #dbe4ec; border-radius: 8px; padding: 12px 14px; overflow-x: auto; font-size: 12.5px; }
|
|
71
|
+
details { margin: 8px 0 0; } summary { cursor: pointer; font-size: 13px; color: #5a6672; }
|
|
72
|
+
.rules { background: #f0f0e8; border-radius: 10px; padding: 14px 18px; font-size: 13.5px; }
|
|
73
|
+
.back { font-size: 13px; }
|
|
74
|
+
</style>
|
|
75
|
+
</head>
|
|
76
|
+
<body>
|
|
77
|
+
<p class="back"><a href="/">← back to the preview</a></p>
|
|
78
|
+
<h1>The content model</h1>
|
|
79
|
+
<p class="lede">Everything your template reads lives on one tree: <code>site.brand</code>, <code>site.nav</code>, <code>site.socials</code>, <code>site.locale</code> and the typed collections below. Model version <strong>${escapeHtml(contentModel.version)}</strong>. The validator computes your content footprint from the paths you read and stamps the minimum model version at publish, you never declare versions.</p>
|
|
80
|
+
<div class="rules">
|
|
81
|
+
<strong>Four rules the model never breaks (so neither should you):</strong>
|
|
82
|
+
every collection is bounded (cap + <code>moreHref</code>), link onward, never assume you have everything;
|
|
83
|
+
enum fields are open, branch on the values you style, fall back for the rest;
|
|
84
|
+
optional fields are explicitly nullable, always branch;
|
|
85
|
+
the model only grows, nothing you read today is ever removed within the major.
|
|
86
|
+
Replace the preview data with your own via <code>preview-content.json</code> (shape fixed, hot-reloaded, never packaged).
|
|
87
|
+
</div>
|
|
88
|
+
<nav class="toc">${toc}</nav>
|
|
89
|
+
${sectionsHtml}
|
|
90
|
+
</body>
|
|
91
|
+
</html>`;
|
|
92
|
+
}
|