CtrlK
BlogDocsLog inGet started
Tessl Logo

adobe/stardust

Redesign an existing website to make it better. Built on top of impeccable.

71

Quality

89%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

ai-readability.mjsskills/deploy/scripts/

#!/usr/bin/env node
/**
 * skills/deploy/scripts/ai-readability.mjs — the AI-readability gate (#100, reference/ai-readability.md).
 *
 * Exact reimplementation of the formula Adobe's "AI Content Visibility Checker" uses (read from the
 * extension's analyzer, v3.1.0):  score = min(100, servedWords / renderedWords × 100), where served is
 * the page fetched with a ChatGPT-User UA and no JavaScript, rendered is the live DOM serialised as
 * textContent (hidden text counts), both sides stripped of script (JSON-LD kept as text), style,
 * template, media, cookie/consent containers and — by default — nav/header/footer landmarks.
 *
 * Per page it reports:
 *   strict     the checker's popup number (landmarks ignored) and the toggle-off variant
 *   code       strict with referenced fragment documents credited to the served side and the
 *              --exclude-blocks app blocks removed from the rendered side (what block code owns)
 *   servedGap  rendered words absent from the served HTML, attributed per block (metric 2:
 *              what a non-rendering crawler never reads — nav/footer fetch, fragments, index cards)
 *
 *   node skills/deploy/scripts/ai-readability.mjs --origin https://main--site--org.aem.live \
 *        [--paths file | /path …] [--min 98] [--token-env SITE_TOKEN | --auth-header "token …"] \
 *        [--exclude-blocks client-app,widget,form] [--allowlist file.json] [--json out.json] [--verbose]
 *
 * Exit 1 when any page's code score is below --min; 2 on infrastructure failure.
 * Allowlist: [{ "block": "location-finder", "string": "N locations", "reason": "…" }] — a runtime
 * value the block is allowed to generate; its words are removed from that block's servedGap and
 * from the code denominator. Entries name a block and a string, never a page.
 */
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

export const CHATGPT_UA = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot';
export const LANDMARKS = 'nav,header,footer,.nav,.navigation,.navbar,.nav-bar,.menu,.main-menu,.header,.site-header,.page-header,.footer,.site-footer,.page-footer,#nav,#navigation,#navbar,#header,#footer,#menu,[role="navigation"],[role="banner"],[role="contentinfo"]';

/* -------------------------------------------------------------- in-page code -- */
/** Runs inside the page. Returns both scenarios, the code score and per-block attribution. */
export function analyse({ served, fragments, landmarks, excludeBlocks, allow }) {
  const tokens = (t) => t.replace(/\s+/g, ' ').replace(/\s*([,.!?;:])\s*/g, '$1 ').trim().split(/\s+/).filter(Boolean);
  const clean = (html, ignoreLandmarks, isServed) => {
    const d = new DOMParser().parseFromString(html, 'text/html');
    d.querySelectorAll('script').forEach((s) => {
      if (s.type === 'application/ld+json' && s.textContent.trim()) {
        const pre = d.createElement('pre');
        try { pre.textContent = JSON.stringify(JSON.parse(s.textContent), null, 2); } catch { pre.textContent = s.textContent; }
        s.replaceWith(pre);
      } else s.remove();
    });
    d.querySelectorAll(isServed ? 'style,template' : 'noscript,style,template').forEach((e) => e.remove());
    d.querySelectorAll('img,video,audio,picture,svg,canvas,embed,object,iframe').forEach((e) => e.remove());
    d.querySelectorAll('[class*="cookie"],[id*="cookie"],[class*="consent"],[id*="consent"]').forEach((e) => {
      const t = e.textContent.toLowerCase();
      if (t.includes('cookie') || t.includes('consent') || t.includes('privacy')) e.remove();
    });
    if (ignoreLandmarks) d.querySelectorAll(landmarks).forEach((e) => e.remove());
    return d;
  };
  const current = `<!DOCTYPE html><html>${document.head.outerHTML}${document.body.outerHTML}</html>`;
  const ratio = (s, r) => (r > 0 ? Math.min(100, Math.round((s / r) * 100)) : 100);
  const scenario = (ignore) => {
    const s = tokens(clean(served, ignore, true).documentElement.textContent).length;
    const r = tokens(clean(current, ignore, false).documentElement.textContent).length;
    return { score: ratio(s, r), served: s, rendered: r, missing: Math.abs(r - s) };
  };
  const strict = scenario(true);
  const landmarksCounted = scenario(false);

  // per-block attribution on the rendered side (set-based: which words the served HTML never has).
  // Text nodes are joined with spaces (textContent glues adjacent elements into bogus tokens) and
  // script/style/template/noscript text is skipped — attribution only; the score above stays faithful.
  const SKIP = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
  const nodeText = (root) => {
    const out = []; const w = (root.ownerDocument || document).createTreeWalker(root, NodeFilter.SHOW_TEXT); let n;
    while ((n = w.nextNode())) { if (!SKIP.has(n.parentElement?.tagName)) out.push(n.textContent); }
    return out.join(' ');
  };
  const servedSet = new Set(tokens(nodeText(clean(served, false, true).documentElement).toLowerCase()));
  const allowByBlock = {};
  (allow || []).forEach((a) => { (allowByBlock[a.block] ||= []).push(...tokens(String(a.string || '').toLowerCase())); });
  const blocks = [];
  const regions = [];
  document.querySelectorAll('main .section > div').forEach((w) => {
    const b = w.querySelector(':scope > .block');
    const name = b ? (b.dataset.blockName || b.classList[0]) : 'default-content';
    regions.push({ name, el: w, variants: b ? [...b.classList].filter((c) => c !== 'block' && c !== name).join(' ') : '' });
  });
  const hd = document.querySelector('header'); if (hd) regions.push({ name: 'header', el: hd, variants: '' });
  const ft = document.querySelector('footer'); if (ft) regions.push({ name: 'footer', el: ft, variants: '' });
  let excludedWords = 0; let allowedWords = 0; let renderedMain = 0;
  regions.forEach(({ name, el, variants }) => {
    const ws = tokens(nodeText(el));
    const allowed = new Set(allowByBlock[name] || []);
    let gap = ws.filter((w) => !servedSet.has(w.toLowerCase()));
    const gapBefore = gap.length;
    gap = gap.filter((w) => !allowed.has(w.toLowerCase()));
    allowedWords += gapBefore - gap.length;
    const excluded = name !== 'header' && name !== 'footer' && (excludeBlocks || []).includes(name);
    if (excluded) excludedWords += ws.length;
    if (name !== 'header' && name !== 'footer') renderedMain += ws.length;
    blocks.push({ block: name, variants, words: ws.length, servedGap: gap.length, excluded, sample: [...new Set(gap)].slice(0, 12).join(' ') });
  });
  blocks.sort((a, b) => b.servedGap - a.servedGap);

  // code score: fragments credited to the served side, app blocks + allowlisted strings removed from the rendered side
  const fragmentWords = (fragments || []).reduce((n, html) => n + tokens(clean(html, true, true).documentElement.textContent).length, 0);
  const codeServed = strict.served + fragmentWords;
  const codeRendered = Math.max(0, strict.rendered - excludedWords - allowedWords);
  const code = { score: ratio(codeServed, codeRendered), served: codeServed, rendered: codeRendered, fragmentWords, excludedWords, allowedWords };

  const servedGapTotal = blocks.filter((b) => b.block !== 'header' && b.block !== 'footer').reduce((n, b) => n + b.servedGap, 0);
  return { strict, landmarksCounted, code, blocks, servedGap: { main: servedGapTotal, chrome: blocks.filter((b) => b.block === 'header' || b.block === 'footer').reduce((n, b) => n + b.servedGap, 0), renderedMain } };
}

/* ------------------------------------------------------------------ driver -- */
async function loadPlaywright() {
  const normalize = (m) => (m.chromium ? m : (m.default?.chromium ? m.default : null));
  try {
    const req = createRequire(join(process.cwd(), 'package.json'));
    const mod = normalize(await import(pathToFileURL(req.resolve('playwright')).href));
    if (mod) return mod;
  } catch { /* fall through */ }
  try { const mod = normalize(await import('playwright')); if (mod) return mod; } catch { /* fall through */ }
  throw new Error('playwright not found: install it in the project (npm i -D playwright)');
}

async function settle(page) {
  await page.waitForFunction(() => {
    const s = [...document.querySelectorAll('[data-section-status]')];
    return s.length === 0 || s.every((e) => e.dataset.sectionStatus === 'loaded');
  }, null, { timeout: 20000 }).catch(() => {});
  await page.waitForTimeout(1500);
}

/** Fragment documents the served page references (fragment links + fragment blocks), as plain HTML. */
async function fetchFragments(request, origin, served, headers) {
  const paths = new Set();
  for (const m of served.matchAll(/href="(\/fragments\/[^"#?]+)"/g)) paths.add(m[1]);
  const ok = []; const html = [];
  for (const p of paths) {
    try {
      const r = await request.get(`${origin}${p}.plain.html`, { headers, timeout: 15000 });
      if (r.ok()) { ok.push(p); html.push(await r.text()); }
    } catch { /* uncredited */ }
  }
  return { paths: ok, html };
}

export async function scorePage(context, origin, path, { excludeBlocks = [], allow = [], headers = {} } = {}) {
  const url = `${origin}${path}`;
  const page = await context.newPage();
  try {
    const servedRes = await context.request.get(url, { headers: { ...headers, 'user-agent': CHATGPT_UA, accept: 'text/html' }, timeout: 20000 });
    if (!servedRes.ok()) return { path, error: `served fetch HTTP ${servedRes.status()}` };
    const served = await servedRes.text();
    const frags = await fetchFragments(context.request, origin, served, headers);
    // fragments the page loads at runtime (metadata-enabled or block-driven, no link in the document)
    const runtime = new Map();
    page.on('response', async (res) => {
      const u = res.url();
      if (!u.startsWith(origin) || !/\/fragments\/[^?#]+\.plain\.html(\?|$)/.test(u) || !res.ok()) return;
      const fp = new URL(u).pathname.replace(/\.plain\.html$/, '');
      if (!frags.paths.includes(fp) && !runtime.has(fp)) { try { runtime.set(fp, await res.text()); } catch { /* skip */ } }
    });
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
    await settle(page);
    // chrome fragments (nav/header/footer/menu) are loaded by blocks the strict score strips — crediting them would inflate code
    const isChrome = (fp) => /(^|\/)(nav|header|footer|menu)[^/]*(\/|$)/i.test(fp);
    const credited = [...frags.paths.map((fp, i) => [fp, frags.html[i]]), ...runtime.entries()].filter(([fp, html]) => html && !isChrome(fp));
    const r = await page.evaluate(analyse, { served, fragments: credited.map(([, html]) => html), landmarks: LANDMARKS, excludeBlocks, allow });
    return { path, fragments: credited.map(([fp]) => fp), chromeFragments: [...frags.paths, ...runtime.keys()].filter(isChrome), ...r };
  } catch (e) {
    return { path, error: e.message.split('\n')[0] };
  } finally { await page.close(); }
}

/* --------------------------------------------------------------------- CLI -- */
const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isMain) {
  const args = process.argv.slice(2);
  const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args.splice(i, 2)[1] : d; };
  const has = (n) => { const i = args.indexOf(n); if (i >= 0) { args.splice(i, 1); return true; } return false; };
  const origin = (opt('--origin', '') || '').replace(/\/$/, '');
  const min = Number(opt('--min', '98'));
  const tokenEnv = opt('--token-env', null);
  const authHeader = opt('--auth-header', null) || (tokenEnv && process.env[tokenEnv] ? `token ${process.env[tokenEnv]}` : null);
  const excludeBlocks = (opt('--exclude-blocks', 'client-app,widget') || '').split(',').map((s) => s.trim()).filter(Boolean);
  const allowFile = opt('--allowlist', null);
  const jsonOut = opt('--json', null);
  const pathsFile = opt('--paths', null);
  const verbose = has('--verbose');
  let paths = args.filter((a) => a.startsWith('/'));
  if (pathsFile && existsSync(pathsFile)) paths = paths.concat(readFileSync(pathsFile, 'utf8').split('\n').map((s) => s.trim()).filter((s) => s.startsWith('/')));
  if (!origin || !paths.length) { console.error('usage: ai-readability.mjs --origin <published origin> [--paths file | /path …] [--min 98]'); process.exit(2); }
  const allow = allowFile && existsSync(allowFile) ? JSON.parse(readFileSync(allowFile, 'utf8')) : [];

  const { chromium } = await loadPlaywright();
  const browser = await chromium.launch();
  const headers = authHeader ? { authorization: authHeader } : {};
  const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, extraHTTPHeaders: headers });
  const results = [];
  let fail = false;
  for (const p of paths) {
    const r = await scorePage(context, origin, p, { excludeBlocks, allow, headers });
    results.push(r);
    if (r.error) { console.log(`${p}\n  ERROR ${r.error}`); fail = true; continue; }
    const flag = r.code.score < min ? 'FAIL' : 'ok';
    console.log(`${p}\n  strict ${String(r.strict.score).padStart(3)}%  (served ${r.strict.served} / rendered ${r.strict.rendered}, missing ${r.strict.missing}; landmarks counted ${r.landmarksCounted.score}%)`
      + `\n  code   ${String(r.code.score).padStart(3)}%  ${flag}  (fragments credited +${r.code.fragmentWords}${r.fragments.length ? ` [${r.fragments.join(' ')}]` : ''}, app blocks −${r.code.excludedWords}, allowlisted −${r.code.allowedWords})`
      + `\n  servedGap main ${r.servedGap.main} / ${r.servedGap.renderedMain} words, chrome ${r.servedGap.chrome}`);
    const rows = r.blocks.filter((b) => b.servedGap > 0 || b.excluded);
    (verbose ? rows : rows.slice(0, 5)).forEach((b) => console.log(`    ${String(b.servedGap).padStart(5)} / ${String(b.words).padStart(5)}  ${(b.block + (b.variants ? ` [${b.variants}]` : '')).padEnd(40)}${b.excluded ? ' (excluded)' : ''} ${b.sample}`));
    if (r.code.score < min) fail = true;
  }
  await browser.close();
  if (jsonOut) writeFileSync(jsonOut, JSON.stringify({ origin, min, excludeBlocks, allowlist: allow, generatedAt: new Date().toISOString(), pages: results }, null, 2));
  const scored = results.filter((r) => !r.error);
  if (scored.length) console.log(`\n${scored.length} pages: strict min ${Math.min(...scored.map((r) => r.strict.score))}%, code min ${Math.min(...scored.map((r) => r.code.score))}% (gate ${min}%)`);
  process.exit(fail ? 1 : 0);
}

skills

README.md

tile.json