Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cli-type-generation.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/mgmt-contents-envelope.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions .changeset/sdk-audit-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <path>', 'output path for generated types', './b10cks/types')
.option('-o, --out <path>', 'output path for generated types', DEFAULT_OUTPUT_DIR)
.option(
'--dir <path>',
'local schema directory used when no space ID is given',
Expand Down
28 changes: 20 additions & 8 deletions packages/cli/src/services/TypeGeneratorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import BaseService from './BaseService.js'

type BlockList = Record<string, { name: string; tags: string[] }>

/** 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
Expand All @@ -33,14 +36,20 @@ export class TypesGeneratorService extends BaseService {
private additionalTypeDeclarations: string[] = []
private declaredAdditionalTypes = new Set<string>()

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)
}
}

Expand Down Expand Up @@ -298,16 +307,19 @@ export type B10cksPrice = Record<string, number | null>
}

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<string, SchemaField>).map(
([key, schema]) => {
const properties = Object.entries(block.schema as Record<string, SchemaField>)
// 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`
}
Expand Down
69 changes: 69 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/data-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
IBContent,
IBContentQueryParams,
IBDataEntry,
IBDataEntryParams,
IBDataSource,
IBGetBlocksParams,
IBGetContentsParams,
Expand Down Expand Up @@ -54,6 +55,7 @@ export type RedirectMap = Record<string, { target: string; status_code: number }

export interface GetConfigOptions extends Omit<IBContentQueryParams, 'token' | 'full_slug'> {
slug?: string
/** @deprecated Use `language_iso`, matching every other content param. */
language?: string
bypassCache?: boolean
}
Expand Down Expand Up @@ -273,7 +275,7 @@ export class B10cksDataApi {

async getDataEntries(
source: string,
params: ApiQueryParams = {},
params: IBDataEntryParams = {},
options: CollectionFetchOptions = {}
): Promise<IBDataEntry[]> {
return this.getCollection<IBDataEntry>(`datasources/${source}/entries`, params, options)
Expand Down
103 changes: 103 additions & 0 deletions packages/client/src/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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>([^<]*)<\/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('<lastmod>2026-01-01T00:00:00Z</lastmod>')
})
})

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',
])
})
})
Loading