diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..fb5dfaf35 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to walkerOS + +walkerOS is open source and will remain open source. We believe companies should +own their data infrastructure. True data ownership only comes when you control +your data collection. Thanks for considering a contribution, we appreciate them +all. + +## Ways to contribute + +- **Report bugs** via + [GitHub issues](https://github.com/elbwalker/walkerOS/issues) (issue templates + available) +- **Suggest features**: open an issue first so we can discuss the approach + before you invest time in code +- **Improve documentation**: the docs live in `website/docs/` +- **Contribute code**: fix bugs, improve packages, or create new destinations, + sources, or transformers (the `skills/` folder has step-by-step guides) +- **Help other users**: answer questions in issues and discussions + +## Getting started + +The easiest setup is the devcontainer, which installs all dependencies and +tooling automatically. A manual setup works too: + +```bash +npm install # Install dependencies +npm run build # Build all packages +npm run dev # Watch mode +``` + +For the full setup guide, package structure, and verification scripts, see the +[contributing documentation](https://www.walkeros.io/docs/contributing) and +[AGENT.md](./AGENT.md), the quick reference for contributors and AI assistants. + +## Development workflow + +- **Test first.** walkerOS follows test-driven development with Jest. Write the + test, watch it fail, then implement. +- **Verify the smallest scope that proves your change:** + + ```bash + npm run verify:touched -- # One package: typecheck + lint + test + npm run verify:affected # Everything affected since origin/main + ``` + +- **Event naming** is `"entity action"` with a space (`"page view"`, not + `"page_view"`). +- **No `any`** in production code. If types don't fit, fix the code, not the + types. + +## Pull requests + +1. For anything larger than a small fix, open an issue first and outline the + approach. +2. Keep the PR scoped: one concern per pull request. +3. Include tests for the change and make sure verification passes. +4. Add a changeset (`npx changeset`) when the change affects published packages. + Skip it for docs, CI, or internal refactoring. +5. CI runs typecheck, lint, and tests on every PR. + +## Licensing + +walkerOS is licensed under the [MIT license](./LICENSE). By submitting a +contribution, you agree that: + +- your contribution is provided under the same MIT license that covers the + project (inbound = outbound), and +- you have the right to submit the work under this license: it is your own work, + or you are permitted to contribute it (for example by your employer, if you + contribute in the course of your employment). + +There is no CLA to sign. If your company's legal team has questions about +contributing, we are happy to talk to them directly: +[hello@elbwalker.com](mailto:hello@elbwalker.com). + +## Questions + +- [Open an issue](https://github.com/elbwalker/walkerOS/issues) +- [Send an email](mailto:hello@elbwalker.com) diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index ff5fffae3..ef1d9d77d 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -5,12 +5,30 @@ import type { PluginOptions as LlmsTxtOptions } from '@signalwire/docusaurus-plu import { version as coreVersion } from '../packages/core/package.json'; import restoreExpressionIndent from './src/remark/restore-expression-indent'; import normalizeExportLinks from './src/remark/normalize-export-links'; +import prependExportContext from './src/remark/prepend-export-context'; const vars = { github: 'https://github.com/elbwalker/walkerOS/', linkedin: 'https://www.linkedin.com/company/elbwalker/', + site: 'https://www.walkeros.io', + npm: 'https://www.npmjs.com/org/walkeros', }; +// The llms.txt header is a single blockquote: the plugin writes one `> ` in +// front of `siteDescription` and nothing else, so a multi-paragraph preamble +// carries its own continuation markers. +// +// Keep it short and factual. It is the first thing a model reads about +// walkerOS, and its job is to correct the two things a training corpus gets +// wrong: the package namespace, and what category the project is in. +const llmsTxtPreamble = [ + 'Privacy-first, composable event data collection (Source → Collector → Destination).', + 'Current namespace: packages are published under `@walkeros/*` and the command line binary is `walkeros`. Package names from the walker.js era are historical and should not be suggested for new work.', + 'walkerOS is not a product analytics tool, not a consent management platform, and not a business intelligence layer. It collects events and routes them to those tools.', + `To prove an integration works without calling a real endpoint, run \`walkeros push flow.json --event '{"name":"product add"}' --simulate destination.NAME\`. It runs the flow and reports what the destination would have sent.`, + `Canonical index: ${vars.site}/llms.txt. Generated ${new Date().toISOString().slice(0, 10)}.`, +].join('\n>\n> '); + const config: Config = { title: 'walkerOS', tagline: 'Open-source event data collection platform', @@ -32,7 +50,10 @@ const config: Config = { }, }, - // Set the production url of your site here + // Set the production url of your site here. + // Keep this a plain string literal matching `vars.site`: the LLM export guard + // reads the value straight out of this file rather than importing the config, + // so a reference here leaves it with no url to check links against. url: 'https://www.walkeros.io', // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' @@ -64,6 +85,48 @@ const config: Config = { }, }, + // Site-wide head tags. Everything here is published, machine-read copy, so it + // states facts about the project and nothing else. + headTags: [ + // Docusaurus emits og:title, og:description, og:image, og:url and og:locale + // from the theme, but never og:type. + { + tagName: 'meta', + attributes: { + property: 'og:type', + content: 'website', + }, + }, + // llms.txt v2 discovery: the index that describes this site. The per-page + // `rel="alternate" type="text/markdown"` half is emitted from the doc item + // itself, since only doc routes have a Markdown companion. + { + tagName: 'link', + attributes: { + rel: 'describedby', + href: `${vars.site}/llms.txt`, + }, + }, + { + tagName: 'script', + attributes: { + type: 'application/ld+json', + }, + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'SoftwareApplication', + name: 'walkerOS', + description: + 'Open source event data collection. Sources capture events, a collector processes them, and destinations route them to analytics and marketing tools.', + url: vars.site, + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Browser, Node.js', + license: 'https://opensource.org/licenses/MIT', + sameAs: [vars.github, vars.npm], + }), + }, + ], + themes: [ '@docusaurus/theme-live-codeblock', '@docusaurus/theme-mermaid', @@ -249,6 +312,13 @@ const config: Config = { // The plugin fails the build when a `to` is not a real route, so these // stay honest as the docs move. redirects: [ + // `/contact` is the path a reader or a crawler guesses for the legal + // contact details. Forward it to the imprint rather than growing a + // second contact surface that then drifts from it. + { + from: '/contact', + to: '/legal/imprint', + }, { from: '/docs/sources/web/session/detection', to: '/docs/sources/web/session', @@ -662,8 +732,7 @@ const config: Config = { '@signalwire/docusaurus-plugin-llms-txt', { siteTitle: 'walkerOS Documentation', - siteDescription: - 'Privacy-first, composable event data collection (Source → Collector → Destination).', + siteDescription: llmsTxtPreamble, // depth: 2 groups routes like /docs/destinations/web/amplitude into the // "docs/destinations" category, mirroring the pipeline taxonomy. depth: 2, @@ -695,11 +764,18 @@ const config: Config = { // non-root baseUrl. relativePaths: Boolean(process.env.DOCUSAURUS_BASEURL), excludeRoutes: ['/search', '/404', '/tags/**'], - // The export appends `.md` to the route path, so a trailing-slash - // route yields `/docs/mapping/.md` while the page is written to - // `/docs/mapping.md`. Rewrite those targets after the export's own - // link handling. - remarkPlugins: [normalizeExportLinks], + // These run on the mdast of the per-page exports only, so neither + // touches llms.txt: + // - The export appends `.md` to the route path, so a trailing-slash + // route yields `/docs/mapping/.md` while the page is written to + // `/docs/mapping.md`. Rewrite those targets after the export's own + // link handling. + // - An export is read detached from the site, often by an agent that + // landed on one narrow page. Give it a pointer to the index. + remarkPlugins: [ + normalizeExportLinks, + [prependExportContext, { indexUrl: `${vars.site}/llms.txt` }], + ], }, } satisfies LlmsTxtOptions, ], diff --git a/website/scripts/prepend-export-context.test.mjs b/website/scripts/prepend-export-context.test.mjs new file mode 100644 index 000000000..73348a382 --- /dev/null +++ b/website/scripts/prepend-export-context.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import remarkGfm from 'remark-gfm'; + +const SRC = new URL('../src/remark', import.meta.url).pathname; +const { default: prependExportContext } = await import( + `${SRC}/prepend-export-context.ts` +); +const { default: normalizeExportLinks } = await import( + `${SRC}/normalize-export-links.ts` +); + +const INDEX = 'https://www.walkeros.io/llms.txt'; +const EXPECTED = + '> Part of the walkerOS documentation. Project overview and full index: '; + +// Mirrors the export pipeline: remark-gfm is on, the site plugins run after the +// built-ins, then remark-stringify emits the .md file. +function run( + markdown, + plugins = [[prependExportContext, { indexUrl: INDEX }]], +) { + const processor = unified().use(remarkParse).use(remarkGfm); + for (const p of plugins) { + if (Array.isArray(p)) processor.use(p[0], p[1]); + else processor.use(p); + } + return String(processor.use(remarkStringify).processSync(markdown)); +} + +const PAGE = [ + '# Mapping', + '', + 'Transform events on the way to a destination.', + '', + '- [Sources](/docs/sources/.md)', + '', + '```js', + 'const a = 1;', + '```', +].join('\n'); + +test('exact line, as a blockquote, at the very top', () => { + const out = run(PAGE); + assert.equal(out.split('\n')[0], EXPECTED); +}); + +test('the index URL is emitted unescaped and machine readable', () => { + const out = run(PAGE); + assert.doesNotMatch(out, /\\/); + assert.match(out, //); +}); + +test('the page keeps its title, prose, list and code block', () => { + const out = run(PAGE); + const body = out.slice(out.indexOf('# Mapping')); + assert.equal(body.trim(), run(PAGE, []).trim()); +}); + +test('the note is added exactly once when the transform runs twice', () => { + const opts = [prependExportContext, { indexUrl: INDEX }]; + const out = run(PAGE, [opts, opts]); + assert.equal(out.split(EXPECTED).length - 1, 1); +}); + +test('re-running over an already prepended export does not duplicate', () => { + const out = run(run(PAGE)); + assert.equal(out.split(EXPECTED).length - 1, 1); +}); + +test('composes with normalizeExportLinks: /.md targets still get rewritten', () => { + const out = run(PAGE, [ + normalizeExportLinks, + [prependExportContext, { indexUrl: INDEX }], + ]); + assert.equal(out.split('\n')[0], EXPECTED); + assert.match(out, /\(\/docs\/sources\.md\)/); + assert.doesNotMatch(out, /\/docs\/sources\/\.md/); +}); + +test('the note itself is left alone by normalizeExportLinks', () => { + const out = run(PAGE, [ + [prependExportContext, { indexUrl: INDEX }], + normalizeExportLinks, + ]); + assert.equal(out.split('\n')[0], EXPECTED); +}); + +test('a page with no leading heading still gets the note first', () => { + const out = run('Just a paragraph.'); + assert.equal(out, `${EXPECTED}\n\nJust a paragraph.\n`); +}); + +test('a malformed tree is left alone instead of throwing', () => { + const transform = prependExportContext({ indexUrl: INDEX }); + for (const tree of [null, undefined, 'text', 42, {}, { children: 'no' }]) { + assert.doesNotThrow(() => transform(tree)); + } +}); diff --git a/website/src/components/atoms/brandMarks.tsx b/website/src/components/atoms/brandMarks.tsx new file mode 100644 index 000000000..142b31f0a --- /dev/null +++ b/website/src/components/atoms/brandMarks.tsx @@ -0,0 +1,75 @@ +/** + * Official brand marks for the assistant actions. + * + * Each path is the vendor's own artwork, copied verbatim from the source noted + * above it. They are drawn in `currentColor` so the surrounding link controls + * the tone, which keeps the three consistent in both themes. + */ + +import React from 'react'; + +interface MarkProps { + className?: string; +} + +/** + * Markdown mark by Dustin Curtis, dedicated to the public domain (CC0). + * Source: https://raw.githubusercontent.com/dcurtis/markdown-mark/master/svg/markdown-mark.svg + */ +export function MarkdownMark({ className }: MarkProps): React.JSX.Element { + return ( + + ); +} + +/** + * OpenAI mark, served inline by OpenAI at https://openai.com/favicon.svg + */ +export function OpenAIMark({ className }: MarkProps): React.JSX.Element { + return ( + + ); +} + +/** + * Claude mark, served by Anthropic at https://claude.ai/favicon.svg + * The source artwork is filled with Anthropic's #D97757; it is redrawn in + * `currentColor` here so all three marks share one tone. + */ +export function ClaudeMark({ className }: MarkProps): React.JSX.Element { + return ( + + ); +} diff --git a/website/src/components/organisms/markdownActions.module.css b/website/src/components/organisms/markdownActions.module.css new file mode 100644 index 000000000..0f7513b02 --- /dev/null +++ b/website/src/components/organisms/markdownActions.module.css @@ -0,0 +1,70 @@ +/** + * A quiet toolbar above the doc title: one muted label and three brand marks. + * + * No borders and no fill at rest, so the row reads as a caption rather than a + * button bar and never competes with the heading below it. Hover is the only + * lift. Every colour comes from the Infima emphasis scale, which inverts with + * the theme, so one set of rules covers light and dark. + */ + +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.125rem; + margin-bottom: 0.5rem; +} + +.note { + margin-right: 0.5rem; + color: var(--ifm-color-emphasis-600); + font-size: 0.75rem; + line-height: 1.75rem; + white-space: nowrap; +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.375rem; + color: var(--ifm-color-emphasis-600); + text-decoration: none; + transition: + color var(--ifm-transition-fast) ease, + background-color var(--ifm-transition-fast) ease; +} + +.action:hover, +.action:focus-visible { + color: var(--ifm-color-emphasis-900); + /* 300 rather than 200: the page background is already emphasis-200 light, so + the lighter chip is invisible on the theme this site actually ships. */ + background-color: var(--ifm-color-emphasis-300); + text-decoration: none; +} + +/** + * Sized per mark rather than uniformly. The three have different silhouettes: + * the Markdown mark is a 13:8 rectangle, the OpenAI knot is a square with open + * counters, and the Claude burst fills its square to the edges. Matching their + * box sizes would leave the burst reading largest and the rectangle smallest, + * so each is tuned to the same optical weight instead. + */ +.markdownMark { + width: 1.4375rem; + height: 0.875rem; +} + +.openaiMark { + width: 1rem; + height: 1rem; +} + +.claudeMark { + width: 0.9375rem; + height: 0.9375rem; +} diff --git a/website/src/components/organisms/markdownActions.tsx b/website/src/components/organisms/markdownActions.tsx new file mode 100644 index 000000000..1aa42025b --- /dev/null +++ b/website/src/components/organisms/markdownActions.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react'; +import Head from '@docusaurus/Head'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { + MarkdownMark, + OpenAIMark, + ClaudeMark, +} from '@site/src/components/atoms/brandMarks'; +import { + markdownExportPath, + markdownExportUrl, +} from '@site/src/utils/markdown-export'; +import styles from './markdownActions.module.css'; + +export default function MarkdownActions(): React.JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const { metadata } = useDoc(); + + const path = markdownExportPath(metadata.permalink); + const url = markdownExportUrl(siteConfig.url, metadata.permalink); + const prompt = encodeURIComponent(`Read ${url} and help me use walkerOS.`); + + // Rendered on the server so the controls exist without JavaScript, then + // confirmed against the served file. Only a response that says the export is + // missing takes them away: a request that never completed is not evidence of + // absence, and hiding a working link because someone is offline is worse than + // leaving it. Rechecked on client-side navigation to another doc. + const [available, setAvailable] = useState(true); + + useEffect(() => { + let current = true; + setAvailable(true); + + fetch(path, { method: 'HEAD' }) + .then((response) => { + if (current && !response.ok) setAvailable(false); + }) + .catch(() => {}); + + return () => { + current = false; + }; + }, [path]); + + return ( + <> + {/* Only doc routes render a doc item, and every doc route has an export, + so this link relation can never point at a missing file. */} + + + + {available && ( +
+ Ask your AI + + + + + + + + + +
+ )} + + ); +} diff --git a/website/src/remark/prepend-export-context.ts b/website/src/remark/prepend-export-context.ts new file mode 100644 index 000000000..4b5ff9049 --- /dev/null +++ b/website/src/remark/prepend-export-context.ts @@ -0,0 +1,69 @@ +/** + * Prepends one orientation line to every generated Markdown export. + * + * A single page read on its own says nothing about what walkerOS is or where + * the rest of the documentation lives, and the exports are built to be read + * that way: fetched directly, or opened by an agent that followed a link into + * a niche page. One blockquote at the top points at the canonical index. + * + * Registered under the LLM export plugin's `content.remarkPlugins`, which run + * on the mdast of the per-page exports only. llms.txt is assembled from route + * metadata on a separate path and never reaches this transform. + */ + +interface Options { + /** Fully qualified URL of the canonical index. */ + indexUrl: string; +} + +const NOTE_PREFIX = + 'Part of the walkerOS documentation. Project overview and full index: '; + +/** + * The index goes in as a link rather than as text. remark-gfm reads a bare URL + * in a text node as an autolink literal, so remark-stringify escapes it back + * out as `https\://www\.walkeros.io`; a link node stringifies to the plain + * `` autolink instead. + */ +function noteNodes(indexUrl: string): unknown[] { + return [ + { type: 'text', value: NOTE_PREFIX }, + { + type: 'link', + url: indexUrl, + children: [{ type: 'text', value: indexUrl }], + }, + ]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function firstChild(node: unknown, type: string): unknown { + if (!isRecord(node) || node.type !== type) return undefined; + const children = node.children; + return Array.isArray(children) ? children[0] : undefined; +} + +/** Recognises a note a previous run wrote, so a second run is a no-op. */ +function hasNote(node: unknown): boolean { + const paragraph = firstChild(node, 'blockquote'); + const text = firstChild(paragraph, 'paragraph'); + return isRecord(text) && text.type === 'text' && text.value === NOTE_PREFIX; +} + +export default function prependExportContext(options: Options) { + return function transformer(tree: unknown): void { + if (!isRecord(tree)) return; + + const children = tree.children; + if (!Array.isArray(children)) return; + if (hasNote(children[0])) return; + + children.unshift({ + type: 'blockquote', + children: [{ type: 'paragraph', children: noteNodes(options.indexUrl) }], + }); + }; +} diff --git a/website/src/theme/DocItem/Content/index.js b/website/src/theme/DocItem/Content/index.js index fe34b729f..caa011bf0 100644 --- a/website/src/theme/DocItem/Content/index.js +++ b/website/src/theme/DocItem/Content/index.js @@ -1,10 +1,12 @@ import React from 'react'; import Content from '@theme-original/DocItem/Content'; +import MarkdownActions from '@site/src/components/organisms/markdownActions'; import SupportNotice from '@site/src/components/organisms/supportNotice'; export default function ContentWrapper(props) { return ( <> + diff --git a/website/src/utils/markdown-export.ts b/website/src/utils/markdown-export.ts new file mode 100644 index 000000000..0dbf07b66 --- /dev/null +++ b/website/src/utils/markdown-export.ts @@ -0,0 +1,23 @@ +/** + * Resolves the Markdown companion of a doc route. + * + * The LLM export writes one `.md` file per doc route, mirroring the route path + * with any trailing slash removed: `/docs/` is written to `/docs.md` and + * `/docs/mapping/` to `/docs/mapping.md`. Only routes rendered as a doc item + * have one. Generated category indexes and client-redirect stubs are separate + * route types and carry no export, so never derive a link for them. + */ + +/** Path of the Markdown companion, e.g. `/docs/mapping.md`. */ +export function markdownExportPath(permalink: string): string { + const withoutTrailingSlash = permalink.replace(/\/+$/, ''); + return `${withoutTrailingSlash || '/index'}.md`; +} + +/** + * Fully qualified URL of the Markdown companion. The permalink already carries + * the baseUrl, so the site url contributes the origin only. + */ +export function markdownExportUrl(siteUrl: string, permalink: string): string { + return `${siteUrl.replace(/\/+$/, '')}${markdownExportPath(permalink)}`; +} diff --git a/website/static/bunnycdn_errors/404.html b/website/static/bunnycdn_errors/404.html new file mode 100644 index 000000000..886affe06 --- /dev/null +++ b/website/static/bunnycdn_errors/404.html @@ -0,0 +1,303 @@ + + + + + + + Page not found | walkerOS + + + +
+ + + + +

Error 404

+

Page not found

+

+ This address does not exist. It may be mistyped, or the page has moved. + These are good places to pick the thread back up. +

+ + + +

+ Every page on this site is listed in the + sitemap.
+ For AI agents: the full documentation index is at + https://www.walkeros.io/llms.txt +

+
+ +