demeter Component
demeter is a Surface Workflow component that fires Demeter SDK callbacks for Sophi
Intelligence updates. It runs as a component workflow in parallel with the other components
(article-body,
gtm-datalayer), consuming the
properties.page, properties.visitor, and properties.outcome produced by the parent Surface
Workflow (see Website Surface Workflow) — it makes no
decisions of its own. It follows the
surface-fetch-edge recipe.
Like all Surface Workflow components, demeter is authored and configured inside MOS (the
surface editor) per client, not part of the Cloudflare Worker repository. The code below is the
reference implementation a client copies in and adapts.
Responsibilities
- Inject the Sophi
demeterinit snippet (<script id="sophi-init">) so calls todemeter(...)queue safely insophi.demeter.actionseven before the real Demeter SDK has loaded. - Fire
demeter("pageview", ...)— every page, article or section. - Fire
demeter("wall", ...)— only when a wall is displayed on an article (outcome.wallVisibility === 'always'andoutcome.wallType !== 'none'on an article page). - Fire
demeter("userProperties", ...)— visitor type metadata on every page.
Output
- A
beforecontent element: thesophi-initscript, sowindow.demeterand its action queue exist ahead of anything else on the page that might call it. aftercontent elements: the callback script performing the pushes above, plus (when debug logging is on) aconsole.logsummarizing this component’s inputs/outputs.
Behavior Notes
- Section name: derived from the pathname’s first path segment (e.g.
/news/some-article/article_xxx.html→news), defaulting tootherwhen the pattern doesn’t match — seegetSectionNamebelow. - Path normalization: a leading
//(protocol-relative syntax) is collapsed to a single/before parsing with the WHATWGURLAPI, which otherwise treats it as a network-path reference and either throws or silently drops part of the path. - Wall callback: only fires when the page is an article and the parent workflow’s outcome
is
wallVisibility: 'always'with awallTypeother thannone— section pages never fire it, regardless ofoutcome.
Extension Points
Section name extraction
- Purpose: classify the current page into a section label for the
pageviewandwallcallbacks. - Default starting point:
getSectionNamebelow assumes a/section/.../slugURL convention and takes the first path segment. - Status: a client whose CMS uses a different URL scheme (or that has section metadata
available via
resource.meta) replacesgetSectionNamewith their own derivation.
Debug Logging
When env.ENABLE_LOGGING === 'debug', a console.log element is emitted before the callback
script, summarizing this component’s inputs (page.pageType, outcome.wallVisibility/
wallType, visitor.visitorType), the derived flags (isArticle, section,
effectiveWallType, firesWallCallback), and which callbacks the script below will fire.
Reference Implementation
// ─── Demeter Analytics Component ──────────────────────────────────────────────
//
// Fires Demeter SDK callbacks for Sophi Intelligence updates. Runs as a component
// workflow in parallel with other components (article-body, gtm-datalayer).
//
// Callbacks fired:
// demeter("pageview", ...) — every page, article or section
// demeter("wall", ...) — only when a wall is displayed on an article
// demeter("userProperties", ...) — visitor type metadata on every page
type WallType = 'paywall' | 'regwall' | 'none';
type WebElement = { type: 'html'; content: string };
// Queues demeter(...) calls in sophi.demeter.actions until the real SDK loads and drains them.
const sophiInitScript: WebElement = {
type: 'html',
content: `<script id="sophi-init">
window.sophi = window.sophi || {};
sophi.demeter = { actions: [], initStatus: "" };
// Lightweight lifecycle tracking helper
window._s = function (c) {
var d = window.sophi.demeter;
var f = String(Math.floor(performance.now() / 10) / 100).replace(
/^0\\./,
".",
);
d.initStatus = (d.initStatus ? d.initStatus + "," : "") + c + f;
};
window.demeter =
window.demeter ||
function () {
var resolve, reject;
const s = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
sophi.demeter.actions.push([arguments, resolve, reject]);
return s;
};
_s("i");
</script>`,
};
const workflow: SurfaceWorkflow = async ({ resource, surfaceProperties, env }) => {
const debug = env.ENABLE_LOGGING === 'debug';
const path = resource.id || '/';
let pathname = '/';
try {
// Collapse a leading "//" (protocol-relative syntax) into a single "/".
// The WHATWG URL parser treats a leading "//" as a network-path
// reference and parses whatever follows as a hostname — this either
// throws (e.g. "//", "///") or silently drops part of the path
// (e.g. "//foo/bar" → pathname "/", hostname "foo").
const normalized = typeof path === 'string' ? path.replace(/^\/{2,}/, '/') : path;
pathname = new URL(normalized, 'https://example.com').pathname || '/';
} catch {
pathname = typeof path === 'string' && path.startsWith('/') ? path : '/';
}
const section = getSectionName(pathname);
// ─── Read page info from Surface Workflow ─────────────────────────────────
const page = (surfaceProperties?.page ?? {}) as Record<string, unknown>;
const pageType = String(page.pageType ?? 'section');
const article = pageType === 'article';
// ─── Read visitor info from Surface Workflow ──────────────────────────────
const visitor = (surfaceProperties?.visitor ?? {}) as Record<string, unknown>;
const visitorType = String(visitor.visitorType ?? 'anonymous');
// ─── Determine effective wall type ────────────────────────────────────────
// Wall is only shown on article pages when wallVisibility is 'always'
const outcome = (surfaceProperties?.outcome ?? {}) as Record<string, unknown>;
const wallVisibility = String(outcome.wallVisibility ?? 'never');
const wallType = String(outcome.wallType ?? 'none');
const effectiveWallType: WallType =
(article && wallVisibility === 'always' && wallType !== 'none')
? wallType as WallType
: 'none';
const elements: WebElement[] = [];
if (debug) {
const payload = JSON.stringify({
component: 'demeter',
surfaceProperties: {
page: { pageType },
outcome: { wallVisibility, wallType },
visitor: { visitorType },
},
flags: { isArticle: article, section, effectiveWallType, firesWallCallback: effectiveWallType !== 'none' },
output: `pageview callback${effectiveWallType !== 'none' ? ' + wall callback' : ''} + userProperties callback`,
});
elements.push({ type: 'html', content: `<script>console.log("[MOS Debug] demeter", ${payload});</script>` });
}
elements.push(demeterScript(section, effectiveWallType, article, visitorType));
return {
content: {
before: [sophiInitScript],
after: elements,
},
};
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Extracts the section name from a URL pathname.
* e.g. "/news/some-article/article_xxx.html" → "news"
* Returns "other" if the pattern doesn't match.
*/
function getSectionName(pathname: string): string {
const match = pathname.match(/^\/([^/]+)\//);
return match ? match[1] : 'other';
}
/**
* Builds a `<script>` element that invokes the Demeter callback API.
*
* Fires:
* demeter("pageview", { args: { article, section } }) — always
* demeter("wall", { args: { wallType, section } }) — when wall is shown
* demeter("userProperties", { args: { visitorType } }) — always (with visitor type)
*/
function demeterScript(
section: string,
wallType: WallType,
article: boolean,
visitorType?: string,
): WebElement {
const wallCallback =
wallType !== 'none'
? `\ndemeter("wall", { args: { wallType: ${JSON.stringify(wallType)}, section: ${JSON.stringify(section)} } });`
: '';
const userPropertiesCallback = visitorType
? `\ndemeter("userProperties", { args: { visitorType: ${JSON.stringify(visitorType)} } });`
: '';
return {
type: 'html',
content: `<script>
demeter("pageview", { args: { article: ${article}, section: ${JSON.stringify(section)} } });${wallCallback}${userPropertiesCallback}
</script>`,
};
}
export default workflow;
export { getSectionName };