diff --git a/.changeset/cli-type-generation.md b/.changeset/cli-type-generation.md new file mode 100644 index 0000000..5c0517b --- /dev/null +++ b/.changeset/cli-type-generation.md @@ -0,0 +1,10 @@ +--- +'@b10cks/cli': minor +--- + +Fix `generate types -o` path resolution and discriminate generated blocks + +- An explicit `-o` is now resolved against the working directory. Only the default output path is placed under a Nuxt 4 `app/` rootDir, so `-o ./app/b10cks/types` no longer lands in `app/app/…`. +- Each generated block interface carries a literal `block: 'slug'`, narrowing `B10cksItem`'s `block: string`. A heterogeneous body array can be discriminated on `block` without a cast. A schema field named `block` is skipped, since it would redeclare the discriminant. + +The literal `block` is a type-level narrowing. Code that assigns a hand-built object with a widened `block: string` to a generated interface (test fixtures, mocks) needs `as const` or an explicit literal after regenerating. diff --git a/.changeset/mgmt-contents-envelope.md b/.changeset/mgmt-contents-envelope.md new file mode 100644 index 0000000..43cd246 --- /dev/null +++ b/.changeset/mgmt-contents-envelope.md @@ -0,0 +1,7 @@ +--- +'@b10cks/mgmt-client': patch +--- + +Unwrap the `{ data }` envelope on single-content endpoints + +`contents.get`, `create`, `update`, `move`, `publish`, `unpublish` and `schedule` declared a bare `Content` but resolved to `{ data: Content }`, so callers had to branch on the shape themselves. They now unwrap, making the declared type true. An already-bare response is passed through untouched. diff --git a/.changeset/sdk-audit-gaps.md b/.changeset/sdk-audit-gaps.md new file mode 100644 index 0000000..f5c8354 --- /dev/null +++ b/.changeset/sdk-audit-gaps.md @@ -0,0 +1,18 @@ +--- +'@b10cks/client': minor +'@b10cks/vue': minor +'@b10cks/nuxt': minor +'@b10cks/richtext': minor +--- + +Close SDK gaps found auditing three production Nuxt sites + +- `@b10cks/client`: `rv` is now part of `IBBaseQueryParams`, so pinning a request to a revision (or passing `Date.now()` from a server route to sidestep a stale delivery cache) no longer needs an `as object` cast. +- `@b10cks/client`: `getDataEntries` takes a typed `IBDataEntryParams` with `dimension`, the locale-style variant selector for data sources. +- `@b10cks/client`: `GetConfigOptions.language` is deprecated in favour of `language_iso`, matching every other content param. Both still work. +- `@b10cks/nuxt`: `useB10cksConfig` watches `language_iso` as well as `language`, so a config passed `language_iso` refetches on a locale change instead of going stale. +- `@b10cks/nuxt`: new `useB10cksServerApi()`, auto-imported in the server bundle. Nitro routes and middleware get the full `B10cksDataApi` — `getRedirects`, `getSitemap`, `getNamedSitemap` with pagination and caching — instead of hand-rolling paginated fetches and TTL caches. +- `@b10cks/nuxt`: new `useB10cksVersion()` composable, normalizing `?b10cks_vid` to a version string defaulting to `published`. +- `@b10cks/richtext`: new `isRichTextEmpty(document)`, re-exported from `@b10cks/vue/rich-text` and `@b10cks/nuxt`. Reports whether a document renders anything, so a field an editor cleared (an empty paragraph) can skip its wrapper markup. +- `@b10cks/client`: `renderSitemapXml` and `filterSitemapEntries` take a `localePrefix` strategy (`auto` | `always` | `never` | `except-default`). It defaults to `auto`, which prefixes only when the entries span more than one language. A mono-lingual space previously emitted `/en/about` for a page served at `/about`, making every sitemap URL a 404 or a redirect. +- Docs: the client README documents the response-envelope normalization every collection method already does, and points at `filter` for `id` / `canonical_id` / `parent_id` queries. The Nuxt README surfaces `usePreviewContent` from the top of the usage section. diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 5bbf4cf..f92ee0a 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import type { Command } from 'commander' import { readDefinitions, resolveSchemaDir } from '../schema/store.js' -import { TypesGeneratorService } from '../services/TypeGeneratorService.js' +import { DEFAULT_OUTPUT_DIR, TypesGeneratorService } from '../services/TypeGeneratorService.js' import { BaseCommand } from './BaseCommand.js' export class GenerateCommand extends BaseCommand { @@ -12,7 +12,7 @@ export class GenerateCommand extends BaseCommand { ns.command('types') .description('generate TypeScript types from block definitions') .argument('[spaceId]', 'space ID to generate types for (omit to use local schema files)') - .option('-o, --out ', 'output path for generated types', './b10cks/types') + .option('-o, --out ', 'output path for generated types', DEFAULT_OUTPUT_DIR) .option( '--dir ', 'local schema directory used when no space ID is given', diff --git a/packages/cli/src/services/TypeGeneratorService.ts b/packages/cli/src/services/TypeGeneratorService.ts index b0d1f78..a91b606 100644 --- a/packages/cli/src/services/TypeGeneratorService.ts +++ b/packages/cli/src/services/TypeGeneratorService.ts @@ -10,6 +10,9 @@ import BaseService from './BaseService.js' type BlockList = Record +/** Kept in sync with the `-o` default in `generate types`. */ +export const DEFAULT_OUTPUT_DIR = './b10cks/types' + /** The subset of a block field schema that type generation reads. */ interface SchemaField { type?: string @@ -33,14 +36,20 @@ export class TypesGeneratorService extends BaseService { private additionalTypeDeclarations: string[] = [] private declaredAdditionalTypes = new Set() - constructor(outputDir: string = './b10cks/types') { + /** + * @param outputDir Resolved against the working directory. The default is + * additionally placed under `app/` when a Nuxt 4 `app/` rootDir exists; an + * explicit `-o` is taken as given, so `-o ./app/b10cks/types` no longer + * lands in `app/app/…`. + */ + constructor(outputDir: string = DEFAULT_OUTPUT_DIR) { super() if (!path.isAbsolute(outputDir)) { const appDir = path.join(process.cwd(), 'app') - if (fs.existsSync(appDir)) { + if (outputDir === DEFAULT_OUTPUT_DIR && fs.existsSync(appDir)) { outputDir = path.join(appDir, outputDir) } else { - outputDir = path.join(process.cwd(), outputDir) + outputDir = path.resolve(process.cwd(), outputDir) } } @@ -298,16 +307,19 @@ export type B10cksPrice = Record } private generateInterfaceContent(block: Block, typeName: string): string { - let content = `export interface ${typeName} extends B10cksItem {\n` + // The literal `block` narrows B10cksItem's `block: string`, so a mixed + // body array can be discriminated on it without a cast. + let content = `export interface ${typeName} extends B10cksItem {\n\tblock: ${JSON.stringify(block.slug)}\n` if (block.schema) { - const properties = Object.entries(block.schema as Record).map( - ([key, schema]) => { + const properties = Object.entries(block.schema as Record) + // A schema field named `block` would redeclare the discriminant above. + .filter(([key]) => key !== 'block') + .map(([key, schema]) => { const type = this.mapSchemaTypeToTsType(schema.type, key, block.slug, schema) const optional = !schema.required ? '?' : '' return `\t${key}${optional}: ${type}` - } - ) + }) content += `${properties.join('\n')}\n` } diff --git a/packages/client/README.md b/packages/client/README.md index d4fd50f..c6b5c65 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -150,6 +150,33 @@ const params = serializeFilter({ Pass `{ allPages: true }` as the second argument to any collection method to fetch every page automatically. +Every collection method normalizes the response envelope, so a bare array, a +`{ data }` wrapper and a `{ data: { data } }` wrapper all resolve to a plain +array. There is no need to unwrap by hand. + +### Common query params + +`rv` pins a request to a content revision. It defaults to the client's current +revision, so pass it only to override — `Date.now()` from a server route +sidesteps a stale delivery cache: + +```typescript +const entries = await dataApi.getContents({ language_iso: 'de', rv: Date.now() }) +``` + +`getDataEntries` takes `dimension` to read a mutated variant of a data source, +falling back to the stored base value for keys the dimension does not override: + +```typescript +const strings = await dataApi.getDataEntries('translations', { dimension: 'fr' }) +``` + +Filtering by `id`, `canonical_id`, `canonical_parent_id`, `parent_id` and +`include_fallback` goes through `filter` — see [Typed Filters](#typed-filters). +`filter: { canonical_id: { in: [...] } }` serializes to the same +`canonical_id=in:a,b` the API expects, so there is no reason to build that +string yourself. + ## `ApiClient` configuration ```typescript @@ -269,6 +296,48 @@ const xml = renderSitemapXml(filtered, 'https://example.com') const index = renderSitemapIndex(['/sitemap-en.xml', '/sitemap-de.xml'], 'https://example.com') ``` +### Locale prefixing + +Entries always carry a `language_iso`, even in a space that only has one +language, so prefixing on it unconditionally would emit `/en/about` for a page +served at `/about`. `localePrefix` controls that, and defaults to `auto`: +prefix only when the entries span more than one language. + +| Value | Behaviour | +| ---------------- | ------------------------------------------------------------- | +| `auto` (default) | Prefix only when the entry set is multilingual | +| `always` | Prefix every entry | +| `never` | Use paths as stored, for an app that routes the locale itself | +| `except-default` | Prefix every locale but `defaultLocale` | + +```typescript +// Nuxt i18n `prefix_except_default` +const xml = renderSitemapXml(filtered, 'https://example.com', { + localePrefix: 'except-default', + defaultLocale: 'en', +}) +``` + +`filterSitemapEntries` takes the same options, so its dedupe key matches the +paths you go on to render. + +### Building a sitemap in a server route + +The data API paginates and unwraps for you, so a nitro route is short. In Nuxt, +`useB10cksServerApi()` from `@b10cks/nuxt` hands you the same data API: + +```typescript +// server/routes/sitemap.xml.ts +export default defineEventHandler(async (event) => { + const api = useB10cksServerApi() + const entries = await api.getSitemap({}, { allPages: true }) + const siteUrl = getRequestURL(event).origin + + setHeader(event, 'content-type', 'application/xml') + return renderSitemapXml(filterSitemapEntries(entries, { siteUrl }), siteUrl) +}) +``` + ## Breadcrumbs `getBreadcrumb(slug, params)` returns the ancestor trail of an entry, ordered from the tree root down to the entry itself. The entry is addressed by full slug or by content id. diff --git a/packages/client/src/data-api.ts b/packages/client/src/data-api.ts index 0730a00..3c4a695 100644 --- a/packages/client/src/data-api.ts +++ b/packages/client/src/data-api.ts @@ -9,6 +9,7 @@ import type { IBContent, IBContentQueryParams, IBDataEntry, + IBDataEntryParams, IBDataSource, IBGetBlocksParams, IBGetContentsParams, @@ -54,6 +55,7 @@ export type RedirectMap = Record { slug?: string + /** @deprecated Use `language_iso`, matching every other content param. */ language?: string bypassCache?: boolean } @@ -273,7 +275,7 @@ export class B10cksDataApi { async getDataEntries( source: string, - params: ApiQueryParams = {}, + params: IBDataEntryParams = {}, options: CollectionFetchOptions = {} ): Promise { return this.getCollection(`datasources/${source}/entries`, params, options) diff --git a/packages/client/src/sitemap.test.ts b/packages/client/src/sitemap.test.ts new file mode 100644 index 0000000..06f8698 --- /dev/null +++ b/packages/client/src/sitemap.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' + +import { buildLocalizedPath, filterSitemapEntries, renderSitemapXml } from './sitemap' +import type { IBSitemapEntry } from './types' + +function entry( + full_slug: string, + language_iso: string, + robots: string | null = null +): IBSitemapEntry { + return { + id: full_slug, + name: full_slug, + full_slug, + language_iso, + meta: { robots, canonical: null }, + published_at: '2026-01-01T00:00:00Z', + } +} + +function locs(xml: string): string[] { + return [...xml.matchAll(/([^<]*)<\/loc>/g)].map((match) => match[1] as string) +} + +describe('buildLocalizedPath', () => { + it('roots the home slug and skips a double locale prefix', () => { + expect(buildLocalizedPath('home', 'de')).toBe('/de') + expect(buildLocalizedPath('about', 'de')).toBe('/de/about') + expect(buildLocalizedPath('de/about', 'de')).toBe('/de/about') + expect(buildLocalizedPath('about', null)).toBe('/about') + expect(buildLocalizedPath('home', null)).toBe('/') + }) +}) + +describe('renderSitemapXml locale prefixing', () => { + const monolingual = [entry('home', 'en'), entry('about', 'en')] + const multilingual = [entry('about', 'en'), entry('ueber-uns', 'de')] + + it('does not prefix a mono-lingual entry set', () => { + expect(locs(renderSitemapXml(monolingual, 'https://site.com'))).toEqual([ + 'https://site.com/', + 'https://site.com/about', + ]) + }) + + it('prefixes a multilingual entry set', () => { + expect(locs(renderSitemapXml(multilingual, 'https://site.com'))).toEqual([ + 'https://site.com/en/about', + 'https://site.com/de/ueber-uns', + ]) + }) + + it('honors an explicit strategy over the entry set', () => { + expect( + locs(renderSitemapXml(monolingual, 'https://site.com', { localePrefix: 'always' })) + ).toEqual(['https://site.com/en', 'https://site.com/en/about']) + expect( + locs(renderSitemapXml(multilingual, 'https://site.com', { localePrefix: 'never' })) + ).toEqual(['https://site.com/about', 'https://site.com/ueber-uns']) + }) + + it('leaves the default locale unprefixed under except-default', () => { + const xml = renderSitemapXml(multilingual, 'https://site.com', { + localePrefix: 'except-default', + defaultLocale: 'en', + }) + expect(locs(xml)).toEqual(['https://site.com/about', 'https://site.com/de/ueber-uns']) + }) + + it('emits relative paths without a siteUrl, and lastmod when published', () => { + const xml = renderSitemapXml([entry('about', 'en')]) + expect(locs(xml)).toEqual(['/about']) + expect(xml).toContain('2026-01-01T00:00:00Z') + }) +}) + +describe('filterSitemapEntries', () => { + it('drops noindex entries and deduplicates by resolved path', () => { + const entries = [ + entry('about', 'en'), + entry('about', 'en'), + entry('secret', 'en', 'noindex, nofollow'), + ] + expect(filterSitemapEntries(entries).map((e) => e.full_slug)).toEqual(['about']) + }) + + it('deduplicates per locale once the set is multilingual', () => { + const entries = [entry('about', 'en'), entry('about', 'de')] + expect(filterSitemapEntries(entries)).toHaveLength(2) + }) + + it('collapses same-slug locales when prefixing is disabled', () => { + const entries = [entry('about', 'en'), entry('about', 'de')] + expect(filterSitemapEntries(entries, { localePrefix: 'never' })).toHaveLength(1) + }) + + it('filters to a single locale', () => { + const entries = [entry('about', 'en'), entry('ueber-uns', 'de')] + expect(filterSitemapEntries(entries, { locale: 'de' }).map((e) => e.full_slug)).toEqual([ + 'ueber-uns', + ]) + }) +}) diff --git a/packages/client/src/sitemap.ts b/packages/client/src/sitemap.ts index 5e211b8..a41cde9 100644 --- a/packages/client/src/sitemap.ts +++ b/packages/client/src/sitemap.ts @@ -51,13 +51,64 @@ function escapeXml(value: string): string { .replace(/'/g, ''') } -export interface SitemapFilterOptions { +/** + * How a locale segment is applied to an entry's stored `full_slug`. The SDK + * cannot infer the consuming app's routing, so this mirrors the usual i18n + * strategies. + * + * - `auto` (default) prefixes only when the entries span more than one + * `language_iso`. A mono-lingual space is served at `/about`, not `/en/about`, + * even though its entries still carry a language. + * - `always` prefixes every entry. + * - `never` uses paths as stored, for an app that routes the locale some other + * way (a route param, a domain). + * - `except-default` prefixes every locale but {@link SitemapPathOptions.defaultLocale}. + */ +export type SitemapLocalePrefix = 'auto' | 'always' | 'never' | 'except-default' + +export interface SitemapPathOptions { + localePrefix?: SitemapLocalePrefix + /** The unprefixed locale under `localePrefix: 'except-default'`. */ + defaultLocale?: string +} + +export interface SitemapFilterOptions extends SitemapPathOptions { /** Absolute base URL used for deduplication. Without it, paths are compared as strings. */ siteUrl?: string /** Only include entries for this locale (ISO code). */ locale?: string } +/** + * Resolves an entry's path under the chosen prefix strategy. `entries` is only + * read by `auto`, which needs to know whether the set is multilingual. + */ +function resolveEntryPath( + entry: IBSitemapEntry, + options: SitemapPathOptions, + isMultilingual: boolean +): string { + const { localePrefix = 'auto', defaultLocale } = options + + const prefix = + localePrefix === 'always' || + (localePrefix === 'auto' && isMultilingual) || + (localePrefix === 'except-default' && entry.language_iso !== defaultLocale) + + return prefix + ? buildLocalizedPath(entry.full_slug, entry.language_iso) + : buildLocalizedPath(entry.full_slug, null) +} + +function hasMultipleLocales(entries: IBSitemapEntry[]): boolean { + const seen = new Set() + for (const entry of entries) { + if (entry.language_iso) seen.add(entry.language_iso) + if (seen.size > 1) return true + } + return false +} + /** * Filters sitemap entries by locale, deduplicates by resolved URL, and drops * entries whose robots value contains `noindex` or `none` — matching the API's @@ -69,13 +120,14 @@ export function filterSitemapEntries( ): IBSitemapEntry[] { const { siteUrl, locale } = options const seen = new Set() + const isMultilingual = hasMultipleLocales(entries) return entries.filter((entry) => { if (locale && entry.language_iso !== locale) return false const robots = entry.meta?.robots?.toLowerCase() if (robots?.includes('noindex') || robots?.includes('none')) return false - const path = buildLocalizedPath(entry.full_slug, entry.language_iso) + const path = resolveEntryPath(entry, options, isMultilingual) const key = siteUrl ? (toAbsoluteUrl(path, siteUrl) ?? path) : path if (seen.has(key)) return false @@ -87,11 +139,19 @@ export function filterSitemapEntries( /** * Renders `IBSitemapEntry[]` as a `` XML string. * Pass `siteUrl` to emit absolute `` values; without it, relative paths are used. + * + * Locale prefixing follows {@link SitemapPathOptions.localePrefix}, which + * defaults to `auto` — a mono-lingual entry set is emitted unprefixed. */ -export function renderSitemapXml(entries: IBSitemapEntry[], siteUrl?: string): string { +export function renderSitemapXml( + entries: IBSitemapEntry[], + siteUrl?: string, + options: SitemapPathOptions = {} +): string { + const isMultilingual = hasMultipleLocales(entries) const urls = entries .map((entry) => { - const path = buildLocalizedPath(entry.full_slug, entry.language_iso) + const path = resolveEntryPath(entry, options, isMultilingual) const loc = siteUrl ? toAbsoluteUrl(path, siteUrl) : path if (!loc) return '' const lines = [' ', ` ${escapeXml(loc)}`] diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 76ccd1e..ebe42ab 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -195,6 +195,17 @@ export interface IBDataEntry { updated_at: string } +// A type alias, not an interface: only aliases get the implicit index +// signature that keeps them assignable to the client's `Record` +// query params. +export type IBDataEntryParams = Omit & { + /** + * Serves the entries mutated for this dimension (usually a locale), falling + * back to the stored base value for keys the dimension does not override. + */ + dimension?: string +} + export interface IBPaginationParams { page?: number per_page?: number @@ -207,6 +218,12 @@ export interface IBSortParams { export interface IBBaseQueryParams extends IBPaginationParams, IBSortParams { vid?: string version?: string + /** + * Content revision to read at. Defaults to the client's current revision; + * pass it explicitly to pin a request (or `Date.now()` to bypass the + * delivery cache from a server route). + */ + rv?: string | number token: string } diff --git a/packages/mgmt-client/src/resources/contents.ts b/packages/mgmt-client/src/resources/contents.ts index d08987b..3da38f5 100644 --- a/packages/mgmt-client/src/resources/contents.ts +++ b/packages/mgmt-client/src/resources/contents.ts @@ -28,6 +28,18 @@ const isRequestOptions = ( return Object.keys(value).every((key) => key === 'headers') } +/** + * Single-content endpoints answer with a `{ data }` envelope, which the + * declared return types never reflected — consumers had to branch on the shape + * themselves. Unwrapping here makes the long-declared bare `Content` true, and + * the `id` guard keeps an already-bare row untouched. + */ +const isContentEnvelope = (response: Content | { data: Content }): response is { data: Content } => + 'data' in response && !('id' in response) + +const unwrapContent = (response: Content | { data: Content }): Content => + isContentEnvelope(response) ? response.data : response + export class ContentsResource { constructor(private readonly client: HttpClient) {} @@ -48,18 +60,22 @@ export class ContentsResource { payload: CreateContentParams, options?: RequestOptions ): Promise { - return this.client.post( - apiPath`/mgmt/v1/spaces/${spaceId}/contents`, - payload, - options?.headers + return unwrapContent( + await this.client.post( + apiPath`/mgmt/v1/spaces/${spaceId}/contents`, + payload, + options?.headers + ) ) } async get(spaceId: string, contentId: string, options?: RequestOptions): Promise { - return this.client.get( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}`, - undefined, - options?.headers + return unwrapContent( + await this.client.get( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}`, + undefined, + options?.headers + ) ) } @@ -69,10 +85,12 @@ export class ContentsResource { payload: UpdateContentParams, options?: RequestOptions ): Promise { - return this.client.put( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}`, - payload, - options?.headers + return unwrapContent( + await this.client.put( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}`, + payload, + options?.headers + ) ) } @@ -113,10 +131,12 @@ export class ContentsResource { payload: MoveContentParams, options?: RequestOptions ): Promise { - return this.client.post( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/move`, - payload, - options?.headers + return unwrapContent( + await this.client.post( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/move`, + payload, + options?.headers + ) ) } @@ -136,18 +156,22 @@ export class ContentsResource { const payload = isRequestOptions(payloadOrOptions) ? undefined : payloadOrOptions const requestOptions = isRequestOptions(payloadOrOptions) ? payloadOrOptions : options - return this.client.post( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/publish`, - payload, - requestOptions?.headers + return unwrapContent( + await this.client.post( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/publish`, + payload, + requestOptions?.headers + ) ) } async unpublish(spaceId: string, contentId: string, options?: RequestOptions): Promise { - return this.client.post( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/unpublish`, - undefined, - options?.headers + return unwrapContent( + await this.client.post( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/unpublish`, + undefined, + options?.headers + ) ) } @@ -157,10 +181,12 @@ export class ContentsResource { payload: ScheduleContentParams, options?: RequestOptions ): Promise { - return this.client.post( - apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/schedule`, - payload, - options?.headers + return unwrapContent( + await this.client.post( + apiPath`/mgmt/v1/spaces/${spaceId}/contents/${contentId}/schedule`, + payload, + options?.headers + ) ) } diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 9a1aa86..c4b717d 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -51,6 +51,10 @@ var so the token stays out of the repository. Each composable returns the same object as Nuxt's `useAsyncData()` — destructure `data`, `pending`, `error`, and `refresh` as needed. +If your app runs inside the visual editor, wrap the fetched content in +[`usePreviewContent`](#live-preview) so edits stream into the page. It is a +no-op in production, so there is no reason not to. + ```typescript // Single content entry by slug const { useContent } = useB10cksApi() @@ -95,8 +99,48 @@ const { data: trail } = await useBreadcrumb('products/shoes', { language: 'de' } const { useRedirects, useB10cksConfig } = useB10cksApi() const redirects = await useRedirects() const { data: config, pending, error, refresh } = await useB10cksConfig() + +// Config is language-aware and refetches when the locale changes +const { config } = await useB10cksConfig({ language_iso: locale.value }) +``` + +`useB10cksConfig` takes `language_iso`, like every other content param. +`language` still works as a deprecated alias. + +### `useB10cksVersion()` + +The visual editor appends `?b10cks_vid` to preview a draft. The composable +normalizes that query param (which Vue Router types as `string | string[] | +null`) to a version string defaulting to `published`: + +```typescript +const vid = useB10cksVersion() +const { data: page } = await useContent('home', { vid: vid.value }) ``` +### Server routes and middleware + +Nitro has no access to the Vue injection the module's plugin sets up, so server +code gets its own entry point. `useB10cksServerApi()` is auto-imported in the +server bundle and returns the same `B10cksDataApi` — including its paginated +`getRedirects`, `getSitemap` and `getNamedSitemap`, and their built-in caching: + +```typescript +// server/middleware/redirects.ts +export default defineEventHandler(async (event) => { + const url = getRequestURL(event) + if (url.searchParams.has('b10cks_vid')) return + + const redirects = await useB10cksServerApi().getRedirects({}, { allPages: true }) + const hit = redirects[url.pathname] + if (hit) return sendRedirect(event, hit.target, hit.status_code || 301) +}) +``` + +The instance is shared per space so its caches outlive a single request. That +also means the revision is shared — call `syncRevision()` when a route must +read the newest published state. + The helpers use Nuxt's `useAsyncData()` under the hood, so requests participate in SSR payload serialization and are not refetched during hydration. Each helper derives a stable async-data key from its inputs — no manual `key` needed. ### `B10cksComponent` and directives @@ -137,6 +181,8 @@ const block = computed(() => toRootBlock(entry.value)) /> ``` +#### Live preview + For whole-tree reactive updates while editing — including nested and rich text fields — wrap your content in `usePreviewContent` (auto-imported by the module): ```vue diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 2c10ad0..4438855 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -2,6 +2,7 @@ import { addComponentsDir, addImports, addPlugin, + addServerImports, createResolver, defineNuxtModule, extendViteConfig, @@ -77,6 +78,11 @@ export default defineNuxtModule({ as: 'usePageTranslations', from: resolver.resolve('./runtime/composables/usePageTranslations'), }, + { + name: 'useB10cksVersion', + as: 'useB10cksVersion', + from: resolver.resolve('./runtime/composables/useB10cksVersion'), + }, { name: 'usePreviewContent', as: 'usePreviewContent', @@ -89,6 +95,16 @@ export default defineNuxtModule({ }, ]) + // Nitro has no access to the Vue injection the plugin sets up, so server + // routes get their own data API entry point. + addServerImports([ + { + name: 'useB10cksServerApi', + as: 'useB10cksServerApi', + from: resolver.resolve('./runtime/server/useB10cksServerApi'), + }, + ]) + nuxt.options.typescript.hoist.push('@b10cks/vue') }, }) diff --git a/packages/nuxt/src/runtime/composables/useB10cksApi.ts b/packages/nuxt/src/runtime/composables/useB10cksApi.ts index dacc29c..6a1ad78 100644 --- a/packages/nuxt/src/runtime/composables/useB10cksApi.ts +++ b/packages/nuxt/src/runtime/composables/useB10cksApi.ts @@ -8,6 +8,7 @@ import type { IBContent, IBContentQueryParams, IBDataEntry, + IBDataEntryParams, IBDataSource, IBGetContentsParams, IBSitemapEntry, @@ -130,7 +131,7 @@ export type NuxtB10cksApi = Omit< ) => Promise> useDataEntries: ( source: string, - params?: QueryParams, + params?: IBDataEntryParams, options?: UseNuxtB10cksCollectionOptions ) => Promise> useDataSources: ( @@ -261,7 +262,7 @@ export const useB10cksApi = (): NuxtB10cksApi => { const useDataEntries = async ( source: string, - params: QueryParams = {}, + params: IBDataEntryParams = {}, options: UseNuxtB10cksCollectionOptions = {} ): Promise> => { const { allPages = false, key, transform, ...asyncDataOptions } = options @@ -387,9 +388,11 @@ export const useB10cksApi = (): NuxtB10cksApi => { asyncDataOptions ) + // `language_iso` matches every other content param; `language` is the + // deprecated alias, so watch whichever the caller passed. const registerLanguageWatch = () => watch( - () => resolvedParams.value.language, + () => resolvedParams.value.language_iso ?? resolvedParams.value.language, (language, previousLanguage) => { if (language !== previousLanguage) { void asyncData.refresh() diff --git a/packages/nuxt/src/runtime/composables/useB10cksVersion.ts b/packages/nuxt/src/runtime/composables/useB10cksVersion.ts new file mode 100644 index 0000000..3965183 --- /dev/null +++ b/packages/nuxt/src/runtime/composables/useB10cksVersion.ts @@ -0,0 +1,23 @@ +import { computed, type ComputedRef } from 'vue' + +import { useRoute } from '#app' + +/** + * The content version to read, taken from the `?b10cks_vid` query param the + * visual editor appends, and defaulting to `published`. + * + * Pass it straight into a data composable: + * + * ```ts + * const vid = useB10cksVersion() + * const { data } = await useContent('home', { vid: vid.value }) + * ``` + */ +export function useB10cksVersion(): ComputedRef { + const route = useRoute() + + return computed(() => { + const vid = route.query.b10cks_vid + return (Array.isArray(vid) ? vid[0] : vid) || 'published' + }) +} diff --git a/packages/nuxt/src/runtime/server/useB10cksServerApi.ts b/packages/nuxt/src/runtime/server/useB10cksServerApi.ts new file mode 100644 index 0000000..73e34a3 --- /dev/null +++ b/packages/nuxt/src/runtime/server/useB10cksServerApi.ts @@ -0,0 +1,46 @@ +import type { B10cksDataApi, FetchClient } from '@b10cks/client' +import { ApiClient, createB10cksDataApi } from '@b10cks/client' + +import { useRuntimeConfig } from '#imports' + +/** One data API per space, so its caches outlive a single request. */ +const instances = new Map() + +/** + * Data API for nitro contexts (server routes, middleware, plugins), where the + * Vue injection the module's plugin sets up is not available. + * + * The instance is shared per space, so the data API's own redirect and config + * caches survive across requests instead of every route hand-rolling a TTL + * cache. That also means the revision is shared: call `syncRevision()` when a + * route must read the newest published state. + * + * ```ts + * export default defineEventHandler(async (event) => { + * const api = useB10cksServerApi() + * const redirects = await api.getRedirects({}, { allPages: true }) + * const hit = redirects[getRequestURL(event).pathname] + * if (hit) return sendRedirect(event, hit.target, hit.status_code || 301) + * }) + * ``` + */ +export function useB10cksServerApi(): B10cksDataApi { + const { apiUrl, accessToken } = useRuntimeConfig().public.b10cks + const baseUrl = apiUrl || 'https://api.b10cks.com/api' + const cacheKey = `${baseUrl}|${accessToken}` + + const cached = instances.get(cacheKey) + if (cached) return cached + + const api = createB10cksDataApi( + new ApiClient({ + baseUrl, + token: accessToken, + // $fetch's init type (NitroFetchOptions) is wider than RequestInit. + fetchClient: $fetch as unknown as FetchClient, + }) + ) + instances.set(cacheKey, api) + + return api +} diff --git a/packages/nuxt/src/types/index.ts b/packages/nuxt/src/types/index.ts index d76d6e5..281574d 100644 --- a/packages/nuxt/src/types/index.ts +++ b/packages/nuxt/src/types/index.ts @@ -28,6 +28,7 @@ declare module '@nuxt/schema' { export { B10cksRichText, + isRichTextEmpty, renderRichText, type B10cksRichTextProps, type RichTextDocument, diff --git a/packages/richtext/README.md b/packages/richtext/README.md index 2900556..903fd3e 100644 --- a/packages/richtext/README.md +++ b/packages/richtext/README.md @@ -77,6 +77,23 @@ const renderer = createRichTextTextRenderer({ blockSeparator: ' ' }) const text = renderer.render(document) ``` +## Emptiness + +An editor that clears a field usually leaves an empty paragraph behind, which +`renderRichText` still turns into `

`. `isRichTextEmpty` tells you whether +a document renders anything, so you can skip the wrapper markup: + +```typescript +import { isRichTextEmpty } from '@b10cks/richtext' + +isRichTextEmpty(null) // true +isRichTextEmpty({ type: 'doc', content: [{ type: 'paragraph' }] }) // true +isRichTextEmpty({ type: 'doc', content: [{ type: 'horizontalRule' }] }) // false +``` + +Whitespace-only text counts as empty. An image, horizontal rule or table does +not — those render without carrying any text. + ## Internal links The b10cks editor stores internal links as marks with a `content` ID and an optional `anchor`: diff --git a/packages/richtext/src/index.test.ts b/packages/richtext/src/index.test.ts index 1f2b8af..3caa550 100644 --- a/packages/richtext/src/index.test.ts +++ b/packages/richtext/src/index.test.ts @@ -4,6 +4,7 @@ import { createRichTextRenderer, createRichTextTextRenderer, DEFAULT_ALLOWED_SCHEMES, + isRichTextEmpty, renderRichText, renderRichTextAsText, } from './index' @@ -622,4 +623,28 @@ describe('renderRichTextAsText', () => { expect(renderer.render(null)).toBe('') }) }) + describe('isRichTextEmpty', () => { + it('treats nullish and structurally empty documents as empty', () => { + expect(isRichTextEmpty(null)).toBe(true) + expect(isRichTextEmpty(undefined)).toBe(true) + expect(isRichTextEmpty(doc())).toBe(true) + expect(isRichTextEmpty(doc(p()))).toBe(true) + }) + + it('treats whitespace-only text as empty', () => { + expect(isRichTextEmpty(doc(p(text(' '))))).toBe(true) + expect(isRichTextEmpty(doc(p(text('')), p(text('\n'))))).toBe(true) + }) + + it('is not empty when any text node has content', () => { + expect(isRichTextEmpty(doc(p(text('Hello'))))).toBe(false) + expect(isRichTextEmpty(doc(p(), p(text('later'))))).toBe(false) + }) + + it('counts non-container nodes as content', () => { + expect(isRichTextEmpty(doc({ type: 'horizontalRule' }))).toBe(false) + expect(isRichTextEmpty(doc({ type: 'image', attrs: { src: '/a.png' } }))).toBe(false) + expect(isRichTextEmpty(doc({ type: 'table', content: [] }))).toBe(false) + }) + }) }) diff --git a/packages/richtext/src/index.ts b/packages/richtext/src/index.ts index f7b982a..41b692c 100644 --- a/packages/richtext/src/index.ts +++ b/packages/richtext/src/index.ts @@ -437,6 +437,36 @@ export function createRichTextRenderer( export const createRichTextHtmlRenderer = createRichTextRenderer +/** + * Container nodes that carry no content of their own. Everything else (text, + * images, horizontal rules, tables, embedded blocks) counts as content. + */ +const CONTAINER_NODES = new Set([ + 'doc', + 'paragraph', + 'heading', + 'blockquote', + 'codeBlock', + 'bulletList', + 'orderedList', + 'listItem', +]) + +/** + * True when a document renders nothing meaningful, so a caller can skip the + * wrapper markup entirely. An editor that clears a field usually leaves an + * empty paragraph behind, which `renderRichText` still turns into `

`. + * + * Whitespace-only text is empty; an image, horizontal rule or table is not. + */ +export function isRichTextEmpty(document: RichTextDocument | null | undefined): boolean { + if (!document) return true + if (document.type === 'text') return (document.text ?? '').trim() === '' + if (!CONTAINER_NODES.has(document.type)) return false + + return (document.content ?? []).every(isRichTextEmpty) +} + export function renderRichTextAsText( document: RichTextDocument | null | undefined, options: RichTextTextOptions & RichTextExtensionOptions = {} diff --git a/packages/vue/README.md b/packages/vue/README.md index a9cbfd0..18ad8ea 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -210,6 +210,18 @@ const text = renderRichTextAsText(document) const inline = renderRichTextAsText(document, { blockSeparator: ' ' }) ``` +`isRichTextEmpty(document)` reports whether a document renders anything, so you +can skip the wrapper markup for a field an editor cleared (which usually leaves +an empty paragraph behind): + +```typescript +import { isRichTextEmpty } from '@b10cks/vue/rich-text' + +if (!isRichTextEmpty(document)) { + // render the section +} +``` + For repeated rendering, use the factory: ```typescript diff --git a/packages/vue/src/api.ts b/packages/vue/src/api.ts index f15fbca..d57c66c 100644 --- a/packages/vue/src/api.ts +++ b/packages/vue/src/api.ts @@ -10,6 +10,7 @@ import type { IBContent, IBContentQueryParams, IBDataEntry, + IBDataEntryParams, IBDataSource, IBGetContentsParams, IBSitemapEntry, @@ -157,7 +158,7 @@ export function useB10cksApi() { const useDataEntries = ( source: string, - params: QueryParams = {}, + params: IBDataEntryParams = {}, options: Omit, 'params'> = {} ): AsyncState => { const { allPages = false, immediate = false, transform } = options diff --git a/packages/vue/src/rich-text.ts b/packages/vue/src/rich-text.ts index d0574e4..b00fa81 100644 --- a/packages/vue/src/rich-text.ts +++ b/packages/vue/src/rich-text.ts @@ -2,6 +2,7 @@ import { renderRichText as renderBaseRichText, renderRichTextAsText as renderBaseRichTextAsText, createRichTextTextRenderer, + isRichTextEmpty, type RichTextDocument, type RichTextHtmlOptions, type RichTextInternalLinkAttrs, @@ -20,7 +21,7 @@ export type { RichTextTextOptions, RichTextTextRenderer, } -export { createRichTextTextRenderer } +export { createRichTextTextRenderer, isRichTextEmpty } export interface B10cksRichTextProps extends RichTextRenderOptions { document: RichTextDocument | null | undefined