diff --git a/.vitepress/data/modules.ts b/.vitepress/data/modules.ts index 74d687cd..d41cf325 100644 --- a/.vitepress/data/modules.ts +++ b/.vitepress/data/modules.ts @@ -43,7 +43,7 @@ export const publicModules = [ guide: '/en/guide/auth', api: '/en/api/@connectum/auth/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/auth', - example: 'https://github.com/Connectum-Framework/examples/tree/main/auth', + example: 'https://github.com/Connectum-Framework/examples/tree/main/car-sharing', entryPoints: ['createJwtAuthInterceptor', 'createAuthzInterceptor', 'requireAuthContext'], }, { @@ -62,10 +62,10 @@ export const publicModules = [ name: '@connectum/events-nats', category: 'Communication', summary: 'NATS JetStream adapter for the Connectum EventBus.', - guide: '/en/guide/events/adapters', + guide: '/en/guide/events/adapters#nats-jetstream-adapter', api: '/en/api/@connectum/events-nats/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/events-nats', - example: 'https://github.com/Connectum-Framework/examples/tree/main/with-events-nats', + example: 'https://github.com/Connectum-Framework/examples/tree/main/with-events-dlq', entryPoints: ['NatsAdapter', 'NatsAdapterOptions'], }, { @@ -73,7 +73,7 @@ export const publicModules = [ name: '@connectum/events-kafka', category: 'Communication', summary: 'Kafka and Redpanda adapter for the Connectum EventBus.', - guide: '/en/guide/events/adapters', + guide: '/en/guide/events/adapters#kafka-adapter', api: '/en/api/@connectum/events-kafka/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/events-kafka', example: 'https://github.com/Connectum-Framework/examples/tree/main/with-events-kafka', @@ -84,7 +84,7 @@ export const publicModules = [ name: '@connectum/events-redis', category: 'Communication', summary: 'Redis Streams and Valkey adapter for the Connectum EventBus.', - guide: '/en/guide/events/adapters', + guide: '/en/guide/events/adapters#redis-streams-adapter', api: '/en/api/@connectum/events-redis/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/events-redis', example: 'https://github.com/Connectum-Framework/examples/tree/main/with-events-valkey', @@ -95,7 +95,7 @@ export const publicModules = [ name: '@connectum/events-amqp', category: 'Communication', summary: 'AMQP and RabbitMQ adapter with topology and reliable-publishing controls.', - guide: '/en/guide/events/adapters', + guide: '/en/guide/events/adapters#amqp--rabbitmq-adapter', api: '/en/api/@connectum/events-amqp/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/events-amqp', example: 'https://github.com/Connectum-Framework/examples/tree/main/with-events-amqp', @@ -126,7 +126,7 @@ export const publicModules = [ slug: 'reflection', name: '@connectum/reflection', category: 'Operations', - summary: 'gRPC Server Reflection for discovery, inspection, and tooling.', + summary: 'gRPC Server Reflection for schema inspection and client tooling.', guide: '/en/guide/protocols/reflection', api: '/en/api/@connectum/reflection/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/reflection', @@ -150,7 +150,7 @@ export const publicModules = [ guide: '/en/guide/service-communication/service-catalog', api: '/en/api/@connectum/protoc-gen-catalog/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/protoc-gen-catalog', - entryPoints: ['protoc-gen-catalog'], + entryPoints: ['protoc-gen-connectum-catalog'], }, { slug: 'testing', @@ -160,7 +160,6 @@ export const publicModules = [ guide: '/en/guide/testing/integration', api: '/en/api/@connectum/testing/', source: 'https://github.com/Connectum-Framework/connectum/tree/main/packages/testing', - example: 'https://github.com/Connectum-Framework/examples/tree/main/runn', entryPoints: ['createTestServer', 'withTestServer', 'createLocalClient'], }, { diff --git a/.vitepress/homepage/deployment-markup.mjs b/.vitepress/homepage/deployment-markup.mjs index 8e072ab3..c6bcda51 100644 --- a/.vitepress/homepage/deployment-markup.mjs +++ b/.vitepress/homepage/deployment-markup.mjs @@ -15,10 +15,11 @@ export function deploymentMarkup(svg = '') {

Service calls

Same code.
Different deployments.

Keep your Protobuf contracts and service handlers. Run services together as a monolith, separately as microservices, or in a hybrid deployment.

-

Call other services with ctx.call(). The same typed call runs in-process or over RPC, depending on your server configuration.

+

A generated catalog supplies the types for ctx.call(). Local registration and configured remote resolvers choose where each call runs.

+

These diagrams illustrate routing; endpoints, credentials and process deployment remain your configuration.

`; } diff --git a/.vitepress/theme/components/HomePage.vue b/.vitepress/theme/components/HomePage.vue index e6ac4a4b..0ebcfde6 100644 --- a/.vitepress/theme/components/HomePage.vue +++ b/.vitepress/theme/components/HomePage.vue @@ -39,32 +39,32 @@ onBeforeUnmount(() => revealObserver?.disconnect()); // biome-ignore lint/correctness/noUnusedVariables: the Vue template iterates these goal links. const goals = [ { - label: 'New to Connectum', - title: 'Build a first service', - description: 'Go from an empty directory to a verified gRPC call with a focused, linear tutorial.', + label: 'Your first service', + title: 'Build a protected RPC', + description: 'Scaffold a project, add a Protobuf method and check calls with and without a JWT.', link: '/en/guide/quickstart', - action: 'Start the tutorial', + action: 'Follow the quickstart', }, { - label: 'Building a capability', - title: 'Solve a service task', - description: 'Add validation, service calls, events, authentication, or testing with a guide for the task at hand.', - link: '/en/guide/about#choose-your-path', - action: 'Browse guides', + label: 'Connecting services', + title: 'Choose how services communicate', + description: 'Compare typed RPC calls and events, then choose local or remote routing.', + link: '/en/guide/communication', + action: 'Communication guides', }, { - label: 'Operating services', - title: 'Deploy and operate services', - description: 'Configure health checks, observability, shutdown, containers, Kubernetes, or a gateway.', - link: '/en/guide/production/kubernetes', - action: 'Explore operations', + label: 'Deploying a service', + title: 'Choose a deployment setup', + description: 'Package the service for containers and find guidance for Kubernetes, gateways and service meshes.', + link: '/en/guide/deploy', + action: 'Deployment guides', }, { - label: 'Know what you need', + label: 'Exact API', title: 'Find an exact option', - description: 'Jump by module or symbol into the generated API, compatibility matrix, or migration guidance.', + description: 'Look up exported functions, TypeScript interfaces and configuration fields in the generated API.', link: '/en/api/', - action: 'Open reference', + action: 'Open the API', }, ] as const; @@ -72,7 +72,7 @@ const goals = [ const lifecycle = [ ['Define', 'Proto contracts and generated types'], ['Run', 'Server lifecycle and transport'], - ['Protect', 'Validation, auth, and resilience'], + ['Protect', 'Configured validation, auth and resilience'], ['Observe', 'Traces, metrics, and logs'], ['Operate', 'Health, shutdown, and deployment'], ] as const; @@ -97,7 +97,7 @@ const lifecycle = [
Compatibility
- Runtime matrix + Runtime matrix
@@ -105,10 +105,47 @@ const lifecycle = [ +
+
+
+

Fits your stack

+

Build on ConnectRPC and Protobuf.

+
+

+ ConnectRPC supplies the RPC layer. Connectum adds a shared server lifecycle and optional modules for security, telemetry and events. +

+
+ +

+ See what Connectum owns → + · Runtime requirements and limits → +

+
+ +
+
+

From contract to handler

+

Implement your contract in TypeScript.

+

+ Define the API in Protobuf, generate its types and implement the handlers as ordinary functions. Register services, middleware and protocols with + createServer(). +

+ +
+ + + +
+ + +

Start by goal

-

Choose a path for the task at hand.

+

Choose your next step.

@@ -128,7 +165,8 @@ const lifecycle = [

Add capabilities through explicit modules.

- The framework keeps lifecycle and registration predictable while modules remain explicit. Begin with the server; add production behavior as the service grows. + Use the same startup and shutdown model across services, with only the modules each service needs. Your application keeps its database, business logic and + dependency wiring.

    @@ -138,49 +176,22 @@ const lifecycle = [ {{ detail }}
+

+ For a small RPC endpoint, or when your existing setup already covers these needs, ConnectRPC alone may be enough. + Decide whether Connectum fits your service. +

-
-
-
-

Fits your stack

-

Choose a runtime for each service.

-
-

Use ConnectRPC or gRPC, connect to supported brokers, and choose Node.js or Bun for each service.

-
- -
- -
-
-

The central primitive

-

Compose a service with an explicit lifecycle.

-

- Define a Protobuf contract, generate TypeScript types, implement a handler, and start a server with createServer(). Then call the same service with - Connect and gRPC. -

- -
- - - -
- - -

Explore modules

Find modules by capability.

-

Each module links to its guide, configuration help, and generated API reference.

+

Choose a capability, then open its Guide or generated API.

-

Find a module's guide and API →

+

Understand module boundaries →

diff --git a/scripts/check-landing-browser.mjs b/scripts/check-landing-browser.mjs new file mode 100644 index 00000000..9330b297 --- /dev/null +++ b/scripts/check-landing-browser.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; +import { publicModules } from '../.vitepress/data/modules.ts'; +import { findRedirect } from '../.vitepress/data/redirects.ts'; +import { checkHttpLink } from './lib/package-readme-links.mjs'; + +const { values } = parseArgs({ options: { url: { type: 'string', default: 'http://127.0.0.1:4185' }, output: { type: 'string', default: '.tmp/landing-browser' } } }); +assert.ok(process.env.PLAYWRIGHT_MODULE, 'Set PLAYWRIGHT_MODULE to an installed Playwright index.mjs. This check starts no server.'); +const { chromium } = await import(pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href); +const base = new URL(values.url); +const output = resolve(values.output); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ headless: true, channel: 'chrome' }); +const report = { completed: false, site: base.href, layouts: [], clicks: [], keyboard: [], static: [], links: [], errors: [] }; +const deadline = setTimeout(() => void browser.close(), 300_000); +const sectionOrder = ['home-hero', 'home-ecosystem', 'home-example', 'home-deployments', 'home-intents', 'home-lifecycle', 'home-modules', 'home-resources']; +const pages = new Map(); +const links = new Set(); + +async function fetchPage(url) { + const key = new URL(url); + key.hash = ''; + if (!pages.has(key.href)) { + pages.set( + key.href, + (async () => { + const response = await fetch(key, { signal: AbortSignal.timeout(20_000), headers: { 'user-agent': 'Connectum-documentation-link-check' } }); + return { status: response.status, html: await response.text(), finalUrl: response.url }; + })(), + ); + } + return pages.get(key.href); +} + +async function visit(page) { + await page.goto(new URL('/en/', base).href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => document.querySelector('#app')?.__vue_app__); + await page.locator('.home-code[data-phase="ready"]').waitFor(); + await page.locator('.home-deployments[data-phase="ready"]').waitFor(); + assert.ok((await page.locator('.home-code button').count()) >= 5); + assert.ok((await page.locator('.deployment-controls button').count()) >= 3); +} + +async function checkOrder(page) { + const sections = await page.locator('.connectum-home > section').evaluateAll((nodes) => + nodes.map((node) => ({ + name: [...node.classList].find((name) => name !== 'home-section' && name.startsWith('home-')), + top: node.getBoundingClientRect().top + scrollY, + bottom: node.getBoundingClientRect().bottom + scrollY, + labelled: Boolean(document.getElementById(node.getAttribute('aria-labelledby'))?.textContent.trim()), + })), + ); + assert.deepEqual( + sections.map((section) => section.name), + sectionOrder, + ); + for (const [index, section] of sections.entries()) { + assert.ok(section.labelled, `${section.name} has no accessible heading`); + if (index) assert.ok(section.top >= sections[index - 1].bottom - 1, `${section.name} overlaps the previous section`); + } + return sections; +} + +try { + for (const theme of ['light', 'dark']) { + const context = await browser.newContext({ colorScheme: theme, reducedMotion: 'reduce', viewport: { width: 1440, height: 1000 } }); + context.setDefaultTimeout(20_000); + const page = await context.newPage(); + page.on('pageerror', (error) => report.errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') report.errors.push(message.text()); + }); + try { + await visit(page); + assert.equal(await page.locator('html').evaluate((node) => node.classList.contains('dark')), theme === 'dark'); + for (const href of await page.locator('.connectum-home a[href], .VPNav a[href], footer a[href]').evaluateAll((nodes) => nodes.map((node) => node.href))) + links.add(href); + assert.equal(await page.locator('.module-card').count(), 15); + const ecosystem = await page.locator('.home-ecosystem').innerText(); + assert.ok(ecosystem.includes('ConnectRPC supplies the RPC layer.')); + assert.ok(ecosystem.includes('optional modules')); + assert.ok((await page.locator('.home-lifecycle').innerText()).includes('ConnectRPC alone may be enough.')); + for (const width of [1440, 768, 390, 320]) { + await page.setViewportSize({ width, height: 1000 }); + const sections = await checkOrder(page); + const documentWidth = await page.evaluate(() => document.documentElement.scrollWidth); + assert.ok(documentWidth <= width + 1, `${theme}/${width}: horizontal overflow ${documentWidth}`); + await writeFile(resolve(output, `${theme}-${width}.yml`), await page.locator('.connectum-home').ariaSnapshot()); + for (const name of ['home-ecosystem', 'home-example', 'home-deployments', 'home-intents', 'home-lifecycle']) { + await page.locator(`.${name}`).screenshot({ path: resolve(output, `${theme}-${width}-${name}.png`) }); + } + report.layouts.push({ theme, width, documentWidth, sections }); + } + await page.setViewportSize({ width: 1440, height: 1000 }); + const goalLinks = page.locator('.home-goal-grid a'); + await goalLinks.first().focus(); + for (let index = 0; index < 4; index++) { + assert.ok(await goalLinks.nth(index).evaluate((node) => document.activeElement === node)); + const focus = await goalLinks.nth(index).evaluate((node) => ({ + visible: node.matches(':focus-visible'), + outline: getComputedStyle(node).outlineStyle, + outlineWidth: getComputedStyle(node).outlineWidth, + width: node.getBoundingClientRect().width, + height: node.getBoundingClientRect().height, + })); + assert.ok(focus.visible && focus.outline !== 'none' && Number.parseFloat(focus.outlineWidth) > 0, JSON.stringify(focus)); + assert.ok(focus.width >= 24 && focus.height >= 24); + report.keyboard.push({ theme, index, ...focus }); + await page.keyboard.press('Tab'); + } + assert.ok(await page.locator('.home-lifecycle a').evaluate((node) => document.activeElement === node)); + for (const [selector, destination] of [ + ['.home-goal-card:nth-child(1)', '/en/guide/quickstart'], + ['.home-goal-card:nth-child(2)', '/en/guide/communication'], + ['.home-goal-card:nth-child(3)', '/en/guide/deploy'], + ['.home-goal-card:nth-child(4)', '/en/api/'], + ['.home-example a:first-child', '/en/guide/quickstart-manual'], + ['.home-support a', '/en/guide/runtime-compatibility-details#compatibility-matrix'], + ['.deployment-copy a:last-child', '/en/guide/service-communication/resolver-configuration'], + ['.home-modules > .home-section__more a', '/en/guide/production/architecture#extension-seams'], + ['.module-card a[href$="#nats-jetstream-adapter"]', '/en/guide/events/adapters#nats-jetstream-adapter'], + ['.module-card a[href$="#kafka-adapter"]', '/en/guide/events/adapters#kafka-adapter'], + ['.module-card a[href$="#redis-streams-adapter"]', '/en/guide/events/adapters#redis-streams-adapter'], + ['.module-card a[href$="#amqp--rabbitmq-adapter"]', '/en/guide/events/adapters#amqp--rabbitmq-adapter'], + ]) { + await visit(page); + await page.locator(selector).first().click(); + const expected = new URL(destination, base); + await page.waitForURL((url) => url.pathname === expected.pathname && url.hash === expected.hash); + await page.locator('.connectum-home').waitFor({ state: 'detached' }); + await page.locator('main h1').waitFor(); + if (expected.hash) await page.locator(`[id="${expected.hash.slice(1)}"]`).waitFor({ state: 'visible' }); + report.clicks.push({ theme, destination, title: await page.locator('main h1').innerText() }); + } + } catch (error) { + await page.screenshot({ path: resolve(output, `${theme}-failure.png`) }); + throw error; + } finally { + await context.close(); + } + const staticContext = await browser.newContext({ javaScriptEnabled: false, colorScheme: theme, viewport: { width: 390, height: 950 } }); + try { + const staticPage = await staticContext.newPage(); + await staticPage.goto(new URL('/en/', base).href); + const renderedTheme = await staticPage.locator('html').evaluate((node) => (node.classList.contains('dark') ? 'dark' : 'light')); + await checkOrder(staticPage); + assert.equal(await staticPage.locator('.module-card').count(), 15); + assert.ok(await staticPage.locator('.home-code pre').first().isVisible()); + assert.ok(await staticPage.locator('.deployment-static--compact').isVisible()); + await staticPage.locator('.home-goal-card').first().click(); + await staticPage.waitForURL(new URL('/en/guide/quickstart', base).href); + assert.ok((await staticPage.locator('h1').innerText()).includes('First Connectum Service')); + report.static.push({ colorScheme: theme, renderedTheme, sections: sectionOrder, cards: 15, tutorialLinkWorks: true }); + } finally { + await staticContext.close(); + } + } + for (const module of publicModules) { + for (const href of [module.guide, module.api, module.source, module.example].filter(Boolean)) links.add(new URL(href, base).href); + } + for (const href of links) { + const url = new URL(href); + if (url.origin === base.origin) assert.equal(findRedirect(url.pathname), undefined, `Landing links through a compatibility route: ${href}`); + const result = await checkHttpLink(url, fetchPage); + report.links.push({ href, ...result }); + if (!result.ok) report.errors.push(`${href}: ${result.reason}`); + } + assert.deepEqual(report.errors, []); + report.completed = true; +} catch (error) { + report.errors.push(error.stack ?? String(error)); + throw error; +} finally { + clearTimeout(deadline); + try { + await writeFile(resolve(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`); + } finally { + await browser.close(); + } +} +console.log( + `Landing: ${report.layouts.length} layouts, ${report.clicks.length} intentional transitions, keyboard in both themes, ${report.static.length} no-JS fallbacks (actual themes recorded); ${report.links.length} rendered/metadata links with HTTP and fragments checked.`, +); diff --git a/scripts/docs-validate.mjs b/scripts/docs-validate.mjs index 25e87b6e..3e23f90e 100644 --- a/scripts/docs-validate.mjs +++ b/scripts/docs-validate.mjs @@ -248,7 +248,10 @@ for (const module of publicModules) { if (seenSlugs.has(module.slug)) errors.push(`duplicate module slug: ${module.slug}`); seenSlugs.add(module.slug); if (!resolveRoute(module.api)) errors.push(`${module.name}: missing generated API destination ${module.api}`); - if (!resolveRoute(module.guide)) errors.push(`${module.name}: missing guide destination ${module.guide}`); + const [guidePath, guideFragment] = module.guide.split('#'); + const guideRoute = resolveRoute(guidePath); + if (!guideRoute) errors.push(`${module.name}: missing guide destination ${module.guide}`); + else if (guideFragment && !pages.get(guideRoute).anchors.has(guideFragment)) errors.push(`${module.name}: missing guide fragment ${module.guide}`); } const navigationSource = readFileSync(resolve(projectRoot, '.vitepress/config/en.ts'), 'utf8'); diff --git a/tests/homepage/landing.test.mjs b/tests/homepage/landing.test.mjs new file mode 100644 index 00000000..67cf61ad --- /dev/null +++ b/tests/homepage/landing.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { publicModules } from '../../.vitepress/data/modules.ts'; + +const root = new URL('../../', import.meta.url); +const read = (path) => readFileSync(new URL(path, root), 'utf8'); +const home = read('.vitepress/theme/components/HomePage.vue'); + +test('the landing introduces the stack, code and deployment before goals, lifecycle and the unchanged module grid', () => { + const template = home.slice(home.indexOf('