Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/sitetile/astro/content/_site.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
inbox-bubble-except: /blocks, /markers
---

4 changes: 4 additions & 0 deletions packages/sitetile/astro/content/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ supposed to mean.

### Your name
### Tell us more {textarea}

## Existing inbox bubble
%% sitetile: embed %%
<div data-dynamic-coral="inbox-bubble" data-kind="site" data-id="smoke-site" data-kaito="1" data-api-base="https://feelreef.com" data-title="Form coral coverage"></div><script type="module" src="https://feelreef.com/corals/inbox-bubble/v0/inbox-bubble.js"></script>
4 changes: 4 additions & 0 deletions packages/sitetile/astro/content/ko-kr/_site.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
inbox-bubble: off
---

1 change: 1 addition & 0 deletions packages/sitetile/astro/content/markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ sitetile-page: markers
title: Marker coverage — hero variants + linked cells
lang: en-US
locales: en-US, zh-TW
inbox-bubble: on
---

## Logo hero, uncropped
Expand Down
17 changes: 15 additions & 2 deletions packages/sitetile/astro/smoke-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ console.log('▸ astro build → dist-smoke/ (themeless + staged Lilac custom-th
if (existsSync(LILAC_STAGED)) throw new Error(`refusing to shadow an existing staged theme: ${LILAC_STAGED}`);
copyFileSync(LILAC_SOURCE, LILAC_STAGED);
try {
execFileSync('npx', ['astro', 'build', '--outDir', DIST], { cwd: HERE, stdio: 'inherit' });
execFileSync('npx', ['astro', 'build', '--outDir', DIST], {
cwd: HERE, stdio: 'inherit',
env: { ...process.env, SITE_ID: 'smoke-site', PLATFORM_ORIGIN: 'https://feelreef.com' },
});
} finally {
rmSync(LILAC_STAGED, { force: true });
}
Expand Down Expand Up @@ -63,6 +66,8 @@ const signedPost = findPost('a-signed-post');
const unsignedPost = findPost('an-unsigned-post');
const markers = readFileSync(join(DIST, 'markers/index.html'), 'utf8');
const customThemeBuilt = readFileSync(join(DIST, 'custom-theme/index.html'), 'utf8');
const localeExcept = readFileSync(join(DIST, 'zh-tw/blocks/index.html'), 'utf8');
const siteOff = readFileSync(join(DIST, 'ko-kr/index.html'), 'utf8');
// Safe negative-control seam: mutate only the HTML held by this test process, never source/output.
const customTheme = process.env.SITETILE_SMOKE_REMOVE_CUSTOM_MARKER === '1'
? customThemeBuilt.replace(/\sdata-theme-custom(?:="")?/, '')
Expand All @@ -83,6 +88,7 @@ const occ = (s) => occIn(html, s);
// pkg-runtimes SiteLayout body-end conditional imports (bleedblend et al) — Astro emits the
// chunk even when no fixture page opts in; it must stay UNREFERENCED by fixtures.
const ALLOWED_INLINE = [
['site inbox bubble module', '/corals/inbox-bubble/v0/inbox-bubble.js'],
['header-overlay', 'rf-header--overlay'],
['parallax', 'st-hero-layered[data-parallax]'],
['pv-gate', 'pv-notice-ok'],
Expand Down Expand Up @@ -156,7 +162,7 @@ function auditScripts() {
while ((m = re.exec(h))) {
if (!executesAsScript(m[1])) { dataBlocks.push(m[1].trim()); continue; }
const src = /src="([^"]+)"/.exec(m[1]);
const body = src ? readFileSync(join(DIST, src[1].replace(/^\//, '')), 'utf8') : m[2];
const body = src ? (/^https?:\/\//.test(src[1]) ? src[1] : readFileSync(join(DIST, src[1].replace(/^\//, '')), 'utf8')) : m[2];
const hit = [...ALLOWED_INLINE, ...ALLOWED_CHUNKS].find(([, sig]) => body.includes(sig));
if (!hit) unaccounted.push(`${page.slice(DIST.length + 1)}: ${(src ? 'src ' + src[1] + ' → ' : '') + body.trim().slice(0, 70)}`);
}
Expand Down Expand Up @@ -208,6 +214,13 @@ function claimedIcons() {
}

const checks = [
// -- born-on site inbox bubble: site/page resolution + legacy embed dedupe --
['inbox bubble: default on with the build site key, platform origin, and site title', () =>
/<div data-dynamic-coral="inbox-bubble" data-kind="site" data-id="smoke-site" data-api-base="https:\/\/feelreef\.com" data-title="Yamada Letterpress — one character, one piece of lead"><\/div>\s*<script type="module" src="https:\/\/feelreef\.com\/corals\/inbox-bubble\/v0\/inbox-bubble\.js"><\/script>/.test(html)],
['inbox bubble: site off suppresses it', () => occIn(siteOff, 'data-dynamic-coral="inbox-bubble"') === 0],
['inbox bubble: locale-agnostic except suppresses /zh-tw/blocks via /blocks', () => occIn(localeExcept, 'data-dynamic-coral="inbox-bubble"') === 0],
['inbox bubble: explicit page on wins over the site /markers exclusion', () => occIn(markers, 'data-dynamic-coral="inbox-bubble"') === 1],
['inbox bubble: a hand-mounted embed is not doubled', () => occIn(forms, 'data-dynamic-coral="inbox-bubble"') === 1],
// -- home.md: the 5 section types + platform defaults --
// 🩸 2026-08-28. `class="st-hero"` was an EXACT string match, so it broke the moment the section
// gained a second class — which every hero variant now does (`.st-full-bleed`, the opt-out from
Expand Down
19 changes: 17 additions & 2 deletions packages/sitetile/astro/src/layouts/SiteLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BRAND_ICON } from '../lib/brands.mjs';
import { iconHrefs } from '@icons';
import { FEED_PATH } from '../lib/feed.mjs';
import { pageGraph, jsonLdScript } from '../lib/structured-data.mjs';
import { inboxBubbleOn } from '../lib/inbox-bubble.mjs';
import NavNode from '../components/NavNode.astro';
import signetLocaleBannerCss from '@cvernet/signet/locale-banner.css?raw';
// Swappable design-system shell: site.css is the DEFAULT (reef) skin; a page opts into another skin
Expand All @@ -24,6 +25,9 @@ const themes = import.meta.glob('../themes/*.css', { query: '?raw', import: 'def
// (pages/[...loc]/language.astro). Everything else has its hreflang set MEASURED from the content
// glob; see the alternateLocales() block below and locale.mjs for why guessing is not an option.
const { title = '', lang = DEFAULT_LANG, meta = {}, blogPosts = [], alternates = '', robots = '' } = Astro.props;
// Render once so the layout can dedupe a legacy hand-mounted embed before deciding whether to add
// the born-on site bubble. set:html below emits Astro's already-rendered slot, not author input.
const renderedPageBody = await Astro.slots.render('default');
const locales = String(meta.locales || '').split(',').map((s) => s.trim()).filter(Boolean);
const themeName = meta.theme ? String(meta.theme).trim() : '';
// The `?raw` + `set:html` inline path bypasses Vite's CSS minifier, so without this every theme's
Expand Down Expand Up @@ -599,6 +603,11 @@ const ogType = meta['og-type'] ? String(meta['og-type']).trim() : 'website';
// /${lang}/ (which is only meaningful for Lingo). Was `localePath(lang)` → every page on such a site
// wrongly canonicalised to /zh-Hant/. 2026-07-13.
const canonicalHref = Astro.site ? new URL(Astro.url.pathname, Astro.site).href : '';
const bubbleSiteId = String(process.env.SITE_ID || process.env.SITE_KEY || meta['site-key'] || '').trim();
const platformOrigin = String(process.env.PLATFORM_ORIGIN || 'https://feelreef.com').replace(/\/+$/, '');
const bubbleTitle = String(meta['site-title'] || meta.title || title || '').trim();
const bubbleKaito = meta['kaito-corpus'] != null || String(meta.packages || '').split(',').map((v) => v.trim()).includes('kaito');
const bubbleOn = !!bubbleSiteId && inboxBubbleOn(meta, Astro.url.pathname, renderedPageBody);
// Feed link title — WordPress renders "<site> &raquo; Feed"; ours just names the feed the site
// already named. No new copy, no new default to get wrong in somebody else's language.
const feedLinkTitle = `${String(meta['blog-title'] || meta.title || '').trim() || 'Feed'}`;
Expand Down Expand Up @@ -1025,10 +1034,10 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '')
</nav>
)}
</aside>
<div class="st-sidebar-main"><slot /></div>
<div class="st-sidebar-main" set:html={renderedPageBody} />
</div>
) : (
<slot />
<Fragment set:html={renderedPageBody} />
)}
</main>

Expand Down Expand Up @@ -1112,6 +1121,12 @@ const ogSiteName = (meta['site-title'] ? String(meta['site-title']).trim() : '')
{footerLinks.length === 0 && !copyright && <p>{title}</p>}
</slot>
</footer>
{bubbleOn && (
<>
<div data-dynamic-coral="inbox-bubble" data-kind="site" data-id={bubbleSiteId} data-kaito={bubbleKaito ? '1' : undefined} data-api-base={platformOrigin} data-title={bubbleTitle}></div>
<script type="module" src={`${platformOrigin}/corals/inbox-bubble/v0/inbox-bubble.js`}></script>
</>
)}
{/* package runtimes (body-end, module). Astro bundles <script> imports that live
in src/, so the vendored bleedblend auto-runtime ships hashed + tree-shaken. */}
{bleed && bleed.bodyScript && <script>import '../packages/bleedblend/auto.mjs';</script>}
Expand Down
27 changes: 27 additions & 0 deletions packages/sitetile/astro/src/lib/inbox-bubble.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const OFF = /^(off|false|no|0)$/i;
const ON = /^(on|true|yes|1)$/i;

export function localeAgnosticPath(pathname, locales = []) {
const path = `/${String(pathname || '/').split(/[?#]/)[0].replace(/^\/+|\/+$/g, '')}`;
const first = path.split('/')[1].toLowerCase();
const localeSet = new Set(locales.map((v) => String(v).trim().toLowerCase()));
return localeSet.has(first) ? (path.slice(first.length + 1) || '/') : path;
}

export function pathMatches(pattern, pathname) {
const p = String(pattern || '').trim();
if (!p) return false;
return p.endsWith('*') ? pathname.startsWith(p.slice(0, -1)) : pathname === p;
}

export function inboxBubbleOn(meta = {}, pathname = '/', renderedBody = '') {
if (renderedBody.includes('data-dynamic-coral="inbox-bubble"')) return false;
const raw = String(meta['inbox-bubble'] ?? 'on').trim();
if (OFF.test(raw)) return false;
if (ON.test(raw) && Object.prototype.hasOwnProperty.call(meta, 'inbox-bubble-page-override')) return true;
const locales = String(meta.locales || '').split(',').map((v) => v.trim()).filter(Boolean);
const path = localeAgnosticPath(pathname, locales);
const excluded = String(meta['inbox-bubble-except'] || '').split(',').some((p) => pathMatches(p, path));
return !excluded;
}

3 changes: 3 additions & 0 deletions packages/sitetile/astro/src/pages/[...path].astro
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export function getStaticPaths() {
// blog-search etc. consistent across ALL pages instead of each page duplicating the chrome.
const site = parseSite(raw);
const siteConfig = loadSite(files, rel);
// Reserved resolution marker: after Site ⊕ page both values look identical, but the layout
// must know that an explicit page `on` wins over the site's exclusion patterns.
if (Object.prototype.hasOwnProperty.call(site.meta, 'inbox-bubble')) site.meta['inbox-bubble-page-override'] = true;
// 🩸 `site-title` is stashed BEFORE the merge because the merge destroys the distinction the
// head needs. og:site_name must be a constant per site, but it was reading headerBrand, which
// falls back to meta.title — and after this merge meta.title is the PAGE's title on any page
Expand Down
19 changes: 19 additions & 0 deletions packages/sitetile/inbox-bubble.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { inboxBubbleOn, localeAgnosticPath, pathMatches } from './astro/src/lib/inbox-bubble.mjs';

test('inbox bubble defaults on and obeys site off', () => {
assert.equal(inboxBubbleOn({}, '/'), true);
assert.equal(inboxBubbleOn({ 'inbox-bubble': 'off' }, '/'), false);
});

test('suffix patterns are locale agnostic', () => {
assert.equal(localeAgnosticPath('/zh-tw/shop/item', ['en-US', 'zh-TW']), '/shop/item');
assert.equal(pathMatches('/shop/*', '/shop/item'), true);
assert.equal(inboxBubbleOn({ locales: 'en-US, zh-TW', 'inbox-bubble-except': '/shop/*' }, '/zh-tw/shop/item'), false);
});

test('a hand-mounted bubble is deduped', () => {
assert.equal(inboxBubbleOn({}, '/', '<div data-dynamic-coral="inbox-bubble"></div>'), false);
});

8 changes: 7 additions & 1 deletion packages/sitetile/site-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ import { highlightCode, knowsLanguage } from '../cssmd/highlight.js';

const FRONTMATTER_KEY = 'sitetile-page';
const KNOWN_TYPES = ['prose', 'hero', 'grid', 'gallery', 'carousel', 'cta', 'embed', 'collection', 'timeline', 'social', 'tagcloud', 'faq', 'form', 'people'];
// Site-layer vocabulary exported beside KNOWN_TYPES so grammar vendors have one renderer-owned
// source of truth for chrome keys that are otherwise invisible to the section model.
const SITE_LAYER_KEYS = [
{ key: 'inbox-bubble', syntax: 'inbox-bubble: on | off', purpose: 'Show the site visitor Q&A/message bubble on every page by default; a page value overrides the site value.' },
{ key: 'inbox-bubble-except', syntax: 'inbox-bubble-except: /shop/*, /checkout', purpose: 'Comma-separated locale-agnostic paths to suppress the bubble; a trailing * matches any suffix.' },
];

// tagcloudLinks: a tagcloud section body (a markdown list of `- [Label](/href)` items) → an
// ordered [{label, href}]. General — a weighted category/tag cloud is a near-universal WP/Blogger
Expand Down Expand Up @@ -1500,6 +1506,6 @@ export {
heroParts, socialParts, linkButtonsHtml, firstImage, imgTag, tagcloudLinks,
// sidebar layout helpers — exported so the Astro layer can reuse the same parser.
parseSidebarNav,
FRONTMATTER_KEY, KNOWN_TYPES,
FRONTMATTER_KEY, KNOWN_TYPES, SITE_LAYER_KEYS,
deriveDescription, DESC_MAX,
};
Loading