diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 274ae62..7f9c0fe 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -25,6 +25,16 @@ jobs: - name: Structure and frontmatter follow STYLE.md run: node scripts/check-style.mjs + # Runs before the build because it reports EVERY bad id at once in under a + # second, where the build fails on the first one from inside a component. + - name: Personalization markers name things that exist + run: npm run markers:check + + # A marker naming a renamed feature fails silently at runtime — the block + # never satisfies, so a customer who owns Awards is told they do not. + - name: Marker hydrator honours the spec + run: npm test + - name: Breadcrumb data is fresh run: | node scripts/build-breadcrumbs.mjs @@ -44,6 +54,13 @@ jobs: exit 1 } + # The index is what Team Lead retrieves over, so an article that stops + # producing chunks stops being answerable — silently, and only in the + # product. Checked here rather than at sync time, where the corpus has + # already shipped. + - name: Every article still produces retrievable chunks + run: npm run index:check + prose: name: Vale prose lint runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 14e566f..5a459b8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ node_modules/ dist/ .astro/ __pycache__/ + +# Wrangler local dev state (miniflare caches, build temp) +.wrangler/ diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm deleted file mode 100644 index f49a3c4..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal deleted file mode 100644 index b571123..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-shm b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-shm deleted file mode 100644 index d51b577..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-wal b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-wal deleted file mode 100644 index f9b5d06..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/a590acd76969f996ec6e4b599c3c09f58c283a76f2d61392b5d3046caf557602.sqlite-wal and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-shm b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-shm deleted file mode 100644 index 5c33057..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-wal b/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-wal deleted file mode 100644 index bdeaa59..0000000 Binary files a/.wrangler/state/v3/observability/miniflare-wobs-trace-store/metadata.sqlite-wal and /dev/null differ diff --git a/LAUNCH.md b/LAUNCH.md index b4925cf..a643b51 100644 --- a/LAUNCH.md +++ b/LAUNCH.md @@ -288,6 +288,41 @@ competitor scraping us ignores `robots.txt` — while risking the silent loss of search surface that matters later, since new AI engines appear faster than we would notice adding them to an allowlist. The teeth are at the edge. +### The machine index — `/_internal/help-index.json` + +`npm run build` also emits `dist/_internal/help-index.json`: every article, chunked +by heading, with a `contentHash` per article and the sanitized body HTML. This is +what `sessionboard-web-api` pulls in nightly (`HELP_DOCS_SYNC`) to embed for Team +Lead and for the in-product reader. It is the same exposure as the `llms-*.txt` +dumps — the whole corpus in one GET — so the Worker gates every `/_internal/` +request on a bearer token and answers a wrong or missing one with the same 404 an +unknown path gets. + +**Deploying it needs one secret on each side, and they must match:** + +```bash +# Help Center Worker (this repo). NOT a [vars] entry — that would commit it. +cd sessionboard-docs && npx wrangler secret put HELP_INDEX_TOKEN + +# sessionboard-web-api, per environment +HELP_INDEX_URL=https://learn.sessionboard.com/_internal/help-index.json +HELP_INDEX_TOKEN= +``` + +Verify after a deploy — the first is the failure mode to recognize, because a +missing secret looks exactly like a missing file: + +```bash +curl -sI https://learn.sessionboard.com/_internal/help-index.json # 404 +curl -sI -H "Authorization: Bearer $HELP_INDEX_TOKEN" \ + https://learn.sessionboard.com/_internal/help-index.json # 200 +``` + +Leaving `HELP_INDEX_URL`/`HELP_INDEX_TOKEN` unset in web-api is a supported state: +the sync job logs and no-ops rather than failing, so a review app without a Help +Center does not page anyone. A token that is set but *wrong* fails the job loudly, +which is the intended asymmetry. + ### Still to do (needs Cloudflare dashboard access) The Worker stops crawlers that identify themselves honestly. A determined competitor diff --git a/MARKER_SPEC.md b/MARKER_SPEC.md new file mode 100644 index 0000000..2b5538d --- /dev/null +++ b/MARKER_SPEC.md @@ -0,0 +1,120 @@ +# Personalization markers + +One article, three surfaces. A Help Center page is read on the public site, on the public site by someone who happens to be signed in, and inside the product in the Team Lead **Learn** panel. Markers are how one MDX file serves all three without forking the content. + +The split that makes this work: + +- **MDX declares semantics.** "This block needs the Awards feature." +- **The surface decides behaviour.** The public site shows it. The product collapses it behind _"Awards isn't enabled for Acme Summit"_ with a link to turn it on. + +Authors never write the behaviour. There is exactly one implementation of it — [`src/lib/marker-hydration.mjs`](src/lib/marker-hydration.mjs) — vendored into `sessionboard-web-ui-v2` so both surfaces render identically. + +**Current spec version: 1.** Recorded in `tests/fixtures/marker-hydration.json` and asserted by both repos' test suites — the corpus runs here against the source and there against the vendored copy. + +## The four invariants + +These are load-bearing. Every one of them is a decision that cost something to get right. + +**Content is never removed.** An unmet block is collapsed, not deleted. It stays in the DOM so it stays crawlable and citable, so support can screen-share the same article a customer is reading, and so a reader still discovers the feature exists. This is the entire reason personalizing a public page costs no SEO or AEO ground: there is one canonical URL and every crawler sees the full text. + +**Unknown means visible.** If a condition cannot be evaluated — no event loaded yet, or the feature lives on a scope we have no data for — the block renders normally. Telling a customer they lack something they pay for is far worse than showing one extra paragraph. + +**Hydration is idempotent.** React re-renders, the reader switches event, an island rehydrates. Re-running converges instead of nesting wrappers. + +**Attributes are read, never evaluated.** The hydrator walks `data-sb-*` values. It never evaluates a string from the document, so a compromised article cannot become script execution. + +## Authoring + +Import from `@compat` as usual. + +### Conditional on a feature + +```mdx +import { IfFeature } from '@compat'; + + +Scores roll up per program, so a judge only sees their own assignments. + +``` + +Space-separate to require several: ``. Use `as="span"` mid-sentence. + +`id` is a **slug** from the product contract — never a module UUID. Scope is resolved from the contract, not written by you: `awards` happens to be org-level and `coordinators` event-level, and you should not have to know the difference. + +### Conditional on an event setting + +```mdx + +Each speaker still has to confirm from their portal before the session is settled. + +``` + +`id` is a boolean column on the Event model — the things an admin toggles in settings. + +### Add-on callout + +```mdx + +``` + +Reads as _"Awards requires enablement, contact support"_ on the public site. In-product it knows better, so a customer who already owns Awards is not told to email support about Awards. For something sold but not feature-flagged, use free text: `` — no personalization. + +### Link into the product + +```mdx +Open [Submissions](app:EventSubmissions) and check the confirmation column. +``` + +A link scheme rather than a component, because it works inline and inside tables where JSX is awkward. + +No `href` is emitted. Every target needs an event or org id, and this repo cannot know either — a guessed `/event/123/sessions` is someone else's event. In-product the reader resolves the id against the event the reader is actually in; on the public site it stays labelled text, so nobody is handed a dead link. + +Targets are curated, not every route: see `sessionboard-web-ui-v2/src/lib/appRoutes.json`. A target requiring an id docs cannot know (a `sessionId`, a `formId`) is not linkable, and a test in that repo enforces it. + +### Article-level applicability + +Frontmatter, not a marker: + +```yaml +features: ["awards"] +audience: ["organizer"] +jtbd: "get speakers to confirm before the deadline" +``` + +`features` drives the "does this apply to you" banner in the reader **and** drops the article from Team Lead retrieval for events without the feature — so Team Lead never walks someone through a module they cannot open. `audience` keeps admin questions from returning speaker-portal steps. `jtbd` is the job in the reader's words; retrieval matches intent against it far better than against a title. + +`features` is the one with teeth, so it is deliberately under-applied. A **missing** tag means the article shows to everyone, which is where the corpus started. A **wrong** tag tells a paying customer the product cannot do something it can. `npm run taxonomy:tag` only tags folders that map 1:1 to a gated product area for exactly this reason. + +## Everything is validated at build time + +A marker naming something that no longer exists fails **silently** at runtime: the block simply never satisfies, and a customer who does own Awards is told they do not. So nothing is allowed to reach production unverified. + +| Command | Catches | +| --- | --- | +| `npm run markers:check` | Every bad id in the corpus at once, in under a second. Also rejects hand-written `data-sb-*` attributes, which bypass the components and so are never validated. | +| `npm run build` | The same ids, from inside the components, plus unknown `app:` targets. | +| `npm run index:check` | Every article still produces indexable chunks. | +| `npm test` | The hydrator itself, against the shared fixture corpus — including the never-remove-content and idempotence invariants. | +| `markerHydration.test.ts` (web-ui-v2) | The vendored hydrator matches this repo's, byte for byte, and renders the same corpus identically under jsdom. | + +The ids themselves come from the product, not from here: + +```bash +npm run contract:pull # refresh src/data/product-contract.json from web-api + web-ui-v2 +npm run markers:sync # re-vendor the hydrator + fixtures into web-ui-v2 +``` + +Run `contract:pull` when a marker you know is correct is rejected — a feature renamed in `sessionboard-web-api` shows up here as an unknown slug. Commit the result; the contract is checked in so the build needs no network and no sibling checkout. + +## Adding to the spec + +Changing the attribute vocabulary or the hydration behaviour means: + +1. Edit `src/lib/marker-hydration.mjs` — the only implementation. +2. Add cases to `tests/fixtures/marker-hydration.json`. +3. Bump `MARKER_SPEC_VERSION` and the fixture's `specVersion` together. +4. `npm run markers:sync`, then commit **both** repos. + +Skipping step 4 fails the consuming repo's checksum test rather than quietly shipping two behaviours. + +The version tracks the **vocabulary and the behaviour**, not the module's exports. Widening the API without changing what any marker does — `unmetSummary` was exported so the in-product reader's article-level banner uses the same sentence as a collapsed block's summary — needs steps 1, 2 and 4 but not step 3. A bump obliges every surface to ship before it can personalize anything, so it is worth spending only on a real behaviour change. diff --git a/astro.config.mjs b/astro.config.mjs index 09b4709..42d3c40 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -5,6 +5,7 @@ import starlightLlmsTxt from 'starlight-llms-txt'; import starlightLinksValidator from 'starlight-links-validator'; import sidebar from './src/sidebar.json' with { type: 'json' }; import site from './site.json' with { type: 'json' }; +import rehypeAppLinks from './plugins/rehype-app-links.mjs'; export default defineConfig({ site: `https://${site.canonicalHost}`, @@ -12,6 +13,11 @@ export default defineConfig({ // Emit /path.html instead of /path/index.html so Workers assets serve // /sessions/create-a-session without a trailing-slash redirect hop. build: { format: 'file' }, + markdown: { + // Turns `[label](app:RouteId)` into a data-sb-route marker the in-app + // reader resolves against the reader's own event. Throws on an unknown id. + rehypePlugins: [rehypeAppLinks], + }, integrations: [ starlight({ title: 'Sessionboard Help Center', diff --git a/package-lock.json b/package-lock.json index cb23e6c..33ab8df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "sharp": "latest", "starlight-links-validator": "^0.25.2", "starlight-llms-txt": "latest" + }, + "devDependencies": { + "linkedom": "^0.18.13" } }, "node_modules/@astrojs/compiler-binding": { @@ -2907,6 +2910,13 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3878,6 +3888,39 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4317,6 +4360,181 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkedom": { + "version": "0.18.13", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz", + "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^7.0.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.1.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkedom/node_modules/boolbase": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/css-select": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/css-what": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/linkedom/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/linkedom/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/nth-check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6726,6 +6944,13 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" + }, "node_modules/ultrahtml": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", diff --git a/package.json b/package.json index 6e0e985..b76192a 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,18 @@ "dev": "astro dev", "prebuild": "node scripts/build-breadcrumbs.mjs", "build": "astro build", + "postbuild": "node scripts/build-help-index.mjs", "preview": "astro preview", "sidebar": "node scripts/docs-json-to-sidebar.mjs", "breadcrumbs": "node scripts/build-breadcrumbs.mjs", "og": "python3 scripts/generate-og.py", + "contract:pull": "node scripts/pull-product-contract.mjs", + "markers:sync": "node scripts/sync-marker-hydration.mjs", + "markers:check": "node scripts/check-markers.mjs", + "taxonomy:tag": "node scripts/tag-taxonomy.mjs", + "index:build": "node scripts/build-help-index.mjs", + "index:check": "node scripts/build-help-index.mjs --check", + "test": "node --test \"tests/**/*.test.mjs\"", "audit:redirects": "python3 scripts/audit_redirects.py", "cf:check": "python3 scripts/cloudflare_edge_rules.py", "cf:apply": "python3 scripts/cloudflare_edge_rules.py --apply", @@ -26,5 +34,8 @@ }, "allowScripts": { "esbuild@0.28.1": true + }, + "devDependencies": { + "linkedom": "^0.18.13" } } diff --git a/plugins/rehype-app-links.mjs b/plugins/rehype-app-links.mjs new file mode 100644 index 0000000..5d2f1e0 --- /dev/null +++ b/plugins/rehype-app-links.mjs @@ -0,0 +1,92 @@ +/** + * Rewrites `app:` links into route markers. + * + * [Open the form builder](app:EventSessionForms) + * → Open the form builder + * + * Why a link scheme rather than an component: it works inline + * mid-sentence and inside tables, where JSX is awkward, and authors already + * know how to write a markdown link. STYLE.md mandates relative links for docs + * pages; this is the same muscle memory for product pages. + * + * No href is emitted. Every target in the manifest needs an event or org id, + * and this repo cannot know either — a guessed `/event/123/sessions` would be + * someone else's event. The in-app reader resolves the id against the event the + * reader is actually in and turns it back into a real link; on the public site + * it stays labelled text, so a signed-out reader is never handed a dead link. + * + * Unknown ids throw at build time. A stale route id renders as a dead button + * inside the product, and published MDX cannot be fixed by a frontend deploy, + * so this has to fail the build rather than warn. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const contract = JSON.parse( + readFileSync(join(ROOT, 'src', 'data', 'product-contract.json'), 'utf8'), +); + +const APP_SCHEME = /^app:(.+)$/; + +/** Depth-first walk over hast element nodes. */ +function visitElements(node, fn) { + if (!node || typeof node !== 'object') return; + if (node.type === 'element') fn(node); + const children = node.children; + if (!Array.isArray(children)) return; + for (const child of children) visitElements(child, fn); +} + +function hasVisibleText(node) { + if (!node) return false; + if (node.type === 'text') return node.value.trim().length > 0; + if (!Array.isArray(node.children)) return false; + return node.children.some(hasVisibleText); +} + +export default function rehypeAppLinks() { + return (tree, file) => { + const where = file?.path ?? 'unknown file'; + const unknown = []; + + visitElements(tree, (node) => { + if (node.tagName !== 'a') return; + const href = node.properties?.href; + if (typeof href !== 'string') return; + const match = APP_SCHEME.exec(href); + if (!match) return; + + const id = match[1].trim(); + const target = contract.appRoutes[id]; + if (!target) { + unknown.push(id); + return; + } + + delete node.properties.href; + node.properties['data-sb-route'] = id; + node.properties.className = [ + ...(Array.isArray(node.properties.className) ? node.properties.className : []), + 'sb-app-link', + ]; + + // An author who wrote `[](app:EventSessions)` gets the manifest's label + // rather than an empty link. + if (!hasVisibleText(node)) { + node.children = [{ type: 'text', value: target.label }]; + } + }); + + if (unknown.length > 0) { + throw new Error( + `${where}: unknown app: link target(s): ${[...new Set(unknown)].join(', ')}\n` + + ' Valid ids live in src/data/product-contract.json under "appRoutes".\n' + + ' Add the route to sessionboard-web-ui-v2/src/lib/appRoutes.json, then run\n' + + ' `npm run contract:pull` here and commit the result.', + ); + } + }; +} diff --git a/scripts/audit_redirects.py b/scripts/audit_redirects.py index 196782f..3aa8bb4 100644 --- a/scripts/audit_redirects.py +++ b/scripts/audit_redirects.py @@ -140,6 +140,14 @@ def gsc_paths(days: int = 480) -> set[str]: def inapp_paths() -> set[str]: + """ + Help Center URLs the product actually sends people to. + + Test fixtures and doc comments are excluded, and they are the reason this + filtering exists at all: a made-up slug in a unit test is not a link any user + can follow, but it reports here as a hard failure indistinguishable from a real + broken redirect. An audit that cries wolf stops being read. + """ dirs = [ROOT.parent / r / "src" for r in ("sessionboard-web-api", "sessionboard-web-ui-v2", "sessionboard-web-ui")] dirs = [str(d) for d in dirs if d.exists()] @@ -147,11 +155,16 @@ def inapp_paths() -> set[str]: return set() res = subprocess.run( ["rg", "-o", "--no-heading", "--no-filename", + "--glob", "!*.test.*", "--glob", "!*.spec.*", "--glob", "!*.stories.*", + "--glob", "!**/__tests__/**", "--glob", "!**/__mocks__/**", + "--glob", "!**/tests/**", "--glob", "!**/fixtures/**", r"https?://learn\.sessionboard\.com[^\"'\` <)\\]*", *dirs], capture_output=True, text=True, ) - return {ln.split("learn.sessionboard.com", 1)[1] or "/" - for ln in res.stdout.splitlines() if ln.strip()} + paths = {ln.split("learn.sessionboard.com", 1)[1] or "/" + for ln in res.stdout.splitlines() if ln.strip()} + # A prose placeholder such as `https://learn.sessionboard.com/…` is not a URL. + return {p for p in paths if p.isascii()} def check(base: str, path: str) -> dict: @@ -160,7 +173,11 @@ def check(base: str, path: str) -> dict: code = out[0] loc = (out[1] if len(out) > 1 else "").strip().replace(base, "").split("?")[0] target_code = "" - if code == "301" and loc: + # 307/308 as well as 301: the Worker runs `html_handling = auto-trailing-slash`, + # so `/sessions/agenda/` normalizes to `/sessions/agenda` with a temporary + # redirect. That is the configured behavior, and following it is what tells a + # normalization apart from a loop. + if code in ("301", "307", "308") and loc: # Some redirects deliberately leave the Help Center — /release-notes/* # goes to the Canny changelog. Those keep their absolute URL after the # base is stripped, so they must be fetched as-is; prefixing the base @@ -245,13 +262,17 @@ def is_fallthrough(r: dict) -> bool: # straight from the Worker with no hop. Those are the best possible outcome, # not a failure — only legacy URLs are expected to arrive via a 301. direct = [r for r in results if r["code"] == "200"] - ok = redirected + direct + # Trailing-slash normalization by the Worker's own asset handling. + normalized = [r for r in results + if r["code"] in ("307", "308") and r["target_code"] == "200"] + ok = redirected + direct + normalized fallback = [r for r in redirected if is_fallthrough(r)] failed = [r for r in results if r not in ok] print(f" reach a live page: {len(ok)}/{len(results)}") print(f" already canonical (200): {len(direct)}") print(f" redirected (301 -> 200): {len(redirected)}") + print(f" slash-normalized (307 -> 200):{len(normalized)}") print(f" fell through to the fallback: {len(fallback)}") print(f" FAILED: {len(failed)}") diff --git a/scripts/build-help-index.mjs b/scripts/build-help-index.mjs new file mode 100644 index 0000000..ed60ed1 --- /dev/null +++ b/scripts/build-help-index.mjs @@ -0,0 +1,446 @@ +#!/usr/bin/env node +/** + * Emits dist/_internal/help-index.json — the machine-readable form of the Help + * Center that web-api embeds into pgvector for Team Lead retrieval and serves + * back to the in-app reader. + * + * Run after `astro build`, because it reads the BUILT HTML rather than the MDX. + * That matters: taking Astro's rendered output means Starlight components — + * Steps, Tabs, Asides, Accordions — survive into the in-app reader. Shipping raw + * MDX and re-rendering it with a markdown component in React would silently drop + * every one of them, and those components carry the actual instructions. + * + * MDX stays the single source of truth. This file is derived and disposable: + * every article carries a `contentHash` so the sync job re-embeds only what + * changed, and dropping the Postgres tables entirely just means the next run + * rebuilds them. + * + * Chunking is by heading section, so every chunk keeps its heading path and + * yields a deep link (/sessions/create-a-session#add-speakers) rather than + * sending the reader to the top of a long page. + * + * Usage: + * npm run build && node scripts/build-help-index.mjs + * node scripts/build-help-index.mjs --check # verify freshness, write nothing + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DOCS = join(ROOT, 'src', 'content', 'docs'); +const DIST = join(ROOT, 'dist'); +const OUT_DIR = join(DIST, '_internal'); +const OUT = join(OUT_DIR, 'help-index.json'); + +const site = JSON.parse(readFileSync(join(ROOT, 'site.json'), 'utf8')); +const breadcrumbs = JSON.parse(readFileSync(join(ROOT, 'src', 'breadcrumbs.json'), 'utf8')); +const markerSpec = JSON.parse( + readFileSync(join(ROOT, 'tests', 'fixtures', 'marker-hydration.json'), 'utf8'), +).specVersion; + +const CHECK_ONLY = process.argv.includes('--check'); + +/** Shape of this artifact. web-api refuses an index version it does not know. */ +const INDEX_VERSION = 1; + +/** Pages that are not articles. */ +const EXCLUDE = new Set(['404']); + +// Chunk sizing mirrors the transcript corpus (lib/jobs/corpus-embed.js), which +// is tuned for the same embedding model. +const MAX_CHUNK_CHARS = 1200; +const MIN_CHUNK_CHARS = 200; +/** + * Below this, a chunk cannot carry meaning on its own — "Tip", "Step 1", a + * card-grid link label, the tail of a hard split. Embedded as-is it becomes a + * near-random vector that can outrank a real answer, so these are folded into a + * neighbour rather than kept or discarded. + */ +const SLIVER_CHARS = 40; + +// ── frontmatter ──────────────────────────────────────────────────────────── + +/** + * Minimal frontmatter reader. + * + * Only reads the handful of scalar and inline-array fields this index needs, + * which is why it can skip a YAML dependency. Nested keys (`sidebar:`) are + * stepped over rather than parsed. + */ +function readFrontmatter(source) { + const lines = source.split('\n'); + if (lines[0] !== '---') return {}; + const end = lines.indexOf('---', 1); + if (end === -1) return {}; + + const out = {}; + for (const line of lines.slice(1, end)) { + const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line); + if (!match) continue; // nested entry or continuation + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) continue; // a nested block like `sidebar:` + if (value.startsWith('[')) { + out[key] = value + .slice(1, value.lastIndexOf(']')) + .split(',') + .map((v) => v.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } else { + out[key] = value.replace(/^["']|["']$/g, ''); + } + } + return out; +} + +// ── HTML extraction ──────────────────────────────────────────────────────── + +/** + * Slice the inner HTML of the element carrying `class="sl-markdown-content"`. + * + * Matches the opening TAG, not the bare class name: the string + * "sl-markdown-content" also appears earlier in every page, inside the + * speakable JSON-LD block's cssSelector, and anchoring there extracts the + * page's structured data instead of its article. + * + * Depth-counted rather than regex-matched from there: the article body is full + * of nested divs, and a greedy or lazy regex would end at the wrong ``. + * Astro's output is well-formed and divs are never self-closing, so counting is + * exact. + */ +const CONTENT_TAG_RE = + /<([a-zA-Z][\w-]*)[^>]*\sclass="[^"]*\bsl-markdown-content\b[^"]*"[^>]*>/; + +function extractArticleHtml(html) { + const open = CONTENT_TAG_RE.exec(html); + if (!open) return null; + + const tagName = open[1]; + const openStart = open.index; + const openEnd = openStart + open[0].length - 1; + + const openRe = new RegExp(`<${tagName}\\b`, 'gi'); + const closeRe = new RegExp(``, 'gi'); + + let depth = 1; + let cursor = openEnd + 1; + const bodyStart = cursor; + + while (depth > 0 && cursor < html.length) { + openRe.lastIndex = cursor; + closeRe.lastIndex = cursor; + const nextOpen = openRe.exec(html); + const nextClose = closeRe.exec(html); + if (!nextClose) return null; + + if (nextOpen && nextOpen.index < nextClose.index) { + depth += 1; + cursor = nextOpen.index + nextOpen[0].length; + continue; + } + depth -= 1; + if (depth === 0) return html.slice(bodyStart, nextClose.index); + cursor = nextClose.index + nextClose[0].length; + } + return null; +} + +const ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', + '…': '…', + '—': '—', + '–': '–', +}; + +/** Rendered HTML → the plain text that gets embedded. */ +function toText(html) { + return html + .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ') + // Starlight puts a visually-hidden "Section titled “…”" span inside every + // heading anchor. It is invisible to readers but would otherwise prefix a + // third of the corpus, diluting those chunks' embeddings with boilerplate. + .replace(/]*\bclass="[^"]*\bsr-only\b[^"]*"[^>]*>[\s\S]*?<\/span>/gi, ' ') + .replace(//gi, '\n') + // `` and `` break to a newline too: a wide table row is otherwise + // one unbroken line with nowhere to split a chunk. + .replace(/<\/(p|div|li|h[1-6]|tr|td|th|blockquote)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&[a-z]+;/gi, (entity) => ENTITIES[entity.toLowerCase()] ?? ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\s*\n\s*/g, '\n') + .replace(/\n{2,}/g, '\n') + .trim(); +} + +// ── chunking ─────────────────────────────────────────────────────────────── + +/** + * Split article HTML into heading-scoped sections. + * + * Starlight gives every heading an `id`, which becomes the chunk's anchor — so + * a retrieved chunk deep-links to the exact step rather than the page top. + */ +function splitSections(html, title) { + const headingRe = /]*)>([\s\S]*?)<\/h\1>/gi; + const sections = []; + let lastIndex = 0; + let current = { level: 1, anchor: null, heading: title }; + + let match; + while ((match = headingRe.exec(html)) !== null) { + sections.push({ ...current, html: html.slice(lastIndex, match.index) }); + const [, level, attrs, inner] = match; + current = { + level: Number(level), + anchor: /id="([^"]+)"/.exec(attrs)?.[1] ?? null, + heading: toText(inner), + }; + lastIndex = match.index + match[0].length; + } + sections.push({ ...current, html: html.slice(lastIndex) }); + + return sections; +} + +/** + * Sections → embeddable chunks. + * + * Small sections merge into their predecessor and oversized ones split on + * paragraph boundaries, so a chunk is never a two-word sliver nor a wall of + * text that dilutes its own embedding. + */ +function buildChunks(sections, title) { + const chunks = []; + + const push = (section, text) => { + const trimmed = text.trim(); + if (!trimmed) return; + const headingPath = + section.level === 1 || section.heading === title + ? [title] + : [title, section.heading].filter(Boolean); + + // Last-resort bound. Paragraph splitting handles prose, but a single + // unbroken run — one enormous table cell, a pasted block — has no boundary + // to split on, and an oversized chunk both dilutes its embedding and risks + // the model's input limit. Break on whitespace so words stay intact. + let rest = trimmed; + while (rest.length > MAX_CHUNK_CHARS) { + const window = rest.slice(0, MAX_CHUNK_CHARS); + const cut = window.lastIndexOf(' ') > MIN_CHUNK_CHARS ? window.lastIndexOf(' ') : MAX_CHUNK_CHARS; + chunks.push({ anchor: section.anchor, headingPath, text: rest.slice(0, cut).trim() }); + rest = rest.slice(cut).trim(); + } + if (rest) chunks.push({ anchor: section.anchor, headingPath, text: rest }); + }; + + for (const section of sections) { + const text = toText(section.html); + if (!text) continue; + + if (text.length <= MAX_CHUNK_CHARS) { + const previous = chunks[chunks.length - 1]; + // Merge a sliver forward only when it belongs to the same heading, so a + // heading's own anchor is never lost to its neighbour. + if ( + previous && + text.length < MIN_CHUNK_CHARS && + previous.anchor === section.anchor && + previous.text.length + text.length <= MAX_CHUNK_CHARS + ) { + previous.text += `\n${text}`; + continue; + } + push(section, text); + continue; + } + + let buffer = ''; + for (const paragraph of text.split('\n')) { + if (buffer && buffer.length + paragraph.length + 1 > MAX_CHUNK_CHARS) { + push(section, buffer); + buffer = ''; + } + buffer = buffer ? `${buffer}\n${paragraph}` : paragraph; + } + push(section, buffer); + } + + return coalesceSlivers(chunks); +} + +/** + * Fold sub-meaningful chunks into an adjacent one, keeping the neighbour's + * anchor. An article that is nothing but a sliver keeps it — one weak chunk + * beats being unfindable. + */ +function coalesceSlivers(chunks) { + if (chunks.length <= 1) return chunks; + + const out = []; + for (const chunk of chunks) { + const previous = out[out.length - 1]; + if ( + chunk.text.length < SLIVER_CHARS && + previous && + previous.text.length + chunk.text.length <= MAX_CHUNK_CHARS + ) { + previous.text += `\n${chunk.text}`; + continue; + } + out.push(chunk); + } + + // A sliver in first position has no predecessor to merge into, so it merges + // forward instead. + if (out.length > 1 && out[0].text.length < SLIVER_CHARS) { + const [first, second, ...rest] = out; + if (first.text.length + second.text.length <= MAX_CHUNK_CHARS) { + return [{ ...second, text: `${first.text}\n${second.text}` }, ...rest]; + } + } + + return out; +} + +// ── walk ─────────────────────────────────────────────────────────────────── + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.mdx')) out.push(full); + } + return out; +} + +const canonical = (slug, anchor) => + `https://${site.canonicalHost}${slug}${anchor ? `#${anchor}` : ''}`; + +if (!existsSync(DIST)) { + console.error('\n✖ No dist/ — run `npm run build` before building the help index.\n'); + process.exit(1); +} + +const articles = []; +const problems = []; + +for (const file of walk(DOCS).sort()) { + const rel = relative(DOCS, file).replace(/\.mdx$/, ''); + if (EXCLUDE.has(rel)) continue; + + const slug = rel === 'index' ? '/' : `/${rel}`; + const htmlPath = join(DIST, rel === 'index' ? 'index.html' : `${rel}.html`); + if (!existsSync(htmlPath)) { + problems.push(`${rel}: no built page at ${relative(ROOT, htmlPath)}`); + continue; + } + + const front = readFrontmatter(readFileSync(file, 'utf8')); + const bodyHtml = extractArticleHtml(readFileSync(htmlPath, 'utf8')); + if (!bodyHtml) { + problems.push(`${rel}: could not find sl-markdown-content in the built page`); + continue; + } + + const title = front.title ?? rel; + let chunks = buildChunks(splitSections(bodyHtml, title), title); + + // The video walkthroughs are a single embed with no prose, so they yield no + // text to embed. Synthesising a chunk from the title and description keeps + // them retrievable — "is there a video about importing data?" should find one + // — rather than dropping 30 articles out of the index. + if (chunks.length === 0) { + const fallback = [title, front.description].filter(Boolean).join('. '); + if (!fallback) { + problems.push(`${rel}: no text and no description — nothing to index`); + continue; + } + chunks = [{ anchor: null, headingPath: [title], text: fallback }]; + } + + chunks = chunks.map((chunk) => ({ + ...chunk, + canonicalUrl: canonical(slug, chunk.anchor), + })); + + // Hash everything a consumer can observe, so any change that would alter a + // retrieval result or the reader's output invalidates the cached embedding — + // and nothing else does, keeping steady-state re-embedding near zero. + const contentHash = createHash('sha256') + .update( + JSON.stringify({ + title, + description: front.description ?? null, + features: front.features ?? [], + audience: front.audience ?? ['organizer'], + jtbd: front.jtbd ?? null, + bodyHtml, + }), + ) + .digest('hex'); + + articles.push({ + slug, + canonicalUrl: canonical(slug), + title, + description: front.description ?? null, + section: breadcrumbs[rel]?.section ?? null, + features: front.features ?? [], + audience: front.audience ?? ['organizer'], + jtbd: front.jtbd ?? null, + contentHash, + bodyHtml, + chunks, + }); +} + +if (problems.length > 0) { + console.error( + '\n✖ Could not index every article:\n' + + problems.map((p) => ` - ${p}`).join('\n') + + '\n', + ); + process.exit(1); +} + +const index = { + indexVersion: INDEX_VERSION, + // Which marker vocabulary the embedded bodyHtml uses. The reader refuses to + // hydrate an index built for a spec it does not implement. + markerSpecVersion: markerSpec, + canonicalHost: site.canonicalHost, + builtAt: new Date().toISOString(), + articleCount: articles.length, + chunkCount: articles.reduce((n, a) => n + a.chunks.length, 0), + articles, +}; + +if (CHECK_ONLY) { + console.log( + `✓ ${index.articleCount} articles, ${index.chunkCount} chunks indexable ` + + `(marker spec v${markerSpec}).`, + ); + process.exit(0); +} + +mkdirSync(OUT_DIR, { recursive: true }); +writeFileSync(OUT, JSON.stringify(index)); + +const bytes = statSync(OUT).size; +console.log( + `Wrote ${relative(ROOT, OUT)}\n` + + ` ${index.articleCount} articles, ${index.chunkCount} chunks, ` + + `${(bytes / 1024 / 1024).toFixed(2)} MB`, +); diff --git a/scripts/check-markers.mjs b/scripts/check-markers.mjs new file mode 100644 index 0000000..0054bad --- /dev/null +++ b/scripts/check-markers.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * Validates every personalization marker in the corpus against the product + * contract, without running a build. + * + * The Astro components throw on an unknown id too, so this is not the only + * gate — but it is the fast one. It runs in well under a second, so it can sit + * in a pre-commit hook and in CI ahead of the build, and it reports every bad + * marker in the corpus at once rather than failing on the first page Astro + * happens to compile. + * + * It also catches two things a build cannot: + * + * - Raw `data-sb-*` attributes hand-written in MDX. Those bypass the + * components entirely, so nothing validates them, and a typo'd attribute is + * invisible: it renders fine and simply never hydrates. + * - `features:` frontmatter naming a slug that no longer exists, which would + * drop the article from retrieval for every customer who owns the feature. + * + * Usage: + * npm run markers:check + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DOCS = join(ROOT, 'src', 'content', 'docs'); +const CONTRACT = JSON.parse( + readFileSync(join(ROOT, 'src', 'data', 'product-contract.json'), 'utf8'), +); + +const AUDIENCES = new Set(['organizer', 'reviewer', 'speaker', 'participant']); + +const features = new Set(CONTRACT.features); +const settings = new Set(CONTRACT.eventSettings); +const routes = new Set(Object.keys(CONTRACT.appRoutes)); + +const problems = []; + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.mdx')) out.push(full); + } + return out; +} + +/** Line number of a character offset, for an error a human can act on. */ +const lineAt = (source, index) => source.slice(0, index).split('\n').length; + +function report(file, source, index, message) { + problems.push(`${relative(ROOT, file)}:${lineAt(source, index)} ${message}`); +} + +/** Collect every `attr="value"` occurrence of one attribute on one component. */ +function* attrValues(source, component, attr) { + const re = new RegExp(`<${component}\\b[^>]*?\\b${attr}=(?:"([^"]*)"|'([^']*)')`, 'g'); + let match; + while ((match = re.exec(source)) !== null) { + yield { value: match[1] ?? match[2] ?? '', index: match.index }; + } +} + +const tokens = (value) => value.split(/\s+/).filter(Boolean); + +for (const file of walk(DOCS).sort()) { + const source = readFileSync(file, 'utf8'); + + for (const { value, index } of attrValues(source, 'IfFeature', 'id')) { + if (tokens(value).length === 0) report(file, source, index, ' has an empty id'); + for (const slug of tokens(value)) { + if (!features.has(slug)) { + report(file, source, index, ` is not a known feature`); + } + } + } + + for (const { value, index } of attrValues(source, 'IfSetting', 'id')) { + if (tokens(value).length === 0) report(file, source, index, ' has an empty id'); + for (const id of tokens(value)) { + if (!settings.has(id)) { + report(file, source, index, ` is not a known event setting`); + } + } + } + + for (const { value, index } of attrValues(source, 'AddOnNote', 'feature')) { + if (!features.has(value)) { + report(file, source, index, ` is not a known feature`); + } + } + + const appLinkRe = /\]\(app:([^)\s]+)\)/g; + let link; + while ((link = appLinkRe.exec(source)) !== null) { + if (!routes.has(link[1])) { + report(file, source, link.index, `app:${link[1]} is not a known route target`); + } + } + + // Authors write components; the attributes are an output format. A + // hand-written one is unvalidated and fails silently at runtime. + const rawAttrRe = /data-sb-[a-z-]+/g; + let raw; + while ((raw = rawAttrRe.exec(source)) !== null) { + report( + file, + source, + raw.index, + `writes ${raw[0]} by hand — use , , or an app: link instead`, + ); + } + + // Frontmatter taxonomy. Only the inline-array form is emitted by + // taxonomy:tag and used by authors, so that is what is checked. + const frontEnd = source.indexOf('\n---', 3); + const front = source.startsWith('---') && frontEnd !== -1 ? source.slice(0, frontEnd) : ''; + + const featuresLine = /^features:\s*\[(.*)\]\s*$/m.exec(front); + for (const slug of (featuresLine?.[1] ?? '') + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean)) { + if (!features.has(slug)) { + report(file, source, 0, `frontmatter features: names unknown feature "${slug}"`); + } + } + + const audienceLine = /^audience:\s*\[(.*)\]\s*$/m.exec(front); + for (const who of (audienceLine?.[1] ?? '') + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean)) { + if (!AUDIENCES.has(who)) { + report(file, source, 0, `frontmatter audience: names unknown audience "${who}"`); + } + } +} + +if (problems.length > 0) { + console.error( + `\n✖ ${problems.length} invalid marker(s):\n` + + problems.map((p) => ` ${p}`).join('\n') + + '\n\n Valid ids live in src/data/product-contract.json.\n' + + ' If a feature, setting or route is new, run `npm run contract:pull` and commit the result.\n', + ); + process.exit(1); +} + +console.log( + `✓ Markers valid against contract v${CONTRACT.contractVersion} ` + + `(${features.size} features, ${settings.size} settings, ${routes.size} routes).`, +); diff --git a/scripts/pull-product-contract.mjs b/scripts/pull-product-contract.mjs new file mode 100644 index 0000000..dcdde5f --- /dev/null +++ b/scripts/pull-product-contract.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Refresh src/data/product-contract.json from the product repos. + * + * Personalization markers in MDX name real product facts: + * + * → a feature id from web-api + * → an Event boolean column + * [Open the builder](app:EventSessionForms) → a route id from web-ui-v2 + * + * All three can be renamed by someone who will never open this repo, and a + * stale marker fails silently: the block simply never satisfies, so a customer + * who *does* have Awards is told they do not. The contract is committed so the + * docs build can validate every marker with no network and no sibling + * checkout, and this script is how it gets refreshed. + * + * Deliberately writes no timestamp — a generated-at field would make every + * pull a diff and turn the CI freshness check into noise. + * + * Usage: + * npm run contract:pull + * SB_WEB_API_DIR=… SB_WEB_UI_V2_DIR=… npm run contract:pull + */ + +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DOCS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const WORKSPACE = resolve(DOCS_ROOT, '..'); + +const WEB_API_DIR = process.env.SB_WEB_API_DIR ?? join(WORKSPACE, 'sessionboard-web-api'); +const WEB_UI_DIR = process.env.SB_WEB_UI_V2_DIR ?? join(WORKSPACE, 'sessionboard-web-ui-v2'); + +const OUT = join(DOCS_ROOT, 'src', 'data', 'product-contract.json'); + +/** The contract shape this repo's validator understands. */ +const SUPPORTED_CONTRACT_VERSION = 2; + +function fail(message) { + console.error(`\n✖ ${message}\n`); + process.exit(1); +} + +function readWebApiContract() { + const script = join(WEB_API_DIR, 'scripts', 'export-product-contract.js'); + if (!existsSync(script)) { + fail( + `Cannot find ${script}.\n` + + ' Check out sessionboard-web-api beside this repo, or set SB_WEB_API_DIR.', + ); + } + let stdout; + try { + stdout = execFileSync(process.execPath, [script], { + cwd: WEB_API_DIR, + env: { ...process.env, NODE_PATH: '.' }, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + }); + } catch (err) { + fail( + `export-product-contract.js failed in ${WEB_API_DIR}.\n` + + ' Run `npm install` there first — it loads the Event model to read the settings columns.\n' + + ` ${err.stderr || err.message}`, + ); + } + const contract = JSON.parse(stdout); + if (contract.contractVersion !== SUPPORTED_CONTRACT_VERSION) { + fail( + `web-api exports contractVersion ${contract.contractVersion} but this repo ` + + `understands ${SUPPORTED_CONTRACT_VERSION}. Update scripts/check-markers.mjs ` + + 'and the marker spec before pulling.', + ); + } + return contract; +} + +function readAppRoutes() { + const file = join(WEB_UI_DIR, 'src', 'lib', 'appRoutes.json'); + if (!existsSync(file)) { + fail( + `Cannot find ${file}.\n` + + ' Check out sessionboard-web-ui-v2 beside this repo, or set SB_WEB_UI_V2_DIR.', + ); + } + return JSON.parse(readFileSync(file, 'utf8')); +} + +const api = readWebApiContract(); +const appRoutes = readAppRoutes(); + +const contract = { + '//': + 'GENERATED by scripts/pull-product-contract.mjs — do not hand-edit. ' + + 'Run `npm run contract:pull` with sessionboard-web-api and ' + + 'sessionboard-web-ui-v2 checked out beside this repo.', + contractVersion: api.contractVersion, + // Valid values. Always slugs — never the module UUIDs that + // Sessions and Applications carry internally. + features: api.features, + // Which registry each slug lives in: event features, org features, or both. + // Awards is org-only, so an event-only feature check would never satisfy it. + featureScopes: api.featureScopes, + // Per-scope resolution: slug → the id that scope's feature list carries. + featureIdBySlug: api.featureIdBySlug, + // Display names, so a collapsed block can say "Awards isn't enabled for + // Acme Summit" rather than showing the reader a raw slug. + featureNames: api.featureNames, + // Valid values: boolean columns on the Event model. + eventSettings: api.eventSettings, + // Valid app: link targets. + appRoutes, +}; + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, `${JSON.stringify(contract, null, 2)}\n`); + +console.log( + `Wrote ${OUT}\n` + + ` ${contract.features.length} features\n` + + ` ${contract.eventSettings.length} event settings\n` + + ` ${Object.keys(appRoutes).length} app route targets`, +); diff --git a/scripts/sync-marker-hydration.mjs b/scripts/sync-marker-hydration.mjs new file mode 100644 index 0000000..10bcf33 --- /dev/null +++ b/scripts/sync-marker-hydration.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Vendor the marker hydrator and its fixture corpus into sessionboard-web-ui-v2. + * + * The docs site and the in-product reader must render a conditional block + * identically — same collapse, same wording, same "unknown means visible" + * fallback. Two implementations of that would drift the first time either side + * fixed a bug, and the failure is invisible: an article merely reads slightly + * wrong on one surface. + * + * So there is one implementation, here, and the product repo gets a byte-exact + * copy. `markerHydration.test.ts` there runs the shared corpus against its copy + * and asserts the recorded checksums still match, so an edit to the vendored + * file — or a change here that was never synced — fails on the consuming side. + * + * Usage: + * npm run markers:sync + * npm run markers:sync -- --check # verify only, exit 1 on drift + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DOCS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const WORKSPACE = resolve(DOCS_ROOT, '..'); +const WEB_UI_DIR = process.env.SB_WEB_UI_V2_DIR ?? join(WORKSPACE, 'sessionboard-web-ui-v2'); + +const CHECK_ONLY = process.argv.includes('--check'); + +const VENDOR_DIR = join(WEB_UI_DIR, 'src', 'lib', 'help'); + +const FILES = [ + { + from: join(DOCS_ROOT, 'src', 'lib', 'marker-hydration.mjs'), + to: join(VENDOR_DIR, 'markerHydration.mjs'), + banner: true, + }, + { + from: join(DOCS_ROOT, 'tests', 'fixtures', 'marker-hydration.json'), + to: join(VENDOR_DIR, 'markerHydration.fixtures.json'), + banner: false, + }, +]; + +const CHECKSUMS = join(VENDOR_DIR, 'markerHydration.checksums.json'); + +const BANNER = `/** + * VENDORED FILE — DO NOT EDIT HERE. + * + * Source of truth: sessionboard-docs/src/lib/marker-hydration.mjs + * Refresh with: cd ../sessionboard-docs && npm run markers:sync + * + * Edit the source, re-run the sync, and commit both repos. markerHydration.test.ts + * fails if this copy and its recorded checksum disagree. + */ +`; + +const sha = (text) => createHash('sha256').update(text).digest('hex'); + +if (!existsSync(WEB_UI_DIR)) { + console.error( + `\n✖ Cannot find ${WEB_UI_DIR}.\n` + + ' Check out sessionboard-web-ui-v2 beside this repo, or set SB_WEB_UI_V2_DIR.\n', + ); + process.exit(1); +} + +const checksums = { specVersion: null, files: {} }; +const drift = []; + +for (const file of FILES) { + const source = readFileSync(file.from, 'utf8'); + const payload = file.banner ? `${BANNER}\n${source}` : source; + + // Checksum the SOURCE, not the bannered payload, so the recorded value is + // comparable across repos regardless of the banner text. + checksums.files[file.to.replace(`${WEB_UI_DIR}/`, '')] = sha(source); + + if (CHECK_ONLY) { + if (!existsSync(file.to)) { + drift.push(`${file.to} is missing`); + continue; + } + const current = readFileSync(file.to, 'utf8'); + if (current !== payload) drift.push(`${file.to} differs from the source`); + continue; + } + + mkdirSync(dirname(file.to), { recursive: true }); + writeFileSync(file.to, payload); +} + +const specVersion = JSON.parse( + readFileSync(join(DOCS_ROOT, 'tests', 'fixtures', 'marker-hydration.json'), 'utf8'), +).specVersion; +checksums.specVersion = specVersion; + +const checksumJson = `${JSON.stringify( + { + '//': + 'GENERATED by sessionboard-docs/scripts/sync-marker-hydration.mjs. ' + + 'markerHydration.test.ts asserts the vendored files still hash to these values.', + ...checksums, + }, + null, + 2, +)}\n`; + +if (CHECK_ONLY) { + if (existsSync(CHECKSUMS)) { + if (readFileSync(CHECKSUMS, 'utf8') !== checksumJson) { + drift.push(`${CHECKSUMS} is stale`); + } + } else { + drift.push(`${CHECKSUMS} is missing`); + } + + if (drift.length > 0) { + console.error( + '\n✖ Vendored marker hydration is out of sync:\n' + + drift.map((d) => ` - ${d}`).join('\n') + + '\n\n Run `npm run markers:sync` and commit both repos.\n', + ); + process.exit(1); + } + console.log('✓ Vendored marker hydration matches the source.'); + process.exit(0); +} + +writeFileSync(CHECKSUMS, checksumJson); + +console.log( + `Synced marker spec v${specVersion} into ${VENDOR_DIR}\n` + + FILES.map((f) => ` ${f.to.replace(`${WEB_UI_DIR}/`, '')}`).join('\n'), +); diff --git a/scripts/tag-taxonomy.mjs b/scripts/tag-taxonomy.mjs new file mode 100644 index 0000000..e5f7b51 --- /dev/null +++ b/scripts/tag-taxonomy.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Seeds `features:` and `audience:` frontmatter across the corpus. + * + * Retrieval quality and the reader's "does this apply to me" banner both depend + * on this taxonomy, and hand-tagging 227 articles would never finish. Folder + * structure already encodes most of it, so this seeds what the folders make + * obvious and leaves the rest for authors. + * + * Deliberately CONSERVATIVE. A wrong `features:` tag is worse than a missing + * one: it collapses the applicability banner and drops the article from + * retrieval for customers who do own the feature, so they are told the product + * cannot do something it can. A missing tag just means the article shows to + * everyone, which is where the corpus is today. So a folder is only mapped when + * it corresponds 1:1 to a gated product area — `faq/`, `settings/`, + * `integrations/` and friends stay untagged on purpose. + * + * Idempotent: an existing `features:` or `audience:` is never overwritten, so + * an author's hand-tuned value survives every re-run. + * + * Usage: + * npm run taxonomy:tag + * npm run taxonomy:tag -- --dry-run + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const DOCS = join(ROOT, 'src', 'content', 'docs'); +const CONTRACT = JSON.parse( + readFileSync(join(ROOT, 'src', 'data', 'product-contract.json'), 'utf8'), +); + +const DRY_RUN = process.argv.includes('--dry-run'); + +/** + * Folder → feature slugs. Only 1:1 mappings belong here. + * + * Notably absent, and why: + * faq/, videos/, get-started/, concepts/ — span every product area + * contacts/, communications/, settings/ — core surfaces, not gated + * integrations/, apps/ — one feature per integration + * reporting/ — mostly core; only the builder is gated + * site/, events/, event-team/, speakers/ — no single feature covers them + * portals/ — portals are core. There is a + * `new_portals` feature, but it gates the redesigned portal experience, not + * the existence of portals, so tagging these with it would hide portal docs + * from everyone still on the current one. + */ +const FEATURES_BY_FOLDER = { + 'speaker-crm': ['crm'], + marketing: ['marketing'], + awards: ['awards'], + applications: ['applications'], + documents: ['documents'], + studio: ['studio'], + agents: ['coordinators'], + sessions: ['sessions'], + // Evaluation Plans are part of the Sessions module — an event without + // Sessions has nothing to evaluate. + evaluations: ['sessions'], +}; + +/** + * Per-file overrides, for folders that mix two gated areas or hold one gated + * article among core ones. + */ +const FEATURES_BY_FILE = { + 'sponsors-exhibitors/sponsors.mdx': ['sponsors'], + 'sponsors-exhibitors/sponsor-settings.mdx': ['sponsors'], + 'sponsors-exhibitors/sponsor-intake-form.mdx': ['sponsors'], + 'sponsors-exhibitors/exhibitor-intake-form.mdx': ['exhibitors'], + 'sponsors-exhibitors/adding-exhibitor-groups-contacts.mdx': ['exhibitors'], + 'reporting/insights-ai.mdx': ['ai_reports'], +}; + +/** + * Folder → audience. Unlike features, audience never hides content — it only + * steers retrieval, so an admin question stops returning speaker-portal steps. + */ +const AUDIENCE_BY_FOLDER = { + participants: ['participant'], +}; + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (entry.endsWith('.mdx')) out.push(full); + } + return out; +} + +/** Split a file into [frontmatterLines, rest] without a YAML dependency. */ +function splitFrontmatter(source) { + const lines = source.split('\n'); + if (lines[0] !== '---') return null; + const end = lines.indexOf('---', 1); + if (end === -1) return null; + return { front: lines.slice(1, end), body: lines.slice(end + 1) }; +} + +const yamlList = (key, values) => `${key}: [${values.map((v) => `"${v}"`).join(', ')}]`; + +const invalid = []; +for (const [source, features] of [ + ...Object.entries(FEATURES_BY_FOLDER), + ...Object.entries(FEATURES_BY_FILE), +]) { + for (const slug of features) { + if (!CONTRACT.features.includes(slug)) invalid.push(`${source} → ${slug}`); + } +} +if (invalid.length > 0) { + console.error( + '\n✖ This script maps folders to features that no longer exist:\n' + + invalid.map((i) => ` - ${i}`).join('\n') + + '\n\n Run `npm run contract:pull`, then fix the maps in this file.\n', + ); + process.exit(1); +} + +let changed = 0; +let skipped = 0; + +for (const file of walk(DOCS)) { + const rel = relative(DOCS, file); + const folder = rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : ''; + + const features = FEATURES_BY_FILE[rel] ?? FEATURES_BY_FOLDER[folder] ?? null; + const audience = AUDIENCE_BY_FOLDER[folder] ?? null; + if (!features && !audience) { + skipped += 1; + continue; + } + + const source = readFileSync(file, 'utf8'); + const parts = splitFrontmatter(source); + if (!parts) { + console.warn(` ! ${rel} has no frontmatter block — skipped`); + skipped += 1; + continue; + } + + const additions = []; + const hasKey = (key) => parts.front.some((line) => line.startsWith(`${key}:`)); + if (features && !hasKey('features')) additions.push(yamlList('features', features)); + if (audience && !hasKey('audience')) additions.push(yamlList('audience', audience)); + + if (additions.length === 0) { + skipped += 1; + continue; + } + + const next = ['---', ...parts.front, ...additions, '---', ...parts.body].join('\n'); + changed += 1; + if (DRY_RUN) { + console.log(` would tag ${rel}: ${additions.join(' ')}`); + } else { + writeFileSync(file, next); + } +} + +console.log( + `${DRY_RUN ? 'Would tag' : 'Tagged'} ${changed} article(s); ` + + `${skipped} left for authors (no confident mapping, or already tagged).`, +); diff --git a/src/components/compat/AddOnNote.astro b/src/components/compat/AddOnNote.astro index 4422d07..79206ec 100644 --- a/src/components/compat/AddOnNote.astro +++ b/src/components/compat/AddOnNote.astro @@ -3,17 +3,48 @@ * Callout for features that require enablement (paid add-ons or * support-enabled features): Speaker CRM, Awards, SSO, Insights, * Program Site, Custom Email Domain, etc. + * + * Takes a validated feature slug so the same callout can be personalized. On + * the public site it renders exactly what it always did — "Awards requires + * enablement, contact support". In the product the reader knows whether this + * event actually has the feature, so a customer who already owns Awards is not + * told to email support about Awards. + * + * `feature` must exist in src/data/product-contract.json. A free-text label is + * still accepted via `label` for the handful of things that are sold but are not + * feature flags; that form gets no personalization. */ import { Aside } from '@astrojs/starlight/components'; +import contract from '../../data/product-contract.json'; interface Props { + /** Feature slug from the product contract — enables personalization. */ feature?: string; + /** Free-text name, for sold things that have no feature flag. */ + label?: string; } -const { feature } = Astro.props; + +const { feature, label } = Astro.props; + +if (feature && !contract.features.includes(feature)) { + throw new Error( + ` is not a known feature.\n` + + ' Valid slugs live in src/data/product-contract.json.\n' + + ' If the feature is new, run `npm run contract:pull` and commit the result.', + ); +} + +const name = feature ? (contract.featureNames as Record)[feature] : null; +const subject = name ?? label ?? null; --- diff --git a/src/components/compat/IfFeature.astro b/src/components/compat/IfFeature.astro new file mode 100644 index 0000000..534bb97 --- /dev/null +++ b/src/components/compat/IfFeature.astro @@ -0,0 +1,56 @@ +--- +/** + * Marks a block as depending on one or more product features. + * + * Declares SEMANTICS only — it never decides what "not enabled" looks like. + * On the public site the content renders normally and fully visible, so there + * is one canonical page, no crawler variance, and nothing hidden from search or + * AI answer engines. In the product (and for a signed-in reader on the Help + * Center) the hydrator collapses it behind a summary naming the reader's own + * event, with a link to turn the feature on. Content is never removed from the + * DOM on any surface. + * + * Usage: + * + * Only relevant to customers with Awards. + * + * + * // needs both + * + * Slugs are validated against src/data/product-contract.json at build time. + * Note that scope is resolved from the contract, not written here: `awards` is + * an org-level feature and `coordinators` an event-level one, and an author + * should not have to know the difference. + */ +import contract from '../../data/product-contract.json'; + +interface Props { + /** One or more feature slugs, space separated. All must be enabled. */ + id: string; + /** Render as this element instead of a div (use "span" mid-sentence). */ + as?: string; +} + +const { id, as = 'div' } = Astro.props; + +const ids = String(id ?? '') + .split(/\s+/) + .filter(Boolean); + +if (ids.length === 0) { + throw new Error(' needs at least one feature slug in `id`.'); +} + +const unknown = ids.filter((slug) => !contract.features.includes(slug)); +if (unknown.length > 0) { + throw new Error( + ` names unknown feature(s): ${unknown.join(', ')}.\n` + + ' Valid slugs live in src/data/product-contract.json.\n' + + ' If the feature is new, run `npm run contract:pull` and commit the result.', + ); +} + +const Tag = as; +--- + + diff --git a/src/components/compat/IfSetting.astro b/src/components/compat/IfSetting.astro new file mode 100644 index 0000000..7de693b --- /dev/null +++ b/src/components/compat/IfSetting.astro @@ -0,0 +1,48 @@ +--- +/** + * Marks a block as depending on one or more event settings being switched on. + * + * Same contract as IfFeature: semantics here, behaviour in the reader. Fully + * visible on the public site; collapsed behind a summary naming the reader's + * event in the product, with a deep link to the setting that turns it on. + * + * Usage: + * + * Speakers confirm from their portal before the session is locked. + * + * + * Ids are boolean columns on the Event model, validated against + * src/data/product-contract.json at build time. + */ +import contract from '../../data/product-contract.json'; + +interface Props { + /** One or more event setting ids, space separated. All must be true. */ + id: string; + /** Render as this element instead of a div (use "span" mid-sentence). */ + as?: string; +} + +const { id, as = 'div' } = Astro.props; + +const ids = String(id ?? '') + .split(/\s+/) + .filter(Boolean); + +if (ids.length === 0) { + throw new Error(' needs at least one setting id in `id`.'); +} + +const unknown = ids.filter((setting) => !contract.eventSettings.includes(setting)); +if (unknown.length > 0) { + throw new Error( + ` names unknown setting(s): ${unknown.join(', ')}.\n` + + ' Valid ids live in src/data/product-contract.json (boolean columns on the Event model).\n' + + ' If the setting is new, run `npm run contract:pull` and commit the result.', + ); +} + +const Tag = as; +--- + + diff --git a/src/components/compat/index.ts b/src/components/compat/index.ts index a75bfef..4b6310e 100644 --- a/src/components/compat/index.ts +++ b/src/components/compat/index.ts @@ -10,3 +10,7 @@ export { default as Accordion } from './Accordion.astro'; export { default as AccordionGroup } from './AccordionGroup.astro'; export { default as AddOnNote } from './AddOnNote.astro'; export { default as Walkthrough } from './Walkthrough.astro'; +// Personalization markers — inert and fully visible on the public site, +// hydrated against the reader's real event in the product. See MARKER_SPEC.md. +export { default as IfFeature } from './IfFeature.astro'; +export { default as IfSetting } from './IfSetting.astro'; diff --git a/src/content.config.ts b/src/content.config.ts index 6a7b7a0..189d1c7 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,7 +1,54 @@ -import { defineCollection } from 'astro:content'; +import { defineCollection, z } from 'astro:content'; import { docsLoader } from '@astrojs/starlight/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; +import contract from './data/product-contract.json'; + +/** + * Retrieval and personalization taxonomy. + * + * These three fields are what let the same article be found by the right person + * and read as if written for their event: + * + * features — which product features the article assumes. Drives the + * "does this apply to you" banner in the reader and drops chunks from + * retrieval for events that do not have the feature, so Team Lead never + * tells someone to click into Awards when Awards is off. + * audience — who the article is written for. Keeps admin questions from + * returning speaker-portal instructions. + * jtbd — the job the reader is trying to finish, in their words. Retrieval + * matches intent better against this than against a title. + * + * `features` is validated against the committed product contract, so a feature + * renamed in web-api fails the build here instead of quietly producing an + * article that claims a paying customer lacks something. + */ +const FEATURE_IDS = contract.features as [string, ...string[]]; + +const AUDIENCES = ['organizer', 'reviewer', 'speaker', 'participant'] as const; + export const collections = { - docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + docs: defineCollection({ + loader: docsLoader(), + schema: docsSchema({ + extend: z.object({ + features: z + .array(z.enum(FEATURE_IDS)) + .default([]) + .describe( + 'Product features this article assumes. Slugs from the product contract — run `npm run contract:pull` if a new one is missing.', + ), + audience: z + .array(z.enum(AUDIENCES)) + .default(['organizer']) + .describe('Who this article is written for. Defaults to event organizers.'), + jtbd: z + .string() + .optional() + .describe( + 'The job the reader is trying to get done, in their words — e.g. "get speakers to confirm before the deadline".', + ), + }), + }), + }), }; diff --git a/src/content/docs/agents/overview.mdx b/src/content/docs/agents/overview.mdx index 5432ed2..3a2a50e 100644 --- a/src/content/docs/agents/overview.mdx +++ b/src/content/docs/agents/overview.mdx @@ -1,6 +1,7 @@ --- title: "Agents overview" description: "Supervised AI agents that work alongside your team across sourcing, program, and content tasks." +features: ["coordinators"] --- import { Note, Info, Card, CardGroup } from '@compat'; diff --git a/src/content/docs/applications/building-your-submission-form.mdx b/src/content/docs/applications/building-your-submission-form.mdx index de3de30..2e14a6d 100644 --- a/src/content/docs/applications/building-your-submission-form.mdx +++ b/src/content/docs/applications/building-your-submission-form.mdx @@ -3,6 +3,7 @@ title: "Building your submission form (Sessions 2.0)" sidebar: label: "Build your submission form (Sessions 2.0)" description: "Design the form submitters use to enter abstracts or sessions with custom participant roles, conditional logic, and built-in payments." +features: ["applications"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/applications/create-applications.mdx b/src/content/docs/applications/create-applications.mdx index 5e489d2..6d85419 100644 --- a/src/content/docs/applications/create-applications.mdx +++ b/src/content/docs/applications/create-applications.mdx @@ -1,6 +1,7 @@ --- title: "Create applications" description: "Applications can be customized to collect any information you want to store with the person." +features: ["applications"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/applications/pp-1.mdx b/src/content/docs/applications/pp-1.mdx index 422b085..febe0e4 100644 --- a/src/content/docs/applications/pp-1.mdx +++ b/src/content/docs/applications/pp-1.mdx @@ -1,6 +1,7 @@ --- title: "Edit your application" description: "You have just submitted your application; however, you may want to make some edits to it." +features: ["applications"] --- You have just submitted your application; however, you may want to make some edits to it. No worries! Below, you will find instructions on how to make edits to your application before the close date. diff --git a/src/content/docs/awards/awards-list-of-supported-payment-gateways.mdx b/src/content/docs/awards/awards-list-of-supported-payment-gateways.mdx index e8e7365..4578245 100644 --- a/src/content/docs/awards/awards-list-of-supported-payment-gateways.mdx +++ b/src/content/docs/awards/awards-list-of-supported-payment-gateways.mdx @@ -1,6 +1,7 @@ --- title: "Awards list of supported payment gateways" description: "Sessionboard supports over 100 payment gateways." +features: ["awards"] --- Sessionboard supports over 100 payment gateways. When adding a new gateway, you can choose from the following featured providers: diff --git a/src/content/docs/awards/awards-notifications-email-templates.mdx b/src/content/docs/awards/awards-notifications-email-templates.mdx index 46aeec8..1494fe3 100644 --- a/src/content/docs/awards/awards-notifications-email-templates.mdx +++ b/src/content/docs/awards/awards-notifications-email-templates.mdx @@ -1,6 +1,7 @@ --- title: "Awards notifications & email templates" description: "Configure automated emails for every stage of your awards program — from submission confirmation to winner announcements." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/awards/awards-pricing-payments-invoices.mdx b/src/content/docs/awards/awards-pricing-payments-invoices.mdx index 5e9919f..56d8b37 100644 --- a/src/content/docs/awards/awards-pricing-payments-invoices.mdx +++ b/src/content/docs/awards/awards-pricing-payments-invoices.mdx @@ -1,6 +1,7 @@ --- title: "Awards pricing, payments & invoices" description: "Configure submission fees, payment collection, and invoice management for your awards program." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/awards/awards-submission-form.mdx b/src/content/docs/awards/awards-submission-form.mdx index 89a627b..4dc70d6 100644 --- a/src/content/docs/awards/awards-submission-form.mdx +++ b/src/content/docs/awards/awards-submission-form.mdx @@ -1,6 +1,7 @@ --- title: "Building your awards submission form" description: "Configure the submission form that applicants or nominators will complete for your program." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/awards/getting-started.mdx b/src/content/docs/awards/getting-started.mdx index fb56369..5d5bcae 100644 --- a/src/content/docs/awards/getting-started.mdx +++ b/src/content/docs/awards/getting-started.mdx @@ -1,6 +1,7 @@ --- title: "Getting started with awards" description: "Create and manage awards, grants, or scholarship programs with an eight-step guided setup wizard." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/awards/managing-submissions-reviewers-rounds.mdx b/src/content/docs/awards/managing-submissions-reviewers-rounds.mdx index d21cf43..133864d 100644 --- a/src/content/docs/awards/managing-submissions-reviewers-rounds.mdx +++ b/src/content/docs/awards/managing-submissions-reviewers-rounds.mdx @@ -1,6 +1,7 @@ --- title: "Managing submissions, reviewers & rounds" description: "Track incoming entries, assign reviewers, configure scoring rubrics, and manage the evaluation lifecycle for your awards program." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/awards/setting-up-your-sessionboard-site.mdx b/src/content/docs/awards/setting-up-your-sessionboard-site.mdx index 8db6c2b..fdf263d 100644 --- a/src/content/docs/awards/setting-up-your-sessionboard-site.mdx +++ b/src/content/docs/awards/setting-up-your-sessionboard-site.mdx @@ -1,6 +1,7 @@ --- title: "Setting up your Sessionboard site" description: "Create and customize a branded portal where submitters can log in, submit entries, and track their awards/form submissions." +features: ["awards"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/documents/document-generation.mdx b/src/content/docs/documents/document-generation.mdx index 5249429..49193a8 100644 --- a/src/content/docs/documents/document-generation.mdx +++ b/src/content/docs/documents/document-generation.mdx @@ -1,6 +1,7 @@ --- title: "Create documents" description: "Create personalized documents from your event's speaker, session, sponsor, and exhibitor data using the Print module (previously Documents)." +features: ["documents"] --- import { Note } from '@compat'; diff --git a/src/content/docs/evaluations/ai-evaluations.mdx b/src/content/docs/evaluations/ai-evaluations.mdx index 73e7180..13fade6 100644 --- a/src/content/docs/evaluations/ai-evaluations.mdx +++ b/src/content/docs/evaluations/ai-evaluations.mdx @@ -1,6 +1,7 @@ --- title: "AI evaluations" description: "Automate submission review with AI-generated scores, summaries, and feedback from customizable virtual evaluator personas." +features: ["sessions"] --- import { Note, Tip, Steps, Step } from '@compat'; diff --git a/src/content/docs/evaluations/evaluation-plans.mdx b/src/content/docs/evaluations/evaluation-plans.mdx index b8f93f7..8cdeb8b 100644 --- a/src/content/docs/evaluations/evaluation-plans.mdx +++ b/src/content/docs/evaluations/evaluation-plans.mdx @@ -1,6 +1,7 @@ --- title: "Create & manage evaluation plans" description: "Gather structured feedback on submitted sessions from assigned evaluators using rating scales and rubrics." +features: ["sessions"] --- import { Note, Tip, Warning, Steps, Step } from '@compat'; diff --git a/src/content/docs/evaluations/evaluation-summary.mdx b/src/content/docs/evaluations/evaluation-summary.mdx index 6e8bd45..a948a82 100644 --- a/src/content/docs/evaluations/evaluation-summary.mdx +++ b/src/content/docs/evaluations/evaluation-summary.mdx @@ -1,6 +1,7 @@ --- title: "Evaluation summary" description: "The Evaluation Summary Page is a powerful new dashboard that provides a high-level overview of all session evaluations within your event." +features: ["sessions"] --- The Evaluation Summary Page is a powerful new dashboard that provides a high-level overview of all session evaluations within your event. This page is designed to help event teams quickly assess evaluation progress, identify top-performing sessions, and track evaluator engagement in real-time. diff --git a/src/content/docs/evaluations/evaluators-how-to-evaluate-sessions.mdx b/src/content/docs/evaluations/evaluators-how-to-evaluate-sessions.mdx index 4379a05..5e80ae7 100644 --- a/src/content/docs/evaluations/evaluators-how-to-evaluate-sessions.mdx +++ b/src/content/docs/evaluations/evaluators-how-to-evaluate-sessions.mdx @@ -3,6 +3,7 @@ title: "How to evaluate sessions" sidebar: label: "Evaluate sessions (classic)" description: "As an Evaluator, you will be able to review submissions and rate them based on the session and speaker details." +features: ["sessions"] --- As an Evaluator, you will be able to review submissions and rate them based on the session and speaker details. If allowed by the event admin, Evaluators will also be able to provide additional written feedback. diff --git a/src/content/docs/evaluations/how-to-evaluate-sessions.mdx b/src/content/docs/evaluations/how-to-evaluate-sessions.mdx index 9b982fc..0081e77 100644 --- a/src/content/docs/evaluations/how-to-evaluate-sessions.mdx +++ b/src/content/docs/evaluations/how-to-evaluate-sessions.mdx @@ -3,6 +3,7 @@ title: "Evaluate sessions" sidebar: label: "Evaluate sessions (Sessions 2.0)" description: "Log in to your Program Site, view your assigned evaluation plans, and complete scorecards for each submission." +features: ["sessions"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/evaluations/setting-up-round-based-evaluations.mdx b/src/content/docs/evaluations/setting-up-round-based-evaluations.mdx index 9949cce..d47aaca 100644 --- a/src/content/docs/evaluations/setting-up-round-based-evaluations.mdx +++ b/src/content/docs/evaluations/setting-up-round-based-evaluations.mdx @@ -3,6 +3,7 @@ title: "Setting up round-based evaluations (Sessions 2.0)" sidebar: label: "Round-based evaluations (Sessions 2.0)" description: "Round-based evaluations replace the single-stage evaluation model from the previous version." +features: ["sessions"] --- import { Note, Tip, Warning } from '@compat'; diff --git a/src/content/docs/marketing/advocacy.mdx b/src/content/docs/marketing/advocacy.mdx index 177c460..02d93bc 100644 --- a/src/content/docs/marketing/advocacy.mdx +++ b/src/content/docs/marketing/advocacy.mdx @@ -3,6 +3,7 @@ title: "Advocacy campaigns" description: "Turn speakers, sponsors, and exhibitors into a measurable sharing channel with personalized share cards and tracked links." sidebar: label: "Advocacy" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/attendee-view.mdx b/src/content/docs/marketing/attendee-view.mdx index de0e446..b2f4350 100644 --- a/src/content/docs/marketing/attendee-view.mdx +++ b/src/content/docs/marketing/attendee-view.mdx @@ -3,6 +3,7 @@ title: "Attendee view" description: "Publish a public, branded hub where attendees browse sessions, watch replays, read AI recaps, and follow live captions." sidebar: label: "Attendee view" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/brand-kits.mdx b/src/content/docs/marketing/brand-kits.mdx index 19f6307..f3f35ae 100644 --- a/src/content/docs/marketing/brand-kits.mdx +++ b/src/content/docs/marketing/brand-kits.mdx @@ -3,6 +3,7 @@ title: "Brand kits" description: "Define colors, fonts, logos, and an AI-learned brand voice once, then apply them across every event and every AI-generated output." sidebar: label: "Brand" +features: ["marketing"] --- import { Note, Tip, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/clips.mdx b/src/content/docs/marketing/clips.mdx index d5106bc..328cc48 100644 --- a/src/content/docs/marketing/clips.mdx +++ b/src/content/docs/marketing/clips.mdx @@ -3,6 +3,7 @@ title: "Clips" description: "Cut short, shareable videos out of your recordings with AI suggestions, then export them vertical or square with captions." sidebar: label: "Clips" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/content-plan.mdx b/src/content/docs/marketing/content-plan.mdx index 8895ecd..1508295 100644 --- a/src/content/docs/marketing/content-plan.mdx +++ b/src/content/docs/marketing/content-plan.mdx @@ -3,6 +3,7 @@ title: "Content plan" description: "Turn one session or topic into a scheduled, LinkedIn-anchored content calendar, then draft each item in Create." sidebar: label: "Plan" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/create.mdx b/src/content/docs/marketing/create.mdx index 85aec37..bd67aa9 100644 --- a/src/content/docs/marketing/create.mdx +++ b/src/content/docs/marketing/create.mdx @@ -3,6 +3,7 @@ title: "Create" description: "Draft LinkedIn posts, blogs, white papers, and carousels grounded in your own sessions and recordings, with citations." sidebar: label: "Create" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/dispatch.mdx b/src/content/docs/marketing/dispatch.mdx index c1b87d3..20e0428 100644 --- a/src/content/docs/marketing/dispatch.mdx +++ b/src/content/docs/marketing/dispatch.mdx @@ -3,6 +3,7 @@ title: "Dispatch" description: "Package event content into share kits for your sales, membership, and marketing teams — delivered by Slack and email, with a leaderboard." sidebar: label: "Dispatch" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/live-transcribe.mdx b/src/content/docs/marketing/live-transcribe.mdx index 86a9483..a99c1be 100644 --- a/src/content/docs/marketing/live-transcribe.mdx +++ b/src/content/docs/marketing/live-transcribe.mdx @@ -3,6 +3,7 @@ title: "Live Transcribe" description: "Capture sessions as they happen and turn them into transcripts, AI summaries, insights, and recap drafts." sidebar: label: "Live Transcribe" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/media-library.mdx b/src/content/docs/marketing/media-library.mdx index b17c4d0..e392e41 100644 --- a/src/content/docs/marketing/media-library.mdx +++ b/src/content/docs/marketing/media-library.mdx @@ -3,6 +3,7 @@ title: "Media library" description: "Bring recordings in from uploads, YouTube, Vimeo, and connected platforms, then get transcripts, chapters, clips, and quotes attributed to your speakers." sidebar: label: "Media" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/posts.mdx b/src/content/docs/marketing/posts.mdx index 3256675..89aabcc 100644 --- a/src/content/docs/marketing/posts.mdx +++ b/src/content/docs/marketing/posts.mdx @@ -3,6 +3,7 @@ title: "Posts" description: "Browse everything you've drafted in Create — LinkedIn posts, blogs, white papers, and carousels — and reopen any of them to edit." sidebar: label: "Posts" +features: ["marketing"] --- import { Note, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/print-agendas.mdx b/src/content/docs/marketing/print-agendas.mdx index 2889620..01896d7 100644 --- a/src/content/docs/marketing/print-agendas.mdx +++ b/src/content/docs/marketing/print-agendas.mdx @@ -1,6 +1,7 @@ --- title: "Print agendas" description: "Build program books, daily schedules, and speaker handouts from your live event data, then export to PDF or share them at a public URL." +features: ["marketing"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/marketing/quotes.mdx b/src/content/docs/marketing/quotes.mdx index 42bf2d4..911bd51 100644 --- a/src/content/docs/marketing/quotes.mdx +++ b/src/content/docs/marketing/quotes.mdx @@ -3,6 +3,7 @@ title: "Quotes library" description: "Pull quotable lines out of your transcripts with AI, edit and attribute them, then turn them into LinkedIn posts and quote cards." sidebar: label: "Quotes" +features: ["marketing"] --- import { Note, Tip, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/ready-room.mdx b/src/content/docs/marketing/ready-room.mdx index fc1943b..5d78b57 100644 --- a/src/content/docs/marketing/ready-room.mdx +++ b/src/content/docs/marketing/ready-room.mdx @@ -3,6 +3,7 @@ title: "Ready Room for AV teams" description: "Give AV crews magic-link access to session schedules, speaker files, and transcription controls — with no Sessionboard account." sidebar: label: "Ready Room" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/recaps.mdx b/src/content/docs/marketing/recaps.mdx index a7953a1..77c4b66 100644 --- a/src/content/docs/marketing/recaps.mdx +++ b/src/content/docs/marketing/recaps.mdx @@ -3,6 +3,7 @@ title: "Recaps" description: "Turn captured sessions into an event report, daily debriefs, and per-session recaps you edit, approve, and publish." sidebar: label: "Recaps" +features: ["marketing"] --- import { Note, Tip, Warning, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/marketing/what-is-sessionboard-marketing.mdx b/src/content/docs/marketing/what-is-sessionboard-marketing.mdx index 1dfe266..07eeb8d 100644 --- a/src/content/docs/marketing/what-is-sessionboard-marketing.mdx +++ b/src/content/docs/marketing/what-is-sessionboard-marketing.mdx @@ -3,6 +3,7 @@ title: "What is Sessionboard Marketing?" description: "Capture every session, catalog it in one library, and distribute it through your speakers and your team — turning each event into a year-round content engine." sidebar: label: "Overview" +features: ["marketing"] --- import { Note, Tip, Card, CardGroup } from '@compat'; diff --git a/src/content/docs/participants/access-portal.mdx b/src/content/docs/participants/access-portal.mdx index 3dfd418..f83fbb7 100644 --- a/src/content/docs/participants/access-portal.mdx +++ b/src/content/docs/participants/access-portal.mdx @@ -1,6 +1,7 @@ --- title: "Access your portal" description: "Log in to your event portal to complete tasks, submit files, and view event information." +audience: ["participant"] --- import { Tip } from '@compat'; diff --git a/src/content/docs/participants/edit-submission.mdx b/src/content/docs/participants/edit-submission.mdx index 13025d7..ac30cad 100644 --- a/src/content/docs/participants/edit-submission.mdx +++ b/src/content/docs/participants/edit-submission.mdx @@ -1,6 +1,7 @@ --- title: "View & edit your submission" description: "Make changes to your session submission before the submission close date." +audience: ["participant"] --- You have just submitted your session information; however, you may want to make some adjustments to it. No worries! Below, you will find instructions on how to make edits to your submission **before the submission close date**. diff --git a/src/content/docs/participants/exhibitor-portal.mdx b/src/content/docs/participants/exhibitor-portal.mdx index ce7ddbd..f61f497 100644 --- a/src/content/docs/participants/exhibitor-portal.mdx +++ b/src/content/docs/participants/exhibitor-portal.mdx @@ -3,6 +3,7 @@ title: "Share your group portal with colleagues" sidebar: label: "Share group portal access" description: "Sometimes the power of two is better than one." +audience: ["participant"] --- Sometimes the power of two is better than one. By giving colleagues access to the portal, they are able to complete tasks for the group and view any additional information provided by the event admin. diff --git a/src/content/docs/participants/how-to-add-of-edit-speaker-information-for-an-accepted-session.mdx b/src/content/docs/participants/how-to-add-of-edit-speaker-information-for-an-accepted-session.mdx index eefb595..94d5405 100644 --- a/src/content/docs/participants/how-to-add-of-edit-speaker-information-for-an-accepted-session.mdx +++ b/src/content/docs/participants/how-to-add-of-edit-speaker-information-for-an-accepted-session.mdx @@ -3,6 +3,7 @@ title: "Add or edit session speakers" sidebar: label: "Add or edit session speakers" description: "Learn how to edit the current speakers associated with your session." +audience: ["participant"] --- import { Note } from '@compat'; diff --git a/src/content/docs/participants/how-to-change-my-portal-username-or-email.mdx b/src/content/docs/participants/how-to-change-my-portal-username-or-email.mdx index b7cfa6e..d56a9a4 100644 --- a/src/content/docs/participants/how-to-change-my-portal-username-or-email.mdx +++ b/src/content/docs/participants/how-to-change-my-portal-username-or-email.mdx @@ -3,6 +3,7 @@ title: "Change your username or email" sidebar: label: "Change your username or email" description: "PORTAL ACCESS If you are a contact who needs to access a portal, select the event name from the dashboard home page." +audience: ["participant"] --- 1. Using the following link log into Sessionboard: https://app.sessionboard.com/login diff --git a/src/content/docs/participants/overview.mdx b/src/content/docs/participants/overview.mdx index 3b8a4ca..a9a8805 100644 --- a/src/content/docs/participants/overview.mdx +++ b/src/content/docs/participants/overview.mdx @@ -4,6 +4,7 @@ description: "For speakers, sponsors, and exhibitors: how to log in to your even sidebar: label: "Guide overview" order: 0 +audience: ["participant"] --- import { Card, CardGroup } from '@compat'; diff --git a/src/content/docs/participants/pp-how-to-access-additional-event-portals-you-are-associated-with.mdx b/src/content/docs/participants/pp-how-to-access-additional-event-portals-you-are-associated-with.mdx index 79cedd7..681008f 100644 --- a/src/content/docs/participants/pp-how-to-access-additional-event-portals-you-are-associated-with.mdx +++ b/src/content/docs/participants/pp-how-to-access-additional-event-portals-you-are-associated-with.mdx @@ -3,6 +3,7 @@ title: "Access your other event portals" sidebar: label: "Access your other event portals" description: "If you are a part of multiple events using Sessionboard, then this article will provide you with instructions on switching between the different portals without having to log out of your existing one." +audience: ["participant"] --- If you are a part of multiple events using Sessionboard, then this article will provide you with instructions on switching between the different portals without having to log out of your existing one. diff --git a/src/content/docs/participants/pp-how-to-view-and-download-files-from-my-portal.mdx b/src/content/docs/participants/pp-how-to-view-and-download-files-from-my-portal.mdx index defb6cf..be54db0 100644 --- a/src/content/docs/participants/pp-how-to-view-and-download-files-from-my-portal.mdx +++ b/src/content/docs/participants/pp-how-to-view-and-download-files-from-my-portal.mdx @@ -3,6 +3,7 @@ title: "View and download portal files" sidebar: label: "View & download files" description: "Log into your portal and use the Files widget to view and download files shared with you by your event team." +audience: ["participant"] --- 1. Log into your portal diff --git a/src/content/docs/participants/save-a-submission-as-a-draft.mdx b/src/content/docs/participants/save-a-submission-as-a-draft.mdx index 7e5d8be..48ea853 100644 --- a/src/content/docs/participants/save-a-submission-as-a-draft.mdx +++ b/src/content/docs/participants/save-a-submission-as-a-draft.mdx @@ -1,6 +1,7 @@ --- title: "Save a submission as a draft" description: "When submitting a session proposal, you may not always have all the required information on hand." +audience: ["participant"] --- import { Note } from '@compat'; diff --git a/src/content/docs/participants/speaker-headshot-dos-and-donts.mdx b/src/content/docs/participants/speaker-headshot-dos-and-donts.mdx index 618585d..eaf28d0 100644 --- a/src/content/docs/participants/speaker-headshot-dos-and-donts.mdx +++ b/src/content/docs/participants/speaker-headshot-dos-and-donts.mdx @@ -1,6 +1,7 @@ --- title: "Speaker headshot do's and don'ts" description: "Tips for submitting a speaker headshot: face the camera, use a well-lit photo and a neutral background, and avoid uneven lighting or busy backgrounds." +audience: ["participant"] --- Your headshot appears on the event agenda, program site, and any speaker embeds, so it's worth submitting a photo that shows your best side. Follow these simple tips. diff --git a/src/content/docs/participants/updated-portal.mdx b/src/content/docs/participants/updated-portal.mdx index fa27783..3cf1550 100644 --- a/src/content/docs/participants/updated-portal.mdx +++ b/src/content/docs/participants/updated-portal.mdx @@ -1,6 +1,7 @@ --- title: "Understanding a portal task" description: "What each part of a portal task means — task name, description, due date, required marker, and status — and how to mark a task complete." +audience: ["participant"] --- Tasks are the action items your event team assigns to you in the portal. Each task in your task list shows the same set of details. diff --git a/src/content/docs/participants/updated-portals-1.mdx b/src/content/docs/participants/updated-portals-1.mdx index 9b95625..e64ca16 100644 --- a/src/content/docs/participants/updated-portals-1.mdx +++ b/src/content/docs/participants/updated-portals-1.mdx @@ -3,6 +3,7 @@ title: "View a wiki page in your portal" sidebar: label: "View wiki pages" description: "Log into your portal and use the Resources widget to view wiki pages shared with you by your event team." +audience: ["participant"] --- 1. Log into your portal diff --git a/src/content/docs/participants/updated-portals.mdx b/src/content/docs/participants/updated-portals.mdx index 7e4b809..8651451 100644 --- a/src/content/docs/participants/updated-portals.mdx +++ b/src/content/docs/participants/updated-portals.mdx @@ -3,6 +3,7 @@ title: "Switch between your portals" sidebar: label: "Switch between portals" description: "As a portal user, you may be designated as a speaker while also having access to additional user or group portals." +audience: ["participant"] --- As a portal user, you may be designated as a speaker while also having access to additional user or group portals. For example, you may be affiliated with a sponsor group and need to access your sponsor portal, or you may be a speaker’s assistant and need to access their portal to complete tasks on their behalf. diff --git a/src/content/docs/participants/upload-files.mdx b/src/content/docs/participants/upload-files.mdx index cc2386c..c7d532b 100644 --- a/src/content/docs/participants/upload-files.mdx +++ b/src/content/docs/participants/upload-files.mdx @@ -1,6 +1,7 @@ --- title: "Upload files & make comments" description: "Upload supporting materials to your session and communicate with event admins through comments." +audience: ["participant"] --- If **Files** is enabled for your event, you can upload supporting documents that will accompany your session. Read more below on how to upload your materials as well as communicate to the event admins through comments. diff --git a/src/content/docs/reporting/insights-ai.mdx b/src/content/docs/reporting/insights-ai.mdx index 590b4e9..eab8fdb 100644 --- a/src/content/docs/reporting/insights-ai.mdx +++ b/src/content/docs/reporting/insights-ai.mdx @@ -1,6 +1,7 @@ --- title: "Insights: AI reports & dashboards" description: "Create, share, and schedule reports with AI-powered query generation, live share links, and dashboards." +features: ["ai_reports"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/sessions/accept-decline.mdx b/src/content/docs/sessions/accept-decline.mdx index 1182510..b3e41a0 100644 --- a/src/content/docs/sessions/accept-decline.mdx +++ b/src/content/docs/sessions/accept-decline.mdx @@ -1,9 +1,10 @@ --- title: "Accept or decline submissions" description: "Review submissions and update session status through Sessionboard's five status categories." +features: ["sessions"] --- -import { Tip } from '@compat'; +import { Tip, IfSetting } from '@compat'; After a speaker submits a session, the session appears in the Sessions module. The event admin then reviews the submission, along with any evaluator feedback from the Evaluation Plans, to approve or deny the session. @@ -56,6 +57,10 @@ Once sessions have been submitted, admin users can update their status to be eit Once a submission status has been changed to "Accepted", the session will be visible within the [Agenda](/sessions/agenda) for the event team. + +Accepting a session is not the end of the handoff. Each speaker still has to confirm their participation from their portal before the session is settled, so check the confirmation column in [Submissions](app:EventSubmissions) after a decision round. + + ## How To Review Submissions ### Review All Submissions diff --git a/src/content/docs/sessions/agenda.mdx b/src/content/docs/sessions/agenda.mdx index 020cceb..ab330f6 100644 --- a/src/content/docs/sessions/agenda.mdx +++ b/src/content/docs/sessions/agenda.mdx @@ -1,6 +1,7 @@ --- title: "Build & manage your agenda" description: "Schedule sessions with five agenda views and automatic conflict detection." +features: ["sessions"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/sessions/converting-a-session-to-a-subsession.mdx b/src/content/docs/sessions/converting-a-session-to-a-subsession.mdx index 1f28db5..738ee16 100644 --- a/src/content/docs/sessions/converting-a-session-to-a-subsession.mdx +++ b/src/content/docs/sessions/converting-a-session-to-a-subsession.mdx @@ -1,6 +1,7 @@ --- title: "Converting a session to a subsession" description: "With subsessions, you have the ability to convert a pre-existing parent session into a subsession. Reverting a subsession to a parent session is just as easy!" +features: ["sessions"] --- With subsessions, you have the ability to convert a pre-existing parent session into a subsession. Reverting a subsession to a parent session is just as easy! diff --git a/src/content/docs/sessions/create-a-session.mdx b/src/content/docs/sessions/create-a-session.mdx index 1d40dd9..d49b5e7 100644 --- a/src/content/docs/sessions/create-a-session.mdx +++ b/src/content/docs/sessions/create-a-session.mdx @@ -1,6 +1,7 @@ --- title: "Create a session" description: "Add a session manually as an admin, without a speaker filling out the submission form." +features: ["sessions"] --- import { Tip } from '@compat'; diff --git a/src/content/docs/sessions/create-a-subsession.mdx b/src/content/docs/sessions/create-a-subsession.mdx index 90f4cc3..4785a4d 100644 --- a/src/content/docs/sessions/create-a-subsession.mdx +++ b/src/content/docs/sessions/create-a-subsession.mdx @@ -1,6 +1,7 @@ --- title: "Create a subsession" description: "In Sessionboard, a parent session is the main session that appears on your agenda—it acts as the overarching container for related content." +features: ["sessions"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sessions/draft-submissions.mdx b/src/content/docs/sessions/draft-submissions.mdx index 8b9bc6a..70d163e 100644 --- a/src/content/docs/sessions/draft-submissions.mdx +++ b/src/content/docs/sessions/draft-submissions.mdx @@ -1,6 +1,7 @@ --- title: "Draft submissions" description: "When submitting a session proposal, you may not always have all the required information on hand." +features: ["sessions"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sessions/embeds.mdx b/src/content/docs/sessions/embeds.mdx index ffaeb38..963ee0a 100644 --- a/src/content/docs/sessions/embeds.mdx +++ b/src/content/docs/sessions/embeds.mdx @@ -1,6 +1,7 @@ --- title: "Agenda & speaker embeds" description: "Export a live feed of your agenda, sessions, and speakers to embed in your website or app." +features: ["sessions"] --- import { Tip } from '@compat'; diff --git a/src/content/docs/sessions/enable-upload-download-content.mdx b/src/content/docs/sessions/enable-upload-download-content.mdx index 0770b65..4bf3d75 100644 --- a/src/content/docs/sessions/enable-upload-download-content.mdx +++ b/src/content/docs/sessions/enable-upload-download-content.mdx @@ -1,6 +1,7 @@ --- title: "Enable, upload, & download files" description: "Collecting presentations and audio-visual materials for conference sessions can be a time-consuming process." +features: ["sessions"] --- import { Tip, Warning } from '@compat'; diff --git a/src/content/docs/sessions/program-settings.mdx b/src/content/docs/sessions/program-settings.mdx index 4216bc7..b018cd0 100644 --- a/src/content/docs/sessions/program-settings.mdx +++ b/src/content/docs/sessions/program-settings.mdx @@ -1,6 +1,7 @@ --- title: "Program settings" description: "The configuration hub for your agenda — personas, rooms, tracks, tags, levels, formats, languages, roles, and custom statuses." +features: ["sessions"] --- import { Note, Tip, Warning } from '@compat'; diff --git a/src/content/docs/sessions/reverting-a-subsession-back-to-a-parent-session.mdx b/src/content/docs/sessions/reverting-a-subsession-back-to-a-parent-session.mdx index fe19f27..33d90ca 100644 --- a/src/content/docs/sessions/reverting-a-subsession-back-to-a-parent-session.mdx +++ b/src/content/docs/sessions/reverting-a-subsession-back-to-a-parent-session.mdx @@ -1,6 +1,7 @@ --- title: "Reverting a subsession back to a parent session" description: "Revert a subsession back to a standalone parent session from the Sessions module. Note: integrations currently do not support subsessions." +features: ["sessions"] --- ❗️Note: Integrations currently do not support subsessions. diff --git a/src/content/docs/sessions/session-settings.mdx b/src/content/docs/sessions/session-settings.mdx index cd361b2..429491c 100644 --- a/src/content/docs/sessions/session-settings.mdx +++ b/src/content/docs/sessions/session-settings.mdx @@ -1,6 +1,7 @@ --- title: "Program settings" description: "Program Settings, Personas, Rooms, Tracks, Tags, Levels, Formats, Languages, Roles, Files, Statuses" +features: ["sessions"] --- import { Note, Warning } from '@compat'; diff --git a/src/content/docs/sessions/submission-forms.mdx b/src/content/docs/sessions/submission-forms.mdx index d2f02d4..7632565 100644 --- a/src/content/docs/sessions/submission-forms.mdx +++ b/src/content/docs/sessions/submission-forms.mdx @@ -1,6 +1,7 @@ --- title: "Session submission forms" description: "Build and customize the forms that collect session and speaker information during your call for papers." +features: ["sessions"] --- import { Note, Tip, Warning } from '@compat'; diff --git a/src/content/docs/sessions/viewing-subsessions-within-the-agenda.mdx b/src/content/docs/sessions/viewing-subsessions-within-the-agenda.mdx index 425450c..bf15778 100644 --- a/src/content/docs/sessions/viewing-subsessions-within-the-agenda.mdx +++ b/src/content/docs/sessions/viewing-subsessions-within-the-agenda.mdx @@ -1,6 +1,7 @@ --- title: "Viewing subsessions within the agenda" description: "❗️Note: Integrations currently do not support subsessions. Once you have created a subsession within a parent session, you can view it in your agenda." +features: ["sessions"] --- ❗️Note: Integrations currently do not support subsessions. diff --git a/src/content/docs/speaker-crm/creating-crm-fields.mdx b/src/content/docs/speaker-crm/creating-crm-fields.mdx index 4302f36..db708e1 100644 --- a/src/content/docs/speaker-crm/creating-crm-fields.mdx +++ b/src/content/docs/speaker-crm/creating-crm-fields.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: contact fields" description: "Create and manage contact fields, system defaults, custom fields, field types, and how to use them across views and the directory." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/crm-add-contact-to-events.mdx b/src/content/docs/speaker-crm/crm-add-contact-to-events.mdx index 1cdd60d..ddd0811 100644 --- a/src/content/docs/speaker-crm/crm-add-contact-to-events.mdx +++ b/src/content/docs/speaker-crm/crm-add-contact-to-events.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: add contacts to an event" description: "This guide applies only to organizations that have the Speaker CRM enabled. Have questions or want to receive pricing for the Speaker CRM?" +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/crm-advanced-search.mdx b/src/content/docs/speaker-crm/crm-advanced-search.mdx index cc97914..46ee73b 100644 --- a/src/content/docs/speaker-crm/crm-advanced-search.mdx +++ b/src/content/docs/speaker-crm/crm-advanced-search.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: advanced search" description: "Filter your contact directory using AND/OR logic across demographics, sessions, and custom fields to find the right contacts and save them as dynamic or curated segments." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/crm-best-practices.mdx b/src/content/docs/speaker-crm/crm-best-practices.mdx index d3a0eac..ce556d3 100644 --- a/src/content/docs/speaker-crm/crm-best-practices.mdx +++ b/src/content/docs/speaker-crm/crm-best-practices.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: set-up & best practices" description: "Get your Speaker CRM up and running: invite your team, define custom fields, import existing contacts, and build segments for targeted outreach." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/crm-segments.mdx b/src/content/docs/speaker-crm/crm-segments.mdx index d9dff22..02d5518 100644 --- a/src/content/docs/speaker-crm/crm-segments.mdx +++ b/src/content/docs/speaker-crm/crm-segments.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: segments" description: "Create dynamic and curated contact lists, manage who's in them, and reuse them across campaigns for more targeted outreach." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/crm.mdx b/src/content/docs/speaker-crm/crm.mdx index 38b3147..4621ef0 100644 --- a/src/content/docs/speaker-crm/crm.mdx +++ b/src/content/docs/speaker-crm/crm.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: communications" description: "Send targeted emails to contacts directly from the CRM, create reusable templates, personalize with merge tags, and track delivery and engagement through campaign history." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/inviting-organization-team-members.mdx b/src/content/docs/speaker-crm/inviting-organization-team-members.mdx index f1e3fa7..2f94f69 100644 --- a/src/content/docs/speaker-crm/inviting-organization-team-members.mdx +++ b/src/content/docs/speaker-crm/inviting-organization-team-members.mdx @@ -1,6 +1,7 @@ --- title: "Inviting organization team members" description: "Invite team members to your organization from Settings → Team. Everyone with organization access holds the Admin User permission." +features: ["crm"] --- Everyone you invite at the organization level holds the **Admin User** permission. Use event-level permissions when you need finer control over what someone can see and do. diff --git a/src/content/docs/speaker-crm/overview.mdx b/src/content/docs/speaker-crm/overview.mdx index 90d8053..84f2413 100644 --- a/src/content/docs/speaker-crm/overview.mdx +++ b/src/content/docs/speaker-crm/overview.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM overview" description: "Your speakers and event contacts in one place — search, invite, and activate across your entire event portfolio." +features: ["crm"] --- import { Tip } from '@compat'; diff --git a/src/content/docs/speaker-crm/pipeline.mdx b/src/content/docs/speaker-crm/pipeline.mdx index be539ad..75f914e 100644 --- a/src/content/docs/speaker-crm/pipeline.mdx +++ b/src/content/docs/speaker-crm/pipeline.mdx @@ -1,6 +1,7 @@ --- title: "Pipeline" description: "Move prospective speakers through your sourcing workflow with a centralized, kanban-style pipeline." +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/speaker-crm/speaker-crm-interest-forms.mdx b/src/content/docs/speaker-crm/speaker-crm-interest-forms.mdx index c8e934e..dbdfefe 100644 --- a/src/content/docs/speaker-crm/speaker-crm-interest-forms.mdx +++ b/src/content/docs/speaker-crm/speaker-crm-interest-forms.mdx @@ -1,6 +1,7 @@ --- title: "Speaker CRM: interest forms" description: "Collect speaker and session interest year-round and route every submission into your Speaker CRM Pipeline" +features: ["crm"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sponsors-exhibitors/adding-exhibitor-groups-contacts.mdx b/src/content/docs/sponsors-exhibitors/adding-exhibitor-groups-contacts.mdx index a149f8f..1211424 100644 --- a/src/content/docs/sponsors-exhibitors/adding-exhibitor-groups-contacts.mdx +++ b/src/content/docs/sponsors-exhibitors/adding-exhibitor-groups-contacts.mdx @@ -1,6 +1,7 @@ --- title: "Create exhibitor groups & contacts" description: "This guide applies only to events that have the Exhibitor module enabled." +features: ["exhibitors"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sponsors-exhibitors/exhibitor-intake-form.mdx b/src/content/docs/sponsors-exhibitors/exhibitor-intake-form.mdx index 7222ac3..cca9776 100644 --- a/src/content/docs/sponsors-exhibitors/exhibitor-intake-form.mdx +++ b/src/content/docs/sponsors-exhibitors/exhibitor-intake-form.mdx @@ -1,6 +1,7 @@ --- title: "Exhibitor intake form" description: "Create and share an exhibitor intake form to collect company details, contacts, and materials from your exhibitors." +features: ["exhibitors"] --- --- diff --git a/src/content/docs/sponsors-exhibitors/sponsor-intake-form.mdx b/src/content/docs/sponsors-exhibitors/sponsor-intake-form.mdx index 4ebcd86..b50434c 100644 --- a/src/content/docs/sponsors-exhibitors/sponsor-intake-form.mdx +++ b/src/content/docs/sponsors-exhibitors/sponsor-intake-form.mdx @@ -1,6 +1,7 @@ --- title: "Sponsor intake form" description: "This guide applies only to organizations that have Sponsors/Exhibitors enabled." +features: ["sponsors"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sponsors-exhibitors/sponsor-settings.mdx b/src/content/docs/sponsors-exhibitors/sponsor-settings.mdx index 18eef6c..18f2e12 100644 --- a/src/content/docs/sponsors-exhibitors/sponsor-settings.mdx +++ b/src/content/docs/sponsors-exhibitors/sponsor-settings.mdx @@ -1,6 +1,7 @@ --- title: "Sponsor tiers" description: "This guide applies only to organizations that have Sponsors/Exhibitors enabled. Tiers can be utilized to sync with our integration patterns (i.e." +features: ["sponsors"] --- import { Note } from '@compat'; diff --git a/src/content/docs/sponsors-exhibitors/sponsors.mdx b/src/content/docs/sponsors-exhibitors/sponsors.mdx index 3750f97..7faf6af 100644 --- a/src/content/docs/sponsors-exhibitors/sponsors.mdx +++ b/src/content/docs/sponsors-exhibitors/sponsors.mdx @@ -1,6 +1,7 @@ --- title: "Create sponsor groups & contacts" description: "Add sponsor groups and their contacts, with or without an intake form." +features: ["sponsors"] --- import { Note, Tip } from '@compat'; diff --git a/src/content/docs/studio/ai-agenda-builder.mdx b/src/content/docs/studio/ai-agenda-builder.mdx index 79161a0..633c05c 100644 --- a/src/content/docs/studio/ai-agenda-builder.mdx +++ b/src/content/docs/studio/ai-agenda-builder.mdx @@ -1,6 +1,7 @@ --- title: "AI agenda builder" description: "Generate a draft agenda from your sessions, guide it with scheduling rules, then review and commit only the changes you accept." +features: ["studio"] --- import { Note, Tip, Steps, Step, Accordion, AccordionGroup } from '@compat'; diff --git a/src/content/docs/studio/remix-session-speaker-content.mdx b/src/content/docs/studio/remix-session-speaker-content.mdx index c7376cd..44a292d 100644 --- a/src/content/docs/studio/remix-session-speaker-content.mdx +++ b/src/content/docs/studio/remix-session-speaker-content.mdx @@ -1,6 +1,7 @@ --- title: "Remix session & speaker content" description: "Use AI to rewrite session titles, descriptions, tags, and tracks in bulk — with a tone you choose and a review step before anything is saved." +features: ["studio"] --- import { Note, Tip, Steps, Step } from '@compat'; diff --git a/src/data/product-contract.json b/src/data/product-contract.json new file mode 100644 index 0000000..cb6cdf3 --- /dev/null +++ b/src/data/product-contract.json @@ -0,0 +1,685 @@ +{ + "//": "GENERATED by scripts/pull-product-contract.mjs — do not hand-edit. Run `npm run contract:pull` with sessionboard-web-api and sessionboard-web-ui-v2 checked out beside this repo.", + "contractVersion": 2, + "features": [ + "ai_reports", + "applications", + "attend", + "awards", + "campaigns", + "clone_events", + "community", + "community_marketing", + "contracts", + "coordinators", + "crm", + "customizations", + "dispatches", + "documents", + "email_campaigns", + "embeds_v2", + "exhibitors", + "exhibitors_pro", + "field_rules", + "generate_topics", + "integration_reporting", + "languages", + "marketing", + "marketing_advocacy", + "marketing_brand", + "marketing_clips", + "marketing_create", + "marketing_dispatch", + "marketing_media", + "marketing_plan", + "marketing_posts", + "marketing_quotes", + "marketing_transcribe", + "marketing_voices", + "media", + "new_navigation", + "new_portals", + "notifications", + "org_portal_tasks", + "pipeline", + "portfolios", + "poster_gallery", + "print_agenda", + "requests", + "sessionboard_imports", + "sessions", + "sessions_v2", + "setup_center", + "speaker_acceptance", + "speaker_affiliations", + "speaker_prospects", + "sponsors", + "sponsors_pro", + "sso", + "stories", + "studio", + "submitter_fields", + "unique_contacts", + "virtual_evaluators", + "webhooks", + "webinars" + ], + "featureScopes": { + "ai_reports": [ + "event", + "org" + ], + "applications": [ + "event" + ], + "attend": [ + "event" + ], + "awards": [ + "org" + ], + "campaigns": [ + "org" + ], + "clone_events": [ + "org" + ], + "community": [ + "org" + ], + "community_marketing": [ + "event" + ], + "contracts": [ + "org" + ], + "coordinators": [ + "event" + ], + "crm": [ + "org" + ], + "customizations": [ + "event", + "org" + ], + "dispatches": [ + "org" + ], + "documents": [ + "event" + ], + "email_campaigns": [ + "event" + ], + "embeds_v2": [ + "event" + ], + "exhibitors": [ + "org" + ], + "exhibitors_pro": [ + "event" + ], + "field_rules": [ + "event" + ], + "generate_topics": [ + "org" + ], + "integration_reporting": [ + "org" + ], + "languages": [ + "event" + ], + "marketing": [ + "event", + "org" + ], + "marketing_advocacy": [ + "event", + "org" + ], + "marketing_brand": [ + "event", + "org" + ], + "marketing_clips": [ + "event", + "org" + ], + "marketing_create": [ + "event", + "org" + ], + "marketing_dispatch": [ + "event", + "org" + ], + "marketing_media": [ + "event", + "org" + ], + "marketing_plan": [ + "event", + "org" + ], + "marketing_posts": [ + "event", + "org" + ], + "marketing_quotes": [ + "event", + "org" + ], + "marketing_transcribe": [ + "event", + "org" + ], + "marketing_voices": [ + "event", + "org" + ], + "media": [ + "org" + ], + "new_navigation": [ + "event", + "org" + ], + "new_portals": [ + "event" + ], + "notifications": [ + "org" + ], + "org_portal_tasks": [ + "org" + ], + "pipeline": [ + "event" + ], + "portfolios": [ + "org" + ], + "poster_gallery": [ + "event" + ], + "print_agenda": [ + "event" + ], + "requests": [ + "org" + ], + "sessionboard_imports": [ + "event", + "org" + ], + "sessions": [ + "event", + "org" + ], + "sessions_v2": [ + "event" + ], + "setup_center": [ + "org" + ], + "speaker_acceptance": [ + "event" + ], + "speaker_affiliations": [ + "event" + ], + "speaker_prospects": [ + "org" + ], + "sponsors": [ + "org" + ], + "sponsors_pro": [ + "event" + ], + "sso": [ + "org" + ], + "stories": [ + "event" + ], + "studio": [ + "event" + ], + "submitter_fields": [ + "event" + ], + "unique_contacts": [ + "org" + ], + "virtual_evaluators": [ + "event" + ], + "webhooks": [ + "org" + ], + "webinars": [ + "org" + ] + }, + "featureIdBySlug": { + "event": { + "ai_reports": "ai_reports", + "applications": "7398718c-0d60-4ee5-80fe-1e32062dd82e", + "attend": "attend", + "community_marketing": "community_marketing", + "coordinators": "coordinators", + "customizations": "customizations", + "documents": "documents", + "email_campaigns": "email_campaigns", + "embeds_v2": "embeds_v2", + "exhibitors_pro": "exhibitors_pro", + "field_rules": "field_rules", + "languages": "languages", + "marketing": "marketing", + "marketing_advocacy": "marketing_advocacy", + "marketing_brand": "marketing_brand", + "marketing_clips": "marketing_clips", + "marketing_create": "marketing_create", + "marketing_dispatch": "marketing_dispatch", + "marketing_media": "marketing_media", + "marketing_plan": "marketing_plan", + "marketing_posts": "marketing_posts", + "marketing_quotes": "marketing_quotes", + "marketing_transcribe": "marketing_transcribe", + "marketing_voices": "marketing_voices", + "new_navigation": "new_navigation", + "new_portals": "new_portals", + "pipeline": "pipeline", + "poster_gallery": "poster_gallery", + "print_agenda": "print_agenda", + "sessionboard_imports": "sessionboard_imports", + "sessions": "ea57fa34-9f92-48f2-93f8-d3d76cbeab74", + "sessions_v2": "sessions_v2", + "speaker_acceptance": "speaker_acceptance", + "speaker_affiliations": "speaker_affiliations", + "sponsors_pro": "sponsors_pro", + "stories": "stories", + "studio": "studio", + "submitter_fields": "submitter_fields", + "virtual_evaluators": "virtual_evaluators" + }, + "org": { + "ai_reports": "ai_reports", + "awards": "awards", + "campaigns": "campaigns", + "clone_events": "clone_events", + "community": "community", + "contracts": "contracts", + "crm": "crm", + "customizations": "customizations", + "dispatches": "dispatches", + "exhibitors": "exhibitors", + "generate_topics": "generate_topics", + "integration_reporting": "integration_reporting", + "marketing": "marketing", + "marketing_advocacy": "marketing_advocacy", + "marketing_brand": "marketing_brand", + "marketing_clips": "marketing_clips", + "marketing_create": "marketing_create", + "marketing_dispatch": "marketing_dispatch", + "marketing_media": "marketing_media", + "marketing_plan": "marketing_plan", + "marketing_posts": "marketing_posts", + "marketing_quotes": "marketing_quotes", + "marketing_transcribe": "marketing_transcribe", + "marketing_voices": "marketing_voices", + "media": "media", + "new_navigation": "new_navigation", + "notifications": "notifications", + "org_portal_tasks": "org_portal_tasks", + "portfolios": "portfolios", + "requests": "requests", + "sessionboard_imports": "sessionboard_imports", + "sessions": "sessions", + "setup_center": "setup_center", + "speaker_prospects": "speaker_prospects", + "sponsors": "sponsors", + "sso": "sso", + "unique_contacts": "unique_contacts", + "webhooks": "webhooks", + "webinars": "webinars" + } + }, + "featureNames": { + "ai_reports": "Report & Dashboard Builder", + "applications": "Applications", + "attend": "Attend", + "awards": "Awards", + "campaigns": "Briefs", + "clone_events": "Clone Events", + "community": "Community", + "community_marketing": "Community Marketing", + "contracts": "Contracts", + "coordinators": "AI Event Agents", + "crm": "CRM", + "customizations": "Customizations", + "dispatches": "Dispatches", + "documents": "Documents", + "email_campaigns": "Email Campaigns", + "embeds_v2": "Embeds V2", + "exhibitors": "Exhibitors", + "exhibitors_pro": "Exhibitors Pro", + "field_rules": "Field Rules", + "generate_topics": "Generate Topics", + "integration_reporting": "Integration Reporting", + "languages": "Language Variants & Translations", + "marketing": "Marketing", + "marketing_advocacy": "Advocacy", + "marketing_brand": "Brand", + "marketing_clips": "Clips", + "marketing_create": "Create", + "marketing_dispatch": "Dispatch", + "marketing_media": "Media", + "marketing_plan": "Plan", + "marketing_posts": "Posts", + "marketing_quotes": "Quotes", + "marketing_transcribe": "Transcribe", + "marketing_voices": "Voices", + "media": "Media", + "new_navigation": "New Navigation", + "new_portals": "New Portals", + "notifications": "Notifications & Messaging", + "org_portal_tasks": "Org Portal Tasks", + "pipeline": "Pipeline", + "portfolios": "Portfolios", + "poster_gallery": "Poster Gallery", + "print_agenda": "Print Agenda", + "requests": "Requests", + "sessionboard_imports": "Sessionboard Imports", + "sessions": "Sessions", + "sessions_v2": "Sessions 2.0", + "setup_center": "Setup Center", + "speaker_acceptance": "Accept Speakers", + "speaker_affiliations": "Speaker Affiliations", + "speaker_prospects": "Speaker Prospects", + "sponsors": "Sponsors", + "sponsors_pro": "Sponsors Pro", + "sso": "SSO", + "stories": "Stories", + "studio": "Studio", + "submitter_fields": "Submitter Information", + "unique_contacts": "Unique Contacts", + "virtual_evaluators": "AI Evaluators", + "webhooks": "Webhooks", + "webinars": "Webinars" + }, + "eventSettings": [ + "enable_additional_contacts", + "enable_auto_contact_portal_access", + "enable_external_users", + "enable_headshot_compression", + "enable_new_ui", + "enable_primary_speakers", + "enable_session_withdrawal", + "enable_speaker_acceptance", + "enable_speaker_acceptance_for_subsessions", + "enable_speaker_acceptance_portal_visibility", + "flag_enable_config_sets", + "flag_enable_integrations", + "flag_enable_permissions", + "flag_enable_reports", + "flag_enable_webhooks" + ], + "appRoutes": { + "EventDashboard": { + "path": "dashboard", + "label": "the event dashboard", + "scope": "event" + }, + "EventSetup": { + "path": "setup", + "label": "Setup Center", + "scope": "event" + }, + "EventNotifications": { + "path": "notifications", + "label": "your notifications", + "scope": "event" + }, + "EventBilling": { + "path": "billing", + "label": "event billing", + "scope": "event" + }, + "EventEarlyAccess": { + "path": "early-access", + "label": "Early Access settings", + "scope": "event" + }, + "EventApps": { + "path": "apps", + "label": "Apps", + "scope": "event" + }, + "EventSessions": { + "path": "sessions", + "label": "Sessions", + "scope": "event" + }, + "EventSubmissions": { + "path": "sessions/submissions", + "label": "Submissions", + "scope": "event" + }, + "EventSessionForms": { + "path": "sessions/forms", + "label": "the session form builder", + "scope": "event" + }, + "EventSessionSettings": { + "path": "sessions/settings", + "label": "Session settings", + "scope": "event" + }, + "EventAbstracts": { + "path": "abstracts", + "label": "Abstracts", + "scope": "event" + }, + "EventAgenda": { + "path": "sessions/agenda", + "label": "the agenda", + "scope": "event" + }, + "EventAgendaRooms": { + "path": "sessions/agenda/rooms", + "label": "agenda rooms", + "scope": "event" + }, + "EventConflicts": { + "path": "sessions/conflicts", + "label": "conflict detection", + "scope": "event" + }, + "EventInvoices": { + "path": "sessions/invoices", + "label": "Invoices", + "scope": "event" + }, + "EventProgramSite": { + "path": "site", + "label": "the Program Site", + "scope": "event" + }, + "EventEvaluation": { + "path": "sessions/evaluation", + "label": "Evaluations", + "scope": "event" + }, + "EventEvaluationPlans": { + "path": "sessions/evaluation/plans", + "label": "Evaluation Plans", + "scope": "event" + }, + "EventEvaluationReviewers": { + "path": "sessions/evaluation/reviewers", + "label": "evaluation reviewers", + "scope": "event" + }, + "EventMyEvaluations": { + "path": "sessions/evaluation/my-evaluations", + "label": "My Evaluations", + "scope": "event" + }, + "EventContacts": { + "path": "contacts", + "label": "Contacts", + "scope": "event" + }, + "EventSpeakers": { + "path": "contacts/speakers", + "label": "Speakers", + "scope": "event" + }, + "EventChairpersons": { + "path": "contacts/chairpersons", + "label": "Chairpersons", + "scope": "event" + }, + "EventModerators": { + "path": "contacts/moderators", + "label": "Moderators", + "scope": "event" + }, + "EventSponsors": { + "path": "sponsors", + "label": "Sponsors", + "scope": "event" + }, + "EventExhibitors": { + "path": "exhibitors", + "label": "Exhibitors", + "scope": "event" + }, + "EventPortalForms": { + "path": "portals/forms", + "label": "portal forms", + "scope": "event" + }, + "EventFileRequests": { + "path": "portals/file-requests", + "label": "File Requests", + "scope": "event" + }, + "EventPortalTasks": { + "path": "portals/tasks", + "label": "portal tasks", + "scope": "event" + }, + "EventPortalPages": { + "path": "portals/pages", + "label": "portal pages", + "scope": "event" + }, + "EventLibrary": { + "path": "library", + "label": "the Library", + "scope": "event" + }, + "EventFiles": { + "path": "library/files", + "label": "Files", + "scope": "event" + }, + "EventFields": { + "path": "library/fields", + "label": "Fields", + "scope": "event" + }, + "EventTags": { + "path": "library/tags", + "label": "Tags", + "scope": "event" + }, + "EventEmailCampaigns": { + "path": "send/campaigns", + "label": "email campaigns", + "scope": "event" + }, + "EventEmailTemplates": { + "path": "send/templates", + "label": "email templates", + "scope": "event" + }, + "EventEmailHistory": { + "path": "send/history", + "label": "email history", + "scope": "event" + }, + "EventStudio": { + "path": "studio", + "label": "Studio", + "scope": "event" + }, + "EventCmsEmbeds": { + "path": "cms/embeds", + "label": "Embeds", + "scope": "event" + }, + "EventReports": { + "path": "reports", + "label": "Reports", + "scope": "event" + }, + "EventAttend": { + "path": "attend", + "label": "Attend", + "scope": "event" + }, + "OrgAwardsPrograms": { + "path": "awards/programs", + "label": "Awards programs", + "scope": "org" + }, + "OrgAwardsSubmissions": { + "path": "awards/submissions", + "label": "Awards submissions", + "scope": "org" + }, + "OrgAwardsReviewers": { + "path": "awards/reviewers", + "label": "Awards reviewers", + "scope": "org" + }, + "OrgContacts": { + "path": "contacts", + "label": "the org contact directory", + "scope": "org" + }, + "OrgSessionForms": { + "path": "sessions/forms", + "label": "org interest forms", + "scope": "org" + }, + "OrgReports": { + "path": "reports", + "label": "org reports", + "scope": "org" + }, + "OrgSettings": { + "path": "settings", + "label": "organization settings", + "scope": "org" + }, + "OrgSettingsTeam": { + "path": "settings/team", + "label": "team settings", + "scope": "org" + }, + "OrgSettingsEmailTemplates": { + "path": "settings/email-templates", + "label": "org email templates", + "scope": "org" + } + } +} diff --git a/src/lib/marker-hydration.mjs b/src/lib/marker-hydration.mjs new file mode 100644 index 0000000..c27cd9d --- /dev/null +++ b/src/lib/marker-hydration.mjs @@ -0,0 +1,363 @@ +/** + * Marker hydration — the one implementation of the personalization spec. + * + * Help Center MDX declares SEMANTICS ("this block needs the awards feature"); + * the surface rendering it decides BEHAVIOUR ("Awards isn't enabled for Acme + * Summit — turn it on"). This module is that decision, and it runs unchanged + * on three surfaces: + * + * 1. the in-product reader (real entitlements from the event), + * 2. learn.sessionboard.com signed out (reader self-selects), + * 3. learn.sessionboard.com signed in (auto-detected). + * + * It is plain DOM, dependency-free, and framework-free precisely so the + * product repo can vendor this exact file instead of maintaining a port that + * drifts. See MARKER_SPEC.md for the authoring contract and + * tests/fixtures/marker-hydration.json for the shared corpus both copies run. + * + * Four invariants. All four are load-bearing, not stylistic: + * + * NEVER REMOVE CONTENT. Unmet blocks are collapsed, never deleted. The text + * stays in the DOM so it stays crawlable and citable, so support can screen + * -share the same article, and so readers still discover what they could + * have. This is the whole reason personalizing a public page costs no SEO. + * + * UNKNOWN MEANS VISIBLE. If we cannot evaluate a condition — no context + * loaded, or the feature lives on a scope we have no data for — the block + * renders normally. Telling a customer they lack a feature they paid for is + * far worse than showing one paragraph too many. + * + * IDEMPOTENT. Re-running on the same subtree (React re-render, context + * change, island rehydrate) must converge, not nest wrappers. + * + * READ ATTRIBUTES, EVALUATE NOTHING. This walks `data-sb-*` values. It + * never evaluates a string from the document, so a compromised article + * cannot become script execution. + */ + +/** + * Bumped when the ATTRIBUTE VOCABULARY or hydration behaviour changes. + * The fixture corpus records the version it was written for; a vendored copy + * claiming a different version fails its parity test rather than silently + * rendering the old semantics. + */ +export const MARKER_SPEC_VERSION = 1; + +const ATTR = { + feature: 'data-sb-feature', + setting: 'data-sb-setting', + route: 'data-sb-route', + addon: 'data-sb-addon', + state: 'data-sb-state', + reason: 'data-sb-reason', +}; + +/** Marks a wrapper this module created, so re-runs reuse it. */ +const COLLAPSE_ATTR = 'data-sb-collapse'; + +const SELECTOR = `[${ATTR.feature}],[${ATTR.setting}],[${ATTR.route}],[${ATTR.addon}]`; + +const tokens = (value) => + String(value || '') + .split(/\s+/) + .map((t) => t.trim()) + .filter(Boolean); + +/** "enable_speaker_acceptance" → "Speaker acceptance". */ +export function settingLabel(id) { + const words = String(id || '') + .replace(/^flag_enable_/, '') + .replace(/^enable_/, '') + .split('_') + .filter(Boolean); + if (words.length === 0) return String(id || ''); + return words.join(' ').replace(/^./, (c) => c.toUpperCase()); +} + +export function featureLabel(slug, contract) { + return contract?.featureNames?.[slug] || settingLabel(slug); +} + +/** + * Evaluate one feature slug against the context. + * + * A slug can live on the event registry, the org registry, or both; "both" + * means either satisfies it, which is what an author means by "they have + * Marketing". Returns 'met' | 'unmet' | 'unknown'. + */ +function evaluateFeature(slug, context) { + const contract = context.contract || {}; + const scopes = contract.featureScopes?.[slug]; + // A slug absent from the contract is a docs bug that the build should have + // caught. At runtime, fail open rather than hide content. + if (!scopes || scopes.length === 0) return 'unknown'; + + let sawData = false; + for (const scope of scopes) { + const enabled = context.features?.[scope]; + if (!Array.isArray(enabled)) continue; + sawData = true; + const resolvedId = contract.featureIdBySlug?.[scope]?.[slug] ?? slug; + if (enabled.includes(resolvedId)) return 'met'; + } + return sawData ? 'unmet' : 'unknown'; +} + +/** Returns 'met' | 'unmet' | 'unknown' for one setting id. */ +function evaluateSetting(id, context) { + const settings = context.settings; + if (!settings || typeof settings !== 'object') return 'unknown'; + if (!(id in settings)) return 'unknown'; + return settings[id] === true ? 'met' : 'unmet'; +} + +/** + * Combine per-token results. Every token must be met for the block to show + * normally; a single unmet collapses it; otherwise unknown wins (fail open). + */ +function combine(results) { + if (results.length === 0) return 'unknown'; + if (results.some((r) => r.state === 'unmet')) return 'unmet'; + if (results.every((r) => r.state === 'met')) return 'met'; + return 'unknown'; +} + +function unmetLabels(results, context) { + return results + .filter((r) => r.state === 'unmet') + .map((r) => + r.kind === 'feature' + ? featureLabel(r.token, context.contract) + : settingLabel(r.token), + ); +} + +/** + * Human summary for something the reader is not entitled to, named after the + * actual event so they know it is about them and not a generic caveat. + * + * Exported because the surface needs the same sentence for its own article-level + * banner, which sits directly above blocks this produces the summary for. Two + * formatters would read as two voices on one screen — and the naive version gets + * the verb wrong the moment there are two labels ("Awards and Sessions isn't"). + */ +export function unmetSummary(labels, context = {}) { + if (!labels || labels.length === 0) return ''; + const subject = + labels.length === 1 + ? labels[0] + : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`; + const verb = labels.length === 1 ? "isn't" : "aren't"; + const scope = context.eventName ? ` for ${context.eventName}` : ''; + return `${subject} ${verb} enabled${scope}`; +} + +/** + * Wrap `el` in a collapsed disclosure, or update the wrapper from a previous + * run. Content is moved, never dropped. + */ +function collapse(el, results, context) { + const doc = el.ownerDocument; + const labels = unmetLabels(results, context); + const summaryText = unmetSummary(labels, context); + + const existing = el.parentElement; + const reuse = + existing && existing.hasAttribute && existing.hasAttribute(COLLAPSE_ATTR) + ? existing + : null; + + const details = reuse || doc.createElement('details'); + if (!reuse) { + details.setAttribute(COLLAPSE_ATTR, ''); + details.className = 'sb-marker-collapsed'; + el.parentNode.insertBefore(details, el); + details.appendChild(el); + } + + let summary = details.querySelector(':scope > summary'); + if (!summary) { + summary = doc.createElement('summary'); + details.insertBefore(summary, details.firstChild); + } + summary.textContent = summaryText; + + // A deep link to the thing that turns it on, when the surface can build one. + const first = results.find((r) => r.state === 'unmet'); + const href = + first && + (first.kind === 'feature' + ? context.featureHref?.(first.token) + : context.settingHref?.(first.token)); + let action = details.querySelector(':scope > [data-sb-enable]'); + if (href) { + if (!action) { + action = doc.createElement('a'); + action.setAttribute('data-sb-enable', ''); + action.className = 'sb-marker-enable'; + details.appendChild(action); + } + action.setAttribute('href', href); + action.textContent = + first.kind === 'feature' ? 'Ask about enabling this' : 'Open this setting'; + } else if (action) { + // Context changed to one that cannot resolve a link — drop the link but + // keep the node, so we never leave a dead href behind. + action.removeAttribute('href'); + } + + return details; +} + +/** Undo a collapse from a previous run, promoting the content back in place. */ +function uncollapse(el) { + const wrapper = el.parentElement; + if (!wrapper || !wrapper.hasAttribute || !wrapper.hasAttribute(COLLAPSE_ATTR)) { + return; + } + wrapper.parentNode.insertBefore(el, wrapper); + wrapper.remove(); +} + +function hydrateConditional(el, context) { + const results = [ + ...tokens(el.getAttribute(ATTR.feature)).map((token) => ({ + kind: 'feature', + token, + state: evaluateFeature(token, context), + })), + ...tokens(el.getAttribute(ATTR.setting)).map((token) => ({ + kind: 'setting', + token, + state: evaluateSetting(token, context), + })), + ]; + if (results.length === 0) return; + + const state = combine(results); + el.setAttribute(ATTR.state, state); + + if (state === 'unmet') { + const labels = unmetLabels(results, context); + el.setAttribute(ATTR.reason, labels.join(', ')); + collapse(el, results, context); + } else { + el.removeAttribute(ATTR.reason); + uncollapse(el); + } +} + +/** + * Resolve an `app:` link to this reader's own event, or degrade to plain text. + * + * The anchor is kept either way — stripping the href rather than replacing the + * node keeps the sentence intact and honours "never remove content". + */ +function hydrateRoute(el, context) { + const id = el.getAttribute(ATTR.route); + const href = context.resolveRoute?.(id) ?? null; + + if (href) { + el.setAttribute('href', href); + el.setAttribute(ATTR.state, 'resolved'); + return; + } + + el.removeAttribute('href'); + el.setAttribute(ATTR.state, 'unresolved'); + // Only supply fallback text when the author left the label empty; an + // author-written label always wins. + if (!el.textContent || !el.textContent.trim()) { + const label = context.routeLabel?.(id); + if (label) el.textContent = label; + } +} + +/** + * Re-point an add-on callout at the reader's own event. + * + * On the public site the component already rendered "contact support to enable + * this". In-product we know whether they have it, so a customer who already + * owns Awards should not be told to contact support about Awards. + */ +function hydrateAddon(el, context) { + const slug = el.getAttribute(ATTR.addon); + const state = evaluateFeature(slug, context); + el.setAttribute(ATTR.state, state); + if (state !== 'unmet' && state !== 'met') return; + + const body = el.querySelector('[data-sb-addon-body]'); + if (!body) return; + + const name = featureLabel(slug, context.contract); + if (state === 'met') { + body.textContent = context.eventName + ? `${name} is enabled for ${context.eventName}.` + : `${name} is enabled.`; + return; + } + body.textContent = context.eventName + ? `${name} isn't enabled for ${context.eventName}.` + : `${name} isn't enabled for your organization.`; +} + +/** + * Hydrate every marker under `root`. + * + * @param {Element} root - subtree containing rendered article HTML. + * @param {object} context - see the module comment; every field optional, and + * an empty context is valid and means "leave everything visible". + * @returns {{hydrated: number, specVersion: number}} + */ +export function hydrateMarkers(root, context = {}) { + if (!root || typeof root.querySelectorAll !== 'function') { + return { hydrated: 0, specVersion: MARKER_SPEC_VERSION }; + } + + // Snapshot first: collapsing mutates the tree around the nodes we are + // walking, and a live list would skip or revisit siblings. + const elements = Array.from(root.querySelectorAll(SELECTOR)); + let hydrated = 0; + + for (const el of elements) { + if (el.hasAttribute(ATTR.route)) { + hydrateRoute(el, context); + hydrated += 1; + continue; + } + if (el.hasAttribute(ATTR.addon)) { + hydrateAddon(el, context); + hydrated += 1; + continue; + } + hydrateConditional(el, context); + hydrated += 1; + } + + root.setAttribute?.('data-sb-hydrated', String(MARKER_SPEC_VERSION)); + return { hydrated, specVersion: MARKER_SPEC_VERSION }; +} + +/** + * Article-level applicability from `features:` frontmatter. + * + * This is what gives all 227 pages a correct "does this apply to me" signal + * the moment the taxonomy lands, before anyone annotates a single block. + */ +export function articleApplicability(features, context = {}) { + const list = Array.isArray(features) ? features.filter(Boolean) : []; + if (list.length === 0) return { state: 'met', missing: [] }; + + const results = list.map((token) => ({ + kind: 'feature', + token, + state: evaluateFeature(token, context), + })); + + return { + state: combine(results), + missing: unmetLabels(results, context), + }; +} + +export const MARKER_ATTRIBUTES = ATTR; diff --git a/src/styles/custom.css b/src/styles/custom.css index 5030410..6a580bb 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -398,3 +398,55 @@ a.sb-card:hover { .sb-card :last-child { margin-bottom: 0; } + +/* ── Personalization markers (see MARKER_SPEC.md) ─────────────────────── */ + +/* + * An `app:` link with no event to resolve against. It is a real product + * destination, so it should read as a named surface rather than as a link that + * failed — underline-dotted, not blue, and no pointer. + */ +.sb-app-link { + color: var(--sl-color-white); + font-weight: 600; + text-decoration: underline dotted var(--sb-border); + text-underline-offset: 0.2em; + cursor: default; +} +/* Resolved in-product, where it navigates for real. */ +.sb-app-link[href] { + color: var(--sl-color-text-accent); + text-decoration-style: solid; + cursor: pointer; +} + +/* + * A block whose feature or setting the reader does not have. Collapsed, never + * removed: the text stays in the DOM so it stays crawlable and the reader can + * still see what they are missing. + */ +.sb-marker-collapsed { + border: 1px solid var(--sb-border); + border-radius: 12px; + padding: 0.75rem 1rem; + margin: 1.5rem 0; + background: var(--sl-color-black); +} +.sb-marker-collapsed > summary { + cursor: pointer; + font-weight: 600; + color: var(--sl-color-gray-2); + font-size: var(--sl-text-sm); +} +.sb-marker-collapsed[open] > summary { + margin-bottom: 0.75rem; +} +.sb-marker-collapsed > :last-child { + margin-bottom: 0; +} +.sb-marker-enable { + display: inline-block; + margin-top: 0.75rem; + font-size: var(--sl-text-sm); + font-weight: 600; +} diff --git a/tests/fixtures/marker-hydration.json b/tests/fixtures/marker-hydration.json new file mode 100644 index 0000000..fc36b74 --- /dev/null +++ b/tests/fixtures/marker-hydration.json @@ -0,0 +1,226 @@ +{ + "//": "Shared corpus for the marker hydration spec. Vendored into sessionboard-web-ui-v2 by scripts/sync-marker-hydration.mjs and executed there (it has a DOM test runner). Cases carry their own miniature contract so that adding a real product feature can never break a fixture.", + "specVersion": 1, + "contract": { + "features": ["awards", "sessions", "coordinators", "marketing"], + "featureScopes": { + "awards": ["org"], + "sessions": ["event", "org"], + "coordinators": ["event"], + "marketing": ["event", "org"] + }, + "featureIdBySlug": { + "event": { + "sessions": "ea57fa34-9f92-48f2-93f8-d3d76cbeab74", + "coordinators": "coordinators", + "marketing": "marketing" + }, + "org": { "awards": "awards", "sessions": "sessions", "marketing": "marketing" } + }, + "featureNames": { + "awards": "Awards", + "sessions": "Sessions", + "coordinators": "AI Event Agents", + "marketing": "Marketing" + } + }, + "cases": [ + { + "name": "event feature enabled renders normally", + "context": { "eventName": "Acme Summit", "features": { "event": ["coordinators"] } }, + "input": "

Run the agent.

", + "expected": "

Run the agent.

" + }, + { + "name": "event feature disabled collapses and names the event", + "context": { "eventName": "Acme Summit", "features": { "event": [] } }, + "input": "

Run the agent.

", + "expected": "
AI Event Agents isn't enabled for Acme Summit

Run the agent.

" + }, + { + "name": "no context at all leaves the block untouched and visible", + "context": {}, + "input": "

Run the agent.

", + "expected": "

Run the agent.

" + }, + { + "name": "sessions resolves through the module UUID rather than its slug", + "context": { + "eventName": "Acme Summit", + "features": { "event": ["ea57fa34-9f92-48f2-93f8-d3d76cbeab74"] } + }, + "input": "

Create a session.

", + "expected": "

Create a session.

" + }, + { + "name": "org-scope feature is satisfied by org entitlements", + "context": { + "eventName": "Acme Summit", + "features": { "event": [], "org": ["awards"] } + }, + "input": "

Score a submission.

", + "expected": "

Score a submission.

" + }, + { + "name": "org-scope feature collapses when org entitlements say no", + "context": { "eventName": "Acme Summit", "features": { "event": [], "org": [] } }, + "input": "

Score a submission.

", + "expected": "
Awards isn't enabled for Acme Summit

Score a submission.

" + }, + { + "name": "org-scope feature stays visible when only event data is loaded", + "context": { "eventName": "Acme Summit", "features": { "event": ["coordinators"] } }, + "input": "

Score a submission.

", + "expected": "

Score a submission.

" + }, + { + "name": "a slug missing from the contract fails open", + "context": { "eventName": "Acme Summit", "features": { "event": [], "org": [] } }, + "input": "

Legacy block.

", + "expected": "

Legacy block.

" + }, + { + "name": "setting on renders normally", + "context": { + "eventName": "Acme Summit", + "settings": { "enable_speaker_acceptance": true } + }, + "input": "

Speakers accept.

", + "expected": "

Speakers accept.

" + }, + { + "name": "setting off collapses with a link to the setting", + "context": { + "eventName": "Acme Summit", + "settings": { "enable_speaker_acceptance": false }, + "settingHref": "/event/42/sessions/settings" + }, + "input": "

Speakers accept.

", + "expected": "
Speaker acceptance isn't enabled for Acme Summit

Speakers accept.

Open this setting
" + }, + { + "name": "unknown setting stays visible", + "context": { "eventName": "Acme Summit", "settings": {} }, + "input": "

Speakers accept.

", + "expected": "

Speakers accept.

" + }, + { + "name": "one unmet token collapses the whole block", + "context": { + "eventName": "Acme Summit", + "features": { "event": ["coordinators"] }, + "settings": { "enable_speaker_acceptance": false } + }, + "input": "

Both needed.

", + "expected": "
Speaker acceptance isn't enabled for Acme Summit

Both needed.

" + }, + { + "name": "two unmet tokens read as a sentence", + "context": { + "eventName": "Acme Summit", + "features": { "event": [] }, + "settings": { "enable_speaker_acceptance": false } + }, + "input": "

Both needed.

", + "expected": "
AI Event Agents and Speaker acceptance aren't enabled for Acme Summit

Both needed.

" + }, + { + "name": "collapsed summary omits the event when none is known", + "context": { "features": { "event": [] } }, + "input": "

Run the agent.

", + "expected": "
AI Event Agents isn't enabled

Run the agent.

" + }, + { + "name": "app link resolves to this event", + "context": { "routes": { "EventSessionForms": "/event/42/sessions/forms" } }, + "input": "

Open the form builder.

", + "expected": "

Open the form builder.

" + }, + { + "name": "unresolvable app link keeps its sentence but loses the href", + "context": {}, + "input": "

Open the form builder.

", + "expected": "

Open the form builder.

" + }, + { + "name": "unresolvable app link with no label falls back to the route label", + "context": { "routeLabels": { "EventSessionForms": "the session form builder" } }, + "input": "

Open .

", + "expected": "

Open the session form builder.

" + }, + { + "name": "add-on callout tells an entitled customer they already have it", + "context": { "eventName": "Acme Summit", "features": { "org": ["awards"] } }, + "input": "", + "expected": "" + }, + { + "name": "add-on callout names the event when not entitled", + "context": { "eventName": "Acme Summit", "features": { "org": [] } }, + "input": "", + "expected": "" + }, + { + "name": "add-on callout is left alone with no entitlement data", + "context": { "eventName": "Acme Summit" }, + "input": "", + "expected": "" + }, + { + "name": "sibling blocks are all visited when an earlier one collapses", + "context": { "eventName": "Acme Summit", "features": { "event": ["marketing"] } }, + "input": "

One.

Two.

", + "expected": "
AI Event Agents isn't enabled for Acme Summit

One.

Two.

" + }, + { + "name": "nested markers each evaluate independently", + "context": { "eventName": "Acme Summit", "features": { "event": ["marketing"], "org": [] } }, + "input": "

Outer.

Inner.

", + "expected": "

Outer.

Awards isn't enabled for Acme Summit

Inner.

" + }, + { + "name": "re-hydrating an unmet block as met removes the collapse", + "context": { "eventName": "Acme Summit", "features": { "event": ["coordinators"] } }, + "input": "
AI Event Agents isn't enabled for Acme Summit

Run the agent.

", + "expected": "

Run the agent.

" + }, + { + "name": "re-hydrating a collapsed block for a different event renames the summary", + "context": { "eventName": "Globex Congress", "features": { "event": [] } }, + "input": "
AI Event Agents isn't enabled for Acme Summit

Run the agent.

", + "expected": "
AI Event Agents isn't enabled for Globex Congress

Run the agent.

" + }, + { + "name": "unmarked content is never touched", + "context": { "eventName": "Acme Summit", "features": { "event": [] } }, + "input": "

Heading

Plain paragraph with a link.

", + "expected": "

Heading

Plain paragraph with a link.

" + } + ], + "applicabilityCases": [ + { + "name": "no frontmatter features applies to everyone", + "features": [], + "context": { "features": { "event": [] } }, + "expected": { "state": "met", "missing": [] } + }, + { + "name": "entitled reader sees no banner", + "features": ["coordinators"], + "context": { "features": { "event": ["coordinators"] } }, + "expected": { "state": "met", "missing": [] } + }, + { + "name": "unentitled reader gets a named banner", + "features": ["awards"], + "context": { "features": { "org": [] } }, + "expected": { "state": "unmet", "missing": ["Awards"] } + }, + { + "name": "no context means no banner rather than a wrong one", + "features": ["awards"], + "context": {}, + "expected": { "state": "unknown", "missing": [] } + } + ] +} diff --git a/tests/marker-hydration.test.mjs b/tests/marker-hydration.test.mjs new file mode 100644 index 0000000..cec8601 --- /dev/null +++ b/tests/marker-hydration.test.mjs @@ -0,0 +1,202 @@ +/** + * Runs the shared corpus against the CANONICAL hydrator, here at its source. + * + * sessionboard-web-ui-v2 runs the same fixtures against its vendored copy, plus + * a checksum test proving the copy is current. That catches drift — but only in + * the product repo's CI, on a different pull request, hours or days after a docs + * change broke it. This suite is the same corpus enforced where the code lives, + * so `npm test` here fails before the copy is ever synced. + * + * linkedom rather than jsdom: the hydrator is deliberately plain DOM with no + * layout, events, or navigation, and a docs repo should not pull a browser + * emulator in to test it. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test, describe } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { parseHTML } from 'linkedom'; + +import { + MARKER_SPEC_VERSION, + articleApplicability, + hydrateMarkers, + settingLabel, + unmetSummary, +} from '../src/lib/marker-hydration.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = JSON.parse( + readFileSync(resolve(here, 'fixtures/marker-hydration.json'), 'utf8'), +); + +const { document } = parseHTML(''); + +/** + * Fixtures stay declarative JSON, so link resolution is expressed as lookup + * maps; the hydrator takes callbacks. Adapt here rather than putting functions + * in a corpus two repos have to parse. + */ +const toContext = (raw = {}) => ({ + eventName: raw.eventName ?? null, + features: raw.features ?? {}, + settings: raw.settings ?? null, + contract: fixtures.contract, + resolveRoute: (id) => raw.routes?.[id] ?? null, + routeLabel: (id) => raw.routeLabels?.[id] ?? null, + settingHref: () => raw.settingHref ?? null, + featureHref: () => null, +}); + +const render = (html) => { + const host = document.createElement('div'); + host.innerHTML = html; + return host; +}; + +/** + * Serialize a subtree with attributes sorted and whitespace-only text dropped. + * + * The corpus is shared with sessionboard-web-ui-v2, which runs it under jsdom, + * and the two DOM implementations emit attributes in different orders. Comparing + * raw `innerHTML` would make this suite fail on that alone, so compare what the + * spec actually constrains: elements, attribute values, and text. + */ +const canonical = (node) => { + if (node.nodeType === 3) return node.textContent?.trim() ?? ''; + if (node.nodeType !== 1) return ''; + const attrs = Array.from(node.attributes ?? []) + .map((a) => `${a.name}="${a.value}"`) + .sort() + .join(' '); + const children = Array.from(node.childNodes).map(canonical).filter(Boolean).join(''); + const tag = node.localName; + return `<${tag}${attrs ? ` ${attrs}` : ''}>${children}`; +}; + +const canonicalChildren = (node) => + Array.from(node.childNodes).map(canonical).filter(Boolean).join(''); + +const canonicalHtml = (html) => canonicalChildren(render(html)); + +/** Every non-empty text node under `node`, in document order. */ +const textNodes = (node, out = []) => { + for (const child of node.childNodes) { + if (child.nodeType === 3) { + const text = child.textContent?.trim(); + if (text) out.push(text); + } else if (child.nodeType === 1) { + textNodes(child, out); + } + } + return out; +}; + +describe('marker hydration corpus', () => { + test('the corpus and the implementation agree on the spec version', () => { + assert.equal(fixtures.specVersion, MARKER_SPEC_VERSION); + }); + + for (const testCase of fixtures.cases) { + test(testCase.name, () => { + const host = render(testCase.input); + hydrateMarkers(host, toContext(testCase.context)); + assert.equal(canonicalChildren(host), canonicalHtml(testCase.expected)); + }); + + test(`is idempotent: ${testCase.name}`, () => { + const host = render(testCase.input); + const context = toContext(testCase.context); + hydrateMarkers(host, context); + const once = host.innerHTML; + hydrateMarkers(host, context); + hydrateMarkers(host, context); + assert.equal(host.innerHTML, once); + }); + } + + test('never drops author content, whatever the context', () => { + // The invariant that makes personalizing a public page cost no SEO: an + // unmet block is collapsed, never deleted. Scoped to pristine article HTML + // — `data-sb-addon` substitutes its body by design, and an input that + // already carries a `data-sb-collapse` wrapper contains our summary text, + // which SHOULD disappear once the block is met. + const conditional = fixtures.cases.filter( + (c) => !c.input.includes('data-sb-addon') && !c.input.includes('data-sb-collapse'), + ); + assert.ok(conditional.length > 0, 'corpus lost its conditional cases'); + + for (const testCase of conditional) { + const host = render(testCase.input); + const before = textNodes(host); + hydrateMarkers(host, toContext(testCase.context)); + const after = textNodes(host); + for (const text of before) { + assert.ok(after.includes(text), `"${text}" vanished in: ${testCase.name}`); + } + } + }); + + test('an unresolved app link keeps its sentence but loses its href', () => { + const host = render('builder'); + hydrateMarkers(host, toContext({})); + const anchor = host.querySelector('a'); + assert.equal(anchor.hasAttribute('href'), false); + assert.equal(anchor.getAttribute('data-sb-state'), 'unresolved'); + assert.equal(anchor.textContent, 'builder'); + }); +}); + +describe('articleApplicability', () => { + for (const testCase of fixtures.applicabilityCases) { + test(testCase.name, () => { + assert.deepEqual( + articleApplicability(testCase.features, toContext(testCase.context)), + testCase.expected, + ); + }); + } +}); + +describe('unmetSummary', () => { + // The in-product reader reuses this for its article-level banner, which is why + // it is exported at all. The plural verb is the reason it is not inlined there. + const acme = { eventName: 'Acme Summit' }; + + test('one label', () => { + assert.equal(unmetSummary(['Awards'], acme), "Awards isn't enabled for Acme Summit"); + }); + + test('two labels take a plural verb', () => { + assert.equal( + unmetSummary(['Awards', 'Sessions'], acme), + "Awards and Sessions aren't enabled for Acme Summit", + ); + }); + + test('three labels read as a list', () => { + assert.equal( + unmetSummary(['Awards', 'Sessions', 'Marketing'], acme), + "Awards, Sessions and Marketing aren't enabled for Acme Summit", + ); + }); + + test('omits the event when none is known', () => { + assert.equal(unmetSummary(['Awards'], {}), "Awards isn't enabled"); + }); + + test('is empty for nothing missing, rather than a dangling sentence', () => { + assert.equal(unmetSummary([], acme), ''); + }); +}); + +describe('settingLabel', () => { + test('reads as prose rather than a column name', () => { + assert.equal(settingLabel('enable_speaker_acceptance'), 'Speaker acceptance'); + assert.equal(settingLabel('flag_enable_webhooks'), 'Webhooks'); + assert.equal(settingLabel(''), ''); + }); +}); diff --git a/worker.js b/worker.js index 891f9cb..f15dc27 100644 --- a/worker.js +++ b/worker.js @@ -92,6 +92,57 @@ const BLOCKED_UA_RE = new RegExp( // not from these, so withholding them costs no visibility. const BULK_EXPORTS = new Set(['/llms.txt', '/llms-full.txt', '/llms-small.txt']); +// ── Machine surface ───────────────────────────────────────────────────── +// dist/_internal/help-index.json is the whole corpus, chunked, that web-api +// pulls in to embed for Team Lead. The ASSETS binding would happily serve it to +// anyone who guessed the path — it is the single most valuable bulk export on +// the site — so every /_internal/ request needs a bearer token. +const INTERNAL_PREFIX = '/_internal/'; + +/** + * Compare a presented token against the configured one without leaking its + * length or matching prefix through response timing. Workers has no + * timingSafeEqual, so both sides are SHA-256'd to a fixed width first and the + * digests compared with a branch-free loop. + */ +async function tokenMatches(presented, expected) { + if (!presented || !expected) return false; + const digest = async (value) => + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); + const [a, b] = await Promise.all([digest(presented), digest(expected)]); + let diff = 0; + for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i]; + return diff === 0; +} + +/** + * Serve the machine index to an authorized caller. + * + * A wrong or missing token gets the same 404 an unknown path would, so probing + * cannot confirm the endpoint exists. Deliberately absent from robots.txt for + * the same reason — a Disallow line would advertise the path, and the token is + * the actual control. Never cached: the sync job diffs by contentHash and a + * stale edge copy would silently pin retrieval to an old corpus. + */ +async function internalResponse(request, url, env) { + const presented = (request.headers.get('Authorization') ?? '').replace(/^Bearer\s+/i, ''); + const notFound = new Response('Not found\n', { + status: 404, + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Robots-Tag': 'noindex' }, + }); + + if (!(await tokenMatches(presented, env.HELP_INDEX_TOKEN))) return notFound; + + const asset = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' })); + if (!asset.ok) return notFound; + + const headers = new Headers(asset.headers); + headers.set('Content-Type', 'application/json; charset=utf-8'); + headers.set('Cache-Control', 'no-store'); + headers.set('X-Robots-Tag', 'noindex, nofollow'); + return new Response(asset.body, { status: 200, headers }); +} + // HubSpot slugs sometimes appear with and without their numeric ID prefix // (e.g. `9156219-cvent-integration` vs `cvent-integration`). Index both forms; // prefix-stripped keys are collision-free (verified against the full map). @@ -181,6 +232,12 @@ export default { return robotsResponse(isProd); } + // Ahead of the crawler gate: this is a service-to-service call, and it must + // not be judged by a User-Agent it does not set. + if (url.pathname.startsWith(INTERNAL_PREFIX)) { + return internalResponse(request, url, env); + } + const ua = request.headers.get('User-Agent') ?? ''; if (BLOCKED_UA_RE.test(ua)) { return new Response('Not available to this crawler. See /robots.txt\n', {