From 3dfb2da0cb0fed0507679120df63671d3e13f43e Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Sun, 6 Sep 2026 20:06:10 +0200 Subject: [PATCH 01/71] chore(deps): bump kirchDev/workflows from v0.8.0 to v0.8.2 --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/fast-forward-queue.yml | 2 +- .github/workflows/promotion-pr.yml | 2 +- .github/workflows/queue-branch.yml | 2 +- .github/workflows/release-please.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d619ed7..4ef7a54d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,4 @@ permissions: jobs: check: name: CI - uses: kirchDev/workflows/.github/workflows/_ci-check.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_ci-check.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6022683f..44775274 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,4 +28,4 @@ jobs: packages: read actions: read contents: read - uses: kirchDev/workflows/.github/workflows/_codeql.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_codeql.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 diff --git a/.github/workflows/fast-forward-queue.yml b/.github/workflows/fast-forward-queue.yml index 8467a90a..068acec3 100644 --- a/.github/workflows/fast-forward-queue.yml +++ b/.github/workflows/fast-forward-queue.yml @@ -34,7 +34,7 @@ jobs: pull-requests: read checks: read statuses: read - uses: kirchDev/workflows/.github/workflows/_fast-forward-queue.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_fast-forward-queue.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 with: pr: ${{ inputs.pr }} secrets: diff --git a/.github/workflows/promotion-pr.yml b/.github/workflows/promotion-pr.yml index ee23edba..a181b162 100644 --- a/.github/workflows/promotion-pr.yml +++ b/.github/workflows/promotion-pr.yml @@ -18,4 +18,4 @@ permissions: jobs: promotion-pr: name: Promotion PR - uses: kirchDev/workflows/.github/workflows/_promotion-pr.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_promotion-pr.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 diff --git a/.github/workflows/queue-branch.yml b/.github/workflows/queue-branch.yml index 17a67919..05d8d827 100644 --- a/.github/workflows/queue-branch.yml +++ b/.github/workflows/queue-branch.yml @@ -20,6 +20,6 @@ jobs: permissions: contents: read pull-requests: write - uses: kirchDev/workflows/.github/workflows/_queue-branch.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_queue-branch.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 secrets: BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0ba2f4b7..43546d60 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,6 +15,6 @@ jobs: permissions: contents: write pull-requests: write - uses: kirchDev/workflows/.github/workflows/_release-please.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 + uses: kirchDev/workflows/.github/workflows/_release-please.yml@1e57db95ff601f03ae87b78e329ef238f9d0cbf6 # v0.8.2 secrets: BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }} From 6a73f4bae4cbb30de6c58817b61cb9c1f4d724d9 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Sun, 6 Sep 2026 21:26:05 +0200 Subject: [PATCH 02/71] feat: serve translated pages as collections of their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A source gains a `locales` list, and so does a ref, resolved the way `status` already is. A string is a folder inside the source's path; an object moves that language to its own folder, repository or ref — which is what React and Vue do outside their tooling, with de.react.dev and the vuejs-translations org. The default locale stays the tree in `path` itself, without a folder, so adding the key moves no URL a site already serves. The locale is deliberately NOT part of the content prefix: i18n puts it in front of the path anyway, so original and translation live under identical content paths in separate collections — which leaves every path comparison in the theme untouched. `expandSources` is exported because content.config.ts and the manifest must agree entry for entry, and a name computed twice is a name that drifts once. --- app/types/duxt.d.ts | 44 +++++- sources-resolve.ts | 281 +++++++++++++++++++++++++++++++--- sources.ts | 67 +++++--- tests/sources-locales.test.ts | 148 ++++++++++++++++++ 4 files changed, 502 insertions(+), 38 deletions(-) create mode 100644 tests/sources-locales.test.ts diff --git a/app/types/duxt.d.ts b/app/types/duxt.d.ts index 4e0ee5f4..350177de 100644 --- a/app/types/duxt.d.ts +++ b/app/types/duxt.d.ts @@ -70,8 +70,30 @@ declare global { */ type DuxtRefInput = | string - | { branch: string; label?: string; status?: DuxtSourceStatusInput } - | { tag: string; label?: string; status?: DuxtSourceStatusInput }; + | { + branch: string; + label?: string; + status?: DuxtSourceStatusInput; + locales?: DuxtSourceLocaleInput[]; + } + | { + tag: string; + label?: string; + status?: DuxtSourceStatusInput; + locales?: DuxtSourceLocaleInput[]; + }; + + /** + * One language of a source: the folder it lives in, or where else it lives. + * + * A string is a folder inside the source's `path`. The object form moves the + * language wholesale — its own folder, repository or ref — which is how a + * translation kept by other people at their own pace becomes a source rather + * than a second website. + */ + type DuxtSourceLocaleInput = + | string + | { locale: string; path?: string; repo?: string; ref?: DuxtRefInput }; /** * A documentation source, as a consumer declares it in `app.config.ts`. @@ -86,6 +108,15 @@ declare global { repo?: string; /** Refs to publish as versions. Omitted means the current checkout. */ refs?: DuxtRefInput[]; + /** + * Languages this source is available in, beyond the one in `path`. + * + * The default locale is the tree in `path` itself, so listing it changes + * nothing and a site that adds this key does not move the pages it already + * serves. A ref may override the list, because a translation is usually + * kept for the current version and not for the two behind it. + */ + locales?: DuxtSourceLocaleInput[]; /** Shown in the version switcher and used in the URL; defaults to the ref. */ label?: DuxtText; /** Segment used in the URL for this repository; defaults to the repo name. */ @@ -112,6 +143,11 @@ declare global { showVersion?: boolean; /** The ref served without a version prefix, by name. Defaults to the first. */ defaultRef?: string; + /** + * The locale whose pages are the tree in `path` itself, without a folder. + * Defaults to the first locale any source declares. + */ + defaultLocale?: string; } /** One entry of the resolved source manifest — see `duxtSourceManifest()`. */ @@ -127,6 +163,10 @@ declare global { ref?: string; refKind?: 'branch' | 'tag'; path: string; + /** The locale this collection serves; absent on a site with no translations. */ + locale?: string; + /** True for the locale served from `path` itself, without a folder. */ + isDefaultLocale: boolean; status: DuxtSourceStatusInput; history: boolean; } diff --git a/sources-resolve.ts b/sources-resolve.ts index 021aec81..0554d566 100644 --- a/sources-resolve.ts +++ b/sources-resolve.ts @@ -15,6 +15,20 @@ export interface DuxtSource { * build with "Could not find refs/heads/…". */ refs?: DuxtRef[]; + /** + * Languages this source is available in, beyond the one written in `path`. + * + * A string is the folder inside `path`: `'de-DE'` reads `docs/de-DE/`. An + * object overrides where that language lives — its own folder, its own + * repository, its own ref — which is what lets a translation be maintained by + * other people, at another pace, in a repository of their own. + * + * The DEFAULT locale is the exception: it is the tree in `path` itself, with + * no folder, so listing it changes nothing. That is what keeps this key + * additive — a site that adds `locales` does not move the pages it already + * serves. Which one is the default comes from `defaultLocale`. + */ + locales?: DuxtSourceLocale[]; /** Shown in the version switcher and used in the URL; defaults to the ref. */ label?: string; /** Segment used in the URL for this repository; defaults to the repo name. */ @@ -82,8 +96,44 @@ interface DuxtRefOptions { label?: string; /** This one version's lifecycle, overriding the source's. */ status?: DuxtSourceStatus; + /** + * The languages THIS version is available in, overriding the source's. + * + * Resolved exactly as `status` is (`ref.locales ?? source.locales`), because + * a translation is usually kept for the current version and not for the two + * behind it — and a version whose translation nobody maintains is better + * declared untranslated than served stale. + */ + locales?: DuxtSourceLocale[]; } +/** + * One language of a source: the folder it lives in, or where else it lives. + * + * The string form is the whole of it for a repository that translates in + * place. The object form is what the big projects do instead — React keeps + * `de.react.dev` as its own repository, Vue an entire `vuejs-translations` + * org — because translators work at their own pace under their own review. A + * layer that already sources across repositories and refs can offer that as a + * source entry rather than as a second website. + */ +export type DuxtSourceLocale = + | string + | { + /** The locale code, matching one the site serves. */ + locale: string; + /** Folder holding this language, relative to the repository root. */ + path?: string; + /** A repository of its own — `owner/name` or a git URL. */ + repo?: string; + /** A ref of its own, when the translation is versioned separately. */ + ref?: DuxtRef; + }; + +/** The code of a locale entry, whichever form it takes. */ +export const localeCode = (locale: DuxtSourceLocale): string => + typeof locale === 'string' ? locale : locale.locale; + /** * The tag shorthand: the newest one, resolved at build time. * @@ -113,6 +163,16 @@ export interface DuxtSourcesOptions { showVersion?: boolean; /** The ref served without a version prefix, by name. Defaults to the first. */ defaultRef?: string; + /** + * The locale whose pages are the tree in `path` itself, with no folder. + * + * Defaults to the first entry of the first `locales` list, so a site that + * never sets it still resolves the same way on both sides. It has to be + * read from the config rather than from `i18n.defaultLocale`: the + * collections are declared in `content.config.ts`, which has no access to + * the Nuxt config, and the two halves must land on identical names. + */ + defaultLocale?: string; } /** One resolved source: which collection serves which URL prefix. */ @@ -141,6 +201,15 @@ export interface DuxtResolvedSource { refKind?: 'branch' | 'tag'; /** Folder inside the repository holding the Markdown. */ path: string; + /** + * The locale this collection serves, when the site has translations. + * + * Absent on a site with none — which keeps every existing collection name, + * prefix and query exactly as it was. + */ + locale?: string; + /** True for the locale served from `path` itself, without a folder. */ + isDefaultLocale: boolean; /** Where this version sits in its life; `current` unless stated. */ status: DuxtSourceStatus; /** Whether the build may read this source's git history. */ @@ -210,6 +279,76 @@ export const repoUrl = (repo: string) => { return repo.includes('://') ? repo : `https://github.com/${repo}`; }; +/** + * The locales one source-and-ref combination expands into. + * + * `[undefined]` — one entry, no locale — is the shape every site had before + * this key existed, and the one every site without it still has. That is what + * keeps the whole feature additive: no `locales`, no second collection, no + * change to a single name. + */ +function localesOf( + source: DuxtSource, + ref: DuxtRef | undefined +): (DuxtSourceLocale | undefined)[] { + const declared = + (ref && typeof ref === 'object' ? ref.locales : undefined) ?? + source.locales; + + return declared?.length ? declared : [undefined]; +} + +/** The first locale any source declares — the default when none is configured. */ +function firstLocale(sources: DuxtSource[]): string | undefined { + for (const source of sources) { + const declared = + source.locales ?? + source.refs?.flatMap((ref) => + typeof ref === 'object' && ref.locales ? ref.locales : [] + ); + + if (declared?.length) return localeCode(declared[0]!); + } + + return undefined; +} + +/** + * Where one language of a source actually lives. + * + * The default locale is the tree in `path` itself; every other language is a + * folder inside it unless the entry says otherwise. An object may move the + * language wholesale — its own folder, repository and ref — which is the case + * a translation maintained by other people needs. + */ +function localeEntry( + source: DuxtSource, + locale: DuxtSourceLocale | undefined, + defaultLocale: string | undefined +): { path: string; repo?: string; ref?: DuxtRef } { + const base = source.path ?? 'docs'; + if (!locale) return { path: base, repo: source.repo }; + + const code = localeCode(locale); + const override = typeof locale === 'string' ? undefined : locale; + + if (code === defaultLocale) { + return { + path: override?.path ?? base, + repo: override?.repo ?? source.repo, + ref: override?.ref + }; + } + + return { + // A folder INSIDE the source's own path, so one repository holds its + // translations beside the original — and an override escapes that. + path: override?.path ?? `${base}/${code}`, + repo: override?.repo ?? source.repo, + ref: override?.ref + }; +} + /** * Resolve the list once: names, prefixes and labels. * @@ -224,16 +363,47 @@ export const repoUrl = (repo: string) => { * or versions exist at all — which is also what keeps the scheme routable, * since the shape is fixed before the first request. */ +/** One expanded combination: a source, at a ref, in a language. */ +export interface DuxtExpandedSource { + source: DuxtSource; + ref?: DuxtRef; + locale?: DuxtSourceLocale; + /** Where this combination's Markdown actually lives. */ + effective: { path: string; repo?: string; ref?: DuxtRef }; +} + +/** + * The one expansion both halves of the layer read. + * + * `content.config.ts` declares the collections and the duxt module resolves + * the manifest, and the two MUST agree entry for entry — the manifest tells + * the theme which collection serves a route, and a name computed twice is a + * name that drifts once. Exported for exactly that reason. + */ +export function expandSources( + sources: DuxtSource[], + options: DuxtSourcesOptions = {} +): DuxtExpandedSource[] { + const defaultLocale = options.defaultLocale ?? firstLocale(sources); + + return sources.flatMap((source) => + (source.refs?.length ? source.refs : [undefined]).flatMap((ref) => + localesOf(source, ref).map((locale) => ({ + source, + ref, + locale, + effective: localeEntry(source, locale, defaultLocale) + })) + ) + ); +} + export function resolveSources( sources: DuxtSource[], options: DuxtSourcesOptions = {} ): DuxtResolvedSource[] { - const expanded = sources.flatMap((source) => - (source.refs?.length ? source.refs : [undefined]).map((ref) => ({ - source, - ref - })) - ); + const defaultLocale = options.defaultLocale ?? firstLocale(sources); + const expanded = expandSources(sources, options); const repos = new Set(sources.map((source) => source.repo ?? '')); const refs = new Set( @@ -250,10 +420,13 @@ export function resolveSources( const resolved: DuxtResolvedSource[] = []; const taken = new Map(); - for (const { source, ref } of expanded) { - const name = ref ? refName(ref) : undefined; + for (const { source, ref, locale, effective: entry } of expanded) { + const effectiveRef = entry.ref ?? ref; + const name = effectiveRef ? refName(effectiveRef) : undefined; const label = (ref && typeof ref === 'object' ? ref.label : undefined) ?? source.label; + const code = locale ? localeCode(locale) : undefined; + const isDefaultLocale = !code || code === defaultLocale; const version = name ? slugify(label ?? name) : undefined; const isDefault = !name || name === defaultRef; @@ -261,11 +434,22 @@ export function resolveSources( if (withRepo) segments.push(repoSlug(source)); if (withVersion && version && !isDefault) segments.push(version); + // THE LOCALE IS NOT PART OF THE PREFIX. @nuxtjs/i18n already puts it in + // front of the path, so a collection carrying it too would spell it twice + // in one URL. Original and translation therefore live under IDENTICAL + // content paths in different collections — which leaves every path + // comparison in the theme untouched and makes the fallback a second query + // for the same path. const prefix = segments.length ? `/${segments.join('/')}` : ''; const collection = - ['docs', ...segments].map(identifier).join('_') || 'docs'; - - const previous = taken.get(prefix); + ['docs', ...(isDefaultLocale ? [] : [code!]), ...segments] + .map(identifier) + .join('_') || 'docs'; + + // Claimed per locale: two languages serving one prefix is not a collision, + // it is the point. + const claim = `${code ?? ''}|${prefix}`; + const previous = taken.get(claim); if (previous) { // The one ambiguity the build-time decision leaves: a docs folder named // like a repository or a version. Rejected rather than resolved silently. @@ -276,7 +460,7 @@ export function resolveSources( ); } taken.set( - prefix, + claim, `${source.repo ?? 'this repository'}${name ? `@${name}` : ''}` ); @@ -286,28 +470,89 @@ export function resolveSources( repo: withRepo ? repoSlug(source) : undefined, version, isDefault, - repository: source.repo ?? source.origin?.repo, - repositoryUrl: source.repo - ? repoUrl(source.repo) + repository: entry.repo ?? source.origin?.repo, + repositoryUrl: entry.repo + ? repoUrl(entry.repo) : source.origin?.repo ? repoUrl(source.origin.repo) : undefined, ref: name ?? source.origin?.ref, - refKind: ref ? (refIsTag(ref) ? 'tag' : 'branch') : undefined, - path: source.path ?? 'docs', + refKind: effectiveRef + ? refIsTag(effectiveRef) + ? 'tag' + : 'branch' + : undefined, + path: entry.path, + locale: code, + isDefaultLocale, status: (ref && typeof ref === 'object' ? ref.status : undefined) ?? source.status ?? 'current', // A local source is a full checkout already; a remote one has to be // unshallowed, which is why it has to be asked for. - history: source.repo ? (source.history ?? false) : true + history: entry.repo ? (source.history ?? false) : true }); } return resolved; } +/** + * The locales a page may be served from, best first. + * + * A translated Markdown file cannot be merged the way a locale FILE is: a page + * is translated whole or not at all, so this is a chain of choices rather than + * a deep merge. It follows the rule the layer already applies twice — the + * `files` arrays in `nuxt.config.ts` and `resolveDuxtText` both let the + * language carry the content and the region carry only its deviations: + * + * 1. the locale itself — `de-DE` reads `de-DE`; + * 2. its base language — `de-DE` reads a `de` tree, which is what lets one + * `pt/` folder serve both `pt-PT` and `pt-BR`; + * 3. a sibling of the same language — `pt-BR` reads `pt-PT` before it gives + * up on Portuguese and falls back to English; + * 4. `fallbackLocale`, the value vue-i18n already carries for missing keys, + * so the interface and the pages agree on where they fall back to; + * 5. the default locale, which is the untranslated original. + * + * `undefined` closes every chain: it is the collection of a site that declares + * no locales at all, and the one a translation ultimately falls back to. + */ +export function localeChain( + locale: string | undefined, + available: (string | undefined)[], + fallbackLocale?: string | string[] +): (string | undefined)[] { + const has = (code: string | undefined) => available.includes(code); + const chain: (string | undefined)[] = []; + + const add = (code: string | undefined) => { + if (!chain.includes(code) && has(code)) chain.push(code); + }; + + if (locale) { + add(locale); + + const base = locale.split('-')[0]!; + add(base); + + // A sibling region of the same language, in the order the site declares + // them: `pt-BR` takes `pt-PT` over English, every time. + for (const code of available) { + if (code && code !== locale && code.split('-')[0] === base) add(code); + } + } + + for (const code of [fallbackLocale ?? []].flat()) add(code); + + // The original. Always last, and always reachable — a page that exists in no + // translation still has to render. + if (!chain.includes(undefined)) chain.push(undefined); + + return chain; +} + /** * The resolved list, as data the app can read. * diff --git a/sources.ts b/sources.ts index 31159723..3ad784f4 100644 --- a/sources.ts +++ b/sources.ts @@ -3,7 +3,13 @@ import { dirname, join } from 'node:path'; import { defineCollection, z } from '@nuxt/content'; import { defineSitemapSchema } from '@nuxtjs/sitemap/content'; import type { DuxtSource, DuxtSourcesOptions } from './sources-resolve'; -import { refName, repoUrl, resolveSources } from './sources-resolve'; +import { + expandSources, + refIsTag, + refName, + repoUrl, + resolveSources +} from './sources-resolve'; import { resolveLatestRefs } from './sources-git'; export type { DuxtResolvedSource, @@ -141,36 +147,61 @@ export function duxtSources( const resolved = resolveSources(sources, options); const collections: Record> = {}; - const expanded = sources.flatMap((source) => - (source.refs?.length ? source.refs : [undefined]).map((ref) => ({ - source, - ref - })) - ); + // The SAME expansion the manifest is built from, so entry `n` here and entry + // `n` there are the same source, ref and language. See `expandSources`. + const expanded = expandSources(sources, options); resolved.forEach((entry, index) => { - const { source, ref } = expanded[index]!; + const { source, ref, effective } = expanded[index]!; + + // A locale entry may carry a ref of its own — a translation repository + // that tags on its own schedule. + const usedRef = effective.ref ?? ref; + + /** + * The translations living INSIDE this source's own folder. + * + * The default locale reads `docs/` with `**\/*.md`, and that glob happily + * swallows `docs/de-DE/` — the original collection would carry every + * translated page a second time, under a path beginning with the locale, + * and the sidebar would show both. So each folder a sibling language + * occupies is excluded from the language that contains it. + */ + const nested = expanded + .filter( + (other) => + other.source === source && + other.effective.repo === effective.repo && + other.effective.path !== effective.path && + other.effective.path.startsWith(`${effective.path}/`) + ) + .map( + (other) => `${other.effective.path.slice(effective.path.length + 1)}/**` + ); collections[entry.collection] = defineCollection({ type: 'page', schema: pageSchema, - source: source.repo + source: effective.repo ? { - exclude: excluded(), + exclude: [ + ...excluded(), + ...nested.map((glob) => `${effective.path}/${glob}`) + ], // A tag lives outside refs/heads, so it has to be passed as a tag — // asking git for a branch by that name fails the build outright. - repository: ref - ? typeof ref === 'object' && 'tag' in ref - ? { url: repoUrl(source.repo), tag: ref.tag } - : { url: repoUrl(source.repo), branch: refName(ref) } - : repoUrl(source.repo), - include: `${source.path ?? 'docs'}/**/*.md`, + repository: usedRef + ? refIsTag(usedRef) + ? { url: repoUrl(effective.repo), tag: refName(usedRef) } + : { url: repoUrl(effective.repo), branch: refName(usedRef) } + : repoUrl(effective.repo), + include: `${effective.path}/**/*.md`, prefix: entry.prefix } : { - exclude: excluded(), + exclude: [...excluded(), ...nested], include: '**/*.md', - cwd: join(repositoryRoot(), source.path ?? 'docs'), + cwd: join(repositoryRoot(), effective.path), prefix: entry.prefix } }); diff --git a/tests/sources-locales.test.ts b/tests/sources-locales.test.ts new file mode 100644 index 00000000..f0af0183 --- /dev/null +++ b/tests/sources-locales.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { localeChain, resolveSources } from '../sources-resolve'; + +describe('translated sources', () => { + it('changes nothing for a site that declares no locales', () => { + const resolved = resolveSources([{ path: 'docs' }]); + + // The whole feature is additive or it is a breaking change: the collection + // a plain site gets must still be called `docs`, at the root, with no + // locale attached to it. + expect(resolved).toHaveLength(1); + expect(resolved[0]).toMatchObject({ + collection: 'docs', + prefix: '', + locale: undefined, + isDefaultLocale: true, + path: 'docs' + }); + }); + + it('reads the default locale from the source folder itself', () => { + const resolved = resolveSources([ + { path: 'docs', locales: ['en-GB', 'de-DE'] } + ]); + + const [original, german] = resolved; + + // No `en/` folder, and the name a single-language site already had. + expect(original).toMatchObject({ + collection: 'docs', + path: 'docs', + locale: 'en-GB', + isDefaultLocale: true + }); + + expect(german).toMatchObject({ + collection: 'docs_de_DE', + path: 'docs/de-DE', + locale: 'de-DE', + isDefaultLocale: false + }); + }); + + it('keeps the locale out of the URL prefix', () => { + const resolved = resolveSources([ + { path: 'docs', locales: ['en-GB', 'de-DE'] } + ]); + + // i18n puts the locale in front of the path itself. A collection carrying + // it too would spell it twice in one URL — and identical content paths are + // what make the fallback a second query for the same path. + expect(resolved.every((source) => source.prefix === '')).toBe(true); + }); + + it('lets a ref override the languages its version is kept in', () => { + const resolved = resolveSources( + [ + { + path: 'docs', + locales: ['en-GB', 'de-DE'], + refs: [{ tag: 'v2' }, { tag: 'v1', locales: ['en-GB'] }] + } + ], + { defaultLocale: 'en-GB', defaultRef: 'v2' } + ); + + // v2 in both languages, v1 only in the original: the common case, because + // nobody keeps the translation of a version they no longer develop. + expect( + resolved.map((source) => [source.collection, source.locale]) + ).toEqual([ + ['docs', 'en-GB'], + ['docs_de_DE', 'de-DE'], + ['docs_v1', 'en-GB'] + ]); + }); + + it('takes a translation from a repository of its own', () => { + const [, german] = resolveSources([ + { + path: 'docs', + locales: [ + 'en-GB', + { locale: 'de-DE', repo: 'acme/docs-de', path: 'docs' } + ] + } + ]); + + // What React and Vue do outside their tooling — a translation repository + // with its own maintainers — as one source entry. + expect(german).toMatchObject({ + locale: 'de-DE', + path: 'docs', + repository: 'acme/docs-de' + }); + }); + + it('does not treat two languages on one prefix as a collision', () => { + // Same prefix, twice, is the point rather than the error the resolver + // throws for two sources claiming one URL. + expect(() => + resolveSources([{ path: 'docs', locales: ['en-GB', 'de-DE'] }]) + ).not.toThrow(); + }); +}); + +describe('localeChain', () => { + const available = ['en-GB', 'de-DE', 'pt-PT', 'pt-BR']; + + it('reads its own language first', () => { + expect(localeChain('de-DE', available)[0]).toBe('de-DE'); + }); + + it('falls back to a sibling region before leaving the language', () => { + // pt-BR reads European Portuguese before it gives up and shows English — + // the rule the locale FILES already follow in nuxt.config. + expect(localeChain('pt-BR', ['pt-PT', undefined], 'en-GB')).toEqual([ + 'pt-PT', + undefined + ]); + }); + + it('reads a base-language folder for a regional locale', () => { + // One `pt/` folder serves both Portuguese locales. + expect(localeChain('pt-BR', ['pt', undefined])).toEqual(['pt', undefined]); + }); + + it('uses vue-i18n’s own fallbackLocale before the original', () => { + expect(localeChain('fr-FR', ['en-GB', undefined], 'en-GB')).toEqual([ + 'en-GB', + undefined + ]); + }); + + it('always ends at the untranslated original', () => { + // A page that exists in no translation still has to render. + for (const locale of [undefined, 'de-DE', 'zz-ZZ']) { + expect(localeChain(locale, available.concat(undefined)).at(-1)).toBe( + undefined + ); + } + }); + + it('never repeats a locale', () => { + const chain = localeChain('pt-PT', ['pt-PT', undefined], 'pt-PT'); + expect(chain).toEqual(['pt-PT', undefined]); + }); +}); From 8ff36397f5fa38130859d0971ec8e82d524b83d5 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Sun, 6 Sep 2026 21:26:17 +0200 Subject: [PATCH 03/71] feat: fall back to the nearest language a page exists in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A translated tree is almost never complete — two pages of forty is the normal state of every project that has tried this — so a page missing from a language is answered with the nearest language that has it rather than a 404. Starlight is the only comparable generator that says this out loud, and it is right: the gap belongs to the writer, and hiding the page punishes the reader for it. The chain follows the rule the layer already applies twice, in the locale files and in resolveDuxtText: the locale, its base language, a sibling region, vue-i18n's own fallbackLocale, then the untranslated original. Reading fallbackLocale rather than carrying a second list is what keeps the interface and the pages agreeing on where they fall back to. The reader is told which language they are being shown, and the page carries noindex with a canonical pointing at the language that actually holds it — otherwise seven locales index the same English page. --- app/components/DuxtTranslationBanner.vue | 64 +++++++++++ app/composables/useDuxtCollection.ts | 131 +++++++++++++++++++---- app/pages/[...slug].vue | 68 ++++++++++-- i18n/locales/de/duxt/page.json | 6 +- i18n/locales/en/duxt/page.json | 6 +- i18n/locales/es/duxt/page.json | 6 +- i18n/locales/fr/duxt/page.json | 6 +- i18n/locales/pt-BR/duxt/page.json | 3 + i18n/locales/pt/duxt/page.json | 6 +- 9 files changed, 264 insertions(+), 32 deletions(-) create mode 100644 app/components/DuxtTranslationBanner.vue diff --git a/app/components/DuxtTranslationBanner.vue b/app/components/DuxtTranslationBanner.vue new file mode 100644 index 00000000..90a9b690 --- /dev/null +++ b/app/components/DuxtTranslationBanner.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/composables/useDuxtCollection.ts b/app/composables/useDuxtCollection.ts index cc732f27..631a913b 100644 --- a/app/composables/useDuxtCollection.ts +++ b/app/composables/useDuxtCollection.ts @@ -1,11 +1,13 @@ +import { localeChain } from '../../sources-resolve'; + /** * Which collection serves the current route. * - * `duxtSources` generates one collection per source and version, named after - * the URL prefix it serves. Every query in the theme has to ask the right one, - * or a site with two sources shows nothing at all — the collection a single - * source produces is called `docs`, and the ones a versioned site produces are - * not. + * `duxtSources` generates one collection per source, version and language, + * named after what it serves. Every query in the theme has to ask the right + * one, or a site with two sources shows nothing at all — the collection a + * single source produces is called `docs`, and the ones a versioned or + * translated site produces are not. * * The manifest reaches the app through `app.config.ts`, because Content loads * `content.config.ts` in its own pass and the app never sees the result. Both @@ -14,27 +16,96 @@ export function useDuxtCollection() { const duxt = useDuxtConfig(); const path = useDuxtPath(); + const { locale, fallbackLocale } = useI18n(); + + const sources = computed(() => duxt.resolvedSources ?? []); - /** Longest matching prefix wins: `/app/v2` beats `/app` on `/app/v2/guide`. */ - const current = computed(() => { - const sources = duxt.resolvedSources ?? []; + /** + * The prefix this route falls under, language aside. + * + * Longest matching prefix wins: `/app/v2` beats `/app` on `/app/v2/guide`. + * Read off the DEFAULT-language entries, because the locale never appears in + * a content prefix — every language of one source shares the prefix, which + * is what makes the fallback below a second query for the same path. + */ + const prefix = computed(() => { + const candidates = sources.value.filter((source) => source.isDefaultLocale); + const pool = candidates.length ? candidates : sources.value; return ( - [...sources] - .sort((a, b) => b.prefix.length - a.prefix.length) - .find( - (source) => !source.prefix || path.value.startsWith(source.prefix) - ) ?? - sources.find((source) => !source.prefix) ?? - // The landing page matches NO prefix on a site whose every source has - // one, and there is no unprefixed source to fall back to. It still needs - // a real collection for the navigation the header draws — falling - // through to a literal `docs` names one that such a site does not have, - // and the failing query took the whole render down with it. - sources[0] + ( + [...pool] + .sort((a, b) => b.prefix.length - a.prefix.length) + .find( + (source) => !source.prefix || path.value.startsWith(source.prefix) + ) ?? + pool.find((source) => !source.prefix) ?? + // The landing page matches NO prefix on a site whose every source has + // one, and there is no unprefixed source to fall back to. It still needs + // a real collection for the navigation the header draws — falling + // through to a literal `docs` names one that such a site does not have, + // and the failing query took the whole render down with it. + pool[0] + )?.prefix ?? '' + ); + // NEVER undefined. `''` is the root prefix and a real value; undefined + // matched no source at all, which emptied the chain below and 404ed every + // page on a site whose manifest had not arrived yet. + }); + + /** Every language this prefix is served in, in the order the config lists. */ + const candidates = computed(() => + sources.value.filter((source) => source.prefix === prefix.value) + ); + + /** + * The collections to try, best first. + * + * One entry on a site without translations, which is every site that does + * not set `locales` — and then this composable behaves exactly as it did + * before the key existed. + */ + const chain = computed(() => { + const available = candidates.value.map((source) => source.locale); + + const wanted = localeChain( + locale.value, + available, + // vue-i18n's own fallback, so the interface and the pages agree on where + // a missing translation resolves to rather than each carrying a list. + fallbackLocale.value as string | string[] | undefined ); + + const ordered = wanted + .map((code) => + candidates.value.find((source) => + code === undefined ? source.isDefaultLocale : source.locale === code + ) + ) + .filter(Boolean) as DuxtResolvedSource[]; + + // Never empty. A route whose manifest is missing still has to query + // SOMETHING — an empty chain means no query at all, and the page then 404s + // although it exists. + if (ordered.length) return ordered; + if (candidates.value.length) return candidates.value.slice(0, 1); + + return sources.value.slice(0, 1); }); + const current = computed(() => chain.value[0]); + + /** + * The default-language entry for this prefix. + * + * The one collection guaranteed to hold every page, which is what a + * navigation tree has to be built from — see `overlayTranslations`. + */ + const base = computed( + () => + candidates.value.find((source) => source.isDefaultLocale) ?? current.value + ); + /** Cast because the name is data: Content types collections from the config. */ const name = computed( () => (current.value?.collection ?? 'docs') as DuxtCollectionName @@ -42,7 +113,25 @@ export function useDuxtCollection() { return { collection: name, + /** The complete tree's collection — the default language for this prefix. */ + baseCollection: computed( + () => (base.value?.collection ?? 'docs') as DuxtCollectionName + ), source: current, - sources: computed(() => duxt.resolvedSources ?? []) + /** The collections a missing page falls back to, after the first. */ + fallbacks: computed(() => chain.value.slice(1)), + /** + * Whether this site declares translations at all. + * + * Without it the fallback banner cannot be told apart from the normal + * state of a single-language site: there, every locale but the default one + * is served the same pages by design, and announcing that as a missing + * translation would put a notice on six of seven languages of every duxt + * site that never asked for any of this. + */ + translates: computed(() => + sources.value.some((source) => source.locale && !source.isDefaultLocale) + ), + sources: computed(() => sources.value) }; } diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue index c8ab4421..7da35931 100644 --- a/app/pages/[...slug].vue +++ b/app/pages/[...slug].vue @@ -1,5 +1,5 @@ +``` + +## Was das Schema hinzufügt + +Content speichert nur, was ein Schema deklariert, deshalb sind vier Dinge, die +das Theme braucht, ausdrücklich deklariert. Die vollständige Liste steht in der +[Frontmatter-Referenz](/reference/frontmatter); die Gründe stehen hier: + +- **`icon`, `layout`, `navigation`** — Theme-Felder. Ohne das Schema wird ein + `icon:` im Frontmatter stillschweigend verworfen, bevor die Seitenleiste es je + sieht. +- **`rawbody`** — das Markdown, wie es geschrieben wurde. Zwei Dinge brauchen + es: die Schaltfläche, die eine Seite an ein Modell übergibt, und + `llms-full.txt`. +- **`redirectFrom`** — vom Build in Routenregeln übersetzt; siehe + [Eine Seite verschieben](/guides/move-a-page). +- **`sitemap`** — das, was diese Seiten überhaupt in die Sitemap bringt. Eine + Collection muss das Feld deklarieren, sonst listet die Sitemap die Routen der + Seite und keine einzige Dokumentationsseite. + +## Partials + +`_partials/` im Doku-Ordner **jeder** Quelle speist eine gemeinsame Collection, +und `:partial{name="install"}` in einer beliebigen Seite einer beliebigen Quelle +rendert es. Content liefert keine Include-Direktive, und über mehrere +Repositories hinweg hat diese Lücke überhaupt keinen Behelf — ein +Installationshinweis, der in drei Projekten gleich lauten muss, wird in drei +Projekte kopiert und driftet auseinander. + +Partials sind aus den Seiten-Collections selbst ausgeschlossen. Sonst wäre jedes +Partial auch eine Seite: in der Seitenleiste, in der Suche, in `llms.txt`. + +## Entwürfe sind Dateien, keine Schalter + +Ein Entwurf ist `deploying.draft.md`, nicht `draft: true` im Frontmatter. Der +Schalter ist die naheliegende Schreibweise und die, die nicht funktionieren +kann: Der Inhalt einer Collection wird deklariert, bevor Content eine einzige +Datei gelesen hat — bei einer entfernten Quelle, bevor sie heruntergeladen wurde +— nichts weiß zu diesem Zeitpunkt also, was in irgendeinem Frontmatter steht. +Ein Dateiname ist bekannt. Content entfernt `.draft` aus der URL, die Seite wird +also im Entwicklungsserver unter `/deploying` ausgeliefert und fehlt im Build +schlicht. diff --git a/docs/de/2.concepts/5.build-checks.md b/docs/de/2.concepts/5.build-checks.md new file mode 100644 index 00000000..9349b5f9 --- /dev/null +++ b/docs/de/2.concepts/5.build-checks.md @@ -0,0 +1,53 @@ +--- +title: Was der Build prüft +description: Die stillen Fehlschläge dieser Ebene, in Meldungen verwandelt. +icon: lucide:shield-check +--- + +Jeder Fehler, den diese Ebene tatsächlich hatte, war lautlos: eine Collection, +die Content verwarf, weil ihr Name kein JavaScript-Bezeichner war; eine Seite, +die 404te, weil ein Link veraltet war; eine Navigation, die leer zurückkam. +Keiner der drei sagte etwas — der Build war grün und die Seite falsch. Also +laufen nach dem Build vier Prüfungen, die alle dasselbe lesen: den +Parse-Cache, den Content hinterlässt. + +| Prüfung | Schweregrad | +| :------------------------------------------- | :---------- | +| Eine Collection ohne Seiten | Fehler | +| Ein Ordner, der mit einem URL-Segment kollidiert | Fehler | +| Ein fehlender `title` oder `description` | Warnung | +| Ein toter interner Link oder Anker | Warnung | + +## Warum der Schweregrad nicht einheitlich ist + +Ein Fehler kostet eine Seite, die niemand erreichen kann und die nichts auf der +Website je erklären würde — eine leere Collection ist ein 404 auf jeder Seite +einer Version. Eine Warnung kostet Qualität: Die Seite rendert weiterhin, und +eine entfernte Quelle kann zwischen zwei Releases veralten, ohne dass das die +Schuld dieses Builds wäre. Den Build scheitern zu lassen, weil das Repository +eines anderen weitergezogen ist, würde einen grünen Build von einem Dritten +abhängig machen. + +## Was die Prüfungen wissen und du nicht + +- **Eine Ordnerkollision** ist nur ein Problem, solange ein Präfix aktiv ist. Ein + Doku-Ordner namens `v2` ist auf einer unversionierten Seite in Ordnung und auf + einer versionierten unerreichbar, weil das Präfix gewinnt. Der Build weiß, + welcher Fall vorliegt, und die Meldung benennt die Datei und bietet die Lösung + an. +- **Ein Link wird so aufgelöst, wie das Theme ihn auflöst.** Ein absoluter Pfad + in einer Seite ist relativ zur eigenen Quelle dieser Seite, wird also zuerst + unter dem Präfix der Quelle probiert und danach nackt. Eine Prüfung, die nur + die nackte Form kennt, würde auf einer Multi-Repo-Seite jeden korrekten Link + melden. +- **Externe URLs werden nicht geprüft.** Eine zu verifizieren hieße, das Netz in + den Build zu holen, und ein nicht erreichbarer fremder Host ist kein Grund, + warum deine Dokumentation nicht ausgeliefert wird. + +## Schreibe Links als absolute Dokumentationspfade + +`/concepts/sources`, nicht `../2.concepts/2.sources.md`. Unter welchem Präfix +eine Seite ausgeliefert wird, entscheidet sich zur Build-Zeit — Pfade ohne +Präfix zu schreiben ist deshalb genau das, was dasselbe Markdown auf einer Seite +`/guides/deploying` und auf einer anderen `/acme/v2.0.0/guides/deploying` +bedienen lässt. Es ist außerdem die einzige Form, die die Linkprüfung sieht. diff --git a/docs/de/2.concepts/6.machine-readers.md b/docs/de/2.concepts/6.machine-readers.md new file mode 100644 index 00000000..f03d1366 --- /dev/null +++ b/docs/de/2.concepts/6.machine-readers.md @@ -0,0 +1,48 @@ +--- +title: Maschinelle Leser +description: Was ein Modell bekommt — llms.txt, das rohe Markdown jeder Seite und ein echter MCP-Server. +icon: lucide:bot +--- + +Dokumentation wird von Modellen ebenso gelesen wie von Menschen, und alle vier +Antworten unten werden aus denselben Collections erzeugt, aus denen die Seiten +rendern — keine von ihnen kann also von dem abweichen, was veröffentlicht ist. + +| Pfad | Was es ist | +| :--------------- | :--------------------------------------------------------------------- | +| `/llms.txt` | Ein Index: Seitenname, Zusammenfassung, dann jede Seite als Link-Eintrag | +| `/llms-full.txt` | Dieselben Seiten, vollständig, zu einer Markdown-Datei verkettet | +| `.md` | Der Markdown-Quelltext jeder Seite | +| `/mcp` | Ein MCP-Server — `list_pages`, `search_docs`, `read_page` | + +## Markdown, nicht HTML + +Alles hier übergibt das Markdown so, wie es geschrieben wurde — deshalb bittet +das Collection-Schema Content darum, `rawbody` zu speichern. Ein Modell, dem man +gerendertes HTML gibt, muss ein Layout rückgängig machen, um eine Überschrift zu +finden; eines, dem man die Quelle gibt, liest, was der Autor geschrieben hat, +mit Code-Fences, die noch Fences sind, und einem MDC-Block, der noch ein +Komponentenaufruf ist. + +`.md` wird von einer **Middleware** ausgeliefert statt von einer Route, +denn `.md` ist ein Suffix am letzten Pfadsegment und kein eigenes Segment — es +gibt kein Nitro-Routenmuster, das „jeder Pfad, der auf .md endet" bedeutet. + +## Warum beide llms-Dateien + +Die Konvention von [llmstxt.org](https://llmstxt.org) benennt zwei, und sie +beantworten verschiedene Fragen: Ein Modell, das den Index gelesen hat, muss +immer noch sechzig Seiten abrufen, und ein Modell, dem man die vollständige +Datei gibt, hat sie bereits gelesen. Welche billiger ist, hängt von der Frage +ab — die Seite veröffentlicht deshalb beide. + +## Warum ein echter MCP-Server + +`/mcp` spricht das Protokoll über das offizielle SDK, statt ein JSON-Endpunkt +nach duxts eigenem Entwurf zu sein. Ein Client verbindet sich damit so wie mit +allem anderen, statt dass jemand einen Adapter um eine Form herum schreibt, die +nur dieses Theme hat. + +Seine Suche ist ein `LIKE` über Titel und Beschreibungen, nicht der Index, den +der Browser nutzt: Der wird clientseitig aus dem Collection-Abzug gebaut, und +ein Server-Werkzeug hat die Datenbank selbst. diff --git a/docs/de/2.concepts/7.seo.md b/docs/de/2.concepts/7.seo.md new file mode 100644 index 00000000..f6065566 --- /dev/null +++ b/docs/de/2.concepts/7.seo.md @@ -0,0 +1,50 @@ +--- +title: SEO +description: Was jede Seite für Crawler und Social Cards ausgibt, und der eine Wert, den die Ebene nicht erraten kann. +icon: lucide:search +--- + +Jede Seite gibt einen vollständigen Head aus, eine strukturierte Beschreibung +ihrer selbst, eine Social Card und einen Eintrag in der Sitemap. Ein Wert muss +von dir kommen. + +## Je Seite + +- **Meta** — Titel mit dem Namen der Seite, Beschreibung, `og:title`, + `og:description`, `og:type`, `og:url` und eine Twitter-Card vom Typ + `summary_large_image`. All das steht bereits am Seitenobjekt. +- **JSON-LD** — `TechArticle` plus eine `BreadcrumbList`, gebaut aus derselben + Spur, die auch der Breadcrumb zeichnet. +- **Ein Open-Graph-Bild**, gerendert aus der Vorlage der Ebene. Liefere eine + Komponente gleichen Namens, um sie zu ersetzen. +- **`canonical` und `robots`**, die die Versionshälfte sind — siehe + [URLs und Versionen](/concepts/urls-and-versions). + +## Seitenweit + +`/sitemap.xml` listet die Seiten der Standardversion, je Locale. Eine +Nicht-Standardversion ist ausgeschlossen, weil ihre Seiten ohnehin `noindex` +tragen, und eine `eol`-Version ist ausgeschlossen, ob sie der Standard ist oder +nicht: Eines von beidem aufzulisten hieße, einen Crawler genau das abrufen zu +lassen, was die Seite ihm dann zu verwerfen sagt. `/robots.txt` wird daneben +erzeugt. + +## Der eine Wert, den du angeben musst + +Der Ursprung. `hreflang`, `canonical`, die Sitemap und die Open-Graph-Bilder +sind allesamt absolute URLs, und eine Ebene kann die Domain nicht kennen, auf +die sie ausgerollt wird. Setze `i18n.baseUrl` oder `NUXT_PUBLIC_I18N_BASE_URL`; +die Ebene kopiert es nach `site.url`, wo die SEO-Module nachsehen. + +Bleibt er ungesetzt, fällt jedes von ihnen auf relative Ausgabe zurück, statt +eine Domain zu erfinden. Das ist die bewusste Wahl: Ein relatives `canonical` +löst weiterhin korrekt auf, und ein geratenes ist aktiv falsch — es würde einen +Crawler auf die Seite eines anderen zeigen. + +## Noch eine Variable für ein rollierendes Deployment + +`NUXT_OG_IMAGE_SECRET` signiert die URLs der Open-Graph-Bilder. Ohne sie wird je +Build ein Geheimnis erzeugt, was bei einer Instanz in Ordnung und bei zweien +falsch ist: Eine Card, deren URL von einer Instanz signiert wurde, wird von der +nächsten abgelehnt — ein während eines rollierenden Deployments geteilter Link +verliert also sein Bild. Erzeuge sie einmal; sie rotiert nicht. diff --git a/docs/de/2.concepts/8.localisation.md b/docs/de/2.concepts/8.localisation.md new file mode 100644 index 00000000..e81f9f12 --- /dev/null +++ b/docs/de/2.concepts/8.localisation.md @@ -0,0 +1,104 @@ +--- +title: Lokalisierung +description: Sieben Locales bringt die Ebene mit, und jede konfigurierte Zeichenkette hat drei Formen. +icon: lucide:languages +--- + +Die Zeichenketten des Themes selbst — Navigationsbeschriftungen, Suche, das +Versionsbanner, der 404 — werden übersetzt ausgeliefert in `en-GB`, `en-US`, +`de-DE`, `es-ES`, `fr-FR`, `pt-PT` und `pt-BR`. Eine Seite wählt mit `locales` +aus, welche davon sie bedient, und `i18n.defaultLocale` entscheidet, welche ohne +URL-Präfix ausgeliefert wird. + +`locales` wird zur Build-Zeit gelesen: Locales entscheiden über Routen und +`hreflang`, nicht nur darüber, was eine Komponente zeichnet — die Liste zu +ändern erfordert also einen erneuten Build. + +::callout{type="warning" title="Die Liste einzuschränken braucht diesen Schlüssel, nicht i18ns"} +i18ns eigenes `locales`-Array führt über Ebenen hinweg zusammen, statt zu +ersetzen — dort `['de-DE']` zu deklarieren fügt nichts hinzu und entfernt +nichts: Die anderen sechs bleiben geroutet, indexiert und in `hreflang` +angekündigt. `duxt.locales` ist der Schlüssel, der sie tatsächlich einschränkt. +:: + +## Jede konfigurierte Zeichenkette hat drei Formen + +Alles, was du in die Konfiguration schreibst — eine Beschriftung, ein Titel, +eine Beschreibung — darf ein Literal, ein i18n-Schlüssel oder ein Datensatz mit +einer Zeichenkette je Locale sein: + +```ts +label: 'Repository' // Literal +label: 'app.nav.repository' // eigener Schlüssel +label: { 'en-GB': 'Repository', 'de-DE': 'Repository' } // je Locale +``` + +Welche es ist, entscheidet sich daran, ob die Zeichenkette ein registrierter +Schlüssel ist. Eine einsprachige Seite begegnet den anderen beiden Formen also +nie, und eine Seite mit einer Handvoll Zeichenketten in fünf Sprachen braucht +überhaupt keine Locale-Dateien — die Datensatzform löst über die Basissprache +auf, `pt-PT` deckt also `pt-BR` ab und `en-GB` deckt `en-US` ab. + +## Greife nicht in die Schlüssel der Ebene + +`duxt.defaults.*` übersetzt die Standardwerte, die die Ebene selbst ausliefert. +Diese Schlüssel sind intern: einen davon umzubenennen ist keine brechende +Änderung der Ebene, und i18n beantwortet einen fehlenden Schlüssel damit, den +Schlüssel auszugeben — wer sich auf einen verlässt, erführe es also von einem +Leser und nicht von einem Build. Nimm ein Literal, einen eigenen Schlüssel oder +die Datensatzform. + +## Pfade und Links + +Nur Pfade werden lokalisiert. `useDuxtPath()` liefert den Pfad, unter dem die +**Dokumentation** liegt, ohne Locale-Segment; `useDuxtLink()` schreibt ihn als +Link wieder aus und fügt das Segment erneut hinzu. Eine absolute URL, ein +Mail-Link oder ein bloßer Anker geht unberührt hindurch. Alles, was einen +Dokumentationspfad berechnet, sollte über dieses Paar gehen, statt die Route +direkt zu lesen. + +## Übersetzte Seiten + +Die Oberfläche und die Seiten sind zwei getrennte Entscheidungen. Eine Seite, +die nichts sagt, liefert einen Baum in jeder Locale aus — die Umgebung wechselt +die Sprache, das Markdown nicht — und das ist für die meiste Dokumentation die +richtige Form. + +`locales` an einer Quelle fügt die zweite Hälfte hinzu: + +```ts [app/app.config.ts] +sources: [{ path: 'docs', locales: ['en-GB', 'de'] }] +``` + +Die Standard-Locale ist der Baum in `path` selbst, ohne eigenen Ordner — dieser +Schlüssel verschiebt also nie eine bereits veröffentlichte Seite. Jede andere +Sprache ist ein Ordner darin — `docs/de/` — oder liegt ganz woanders; siehe +[Mehrere Sprachen](/guides/several-languages). + +::callout{type="tip" title="Der Locale gehört nicht in den Content-Pfad"} +`/de-DE/guides/deploying` und `/guides/deploying` sind derselbe Content-Pfad in +zwei Collections. Das Segment gehört i18ns Routing, nie einer Collection — +deshalb bleibt ein Sprachwechsel auf derselben Seite, und deshalb wird eine +fehlende Übersetzung mit einer weiteren Abfrage beantwortet statt mit einer +Weiterleitung. +:: + +## Wenn eine Seite keine Übersetzung hat + +Der Leser bekommt die nächstliegende Sprache, die sie hat, mit einem Banner, der +das sagt, und die Seite bleibt unter `noindex` aus dem Index, mit einem +`canonical`, das auf die Sprache zeigt, die den Text wirklich trägt. Die Kette +ist dieselbe, die die Ebene schon auf Zeichenketten anwendet: + +1. die Locale selbst — `de-DE` liest `de-DE` +2. ihre Basissprache — `de-DE` liest einen `de`-Baum, sodass ein `pt/`-Ordner + sowohl `pt-PT` als auch `pt-BR` bedienen kann +3. ein Geschwister derselben Sprache — `pt-BR` liest `pt-PT`, bevor es aus dem + Portugiesischen herausfällt +4. `fallbackLocale` aus vue-i18n, derselbe Wert, über den eine fehlende + Zeichenkette auflöst +5. das unübersetzte Original + +Ein 404 ist bewusst nicht dabei: Eine Lücke in einer Übersetzung gehört dem, der +sie geschrieben hat, und sie mit dem Verstecken der Seite zu beantworten +bestraft den Leser dafür. diff --git a/docs/de/2.concepts/index.md b/docs/de/2.concepts/index.md new file mode 100644 index 00000000..30d780ad --- /dev/null +++ b/docs/de/2.concepts/index.md @@ -0,0 +1,12 @@ +--- +title: Konzepte +description: Wie die Ebene funktioniert — das Quellenmodell, das URL-Schema, die Collections und die Prüfungen. +icon: lucide:compass +--- + +Die Einstiegsseiten bringen dich von nichts zu einer laufenden Seite. Diese hier +erklären, was die Teile sind, wo die Nahtstellen liegen und warum jedes Stück so +gebaut ist, wie es ist. + +::page-cards +:: diff --git a/docs/de/index.md b/docs/de/index.md new file mode 100644 index 00000000..8e7606df --- /dev/null +++ b/docs/de/index.md @@ -0,0 +1,20 @@ +--- +title: Dokumentation +description: Eine Nuxt-Ebene, die den docs-Ordner eines Repositories in eine versionierte Dokumentationsseite über mehrere Repositories verwandelt. +icon: lucide:book-open-text +navigation: false +--- + +Alles, was duxt tut, in vier Teilen. Beginne links, wenn du neu bist, oder wähle +den Teil, wegen dem du gekommen bist. + +::page-cards +:: + +Zwei Dinge fehlen hier bewusst. Wie man zu duxt selbst beiträgt, steht in +[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md), +und die Entscheidungen hinter der Ebene sind in den +[Architekturentscheidungen](/adr) festgehalten. + +Worauf die Ebene aufbaut und wonach sie gebaut wurde, steht in den +[Danksagungen](/credits). diff --git a/docs/es/1.getting-started/1.installation.md b/docs/es/1.getting-started/1.installation.md new file mode 100644 index 00000000..870f421b --- /dev/null +++ b/docs/es/1.getting-started/1.installation.md @@ -0,0 +1,73 @@ +--- +title: Instalación +description: Añade la capa, extiéndela, escribe Markdown. +icon: lucide:download +--- + +## Añadir la dependencia + +:partial{name="install"} + +## Extender la capa + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +## Escribir tu documentación + +El Markdown va en `docs/`, en la raíz de tu repositorio — resuelto contra el +repositorio y no contra el directorio donde se ejecuta el sitio, de modo que un +sitio en una subcarpeta lo encuentra. Un archivo es una página, una carpeta es +una sección, y el frontmatter lleva el título, la descripción y el icono. + +::file-tree{title="tu-proyecto/"} +--- +tree: + - name: docs/ + children: + - name: index.md + - name: 1.getting-started/ + children: + - name: index.md + - name: 1.installation.md + - name: 2.guides/ + children: + - name: 1.deploying.md + - name: nuxt.config.ts +--- +:: + +```md [docs/1.getting-started/index.md] +--- +title: Introducción +description: Qué es este proyecto. +icon: lucide:rocket +--- + +Tu primera página. +``` + +Un prefijo numérico ordena el árbol y se elimina de la URL: +`1.getting-started/` se sirve en `/getting-started`. Reordenar una sección nunca +mueve una URL — solo renombrar la parte posterior al punto lo hace. + +::callout{type="tip" title="Sin H1 en el cuerpo"} +El `title` de la página se representa como el `

`. Un encabezado propio al +principio del archivo sería el segundo de la página. +:: + +## Qué necesita la compilación + +- **Node 24.** La capa lee su base de datos mediante `node:sqlite`, así que no + se instala ni hace falta ningún controlador nativo de SQLite. +- **Acceso de red a tus fuentes**, si alguna es otro repositorio, y un token + para las privadas — Content lo lee del `auth` de la colección. + +## Resultado + +`nuxt dev` sirve el sitio. Todo lo que dibuja el tema son datos con un valor por +defecto, así que el siguiente paso es el único archivo que lo sobrescribe: +[Configuración](/getting-started/configuration). diff --git a/docs/es/1.getting-started/2.configuration.md b/docs/es/1.getting-started/2.configuration.md new file mode 100644 index 00000000..5a36c1d3 --- /dev/null +++ b/docs/es/1.getting-started/2.configuration.md @@ -0,0 +1,105 @@ +--- +title: Configuración +description: Un archivo configura todo el sitio — qué va en él y cómo se combina. +icon: lucide:settings +--- + +Todo lo que muestra el tema son datos, y `app/app.config.ts` es donde un sitio +los declara. No hay un segundo archivo de configuración: la misma clave `duxt` +lleva el tema, las fuentes de documentación y los enlaces propios del sitio. + +```ts [app/app.config.ts] +export default defineAppConfig({ + duxt: { + title: 'mi proyecto', + version: 'v1.4.0', + sources: [{ path: 'docs' }] + } +}); +``` + +Cada clave y su valor por defecto están en la +[referencia de configuración](/reference/configuration); la entrada `sources` +tiene [su propia página](/reference/sources). + +## Cómo se combina tu configuración + +Tu objeto `duxt` se combina clave por clave sobre los valores por defecto de la +capa, y **un array tuyo reemplaza el de la capa** en lugar de añadirse a él. Eso +importa porque la combinación de `app.config` de Nuxt hace lo contrario: +concatena arrays, lo que te dejaría sin poder eliminar una sola entrada de la +barra de navegación que trae la capa. Por eso la capa no incluye ninguna lista +en `app.config` y las combina ella misma. + +La regla práctica: sobrescribe `footer.copyright` y `footer.legal` queda +intacto; declara `navigation` y las dos entradas de la capa desaparecen. + +## Dos claves se leen en tiempo de compilación + +`sources` y `locales` deciden las colecciones, las rutas y los enlaces +`hreflang`, y las tres quedan fijadas antes de la primera petición. Cambiar +cualquiera de ellas exige recompilar; todo lo demás surte efecto en cuanto se +recarga la configuración. + +## Algunas claves se generan, no se escriben + +`resolvedSources`, `layerVersion` y `layerRepository` aparecen en la +configuración en tiempo de ejecución y pertenecen a la compilación. +`resolvedSources` es el manifiesto que el tema lee realmente — qué colección +sirve qué prefijo de URL — resuelto a partir de la lista `sources` que +escribiste. Escribir cualquiera de los tres a mano se sobrescribe. + +## Lo que la capa no presume saber + +`links`, `aside.links` y `footer.legal` se entregan **vacíos**. Un repositorio, +un gestor de incidencias, una comunidad y un aviso legal nombran cada uno a un +proyecto concreto, así que un valor por defecto con los de duxt pondría ante tus +lectores un «Star on GitHub» que marca el trabajo de otra persona. + +`sections` se entrega vacío por la razón vecina: nombra las partes de primer +nivel de tu árbol de documentación, y la capa no puede saber cómo las llamaste. +Defínelo cuando tu documentación tenga secciones; hasta entonces la fila +permanece oculta y la barra lateral muestra el árbol completo. + +```ts +sections: [ + { label: 'Guías', to: '/guides', icon: 'lucide:book-open' }, + { label: 'Referencia', to: '/reference', icon: 'lucide:list' } +] +``` + +Escribe los tuyos — como cadena simple, o como registro por locale si el sitio +sirve varios idiomas: + +```ts +links: [ + { + icon: 'lucide:github', + to: 'https://github.com/tu/tu-proyecto', + label: { 'en-GB': 'Repository', 'es-ES': 'Repositorio' } + } +] +``` + +::callout{type="warning" title="duxt.defaults.* no es tuyo"} +Las claves i18n de la capa traducen los valores por defecto que ella entrega. +Son internas: renombrar una no es un cambio incompatible, e i18n responde a una +clave inexistente imprimiendo la clave — así que la rotura sería silenciosa en +tu sitio. Usa un literal, una clave propia o la forma de registro de arriba. +:: + +## El origen del sitio + +duxt no puede adivinar el dominio desde el que se servirá, y cuatro cosas lo +necesitan: `hreflang`, `canonical`, el sitemap y las imágenes de Open Graph. +Decláralo una vez, donde Nuxt ya lo pide: + +```ts [nuxt.config.ts] +i18n: { baseUrl: 'https://docs.example.com' } +``` + +o como `NUXT_PUBLIC_I18N_BASE_URL` en el despliegue. La capa lo copia a +`site.url`, que es donde miran los módulos de SEO. Si no se define, cada uno de +ellos degrada a salida relativa en lugar de inventar un dominio — un `canonical` +relativo sigue resolviéndose, uno adivinado es activamente erróneo. Véase +[SEO](/concepts/seo). diff --git a/docs/es/1.getting-started/index.md b/docs/es/1.getting-started/index.md new file mode 100644 index 00000000..a4b57d53 --- /dev/null +++ b/docs/es/1.getting-started/index.md @@ -0,0 +1,36 @@ +--- +title: Introducción +description: Qué es duxt, y qué llega con una sola línea de configuración. +icon: lucide:rocket +--- + +duxt es una capa de Nuxt. La extiendes, pones Markdown en `docs/` y ya tienes un +sitio de documentación: navegación, tabla de contenidos, búsqueda, tema, +`llms.txt` y un servidor MCP incluidos. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +Eso es todo el caso de una sola carpeta. Sin colección, sin diseño, sin +`content.config.ts`. + +## Lo que no es + +duxt no obtiene el contenido. [Nuxt Content v3](https://content.nuxt.com) ya +descarga un repositorio git en una rama o una etiqueta, se autentica contra uno +privado y cachea el resultado por hash — y duxt usa eso en lugar de +reimplementarlo. + +Lo que duxt añade es la parte que, si no, se reescribe en cada repositorio de +documentación: una colección por versión y repositorio, el esquema de URL que +las sostiene, el selector que sabe qué página existe dónde, y un tema encima. +Todo ello se genera a partir de una sola lista — véase +[Fuentes](/concepts/sources). + +## Por dónde seguir + +::page-cards +:: diff --git a/docs/es/2.concepts/1.layers.md b/docs/es/2.concepts/1.layers.md new file mode 100644 index 00000000..a2db5ad9 --- /dev/null +++ b/docs/es/2.concepts/1.layers.md @@ -0,0 +1,50 @@ +--- +title: Capas y sobrescritura +description: Qué te da una capa de Nuxt, qué te quita, y qué rutas no se resuelven como se leen. +icon: lucide:layers +--- + +duxt es una capa, no una plantilla. La extiendes y cada archivo que trae se +vuelve sobrescribible: pon un archivo del mismo nombre en tu propio proyecto y +Nuxt resolverá el tuyo primero. Nada se copia a tu repositorio, así que una +actualización es un cambio de versión y no una fusión. + +## Qué significa esto en la práctica + +| Quieres | Haces | +| :----------------------------------------- | :----------------------------------------------------- | +| Cambiar un texto, un enlace, un interruptor | Definir la clave en tu `app/app.config.ts` | +| Reemplazar una pieza del diseño | Crear `app/components/DuxtFooter.vue` en tu proyecto | +| Rediseñar el tema | Redefinir una variable CSS en tu propia hoja de estilos | +| Añadir una página | Escribir Markdown en `docs/` | + +El orden es deliberado: los tres primeros son compromisos progresivamente +mayores, y un componente que reemplazas es un componente que ahora mantienes. +Véase [Sobrescribir el tema](/guides/override-the-theme). + +## Qué te quita + +Una capa nunca conoce el proyecto que se apoya en ella. Dos consecuencias que +nota quien la consume: + +- **`@` es tuyo, no de la capa.** El alias pertenece a quien extiende duxt, así + que las importaciones de la propia capa usan `@duxt`. El tuyo sigue apuntando + a tu `app/`. +- **Una colección se resuelve contra la capa, no contra ti.** Content registra + como raíz de cada colección la capa que la declaró, de modo que un `cwd` + relativo en la configuración de duxt apuntaría dentro de duxt. Por eso la capa + calcula una ruta absoluta al cargar, lo cual funciona porque + `content.config.ts` es código ejecutado y no un archivo de datos: sobre esa + costura se asienta todo el atajo de `sources`. + +## Por qué está construido así + +La alternativa es una plantilla de inicio: `nuxi init`, y el tema pasa a ser una +copia dentro de tu repositorio. Eso da libertad total y ningún camino de +actualización — cada mejora del tema es un diff que alguien debe aplicar a mano. +Una capa lo invierte: heredas las mejoras y solo pagas donde discrepaste. + +Lo que fija todo esto es la superficie pública. Un componente, una página o una +clave de `app.config` que puedes sobrescribir es un nombre del que dependes, así +que renombrar uno es un cambio incompatible de la capa — que es justo lo que +documenta la [referencia](/reference). diff --git a/docs/es/2.concepts/2.sources.md b/docs/es/2.concepts/2.sources.md new file mode 100644 index 00000000..815f8a82 --- /dev/null +++ b/docs/es/2.concepts/2.sources.md @@ -0,0 +1,59 @@ +--- +title: Fuentes +description: Una lista de fuentes se convierte en cada colección, prefijo y versión que sirve el sitio. +icon: lucide:git-branch +--- + +Una **fuente** es una carpeta con Markdown: en este repositorio o en otro, en el +checkout actual o en refs con nombre. La lista `sources` es el único sitio donde +se declaran, y todo lo demás se genera a partir de ella. + +```ts [app/app.config.ts] +sources: [ + { path: 'docs', slug: 'acme' }, + { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] } +] +``` + +Dos repositorios y tres refs: cinco colecciones, los prefijos de URL que las +sirven, un selector de versiones, las reglas de redirección y las entradas del +sitemap. Nada de eso se escribe a mano. Cada campo está en la +[referencia de fuentes](/reference/sources). + +## Cómo funciona + +Content v3 ya hace la mitad difícil. La fuente de una colección acepta una URL +de repositorio, una rama o etiqueta y credenciales, y Content la descarga y la +cachea por hash — múltiples repositorios, repositorios privados y leer desde una +etiqueta ya existen ahí. duxt no reimplementa nada de eso. + +Lo que duxt añade ocurre al cargar la configuración. `content.config.ts` es +código ejecutado, no un archivo de datos, así que puede leer el `app.config.ts` +del sitio y calcular sus colecciones a partir de esa lista — una colección por +repositorio × ref, cada una con un `cwd` absoluto, un nombre derivado del slug y +el esquema de página que necesita el tema. La misma lista la resuelve una +segunda vez la compilación, en el **manifiesto**: qué colección sirve qué +prefijo de URL, qué ref es la predeterminada, de dónde vino cada página. Ese +manifiesto es lo que lee el tema, y se escribe de vuelta en la configuración de +la app como `resolvedSources`. + +## Por qué está construido así + +Declarar las colecciones a mano no escala en la única dirección en la que la +documentación realmente crece: tres versiones en catorce repositorios son +cuarenta y dos declaraciones, y cada publicación edita las catorce. El atajo +mantiene la lista tan larga como el número de proyectos. + +El coste es que una lista de fuentes expresa menos que una colección escrita a +mano. Es un intercambio deliberado: quien necesite algo que el atajo no sepa +decir escribe su propio `content.config.ts` y toma el control por completo, +porque Content combina el archivo de cada capa y gana el posterior. + +## Lo que se obtiene de paso + +Como una fuente nombra un repositorio, una ref y una carpeta, varias funciones +no necesitan configuración propia: «Editar esta página», la fecha de última +actualización y la lista de personas que han contribuido se derivan de ahí. Leer +el historial de una fuente **remota** cuesta un clon completo, así que espera a +`history: true` — un clon superficial informaría del autor de la punta para cada +archivo, y eso son datos erróneos, no datos ausentes. diff --git a/docs/es/2.concepts/3.urls-and-versions.md b/docs/es/2.concepts/3.urls-and-versions.md new file mode 100644 index 00000000..adb11302 --- /dev/null +++ b/docs/es/2.concepts/3.urls-and-versions.md @@ -0,0 +1,76 @@ +--- +title: URL y versiones +description: Cómo aparecen los segmentos de repositorio y versión, y qué se le dice a quien lee una versión antigua. +icon: lucide:git-compare +--- + +Cada página se sirve en + +```text +/[repo]/[version]/[...ruta] +``` + +con ambos prefijos opcionales — y la decisión de si cada uno aparece se toma +**en tiempo de compilación a partir de la lista de fuentes**, nunca por +petición. + +- Un segmento de repositorio aparece en cuanto hay más de un repositorio, o + cuando `showRepo` lo fuerza. +- Un segmento de versión aparece en cuanto una fuente publica más de una ref, o + cuando `showVersion` lo fuerza. + +Así, una sola carpeta sin versiones sirve `/guides/deploying`, y nada en la URL +delata que existan repositorios o versiones. + +## Por qué los prefijos se deciden al compilar + +Si ambos prefijos fueran opcionales por petición, el primer segmento sería +ambiguo: `/guides/…` podría ser una carpeta, un repositorio o una versión, y +solo consultando los tres se sabría cuál. Como la forma de una URL queda fijada +antes de la primera petición, el enrutador nunca tiene que adivinar. + +Lo que queda es una **colisión de nombres** mientras un prefijo está activo: una +carpeta de documentación llamada como un repositorio o una versión. La +compilación la rechaza en lugar de resolverla en silencio hacia un lado; véase +[Qué comprueba la compilación](/concepts/build-checks). + +## La versión por defecto + +Una ref por repositorio se sirve sin prefijo de versión: la primera de la lista, +o la que nombre `sourceOptions.defaultRef`. Esa es la URL a la que enlazar y a +la que se dirige a los buscadores. + +## Dónde está una versión en su vida + +`status` en una fuente o una ref es un ciclo de vida, no un sinónimo de «es la +predeterminada»: un sitio puede publicar v2 como actual mientras v1 es +simplemente más antigua y v0 está realmente muerta, y hay que decirle al lector +en cuál de las tres se encuentra. + +| Estado | Qué se le dice al lector | En el sitemap | +| :----------- | :-------------------------------------------- | :----------------------- | +| `upcoming` | Esto aún puede cambiar — sin consejo de subir | Solo si es predeterminada | +| `current` | Nada | Solo si es predeterminada | +| `maintained` | Existe una versión más nueva | Solo si es predeterminada | +| `deprecated` | Existe una versión más nueva; actualiza | Solo si es predeterminada | +| `eol` | Esta versión está muerta | Nunca | + +`upcoming` es el único que no trata de la edad. Una versión puede no ser la +predeterminada porque todavía no ha ocurrido, y decirle a ese lector que +«actualice» es exactamente al revés. Semver no puede situar una rama frente a +una etiqueta, así que este caso se declara en la configuración en lugar de +deducirse. + +Junto al aviso, una página de una versión no predeterminada lleva `noindex` y un +`canonical` que apunta a la misma página en la versión predeterminada — de otro +modo, un buscador serviría v0.7.0 con la misma probabilidad que la documentación +de hoy. Las dos mitades van juntas: el aviso es la visible, el link-rel la +invisible, y cualquiera de ellas por separado deja a un público equivocado. + +## Cuando falta una página en una versión + +El selector solo ofrece una versión que realmente tenga la página, y el enlace +canónico resuelve a la ruta existente más cercana. Una URL que no resuelve a +nada se responde con un 404 que sugiere la página más próxima de la navegación — +las URL versionadas se fallan fácilmente por un segmento, y un 404 vacío deja al +lector sin nada que pulsar. diff --git a/docs/es/2.concepts/4.collections.md b/docs/es/2.concepts/4.collections.md new file mode 100644 index 00000000..3dadb8f7 --- /dev/null +++ b/docs/es/2.concepts/4.collections.md @@ -0,0 +1,64 @@ +--- +title: Colecciones +description: Qué construye Content a partir de las fuentes, y las cuatro cosas que añade el esquema. +icon: lucide:database +--- + +Una colección es la unidad de obtención de Content: un glob, un lugar de donde +leerlo y un esquema. duxt declara una por repositorio × ref, así que un sitio +con una carpeta tiene una colección y un sitio con dos repositorios en tres refs +tiene seis. + +Sus **nombres son datos**, no una constante. Un sitio con dos repositorios tiene +`docs_duxt` y `docs_workflows` y ninguna colección llamada `docs` — por eso el +tema lee el nombre de la colección del manifiesto en lugar de fijarlo en el +código, y por eso una consulta propia debería hacer lo mismo: + +```vue + +``` + +## Qué añade el esquema + +Content solo almacena lo que un esquema declara, así que cuatro cosas que el +tema necesita se declaran explícitamente. La lista completa está en la +[referencia de frontmatter](/reference/frontmatter); las razones están aquí: + +- **`icon`, `layout`, `navigation`** — campos del tema. Sin el esquema, un + `icon:` en el frontmatter se descarta en silencio antes de que la barra + lateral llegue a verlo. +- **`rawbody`** — el Markdown tal como se escribió. Dos cosas lo necesitan: el + botón que entrega una página a un modelo, y `llms-full.txt`. +- **`redirectFrom`** — convertido en reglas de ruta por la compilación; véase + [Mover una página](/guides/move-a-page). +- **`sitemap`** — lo que mete estas páginas en el sitemap. Una colección tiene + que declarar el campo, o el sitemap listará las rutas del sitio y ni una sola + página de documentación. + +## Fragmentos + +`_partials/` en la carpeta de documentación de **cualquier** fuente alimenta una +colección compartida, y `:partial{name="install"}` en cualquier página de +cualquier fuente lo representa. Content no trae ninguna directiva de inclusión, +y a través de varios repositorios esa carencia no tiene solución alguna — una +nota de instalación que debe decir lo mismo en tres proyectos se copia en tres +proyectos y se desvía. + +Los fragmentos quedan excluidos de las colecciones de páginas. De lo contrario +cada fragmento sería también una página: en la barra lateral, en la búsqueda, en +`llms.txt`. + +## Los borradores son archivos, no banderas + +Un borrador es `deploying.draft.md`, no `draft: true` en el frontmatter. La +bandera es la escritura obvia y la que no puede funcionar: el contenido de una +colección se declara antes de que Content haya leído un solo archivo — en una +fuente remota, antes de que se haya descargado — así que en ese momento nada +sabe lo que dice ningún frontmatter. Un nombre de archivo sí se conoce. Content +elimina `.draft` de la URL, de modo que la página se sirve en `/deploying` en el +servidor de desarrollo y simplemente no está en la compilación. diff --git a/docs/es/2.concepts/5.build-checks.md b/docs/es/2.concepts/5.build-checks.md new file mode 100644 index 00000000..77a62f93 --- /dev/null +++ b/docs/es/2.concepts/5.build-checks.md @@ -0,0 +1,54 @@ +--- +title: Qué comprueba la compilación +description: Los fallos silenciosos que tuvo esta capa, convertidos en mensajes. +icon: lucide:shield-check +--- + +Todos los errores que esta capa tuvo realmente fueron silenciosos: una colección +que Content descartó porque su nombre no era un identificador de JavaScript, una +página que daba 404 porque un enlace había quedado obsoleto, una navegación que +volvía vacía. Ninguno de los tres dijo nada — la compilación estaba en verde y +el sitio estaba mal. Así que tras la compilación se ejecutan cuatro +comprobaciones, todas leyendo lo mismo: la caché de análisis que Content deja +atrás. + +| Comprobación | Gravedad | +| :--------------------------------------------- | :------- | +| Una colección sin páginas | error | +| Una carpeta que choca con un segmento de URL | error | +| Un `title` o `description` ausente | aviso | +| Un enlace interno o ancla roto | aviso | + +## Por qué la gravedad no es uniforme + +Un error cuesta una página que nadie puede alcanzar y que nada en el sitio +explicaría jamás — una colección vacía es un 404 en cada página de una versión. +Un aviso cuesta calidad: la página sigue representándose, y una fuente remota +puede quedarse obsoleta entre una publicación y la siguiente sin que sea culpa +de esta compilación. Hacer fallar la compilación porque el repositorio de otra +persona haya avanzado convertiría una compilación en verde en algo dependiente +de un tercero. + +## Lo que las comprobaciones saben y tú no + +- **Una colisión de carpetas** solo es un problema mientras un prefijo está + activo. Una carpeta de documentación llamada `v2` está bien en un sitio sin + versiones e inalcanzable en uno versionado, porque gana el prefijo. La + compilación sabe cuál es el caso, y el mensaje nombra el archivo y ofrece la + solución. +- **Un enlace se resuelve como lo resuelve el tema.** Una ruta absoluta escrita + en una página es relativa a la fuente de esa misma página, así que se prueba + primero bajo el prefijo de la fuente y después desnuda. Un verificador que + solo hiciera la forma desnuda informaría de todos los enlaces correctos de un + sitio multirrepositorio. +- **Las URL externas no se comprueban.** Verificar una metería la red en la + compilación, y un host ajeno inaccesible no es motivo para que tu + documentación no se publique. + +## Escribe los enlaces como rutas absolutas de documentación + +`/concepts/sources`, no `../2.concepts/2.sources.md`. El prefijo bajo el que se +sirve una página se decide al compilar, así que escribir rutas sin él es lo que +permite que el mismo Markdown sirva `/guides/deploying` en un sitio y +`/acme/v2.0.0/guides/deploying` en otro. Es también la única forma que ve el +verificador de enlaces. diff --git a/docs/es/2.concepts/6.machine-readers.md b/docs/es/2.concepts/6.machine-readers.md new file mode 100644 index 00000000..30bfd70c --- /dev/null +++ b/docs/es/2.concepts/6.machine-readers.md @@ -0,0 +1,48 @@ +--- +title: Lectores automáticos +description: Qué obtiene un modelo — llms.txt, el Markdown en bruto de cualquier página y un servidor MCP real. +icon: lucide:bot +--- + +La documentación la leen tanto los modelos como las personas, y las cuatro +respuestas de abajo se generan a partir de las mismas colecciones desde las que +se representan las páginas — así que ninguna puede desviarse de lo publicado. + +| Ruta | Qué es | +| :--------------- | :------------------------------------------------------------------------ | +| `/llms.txt` | Un índice: nombre del sitio, resumen y cada página como entrada enlazada | +| `/llms-full.txt` | Las mismas páginas, enteras, concatenadas en un único archivo Markdown | +| `.md` | El código Markdown de cualquier página | +| `/mcp` | Un servidor MCP — `list_pages`, `search_docs`, `read_page` | + +## Markdown, no HTML + +Todo lo de aquí entrega el Markdown tal como se escribió, y por eso el esquema +de la colección pide a Content que almacene `rawbody`. Un modelo al que se le da +HTML representado tiene que deshacer un diseño para encontrar un encabezado; uno +al que se le da la fuente lee lo que escribió el autor, con los bloques de +código todavía como bloques y un bloque MDC todavía como una llamada a +componente. + +`.md` lo sirve un **middleware** en lugar de una ruta, porque `.md` es +un sufijo del último segmento de la ruta y no un segmento propio — no existe un +patrón de ruta de Nitro que signifique «cualquier ruta que termine en .md». + +## Por qué los dos archivos llms + +La convención de [llmstxt.org](https://llmstxt.org) nombra dos, y responden a +preguntas distintas: un modelo que ha leído el índice todavía tiene que +descargar sesenta páginas, y uno al que se le entrega el archivo completo ya las +ha leído. Cuál sale más barato depende de la pregunta, así que el sitio publica +ambos. + +## Por qué un servidor MCP real + +`/mcp` habla el protocolo mediante el SDK oficial en lugar de ser un endpoint +JSON de diseño propio de duxt. Un cliente se conecta a él como se conecta a +cualquier otra cosa, en vez de que alguien escriba un adaptador alrededor de una +forma que solo tiene este tema. + +Su búsqueda es un `LIKE` sobre títulos y descripciones, no el índice que usa el +navegador: ese se construye en el cliente a partir del volcado de la colección, +y una herramienta de servidor tiene la propia base de datos. diff --git a/docs/es/2.concepts/7.seo.md b/docs/es/2.concepts/7.seo.md new file mode 100644 index 00000000..b833fc7d --- /dev/null +++ b/docs/es/2.concepts/7.seo.md @@ -0,0 +1,48 @@ +--- +title: SEO +description: Qué emite cada página para los rastreadores y las tarjetas sociales, y el único valor que la capa no puede adivinar. +icon: lucide:search +--- + +Cada página emite un head completo, una descripción estructurada de sí misma, +una tarjeta social y una entrada en el sitemap. Un valor tiene que venir de ti. + +## Por página + +- **Meta** — título con el nombre del sitio, descripción, `og:title`, + `og:description`, `og:type`, `og:url` y una tarjeta de Twitter + `summary_large_image`. Todo ello ya está en el objeto de la página. +- **JSON-LD** — `TechArticle` más una `BreadcrumbList` construida con el mismo + rastro que dibuja la miga de pan. +- **Una imagen de Open Graph**, generada desde la plantilla de la capa. Entrega + un componente del mismo nombre para reemplazarla. +- **`canonical` y `robots`**, que son la mitad de versiones — véase + [URL y versiones](/concepts/urls-and-versions). + +## Para todo el sitio + +`/sitemap.xml` lista las páginas de la versión predeterminada, por locale. Una +versión no predeterminada queda excluida porque sus páginas llevan `noindex` de +todos modos, y una versión `eol` queda excluida sea o no la predeterminada: +listar cualquiera de las dos pediría a un rastreador que descargue justo lo que +la página le dirá luego que descarte. `/robots.txt` se genera junto a él. + +## El único valor que debes declarar + +El origen. `hreflang`, `canonical`, el sitemap y las imágenes de Open Graph son +todas URL absolutas, y una capa no puede conocer el dominio en el que se +desplegará. Define `i18n.baseUrl` o `NUXT_PUBLIC_I18N_BASE_URL`; la capa lo +copia a `site.url`, que es donde miran los módulos de SEO. + +Si no se define, cada uno de ellos degrada a salida relativa en lugar de +inventar un dominio. Esa es la elección deliberada: un `canonical` relativo +sigue resolviéndose correctamente, y uno adivinado es activamente erróneo — +apuntaría a un rastreador al sitio de otra persona. + +## Una variable más para un despliegue progresivo + +`NUXT_OG_IMAGE_SECRET` firma las URL de las imágenes de Open Graph. Sin ella se +genera un secreto por compilación, lo cual está bien con una instancia y mal con +dos: una tarjeta cuya URL firmó una instancia es rechazada por la siguiente, así +que un enlace compartido durante un despliegue progresivo pierde su imagen. +Genérala una vez; no rota. diff --git a/docs/es/2.concepts/8.localisation.md b/docs/es/2.concepts/8.localisation.md new file mode 100644 index 00000000..570aef55 --- /dev/null +++ b/docs/es/2.concepts/8.localisation.md @@ -0,0 +1,100 @@ +--- +title: Localización +description: La capa trae siete locales, y cada cadena configurada admite tres formas. +icon: lucide:languages +--- + +Las cadenas del propio tema — etiquetas de navegación, búsqueda, el aviso de +versión, el 404 — se entregan traducidas en `en-GB`, `en-US`, `de-DE`, `es-ES`, +`fr-FR`, `pt-PT` y `pt-BR`. Un sitio elige cuáles sirve con `locales`, y +`i18n.defaultLocale` decide cuál se sirve sin prefijo de URL. + +`locales` se lee en tiempo de compilación: los locales deciden rutas y +`hreflang`, no solo lo que dibuja un componente, así que cambiar la lista exige +recompilar. + +::callout{type="warning" title="Restringir la lista necesita esta clave, no la de i18n"} +El array `locales` de i18n se combina entre capas en lugar de reemplazar, así +que declarar `['de-DE']` allí no añade ni quita nada: los otros seis siguen +enrutados, indexados y anunciados en `hreflang`. `duxt.locales` es la clave que +realmente la reduce. +:: + +## Cada cadena configurada admite tres formas + +Todo lo que escribas en la configuración — una etiqueta, un título, una +descripción — puede ser un literal, una clave i18n o un registro con una cadena +por locale: + +```ts +label: 'Repository' // literal +label: 'app.nav.repository' // clave propia +label: { 'en-GB': 'Repository', 'es-ES': 'Repositorio' } // por locale +``` + +Cuál de ellas es se decide según si la cadena es una clave registrada. Un sitio +monolingüe nunca se topa con las otras dos formas, y un sitio con un puñado de +cadenas en cinco idiomas no necesita archivos de locale en absoluto — la forma +de registro se resuelve por el idioma base, así que `pt-PT` cubre `pt-BR` y +`en-GB` cubre `en-US`. + +## No toques las claves de la capa + +`duxt.defaults.*` traduce los valores por defecto que la propia capa entrega. +Esas claves son internas: renombrar una no es un cambio incompatible de la capa, +e i18n responde a una clave inexistente imprimiendo la clave — quien dependa de +una se enteraría por un lector, no por una compilación. Usa un literal, una +clave propia o la forma de registro. + +## Rutas y enlaces + +Solo las rutas se localizan. `useDuxtPath()` da la ruta en la que está la +**documentación**, sin el segmento de locale; `useDuxtLink()` la vuelve a +escribir como enlace, añadiendo de nuevo el segmento. Una URL absoluta, un +enlace de correo o un ancla suelta pasan intactos. Todo lo que calcule una ruta +de documentación debería pasar por ese par en lugar de leer la ruta +directamente. + +## Páginas traducidas + +La interfaz y las páginas son dos decisiones separadas. Un sitio que no diga +nada sirve un mismo árbol en todos los locales — el entorno cambia de idioma, el +Markdown no — y esa es la forma correcta para la mayoría de la documentación. + +`locales` en una fuente añade la segunda mitad: + +```ts [app/app.config.ts] +sources: [{ path: 'docs', locales: ['en-GB', 'es'] }] +``` + +El locale por defecto es el árbol de `path` mismo, sin carpeta propia, así que +añadir esta clave nunca mueve una página ya publicada. Cualquier otro idioma es +una carpeta dentro — `docs/es/` — o algo completamente aparte; véase +[Varios idiomas](/guides/several-languages). + +::callout{type="tip" title="El locale no forma parte de la ruta de contenido"} +`/es-ES/guides/deploying` y `/guides/deploying` son la misma ruta de contenido en +dos colecciones. El segmento pertenece al enrutado de i18n, nunca a una +colección — por eso cambiar de idioma mantiene al lector en la misma página, y +por eso una traducción ausente se resuelve con una consulta más y no con una +redirección. +:: + +## Cuando una página no tiene traducción + +El lector recibe el idioma más cercano que sí la tiene, con un aviso que lo +dice, y la página queda fuera del índice bajo `noindex` con un `canonical` que +apunta al idioma en el que realmente está. La cadena es la que la capa ya aplica +a las cadenas de texto: + +1. el propio locale — `es-ES` lee `es-ES` +2. su idioma base — `es-ES` lee un árbol `es`, de modo que una carpeta `pt/` + puede servir tanto a `pt-PT` como a `pt-BR` +3. un hermano del mismo idioma — `pt-BR` lee `pt-PT` antes de salirse del + portugués +4. `fallbackLocale` de vue-i18n, el mismo valor por el que se resuelve una + cadena ausente +5. el original sin traducir + +Un 404 queda deliberadamente fuera: un hueco en una traducción es de quien +escribe, y responderlo escondiendo la página castiga al lector por ello. diff --git a/docs/es/2.concepts/index.md b/docs/es/2.concepts/index.md new file mode 100644 index 00000000..b4e0ec4a --- /dev/null +++ b/docs/es/2.concepts/index.md @@ -0,0 +1,12 @@ +--- +title: Conceptos +description: Cómo funciona la capa — el modelo de fuentes, el esquema de URL, las colecciones y las comprobaciones. +icon: lucide:compass +--- + +Las páginas de inicio te llevan de cero a un sitio en marcha. Estas explican qué +son las piezas, dónde están las costuras y por qué cada una está construida como +está. + +::page-cards +:: diff --git a/docs/es/index.md b/docs/es/index.md new file mode 100644 index 00000000..6b8ff1c7 --- /dev/null +++ b/docs/es/index.md @@ -0,0 +1,20 @@ +--- +title: Documentación +description: Una capa de Nuxt que convierte la carpeta docs de un repositorio en un sitio de documentación versionado y multirrepositorio. +icon: lucide:book-open-text +navigation: false +--- + +Todo lo que hace duxt, en cuatro partes. Empieza por la izquierda si es tu +primera vez, o ve directamente a la parte que buscas. + +::page-cards +:: + +Dos cosas no están aquí a propósito. Cómo contribuir a duxt está en +[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md), +y las decisiones detrás de la capa están registradas en +[Decisiones de arquitectura](/adr). + +Sobre qué se construye la capa, y después de qué se construyó, está en +[Créditos](/credits). diff --git a/docs/fr/1.getting-started/1.installation.md b/docs/fr/1.getting-started/1.installation.md new file mode 100644 index 00000000..fe73854f --- /dev/null +++ b/docs/fr/1.getting-started/1.installation.md @@ -0,0 +1,74 @@ +--- +title: Installation +description: Ajoutez la couche, étendez-la, écrivez du Markdown. +icon: lucide:download +--- + +## Ajouter la dépendance + +:partial{name="install"} + +## Étendre la couche + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +## Écrire votre documentation + +Le Markdown se place dans `docs/`, à la racine de votre dépôt — résolu par +rapport au dépôt et non au répertoire d’exécution du site, afin qu’un site situé +dans un sous-dossier le trouve. Un fichier devient une page, un dossier une +section, et le frontmatter porte le titre, la description et l’icône. + +::file-tree{title="votre-projet/"} +--- +tree: + - name: docs/ + children: + - name: index.md + - name: 1.getting-started/ + children: + - name: index.md + - name: 1.installation.md + - name: 2.guides/ + children: + - name: 1.deploying.md + - name: nuxt.config.ts +--- +:: + +```md [docs/1.getting-started/index.md] +--- +title: Introduction +description: Ce qu’est ce projet. +icon: lucide:rocket +--- + +Votre première page. +``` + +Un préfixe numérique ordonne l’arborescence et disparaît de l’URL : +`1.getting-started/` est servi sur `/getting-started`. Réordonner une section ne +déplace donc jamais une URL — seul le renommage de la partie après le point le +fait. + +::callout{type="tip" title="Pas de H1 dans le corps"} +Le `title` de la page est rendu comme `

`. Un titre à vous en début de +fichier serait le deuxième de la page. +:: + +## Ce dont la compilation a besoin + +- **Node 24.** La couche lit sa base de données via `node:sqlite` ; aucun pilote + SQLite natif n’est installé ni nécessaire. +- **Un accès réseau à vos sources**, si certaines sont d’autres dépôts, et un + jeton pour les dépôts privés — Content le lit dans l’`auth` de la collection. + +## Résultat + +`nuxt dev` sert le site. Tout ce que le thème dessine est une donnée avec une +valeur par défaut ; l’étape suivante est donc le seul fichier qui la remplace : +[Configuration](/getting-started/configuration). diff --git a/docs/fr/1.getting-started/2.configuration.md b/docs/fr/1.getting-started/2.configuration.md new file mode 100644 index 00000000..af15b72e --- /dev/null +++ b/docs/fr/1.getting-started/2.configuration.md @@ -0,0 +1,108 @@ +--- +title: Configuration +description: Un fichier configure tout le site — ce qu’il contient, et comment il fusionne. +icon: lucide:settings +--- + +Tout ce que le thème affiche est une donnée, et `app/app.config.ts` est +l’endroit où un site les déclare. Il n’y a pas de second fichier de +configuration : la même clé `duxt` porte le thème, les sources de documentation +et les liens propres au site. + +```ts [app/app.config.ts] +export default defineAppConfig({ + duxt: { + title: 'mon projet', + version: 'v1.4.0', + sources: [{ path: 'docs' }] + } +}); +``` + +Chaque clé et sa valeur par défaut figurent dans la +[référence de configuration](/reference/configuration) ; l’entrée `sources` a +[sa propre page](/reference/sources). + +## Comment votre configuration fusionne + +Votre objet `duxt` est fusionné clé par clé par-dessus les valeurs par défaut de +la couche, et **un tableau à vous remplace celui de la couche** au lieu de s’y +ajouter. C’est important parce que la fusion d’`app.config` de Nuxt fait +l’inverse : elle concatène les tableaux, ce qui vous empêcherait de retirer une +seule entrée de la barre de navigation fournie par la couche. La couche ne +livre donc aucune liste dans `app.config` et les fusionne elle-même. + +La règle pratique : remplacez `footer.copyright` et `footer.legal` reste intact ; +déclarez `navigation` et les deux entrées de la couche disparaissent. + +## Deux clés sont lues à la compilation + +`sources` et `locales` déterminent les collections, les routes et les liens +`hreflang`, et les trois sont fixés avant la première requête. Modifier l’une ou +l’autre impose une recompilation ; tout le reste prend effet dès que la +configuration est rechargée. + +## Certaines clés sont générées, non écrites + +`resolvedSources`, `layerVersion` et `layerRepository` apparaissent dans la +configuration à l’exécution et appartiennent à la compilation. +`resolvedSources` est le manifeste que le thème lit réellement — quelle +collection sert quel préfixe d’URL — résolu à partir de la liste `sources` que +vous avez écrite. Écrire l’un des trois à la main est écrasé. + +## Ce que la couche ne prétend pas savoir + +`links`, `aside.links` et `footer.legal` sont livrés **vides**. Un dépôt, un +gestionnaire de tickets, une communauté et des mentions légales désignent chacun +un projet précis : une valeur par défaut reprenant celles de duxt tendrait à vos +lecteurs un « Star on GitHub » qui étoile le travail de quelqu’un d’autre. + +`sections` est vide pour la raison voisine : il nomme les parties de premier +niveau de votre arborescence de documentation, et la couche ne peut pas savoir +comment vous les avez appelées. Définissez-le une fois que votre documentation a +des sections ; jusque-là, la rangée reste masquée et la barre latérale affiche +tout l’arbre. + +```ts +sections: [ + { label: 'Guides', to: '/guides', icon: 'lucide:book-open' }, + { label: 'Référence', to: '/reference', icon: 'lucide:list' } +] +``` + +Écrivez les vôtres — sous forme de chaîne simple, ou d’enregistrement par locale +si le site sert plusieurs langues : + +```ts +links: [ + { + icon: 'lucide:github', + to: 'https://github.com/vous/votre-projet', + label: { 'en-GB': 'Repository', 'fr-FR': 'Dépôt' } + } +] +``` + +::callout{type="warning" title="duxt.defaults.* ne vous appartient pas"} +Les clés i18n de la couche traduisent les valeurs par défaut qu’elle fournit. +Elles sont internes : en renommer une n’est pas un changement cassant, et i18n +répond à une clé absente en affichant la clé — la rupture serait donc +silencieuse sur votre site. Utilisez un littéral, une clé à vous, ou la forme +d’enregistrement ci-dessus. +:: + +## L’origine du site + +duxt ne peut pas deviner le domaine depuis lequel il sera servi, et quatre +choses en ont besoin : `hreflang`, `canonical`, le sitemap et les images Open +Graph. Indiquez-le une fois, là où Nuxt le demande déjà : + +```ts [nuxt.config.ts] +i18n: { baseUrl: 'https://docs.example.com' } +``` + +ou via `NUXT_PUBLIC_I18N_BASE_URL` dans le déploiement. La couche le copie vers +`site.url`, où les modules SEO vont le chercher. Laissé vide, chacun d’eux +retombe sur une sortie relative plutôt que d’inventer un domaine — un +`canonical` relatif se résout encore, un `canonical` deviné est activement faux. +Voir [SEO](/concepts/seo). diff --git a/docs/fr/1.getting-started/index.md b/docs/fr/1.getting-started/index.md new file mode 100644 index 00000000..3fce671b --- /dev/null +++ b/docs/fr/1.getting-started/index.md @@ -0,0 +1,36 @@ +--- +title: Introduction +description: Ce qu’est duxt, et ce qui arrive avec une seule ligne de configuration. +icon: lucide:rocket +--- + +duxt est une couche Nuxt. Vous l’étendez, vous mettez du Markdown dans `docs/`, +et vous avez un site de documentation : navigation, sommaire, recherche, thème, +`llms.txt` et un serveur MCP compris. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +C’est tout le cas du dossier unique. Aucune collection, aucune mise en page, +aucun `content.config.ts`. + +## Ce que ce n’est pas + +duxt ne source pas le contenu. [Nuxt Content v3](https://content.nuxt.com) +télécharge déjà un dépôt git sur une branche ou un tag, s’authentifie auprès +d’un dépôt privé et met le résultat en cache par hachage — et duxt s’en sert +plutôt que de le réimplémenter. + +Ce que duxt ajoute, c’est la partie autrement retapée dans chaque dépôt de +documentation : une collection par version et par dépôt, le schéma d’URL qui les +porte, le sélecteur qui sait quelle page existe où, et un thème par-dessus. Tout +cela est généré à partir d’une seule liste — voir +[Sources](/concepts/sources). + +## Où aller ensuite + +::page-cards +:: diff --git a/docs/fr/2.concepts/1.layers.md b/docs/fr/2.concepts/1.layers.md new file mode 100644 index 00000000..67295416 --- /dev/null +++ b/docs/fr/2.concepts/1.layers.md @@ -0,0 +1,52 @@ +--- +title: Couches et surcharge +description: Ce qu’une couche Nuxt vous donne, ce qu’elle vous retire, et quels chemins ne se résolvent pas comme ils se lisent. +icon: lucide:layers +--- + +duxt est une couche, pas un modèle. Vous l’étendez, et chaque fichier qu’elle +fournit devient surchargeable : placez un fichier du même nom dans votre projet +et Nuxt résout le vôtre en premier. Rien n’est copié dans votre dépôt : une mise +à jour est donc un changement de version, pas une fusion. + +## Ce que cela signifie en pratique + +| Vous voulez | Vous faites | +| :-------------------------------------------- | :----------------------------------------------------- | +| Changer un texte, un lien, une option | Définir la clé dans votre `app/app.config.ts` | +| Remplacer un morceau de la mise en page | Créer `app/components/DuxtFooter.vue` dans votre projet | +| Restyler le thème | Redéfinir une variable CSS dans votre feuille de style | +| Ajouter une page | Écrire du Markdown dans `docs/` | + +L’ordre est délibéré : les trois premiers sont des engagements de plus en plus +lourds, et un composant que vous remplacez est un composant que vous maintenez +désormais. Voir [Surcharger le thème](/guides/override-the-theme). + +## Ce qu’elle vous retire + +Une couche ne connaît jamais le projet qui repose sur elle. Deux conséquences +que l’on ressent : + +- **`@` est à vous, pas à la couche.** L’alias appartient à celui qui étend + duxt ; les imports de la couche utilisent donc `@duxt`. Le vôtre continue de + pointer vers votre `app/`. +- **Une collection se résout par rapport à la couche, pas à vous.** Content + enregistre comme racine de chaque collection la couche qui l’a déclarée : un + `cwd` relatif dans la configuration de duxt pointerait donc à l’intérieur de + duxt. La couche calcule pour cette raison un chemin absolu au chargement, ce + qui fonctionne parce que `content.config.ts` est du code exécuté et non un + fichier de données — et c’est sur cette jointure que repose tout le raccourci + `sources`. + +## Pourquoi c’est construit ainsi + +L’alternative est un modèle de départ : `nuxi init`, et le thème devient une +copie dans votre dépôt. Cela donne une liberté totale et aucun chemin de mise à +jour — chaque amélioration du thème est un diff que quelqu’un doit appliquer à +la main. Une couche inverse cela : vous héritez des améliorations et ne payez +que là où vous n’étiez pas d’accord. + +Ce qui fige tout cela, c’est la surface publique. Un composant, une page ou une +clé d’`app.config` que vous pouvez surcharger est un nom dont vous dépendez : +en renommer un est donc un changement cassant de la couche — ce que documente +précisément la [référence](/reference). diff --git a/docs/fr/2.concepts/2.sources.md b/docs/fr/2.concepts/2.sources.md new file mode 100644 index 00000000..2ba90cf6 --- /dev/null +++ b/docs/fr/2.concepts/2.sources.md @@ -0,0 +1,60 @@ +--- +title: Sources +description: Une liste de sources devient chaque collection, préfixe et version que le site sert. +icon: lucide:git-branch +--- + +Une **source** est un dossier de Markdown : dans ce dépôt ou dans un autre, sur +le checkout courant ou sur des refs nommées. La liste `sources` est le seul +endroit où on les déclare, et tout le reste en est généré. + +```ts [app/app.config.ts] +sources: [ + { path: 'docs', slug: 'acme' }, + { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] } +] +``` + +Deux dépôts et trois refs : cinq collections, les préfixes d’URL qui les +servent, un sélecteur de versions, les règles de redirection et les entrées du +sitemap. Rien de tout cela n’est écrit à la main. Chaque champ figure dans la +[référence des sources](/reference/sources). + +## Comment cela fonctionne + +Content v3 fait déjà la moitié difficile. La source d’une collection accepte une +URL de dépôt, une branche ou un tag et des identifiants, et Content la +télécharge puis la met en cache par hachage — multi-dépôts, dépôts privés et +lecture depuis un tag existent déjà là. duxt n’en réimplémente rien. + +Ce que duxt ajoute se produit au chargement de la configuration. +`content.config.ts` est du code exécuté et non un fichier de données : il peut +donc lire l’`app.config.ts` du site et calculer ses collections à partir de +cette liste — une collection par dépôt × ref, chacune avec un `cwd` absolu, un +nom dérivé du slug et le schéma de page dont le thème a besoin. La même liste +est résolue une seconde fois par la compilation, en **manifeste** : quelle +collection sert quel préfixe d’URL, quelle ref est celle par défaut, d’où vient +chaque page. C’est ce manifeste que le thème lit, et il est réécrit dans la +configuration de l’application sous `resolvedSources`. + +## Pourquoi c’est construit ainsi + +Déclarer les collections à la main ne passe pas à l’échelle dans la seule +direction où la documentation grandit vraiment : trois versions sur quatorze +dépôts font quarante-deux déclarations, et chaque publication en modifie +quatorze. Le raccourci garde la liste aussi longue que le nombre de projets. + +Le coût, c’est qu’une liste de sources exprime moins qu’une collection écrite à +la main. C’est un compromis délibéré : qui a besoin de ce que le raccourci ne +sait pas dire écrit son propre `content.config.ts` et reprend tout, puisque +Content fusionne le fichier de chaque couche, la dernière l’emportant. + +## Ce qui en découle + +Parce qu’une source nomme un dépôt, une ref et un dossier, plusieurs +fonctionnalités n’ont besoin d’aucune configuration propre : « Modifier cette +page », la date de dernière modification et la liste des contributeurs en +découlent toutes. Lire l’historique d’une source **distante** coûte un clone +complet : cela attend donc `history: true` — un clone superficiel attribuerait +chaque fichier à l’auteur de la pointe, ce qui est une donnée fausse plutôt +qu’une donnée manquante. diff --git a/docs/fr/2.concepts/3.urls-and-versions.md b/docs/fr/2.concepts/3.urls-and-versions.md new file mode 100644 index 00000000..1bf3558d --- /dev/null +++ b/docs/fr/2.concepts/3.urls-and-versions.md @@ -0,0 +1,76 @@ +--- +title: URL et versions +description: Comment apparaissent les segments de dépôt et de version, et ce qu’on dit à un lecteur dans une ancienne version. +icon: lucide:git-compare +--- + +Chaque page est servie à + +```text +/[repo]/[version]/[...chemin] +``` + +les deux préfixes étant facultatifs — et la décision de faire apparaître chacun +est prise **à la compilation, à partir de la liste des sources**, jamais par +requête. + +- Un segment de dépôt apparaît dès qu’il y a plus d’un dépôt, ou quand + `showRepo` le force. +- Un segment de version apparaît dès qu’une source publie plus d’une ref, ou + quand `showVersion` le force. + +Un dossier unique non versionné sert donc `/guides/deploying`, et rien dans +l’URL ne trahit l’existence de dépôts ou de versions. + +## Pourquoi les préfixes sont décidés à la compilation + +Si les deux préfixes étaient facultatifs par requête, le premier segment serait +ambigu : `/guides/…` pourrait être un dossier, un dépôt ou une version, et seule +la consultation des trois le dirait. Comme la forme d’une URL est figée avant la +première requête, le routeur n’a jamais à deviner. + +Reste une **collision de noms** tant qu’un préfixe est actif : un dossier de +documentation nommé comme un dépôt ou une version. La compilation la rejette au +lieu de la trancher silencieusement ; voir +[Ce que la compilation vérifie](/concepts/build-checks). + +## La version par défaut + +Une ref par dépôt est servie sans préfixe de version : la première de la liste, +ou celle que nomme `sourceOptions.defaultRef`. C’est l’URL vers laquelle pointer +et celle vers laquelle les moteurs de recherche sont dirigés. + +## Où en est une version dans sa vie + +`status`, sur une source ou une ref, est un cycle de vie et non un synonyme de +« est la version par défaut » : un site peut publier la v2 comme actuelle tandis +que la v1 est simplement plus ancienne et la v0 réellement morte, et le lecteur +doit savoir dans lequel des trois cas il se trouve. + +| Statut | Ce qu’on dit au lecteur | Dans le sitemap | +| :----------- | :----------------------------------------------- | :--------------------- | +| `upcoming` | Cela peut encore changer — pas de conseil de MAJ | Seulement si par défaut | +| `current` | Rien | Seulement si par défaut | +| `maintained` | Une version plus récente existe | Seulement si par défaut | +| `deprecated` | Une version plus récente existe ; mettez à jour | Seulement si par défaut | +| `eol` | Cette version est morte | Jamais | + +`upcoming` est le seul qui ne parle pas d’âge. Une version peut ne pas être +celle par défaut parce qu’elle n’a pas encore eu lieu, et dire à ce lecteur de +« mettre à jour » est exactement à l’envers. Semver ne peut pas situer une +branche face à un tag : ce cas est donc déclaré dans la configuration plutôt que +déduit. + +À côté du bandeau, une page d’une version non par défaut porte `noindex` et un +`canonical` pointant vers la même page dans la version par défaut — sans quoi un +moteur de recherche servirait la v0.7.0 aussi volontiers que la documentation du +jour. Les deux moitiés vont ensemble : le bandeau est la visible, le link-rel +l’invisible, et l’une sans l’autre laisse un public dans l’erreur. + +## Quand une page manque dans une version + +Le sélecteur ne propose qu’une version qui possède réellement la page, et le +lien canonique se résout vers le chemin existant le plus proche. Une URL qui ne +résout vers rien reçoit un 404 qui suggère la page la plus proche dans la +navigation — on rate facilement une URL versionnée d’un segment, et un 404 vide +laisse le lecteur sans rien à cliquer. diff --git a/docs/fr/2.concepts/4.collections.md b/docs/fr/2.concepts/4.collections.md new file mode 100644 index 00000000..999d8a80 --- /dev/null +++ b/docs/fr/2.concepts/4.collections.md @@ -0,0 +1,63 @@ +--- +title: Collections +description: Ce que Content construit à partir des sources, et les quatre choses qu’ajoute le schéma. +icon: lucide:database +--- + +Une collection est l’unité de sourcing de Content : un glob, un endroit d’où le +lire et un schéma. duxt en déclare une par dépôt × ref : un site avec un dossier +a donc une collection, et un site avec deux dépôts sur trois refs en a six. + +Leurs **noms sont des données**, pas une constante. Un site avec deux dépôts a +`docs_duxt` et `docs_workflows` et aucune collection nommée `docs` — c’est +pourquoi le thème lit le nom de la collection dans le manifeste plutôt que de le +coder en dur, et pourquoi une requête à vous devrait faire de même : + +```vue + +``` + +## Ce qu’ajoute le schéma + +Content ne stocke que ce qu’un schéma déclare : quatre éléments dont le thème a +besoin sont donc déclarés explicitement. La liste complète est dans la +[référence du frontmatter](/reference/frontmatter) ; les raisons sont ici : + +- **`icon`, `layout`, `navigation`** — des champs du thème. Sans le schéma, un + `icon:` dans le frontmatter est silencieusement supprimé avant même que la + barre latérale le voie. +- **`rawbody`** — le Markdown tel qu’écrit. Deux choses en ont besoin : le + bouton qui remet une page à un modèle, et `llms-full.txt`. +- **`redirectFrom`** — transformé en règles de route par la compilation ; voir + [Déplacer une page](/guides/move-a-page). +- **`sitemap`** — ce qui met ces pages dans le sitemap. Une collection doit + déclarer le champ, sinon le sitemap liste les routes du site et pas une seule + page de documentation. + +## Fragments + +`_partials/` dans le dossier de documentation de **n’importe quelle** source +alimente une collection partagée, et `:partial{name="install"}` dans n’importe +quelle page de n’importe quelle source l’affiche. Content ne fournit aucune +directive d’inclusion, et à travers plusieurs dépôts ce manque n’a aucun +contournement — une note d’installation qui doit être identique dans trois +projets est copiée dans trois projets et diverge. + +Les fragments sont exclus des collections de pages elles-mêmes. Sinon chaque +fragment serait aussi une page : dans la barre latérale, dans la recherche, dans +`llms.txt`. + +## Les brouillons sont des fichiers, pas des drapeaux + +Un brouillon est `deploying.draft.md`, pas `draft: true` dans le frontmatter. Le +drapeau est l’écriture évidente et celle qui ne peut pas fonctionner : le +contenu d’une collection est déclaré avant que Content ait lu le moindre fichier +— pour une source distante, avant même qu’elle soit téléchargée — donc rien à ce +moment ne sait ce que dit un frontmatter. Un nom de fichier, lui, est connu. +Content retire `.draft` de l’URL : la page est donc servie sur `/deploying` dans +le serveur de développement et simplement absente de la compilation. diff --git a/docs/fr/2.concepts/5.build-checks.md b/docs/fr/2.concepts/5.build-checks.md new file mode 100644 index 00000000..ebe7d3de --- /dev/null +++ b/docs/fr/2.concepts/5.build-checks.md @@ -0,0 +1,53 @@ +--- +title: Ce que la compilation vérifie +description: Les échecs silencieux qu’a connus cette couche, transformés en messages. +icon: lucide:shield-check +--- + +Chaque bogue qu’a réellement connu cette couche était silencieux : une +collection que Content écartait parce que son nom n’était pas un identifiant +JavaScript, une page en 404 parce qu’un lien avait vieilli, une navigation qui +revenait vide. Aucun des trois ne disait quoi que ce soit — la compilation était +verte et le site faux. Quatre vérifications s’exécutent donc après la +compilation, toutes lisant la même chose : le cache d’analyse que Content laisse +derrière lui. + +| Vérification | Gravité | +| :----------------------------------------------- | :------------ | +| Une collection sans aucune page | erreur | +| Un dossier en conflit avec un segment d’URL | erreur | +| Un `title` ou une `description` manquant | avertissement | +| Un lien interne ou une ancre cassés | avertissement | + +## Pourquoi la gravité n’est pas uniforme + +Une erreur coûte une page que personne ne peut atteindre et que rien sur le site +n’expliquerait jamais — une collection vide, c’est un 404 sur chaque page d’une +version. Un avertissement coûte de la qualité : la page s’affiche toujours, et +une source distante peut vieillir d’une publication à l’autre sans que ce soit +la faute de cette compilation. Faire échouer la compilation parce que le dépôt +de quelqu’un d’autre a avancé rendrait une compilation verte dépendante d’un +tiers. + +## Ce que les vérifications savent et pas vous + +- **Une collision de dossiers** n’est un problème que tant qu’un préfixe est + actif. Un dossier de documentation nommé `v2` est correct sur un site non + versionné et inaccessible sur un site versionné, car le préfixe l’emporte. La + compilation sait de quel cas il s’agit, et le message nomme le fichier et + propose la correction. +- **Un lien est résolu comme le thème le résout.** Un chemin absolu écrit dans + une page est relatif à la source de cette page : il est donc essayé d’abord + sous le préfixe de la source, puis nu. Un vérificateur ne faisant que la forme + nue signalerait chaque lien correct d’un site multi-dépôts. +- **Les URL externes ne sont pas vérifiées.** En vérifier une mettrait le réseau + dans la compilation, et un hôte tiers injoignable n’est pas une raison pour + que votre documentation ne soit pas publiée. + +## Écrivez les liens en chemins de documentation absolus + +`/concepts/sources`, et non `../2.concepts/2.sources.md`. Le préfixe sous lequel +une page est servie est décidé à la compilation : écrire des chemins sans +préfixe est donc ce qui permet au même Markdown de servir `/guides/deploying` +sur un site et `/acme/v2.0.0/guides/deploying` sur un autre. C’est aussi la +seule forme que voit le vérificateur de liens. diff --git a/docs/fr/2.concepts/6.machine-readers.md b/docs/fr/2.concepts/6.machine-readers.md new file mode 100644 index 00000000..c9ecbd94 --- /dev/null +++ b/docs/fr/2.concepts/6.machine-readers.md @@ -0,0 +1,50 @@ +--- +title: Lecteurs machines +description: Ce qu’obtient un modèle — llms.txt, le Markdown brut de n’importe quelle page et un vrai serveur MCP. +icon: lucide:bot +--- + +La documentation est lue autant par des modèles que par des personnes, et les +quatre réponses ci-dessous sont générées à partir des mêmes collections que +celles dont les pages sont issues — aucune ne peut donc diverger de ce qui est +publié. + +| Chemin | Ce que c’est | +| :--------------- | :------------------------------------------------------------------------ | +| `/llms.txt` | Un index : nom du site, résumé, puis chaque page comme entrée liée | +| `/llms-full.txt` | Les mêmes pages, entières, concaténées en un seul fichier Markdown | +| `.md` | Le source Markdown de n’importe quelle page | +| `/mcp` | Un serveur MCP — `list_pages`, `search_docs`, `read_page` | + +## Du Markdown, pas du HTML + +Tout ici transmet le Markdown tel qu’il a été écrit, et c’est pourquoi le schéma +de la collection demande à Content de stocker `rawbody`. Un modèle à qui l’on +donne du HTML rendu doit défaire une mise en page pour trouver un titre ; celui +à qui l’on donne la source lit ce que l’auteur a écrit, les blocs de code +toujours des blocs et un bloc MDC toujours un appel de composant. + +`.md` est servi par un **middleware** plutôt que par une route, car `.md` +est un suffixe du dernier segment du chemin et non un segment à part — il +n’existe pas de motif de route Nitro signifiant « tout chemin se terminant par +.md ». + +## Pourquoi les deux fichiers llms + +La convention de [llmstxt.org](https://llmstxt.org) en nomme deux, et ils +répondent à des questions différentes : un modèle qui a lu l’index doit encore +récupérer soixante pages, tandis qu’un modèle à qui l’on remet le fichier +complet les a déjà lues. Lequel revient le moins cher dépend de la question : le +site publie donc les deux. + +## Pourquoi un vrai serveur MCP + +`/mcp` parle le protocole via le SDK officiel plutôt que d’être un point de +terminaison JSON de la conception propre de duxt. Un client s’y connecte comme +il se connecte à n’importe quoi d’autre, au lieu que quelqu’un écrive un +adaptateur autour d’une forme que seul ce thème possède. + +Sa recherche est un `LIKE` sur les titres et les descriptions, et non l’index +qu’utilise le navigateur : celui-ci est construit côté client à partir du +export de la collection, alors qu’un outil serveur dispose de la base de données +elle-même. diff --git a/docs/fr/2.concepts/7.seo.md b/docs/fr/2.concepts/7.seo.md new file mode 100644 index 00000000..2d833ffc --- /dev/null +++ b/docs/fr/2.concepts/7.seo.md @@ -0,0 +1,49 @@ +--- +title: SEO +description: Ce que chaque page émet pour les robots et les cartes sociales, et la seule valeur que la couche ne peut pas deviner. +icon: lucide:search +--- + +Chaque page émet un head complet, une description structurée d’elle-même, une +carte sociale et une entrée dans le sitemap. Une valeur doit venir de vous. + +## Par page + +- **Méta** — titre avec le nom du site, description, `og:title`, + `og:description`, `og:type`, `og:url` et une carte Twitter + `summary_large_image`. Tout cela figure déjà sur l’objet page. +- **JSON-LD** — `TechArticle` plus une `BreadcrumbList` construite à partir du + même fil d’Ariane que celui affiché. +- **Une image Open Graph**, rendue depuis le gabarit de la couche. Fournissez un + composant du même nom pour la remplacer. +- **`canonical` et `robots`**, qui constituent la moitié « versions » — voir + [URL et versions](/concepts/urls-and-versions). + +## À l’échelle du site + +`/sitemap.xml` liste les pages de la version par défaut, par locale. Une version +non par défaut est exclue parce que ses pages portent de toute façon `noindex`, +et une version `eol` est exclue qu’elle soit ou non celle par défaut : lister +l’une ou l’autre demanderait à un robot de récupérer exactement ce que la page +lui dira ensuite d’écarter. `/robots.txt` est généré à côté. + +## La seule valeur que vous devez indiquer + +L’origine. `hreflang`, `canonical`, le sitemap et les images Open Graph sont +toutes des URL absolues, et une couche ne peut pas connaître le domaine sur +lequel elle sera déployée. Définissez `i18n.baseUrl` ou +`NUXT_PUBLIC_I18N_BASE_URL` ; la couche le copie vers `site.url`, où les modules +SEO vont le chercher. + +Laissée vide, chacune retombe sur une sortie relative plutôt que d’inventer un +domaine. C’est le choix délibéré : un `canonical` relatif se résout toujours +correctement, alors qu’un `canonical` deviné est activement faux — il +dirigerait un robot vers le site de quelqu’un d’autre. + +## Une variable de plus pour un déploiement progressif + +`NUXT_OG_IMAGE_SECRET` signe les URL des images Open Graph. Sans elle, un secret +est généré par compilation, ce qui convient pour une instance et non pour deux : +une carte dont l’URL a été signée par une instance est rejetée par la suivante, +et un lien partagé pendant un déploiement progressif perd donc son image. +Générez-la une fois ; elle ne tourne pas. diff --git a/docs/fr/2.concepts/8.localisation.md b/docs/fr/2.concepts/8.localisation.md new file mode 100644 index 00000000..40e4b9c6 --- /dev/null +++ b/docs/fr/2.concepts/8.localisation.md @@ -0,0 +1,99 @@ +--- +title: Localisation +description: La couche fournit sept locales, et chaque chaîne configurée prend trois formes. +icon: lucide:languages +--- + +Les chaînes du thème lui-même — libellés de navigation, recherche, bandeau de +version, page 404 — sont livrées traduites en `en-GB`, `en-US`, `de-DE`, +`es-ES`, `fr-FR`, `pt-PT` et `pt-BR`. Un site choisit celles qu’il sert avec +`locales`, et `i18n.defaultLocale` décide laquelle est servie sans préfixe +d’URL. + +`locales` est lu à la compilation : les locales déterminent les routes et +`hreflang`, pas seulement ce qu’un composant dessine — modifier la liste impose +donc une recompilation. + +::callout{type="warning" title="Restreindre la liste passe par cette clé, pas par celle d’i18n"} +Le tableau `locales` d’i18n fusionne entre les couches au lieu de remplacer : +déclarer `['de-DE']` là n’ajoute ni ne retire rien — les six autres restent +routées, indexées et annoncées dans `hreflang`. `duxt.locales` est la clé qui la +réduit réellement. +:: + +## Chaque chaîne configurée prend trois formes + +Tout ce que vous écrivez dans la configuration — un libellé, un titre, une +description — peut être un littéral, une clé i18n, ou un enregistrement d’une +chaîne par locale : + +```ts +label: 'Repository' // littéral +label: 'app.nav.repository' // votre propre clé +label: { 'en-GB': 'Repository', 'fr-FR': 'Dépôt' } // par locale +``` + +Laquelle c’est se décide selon que la chaîne est une clé enregistrée. Un site +monolingue ne rencontre donc jamais les deux autres formes, et un site avec une +poignée de chaînes en cinq langues n’a besoin d’aucun fichier de locale — la +forme d’enregistrement se résout par la langue de base : `pt-PT` couvre `pt-BR` +et `en-GB` couvre `en-US`. + +## N’allez pas puiser dans les clés de la couche + +`duxt.defaults.*` traduit les valeurs par défaut que la couche fournit +elle-même. Ces clés sont internes : en renommer une n’est pas un changement +cassant de la couche, et i18n répond à une clé absente en affichant la clé — +celui qui en dépendrait l’apprendrait d’un lecteur, pas d’une compilation. +Utilisez un littéral, une clé à vous, ou la forme d’enregistrement. + +## Chemins et liens + +Seuls les chemins sont localisés. `useDuxtPath()` donne le chemin où se trouve +la **documentation**, sans le segment de locale ; `useDuxtLink()` le réécrit en +lien en rajoutant le segment. Une URL absolue, un lien mail ou une simple ancre +passent intacts. Tout ce qui calcule un chemin de documentation devrait passer +par cette paire plutôt que de lire la route directement. + +## Pages traduites + +L’interface et les pages sont deux décisions distinctes. Un site qui ne dit rien +sert un même arbre dans toutes les locales — l’habillage change de langue, pas +le Markdown — et c’est la bonne forme pour la plupart des documentations. + +`locales` sur une source ajoute la seconde moitié : + +```ts [app/app.config.ts] +sources: [{ path: 'docs', locales: ['en-GB', 'fr'] }] +``` + +La locale par défaut est l’arbre de `path` lui-même, sans dossier propre : +ajouter cette clé ne déplace donc jamais une page déjà publiée. Toute autre +langue est un dossier à l’intérieur — `docs/fr/` — ou se trouve entièrement +ailleurs ; voir [Plusieurs langues](/guides/several-languages). + +::callout{type="tip" title="La locale ne fait pas partie du chemin de contenu"} +`/fr-FR/guides/deploying` et `/guides/deploying` sont le même chemin de contenu +dans deux collections. Le segment appartient au routage d’i18n, jamais à une +collection — c’est pourquoi changer de langue garde le lecteur sur la même page, +et pourquoi une traduction manquante se règle par une requête de plus plutôt +que par une redirection. +:: + +## Quand une page n’a pas de traduction + +Le lecteur reçoit la langue la plus proche qui l’a, avec un bandeau qui le dit, +et la page reste hors index sous `noindex`, avec un `canonical` pointant vers la +langue qui la porte réellement. La chaîne est celle que la couche applique déjà +aux chaînes de caractères : + +1. la locale elle-même — `fr-FR` lit `fr-FR` +2. sa langue de base — `fr-FR` lit un arbre `fr`, ce qui permet à un dossier + `pt/` de servir à la fois `pt-PT` et `pt-BR` +3. une sœur de la même langue — `pt-BR` lit `pt-PT` avant de sortir du portugais +4. `fallbackLocale` de vue-i18n, la valeur même par laquelle se résout une + chaîne absente +5. l’original non traduit + +Un 404 en est délibérément absent : un trou dans une traduction appartient à qui +l’écrit, et y répondre en cachant la page en punit le lecteur. diff --git a/docs/fr/2.concepts/index.md b/docs/fr/2.concepts/index.md new file mode 100644 index 00000000..3df9e52a --- /dev/null +++ b/docs/fr/2.concepts/index.md @@ -0,0 +1,12 @@ +--- +title: Concepts +description: Comment fonctionne la couche — le modèle de sources, le schéma d’URL, les collections et les vérifications. +icon: lucide:compass +--- + +Les pages de démarrage vous mènent de rien à un site qui tourne. Celles-ci +expliquent ce que sont les pièces, où se trouvent les jointures, et pourquoi +chacune est construite ainsi. + +::page-cards +:: diff --git a/docs/fr/index.md b/docs/fr/index.md new file mode 100644 index 00000000..d132cf3f --- /dev/null +++ b/docs/fr/index.md @@ -0,0 +1,21 @@ +--- +title: Documentation +description: Une couche Nuxt qui transforme le dossier docs d’un dépôt en un site de documentation versionné et multi-dépôts. +icon: lucide:book-open-text +navigation: false +--- + +Tout ce que fait duxt, en quatre parties. Commencez à gauche si vous découvrez +le projet, ou allez directement à la partie qui vous intéresse. + +::page-cards +:: + +Deux choses n’y figurent pas, délibérément. Contribuer à duxt lui-même est +décrit dans +[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md), +et les décisions derrière la couche sont consignées dans les +[Décisions d’architecture](/adr). + +Ce sur quoi la couche repose, et ce dont elle s’inspire, se trouve dans les +[Remerciements](/credits). diff --git a/docs/pt/1.getting-started/1.installation.md b/docs/pt/1.getting-started/1.installation.md new file mode 100644 index 00000000..7e0d0fd1 --- /dev/null +++ b/docs/pt/1.getting-started/1.installation.md @@ -0,0 +1,73 @@ +--- +title: Instalação +description: Adicione a camada, estenda-a, escreva Markdown. +icon: lucide:download +--- + +## Adicionar a dependência + +:partial{name="install"} + +## Estender a camada + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +## Escrever a documentação + +O Markdown fica em `docs/`, na raiz do repositório — resolvido em relação ao +repositório e não ao diretório onde o site corre, para que um site numa +subpasta o encontre. Um ficheiro torna-se uma página, uma pasta torna-se uma +secção, e o frontmatter transporta o título, a descrição e o ícone. + +::file-tree{title="o-teu-projeto/"} +--- +tree: + - name: docs/ + children: + - name: index.md + - name: 1.getting-started/ + children: + - name: index.md + - name: 1.installation.md + - name: 2.guides/ + children: + - name: 1.deploying.md + - name: nuxt.config.ts +--- +:: + +```md [docs/1.getting-started/index.md] +--- +title: Introdução +description: O que é este projeto. +icon: lucide:rocket +--- + +A tua primeira página. +``` + +Um prefixo numérico ordena a árvore e é retirado do URL: `1.getting-started/` é +servido em `/getting-started`. Reordenar uma secção nunca move um URL — só +renomear a parte depois do ponto o faz. + +::callout{type="tip" title="Sem H1 no corpo"} +O `title` da página é apresentado como `

`. Um cabeçalho teu no início do +ficheiro seria o segundo da página. +:: + +## O que a compilação precisa + +- **Node 24.** A camada lê a sua base de dados através de `node:sqlite`, pelo + que nenhum controlador SQLite nativo é instalado ou necessário. +- **Acesso de rede às tuas fontes**, se alguma delas for outro repositório, e um + token para as privadas — o Content lê-o do `auth` da coleção. + +## Resultado + +`nuxt dev` serve o site. Tudo o que o tema desenha são dados com um valor por +omissão, por isso o passo seguinte é o único ficheiro que o substitui: +[Configuração](/getting-started/configuration). diff --git a/docs/pt/1.getting-started/2.configuration.md b/docs/pt/1.getting-started/2.configuration.md new file mode 100644 index 00000000..197664f5 --- /dev/null +++ b/docs/pt/1.getting-started/2.configuration.md @@ -0,0 +1,104 @@ +--- +title: Configuração +description: Um ficheiro configura o site inteiro — o que lhe pertence e como é combinado. +icon: lucide:settings +--- + +Tudo o que o tema mostra são dados, e `app/app.config.ts` é onde um site os +declara. Não há um segundo ficheiro de configuração: a mesma chave `duxt` +transporta o tema, as fontes de documentação e os links do próprio site. + +```ts [app/app.config.ts] +export default defineAppConfig({ + duxt: { + title: 'o meu projeto', + version: 'v1.4.0', + sources: [{ path: 'docs' }] + } +}); +``` + +Cada chave e o seu valor por omissão estão na +[referência de configuração](/reference/configuration); a entrada `sources` tem +[uma página própria](/reference/sources). + +## Como a tua configuração é combinada + +O teu objeto `duxt` é combinado chave a chave sobre os valores por omissão da +camada, e **um array teu substitui o da camada** em vez de lhe ser acrescentado. +Isso importa porque a combinação de `app.config` do Nuxt faz o contrário: +concatena arrays, o que te deixaria sem poder remover uma única entrada da barra +de navegação que a camada traz. Por isso a camada não entrega listas nenhumas em +`app.config` e combina-as ela própria. + +A regra prática: substitui `footer.copyright` e `footer.legal` fica intacto; +declara `navigation` e as duas entradas da camada desaparecem. + +## Duas chaves são lidas em tempo de compilação + +`sources` e `locales` decidem as coleções, as rotas e os links `hreflang`, e as +três ficam definidas antes do primeiro pedido. Alterar qualquer uma delas exige +nova compilação; tudo o resto tem efeito assim que a configuração é recarregada. + +## Algumas chaves são geradas, não escritas + +`resolvedSources`, `layerVersion` e `layerRepository` aparecem na configuração +em tempo de execução e pertencem à compilação. `resolvedSources` é o manifesto +que o tema realmente lê — que coleção serve que prefixo de URL — resolvido a +partir da lista `sources` que escreveste. Escrever qualquer um dos três à mão é +sobrescrito. + +## O que a camada não presume saber + +`links`, `aside.links` e `footer.legal` são entregues **vazios**. Um +repositório, um gestor de issues, uma comunidade e um aviso legal nomeiam cada +um um projeto concreto, pelo que um valor por omissão com os do duxt entregaria +aos teus leitores um «Star on GitHub» que marca o trabalho de outra pessoa. + +`sections` é entregue vazio pela razão vizinha: nomeia as partes de topo da tua +árvore de documentação, e a camada não pode saber como lhes chamaste. Define-o +quando a tua documentação tiver secções; até lá a linha fica escondida e a barra +lateral mostra a árvore inteira. + +```ts +sections: [ + { label: 'Guias', to: '/guides', icon: 'lucide:book-open' }, + { label: 'Referência', to: '/reference', icon: 'lucide:list' } +] +``` + +Escreve os teus — como texto simples, ou como registo por locale se o site +servir vários idiomas: + +```ts +links: [ + { + icon: 'lucide:github', + to: 'https://github.com/tu/o-teu-projeto', + label: { 'en-GB': 'Repository', 'pt-PT': 'Repositório' } + } +] +``` + +::callout{type="warning" title="duxt.defaults.* não é teu"} +As chaves i18n da camada traduzem os valores por omissão que ela entrega. São +internas: renomear uma não é uma alteração incompatível, e o i18n responde a uma +chave em falta imprimindo a chave — a quebra seria portanto silenciosa no teu +site. Usa um literal, uma chave tua, ou a forma de registo acima. +:: + +## A origem do site + +O duxt não consegue adivinhar o domínio a partir do qual será servido, e quatro +coisas precisam dele: `hreflang`, `canonical`, o sitemap e as imagens Open +Graph. Indica-o uma vez, onde o Nuxt já o pergunta: + +```ts [nuxt.config.ts] +i18n: { baseUrl: 'https://docs.example.com' } +``` + +ou como `NUXT_PUBLIC_I18N_BASE_URL` no deployment. A camada copia-o para +`site.url`, que é onde os módulos de SEO procuram. Se ficar por definir, cada um +deles recai em saída relativa em vez de inventar um domínio — um `canonical` +relativo ainda resolve, um adivinhado está ativamente errado. Ver +[SEO](/concepts/seo). diff --git a/docs/pt/1.getting-started/index.md b/docs/pt/1.getting-started/index.md new file mode 100644 index 00000000..8cb80588 --- /dev/null +++ b/docs/pt/1.getting-started/index.md @@ -0,0 +1,36 @@ +--- +title: Introdução +description: O que é o duxt, e o que chega com uma única linha de configuração. +icon: lucide:rocket +--- + +O duxt é uma camada Nuxt. Estende-a, coloca Markdown em `docs/` e tem um site de +documentação: navegação, índice, pesquisa, tema, `llms.txt` e um servidor MCP +incluídos. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + extends: ['@kirchdev/duxt'] +}); +``` + +É este o caso da pasta única, por inteiro. Sem coleção, sem layout, sem +`content.config.ts`. + +## O que não é + +O duxt não obtém conteúdo. O [Nuxt Content v3](https://content.nuxt.com) já +descarrega um repositório git num ramo ou numa tag, autentica-se perante um +privado e guarda o resultado em cache por hash — e o duxt usa isso em vez de o +reimplementar. + +O que o duxt acrescenta é a parte que de outro modo se volta a escrever em cada +repositório de documentação: uma coleção por versão e repositório, o esquema de +URL que as transporta, o seletor que sabe que página existe onde, e um tema por +cima. Tudo isto é gerado a partir de uma única lista — ver +[Fontes](/concepts/sources). + +## Para onde ir a seguir + +::page-cards +:: diff --git a/docs/pt/2.concepts/1.layers.md b/docs/pt/2.concepts/1.layers.md new file mode 100644 index 00000000..e3686696 --- /dev/null +++ b/docs/pt/2.concepts/1.layers.md @@ -0,0 +1,50 @@ +--- +title: Camadas e substituição +description: O que uma camada Nuxt te dá, o que te tira, e que caminhos não resolvem como se leem. +icon: lucide:layers +--- + +O duxt é uma camada, não um modelo. Estende-la e cada ficheiro que ela traz +passa a ser substituível: coloca um ficheiro com o mesmo nome no teu projeto e o +Nuxt resolve o teu primeiro. Nada é copiado para o teu repositório, por isso uma +atualização é um salto de versão e não uma fusão. + +## O que isso significa na prática + +| Queres | Fazes | +| :----------------------------------------- | :------------------------------------------------------ | +| Mudar um texto, um link, um interruptor | Definir a chave no teu `app/app.config.ts` | +| Substituir uma peça do layout | Criar `app/components/DuxtFooter.vue` no teu projeto | +| Reestilizar o tema | Redefinir uma variável CSS na tua própria folha de estilo | +| Acrescentar uma página | Escrever Markdown em `docs/` | + +A ordem é deliberada: os três primeiros são compromissos progressivamente +maiores, e um componente que substituis é um componente que passas a manter. +Ver [Substituir o tema](/guides/override-the-theme). + +## O que te tira + +Uma camada nunca conhece o projeto que assenta sobre ela. Duas consequências que +se sentem: + +- **`@` é teu, não da camada.** O alias pertence a quem estende o duxt, por isso + as importações da própria camada usam `@duxt`. O teu continua a apontar para o + teu `app/`. +- **Uma coleção resolve contra a camada, não contra ti.** O Content regista como + raiz de cada coleção a camada que a declarou, pelo que um `cwd` relativo na + configuração do próprio duxt apontaria para dentro do duxt. A camada calcula + por isso um caminho absoluto no carregamento, o que funciona porque + `content.config.ts` é código executado e não um ficheiro de dados — e é sobre + essa junção que assenta todo o atalho `sources`. + +## Porque é feito assim + +A alternativa é um modelo inicial: `nuxi init`, e o tema passa a ser uma cópia +dentro do teu repositório. Isso dá liberdade total e nenhum caminho de +atualização — cada melhoria no tema é um diff que alguém tem de aplicar à mão. +Uma camada inverte isso: herdas as melhorias e só pagas onde discordaste. + +O que fixa tudo isto é a superfície pública. Um componente, uma página ou uma +chave de `app.config` que podes substituir é um nome do qual dependes, por isso +renomear um é uma alteração incompatível da camada — que é precisamente o que a +[referência](/reference) documenta. diff --git a/docs/pt/2.concepts/2.sources.md b/docs/pt/2.concepts/2.sources.md new file mode 100644 index 00000000..aabc5883 --- /dev/null +++ b/docs/pt/2.concepts/2.sources.md @@ -0,0 +1,58 @@ +--- +title: Fontes +description: Uma lista de fontes torna-se cada coleção, prefixo e versão que o site serve. +icon: lucide:git-branch +--- + +Uma **fonte** é uma pasta de Markdown: neste repositório ou noutro, no checkout +atual ou em refs nomeadas. A lista `sources` é o único sítio onde são +declaradas, e tudo o resto é gerado a partir dela. + +```ts [app/app.config.ts] +sources: [ + { path: 'docs', slug: 'acme' }, + { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] } +] +``` + +Dois repositórios e três refs: cinco coleções, os prefixos de URL que as servem, +um seletor de versões, as regras de redirecionamento e as entradas do sitemap. +Nada disso é escrito à mão. Cada campo está na +[referência de fontes](/reference/sources). + +## Como funciona + +O Content v3 já faz a metade difícil. A fonte de uma coleção aceita um URL de +repositório, um ramo ou tag e credenciais, e o Content descarrega-a e guarda-a +em cache por hash — vários repositórios, repositórios privados e ler a partir de +uma tag existem todos lá. O duxt não reimplementa nada disso. + +O que o duxt acrescenta acontece ao carregar a configuração. `content.config.ts` +é código executado e não um ficheiro de dados, por isso pode ler o +`app.config.ts` do site e calcular as suas coleções a partir dessa lista — uma +coleção por repositório × ref, cada uma com um `cwd` absoluto, um nome derivado +do slug e o esquema de página de que o tema precisa. A mesma lista é resolvida +uma segunda vez pela compilação, no **manifesto**: que coleção serve que prefixo +de URL, que ref é a predefinida, de onde veio cada página. É esse manifesto que +o tema lê, e é reescrito na configuração da aplicação como `resolvedSources`. + +## Porque é feito assim + +Declarar as coleções à mão não escala exatamente na direção em que a +documentação cresce: três versões em catorze repositórios são quarenta e duas +declarações, e cada lançamento edita as catorze. O atalho mantém a lista tão +longa quanto o número de projetos. + +O custo é que uma lista de fontes exprime menos do que uma coleção escrita à +mão. É uma troca deliberada: quem precisar de algo que o atalho não consegue +dizer escreve um `content.config.ts` próprio e assume por completo, porque o +Content combina o ficheiro de cada camada, ganhando o posterior. + +## O que daí resulta + +Como uma fonte nomeia um repositório, uma ref e uma pasta, várias +funcionalidades não precisam de configuração própria: «Editar esta página», a +data da última alteração e a lista de quem contribuiu derivam todas dela. Ler o +histórico de uma fonte **remota** custa um clone completo, por isso espera por +`history: true` — um clone superficial reportaria o autor da ponta para cada +ficheiro, o que são dados errados em vez de dados em falta. diff --git a/docs/pt/2.concepts/3.urls-and-versions.md b/docs/pt/2.concepts/3.urls-and-versions.md new file mode 100644 index 00000000..0c58d4de --- /dev/null +++ b/docs/pt/2.concepts/3.urls-and-versions.md @@ -0,0 +1,75 @@ +--- +title: URL e versões +description: Como aparecem os segmentos de repositório e versão, e o que é dito a quem lê uma versão antiga. +icon: lucide:git-compare +--- + +Cada página é servida em + +```text +/[repo]/[version]/[...caminho] +``` + +com ambos os prefixos opcionais — e a decisão sobre se cada um aparece é tomada +**em tempo de compilação a partir da lista de fontes**, nunca por pedido. + +- Um segmento de repositório aparece assim que há mais do que um repositório, ou + quando `showRepo` o força. +- Um segmento de versão aparece assim que uma fonte publica mais do que uma ref, + ou quando `showVersion` o força. + +Assim, uma única pasta sem versões serve `/guides/deploying`, e nada no URL +denuncia que repositórios e versões sequer existem. + +## Porque os prefixos são decididos na compilação + +Se ambos os prefixos fossem opcionais por pedido, o primeiro segmento seria +ambíguo: `/guides/…` podia ser uma pasta, um repositório ou uma versão, e só +consultando os três se saberia qual. Como a forma de um URL fica fixada antes do +primeiro pedido, o router nunca tem de adivinhar. + +O que resta é uma **colisão de nomes** enquanto um prefixo está ativo: uma pasta +de documentação com o nome de um repositório ou de uma versão. A compilação +rejeita-a em vez de a resolver em silêncio para um dos lados; ver +[O que a compilação verifica](/concepts/build-checks). + +## A versão por omissão + +Uma ref por repositório é servida sem prefixo de versão: a primeira da lista, ou +a que `sourceOptions.defaultRef` nomear. É esse o URL a que se deve ligar e para +o qual os motores de busca são apontados. + +## Onde está uma versão na sua vida + +`status`, numa fonte ou numa ref, é um ciclo de vida e não um sinónimo de «é a +predefinida»: um site pode publicar a v2 como atual enquanto a v1 é apenas mais +antiga e a v0 está mesmo morta, e é preciso dizer ao leitor em qual dos três +está. + +| Estado | O que é dito ao leitor | No sitemap | +| :----------- | :----------------------------------------------- | :--------------------- | +| `upcoming` | Isto ainda pode mudar — sem conselho de atualizar | Só se for a predefinida | +| `current` | Nada | Só se for a predefinida | +| `maintained` | Existe uma versão mais recente | Só se for a predefinida | +| `deprecated` | Existe uma versão mais recente; atualiza | Só se for a predefinida | +| `eol` | Esta versão está morta | Nunca | + +`upcoming` é o único que não é sobre idade. Uma versão pode não ser a +predefinida por ainda não ter acontecido, e dizer a esse leitor para +«atualizar» é exatamente ao contrário. O semver não consegue situar um ramo +face a uma tag, por isso este caso é declarado na configuração em vez de +deduzido. + +Ao lado do aviso, uma página numa versão não predefinida leva `noindex` e um +`canonical` a apontar para a mesma página na versão predefinida — caso +contrário, um motor de busca serviria a v0.7.0 com a mesma probabilidade que a +documentação de hoje. As duas metades andam juntas: o aviso é a visível, o +link-rel a invisível, e qualquer uma sozinha deixa um público enganado. + +## Quando uma página falta numa versão + +O seletor só oferece uma versão que tenha mesmo a página, e a ligação canónica +resolve para o caminho existente mais próximo. Um URL que não resolve para nada +é respondido com um 404 que sugere a página mais próxima na navegação — é fácil +falhar um URL versionado por um segmento, e um 404 vazio deixa o leitor sem nada +para clicar. diff --git a/docs/pt/2.concepts/4.collections.md b/docs/pt/2.concepts/4.collections.md new file mode 100644 index 00000000..ed3e78b3 --- /dev/null +++ b/docs/pt/2.concepts/4.collections.md @@ -0,0 +1,62 @@ +--- +title: Coleções +description: O que o Content constrói a partir das fontes, e as quatro coisas que o esquema acrescenta. +icon: lucide:database +--- + +Uma coleção é a unidade de obtenção do Content: um glob, um sítio de onde o ler +e um esquema. O duxt declara uma por repositório × ref, por isso um site com uma +pasta tem uma coleção e um site com dois repositórios em três refs tem seis. + +Os seus **nomes são dados**, não uma constante. Um site com dois repositórios +tem `docs_duxt` e `docs_workflows` e nenhuma coleção chamada `docs` — é por isso +que o tema lê o nome da coleção do manifesto em vez de o fixar no código, e por +isso uma consulta tua deve fazer o mesmo: + +```vue + +``` + +## O que o esquema acrescenta + +O Content só guarda o que um esquema declara, por isso quatro coisas de que o +tema precisa são declaradas explicitamente. A lista completa está na +[referência de frontmatter](/reference/frontmatter); as razões estão aqui: + +- **`icon`, `layout`, `navigation`** — campos do tema. Sem o esquema, um `icon:` + no frontmatter é descartado em silêncio antes de a barra lateral sequer o ver. +- **`rawbody`** — o Markdown tal como foi escrito. Duas coisas precisam dele: o + botão que entrega uma página a um modelo, e o `llms-full.txt`. +- **`redirectFrom`** — transformado em regras de rota pela compilação; ver + [Mover uma página](/guides/move-a-page). +- **`sitemap`** — aquilo que põe estas páginas no sitemap. Uma coleção tem de + declarar o campo, ou o sitemap lista as rotas do site e nem uma única página + de documentação. + +## Fragmentos + +`_partials/` na pasta de documentação de **qualquer** fonte alimenta uma coleção +partilhada, e `:partial{name="install"}` em qualquer página de qualquer fonte +apresenta-o. O Content não traz nenhuma diretiva de inclusão, e ao longo de +vários repositórios essa lacuna não tem contorno nenhum — uma nota de instalação +que tem de dizer o mesmo em três projetos é copiada para três projetos e +diverge. + +Os fragmentos são excluídos das próprias coleções de páginas. Caso contrário +cada fragmento seria também uma página: na barra lateral, na pesquisa, no +`llms.txt`. + +## Rascunhos são ficheiros, não sinalizadores + +Um rascunho é `deploying.draft.md`, não `draft: true` no frontmatter. O +sinalizador é a escrita óbvia e a que não pode funcionar: o conteúdo de uma +coleção é declarado antes de o Content ter lido um único ficheiro — numa fonte +remota, antes sequer de a ter descarregado — por isso nada nesse momento sabe o +que diz qualquer frontmatter. Um nome de ficheiro é conhecido. O Content retira +`.draft` do URL, pelo que a página é servida em `/deploying` no servidor de +desenvolvimento e simplesmente não existe na compilação. diff --git a/docs/pt/2.concepts/5.build-checks.md b/docs/pt/2.concepts/5.build-checks.md new file mode 100644 index 00000000..c9bca976 --- /dev/null +++ b/docs/pt/2.concepts/5.build-checks.md @@ -0,0 +1,50 @@ +--- +title: O que a compilação verifica +description: As falhas silenciosas que esta camada teve, transformadas em mensagens. +icon: lucide:shield-check +--- + +Todos os erros que esta camada realmente teve foram silenciosos: uma coleção que +o Content descartou porque o nome não era um identificador JavaScript, uma +página que dava 404 porque uma ligação tinha envelhecido, uma navegação que +voltava vazia. Nenhum dos três disse nada — a compilação estava verde e o site +errado. Por isso correm quatro verificações depois da compilação, todas a ler a +mesma coisa: a cache de análise que o Content deixa para trás. + +| Verificação | Gravidade | +| :-------------------------------------------- | :-------- | +| Uma coleção sem páginas | erro | +| Uma pasta em conflito com um segmento de URL | erro | +| Um `title` ou `description` em falta | aviso | +| Uma ligação interna ou âncora partida | aviso | + +## Porque a gravidade não é uniforme + +Um erro custa uma página que ninguém consegue alcançar e que nada no site +alguma vez explicaria — uma coleção vazia é um 404 em todas as páginas de uma +versão. Um aviso custa qualidade: a página continua a ser apresentada, e uma +fonte remota pode desatualizar-se entre um lançamento e o seguinte sem que isso +seja culpa desta compilação. Fazer a compilação falhar porque o repositório de +outra pessoa avançou tornaria uma compilação verde dependente de um terceiro. + +## O que as verificações sabem e tu não + +- **Uma colisão de pastas** só é um problema enquanto um prefixo está ativo. Uma + pasta de documentação chamada `v2` está bem num site sem versões e é + inalcançável num versionado, porque o prefixo ganha. A compilação sabe qual é + o caso, e a mensagem nomeia o ficheiro e oferece a solução. +- **Uma ligação é resolvida como o tema a resolve.** Um caminho absoluto escrito + numa página é relativo à fonte dessa mesma página, por isso é tentado primeiro + sob o prefixo da fonte e depois nu. Um verificador que fizesse apenas a forma + nua reportaria todas as ligações corretas de um site multirrepositório. +- **URL externos não são verificados.** Verificar um poria a rede dentro da + compilação, e um servidor alheio inacessível não é razão para a tua + documentação não ser publicada. + +## Escreve as ligações como caminhos de documentação absolutos + +`/concepts/sources`, não `../2.concepts/2.sources.md`. O prefixo sob o qual uma +página é servida é decidido na compilação, por isso escrever caminhos sem ele é +o que permite que o mesmo Markdown sirva `/guides/deploying` num site e +`/acme/v2.0.0/guides/deploying` noutro. É também a única forma que o verificador +de ligações vê. diff --git a/docs/pt/2.concepts/6.machine-readers.md b/docs/pt/2.concepts/6.machine-readers.md new file mode 100644 index 00000000..fb8aec7c --- /dev/null +++ b/docs/pt/2.concepts/6.machine-readers.md @@ -0,0 +1,46 @@ +--- +title: Leitores automáticos +description: O que um modelo recebe — llms.txt, o Markdown em bruto de qualquer página e um verdadeiro servidor MCP. +icon: lucide:bot +--- + +A documentação é lida por modelos tanto quanto por pessoas, e as quatro +respostas abaixo são geradas a partir das mesmas coleções de que as páginas são +apresentadas — nenhuma delas pode, portanto, divergir do que está publicado. + +| Caminho | O que é | +| :--------------- | :----------------------------------------------------------------------- | +| `/llms.txt` | Um índice: nome do site, resumo, e cada página como entrada com ligação | +| `/llms-full.txt` | As mesmas páginas, inteiras, concatenadas num único ficheiro Markdown | +| `.md` | O código Markdown de qualquer página | +| `/mcp` | Um servidor MCP — `list_pages`, `search_docs`, `read_page` | + +## Markdown, não HTML + +Tudo aqui entrega o Markdown tal como foi escrito, e é por isso que o esquema da +coleção pede ao Content que guarde `rawbody`. Um modelo a quem se dá HTML +apresentado tem de desfazer um layout para encontrar um cabeçalho; um a quem se +dá a fonte lê o que o autor escreveu, com os blocos de código ainda blocos e um +bloco MDC ainda uma chamada de componente. + +`.md` é servido por um **middleware** em vez de uma rota, porque `.md` é +um sufixo do último segmento do caminho e não um segmento próprio — não existe +padrão de rota do Nitro que signifique «qualquer caminho terminado em .md». + +## Porquê os dois ficheiros llms + +A convenção de [llmstxt.org](https://llmstxt.org) nomeia dois, e respondem a +perguntas diferentes: um modelo que leu o índice ainda tem de ir buscar sessenta +páginas, e um modelo a quem se entrega o ficheiro completo já as leu. Qual sai +mais barato depende da pergunta, por isso o site publica ambos. + +## Porquê um servidor MCP a sério + +`/mcp` fala o protocolo através do SDK oficial em vez de ser um endpoint JSON de +desenho próprio do duxt. Um cliente liga-se-lhe como se liga a qualquer outra +coisa, em vez de alguém escrever um adaptador à volta de uma forma que só este +tema tem. + +A sua pesquisa é um `LIKE` sobre títulos e descrições, não o índice que o +browser usa: esse é construído do lado do cliente a partir do despejo da +coleção, e uma ferramenta de servidor tem a própria base de dados. diff --git a/docs/pt/2.concepts/7.seo.md b/docs/pt/2.concepts/7.seo.md new file mode 100644 index 00000000..fd117147 --- /dev/null +++ b/docs/pt/2.concepts/7.seo.md @@ -0,0 +1,48 @@ +--- +title: SEO +description: O que cada página emite para os rastreadores e os cartões sociais, e o único valor que a camada não consegue adivinhar. +icon: lucide:search +--- + +Cada página emite um head completo, uma descrição estruturada de si mesma, um +cartão social e uma entrada no sitemap. Um valor tem de vir de ti. + +## Por página + +- **Meta** — título com o nome do site, descrição, `og:title`, `og:description`, + `og:type`, `og:url` e um cartão de Twitter `summary_large_image`. Tudo isso já + está no objeto da página. +- **JSON-LD** — `TechArticle` mais uma `BreadcrumbList` construída a partir do + mesmo trilho que a navegação estrutural desenha. +- **Uma imagem Open Graph**, gerada a partir do modelo da camada. Entrega um + componente com o mesmo nome para a substituir. +- **`canonical` e `robots`**, que são a metade das versões — ver + [URL e versões](/concepts/urls-and-versions). + +## Em todo o site + +`/sitemap.xml` lista as páginas da versão predefinida, por locale. Uma versão +não predefinida fica excluída porque as suas páginas levam `noindex` de qualquer +modo, e uma versão `eol` fica excluída seja ou não a predefinida: listar +qualquer uma delas pediria a um rastreador que fosse buscar exatamente aquilo +que a página lhe diz depois para descartar. O `/robots.txt` é gerado ao lado. + +## O único valor que tens de indicar + +A origem. `hreflang`, `canonical`, o sitemap e as imagens Open Graph são todos +URL absolutos, e uma camada não pode conhecer o domínio onde será implementada. +Define `i18n.baseUrl` ou `NUXT_PUBLIC_I18N_BASE_URL`; a camada copia-o para +`site.url`, que é onde os módulos de SEO procuram. + +Se ficar por definir, cada um deles recai em saída relativa em vez de inventar +um domínio. É essa a escolha deliberada: um `canonical` relativo continua a +resolver corretamente, e um adivinhado está ativamente errado — apontaria um +rastreador para o site de outra pessoa. + +## Mais uma variável para uma implementação progressiva + +`NUXT_OG_IMAGE_SECRET` assina os URL das imagens Open Graph. Sem ela é gerado um +segredo por compilação, o que serve para uma instância e está errado para duas: +um cartão cujo URL foi assinado por uma instância é rejeitado pela seguinte, por +isso uma ligação partilhada durante uma implementação progressiva perde a +imagem. Gera-a uma vez; não roda. diff --git a/docs/pt/2.concepts/8.localisation.md b/docs/pt/2.concepts/8.localisation.md new file mode 100644 index 00000000..9b8a4601 --- /dev/null +++ b/docs/pt/2.concepts/8.localisation.md @@ -0,0 +1,97 @@ +--- +title: Localização +description: A camada traz sete locales, e cada texto configurado assume três formas. +icon: lucide:languages +--- + +Os textos do próprio tema — rótulos de navegação, pesquisa, o aviso de versão, o +404 — são entregues traduzidos em `en-GB`, `en-US`, `de-DE`, `es-ES`, `fr-FR`, +`pt-PT` e `pt-BR`. Um site escolhe quais serve com `locales`, e +`i18n.defaultLocale` decide qual é servido sem prefixo de URL. + +`locales` é lido em tempo de compilação: os locales decidem rotas e `hreflang`, +não apenas o que um componente desenha, por isso alterar a lista exige nova +compilação. + +::callout{type="warning" title="Restringir a lista precisa desta chave, não da do i18n"} +O array `locales` do i18n combina entre camadas em vez de substituir, por isso +declarar `['de-DE']` aí não acrescenta nem remove nada: os outros seis +continuam roteados, indexados e anunciados em `hreflang`. `duxt.locales` é a +chave que a reduz de facto. +:: + +## Cada texto configurado assume três formas + +Tudo o que escreves na configuração — um rótulo, um título, uma descrição — pode +ser um literal, uma chave i18n, ou um registo com um texto por locale: + +```ts +label: 'Repository' // literal +label: 'app.nav.repository' // chave própria +label: { 'en-GB': 'Repository', 'pt-PT': 'Repositório' } // por locale +``` + +Qual delas é decide-se conforme o texto seja uma chave registada. Um site +monolingue nunca encontra as outras duas formas, e um site com um punhado de +textos em cinco idiomas não precisa de ficheiros de locale nenhuns — a forma de +registo resolve pela língua base, por isso `pt-PT` cobre `pt-BR` e `en-GB` cobre +`en-US`. + +## Não mexas nas chaves da camada + +`duxt.defaults.*` traduz os valores por omissão que a própria camada entrega. +Essas chaves são internas: renomear uma não é uma alteração incompatível da +camada, e o i18n responde a uma chave em falta imprimindo a chave — quem +dependesse de uma saberia por um leitor, não por uma compilação. Usa um literal, +uma chave tua, ou a forma de registo. + +## Caminhos e ligações + +Só os caminhos são localizados. `useDuxtPath()` dá o caminho onde está a +**documentação**, sem o segmento de locale; `useDuxtLink()` volta a escrevê-lo +como ligação, acrescentando o segmento de novo. Um URL absoluto, uma ligação de +correio ou uma âncora isolada passam intactos. Tudo o que calcule um caminho de +documentação deve passar por este par em vez de ler a rota diretamente. + +## Páginas traduzidas + +A interface e as páginas são duas decisões separadas. Um site que nada diga +serve uma mesma árvore em todos os locales — o enquadramento muda de idioma, o +Markdown não — e essa é a forma certa para a maior parte da documentação. + +`locales` numa fonte acrescenta a segunda metade: + +```ts [app/app.config.ts] +sources: [{ path: 'docs', locales: ['en-GB', 'pt'] }] +``` + +O locale por omissão é a própria árvore em `path`, sem pasta própria, por isso +acrescentar esta chave nunca move uma página já publicada. Qualquer outro idioma +é uma pasta lá dentro — `docs/pt/` — ou fica noutro sítio por completo; ver +[Vários idiomas](/guides/several-languages). + +::callout{type="tip" title="O locale não faz parte do caminho de conteúdo"} +`/pt-PT/guides/deploying` e `/guides/deploying` são o mesmo caminho de conteúdo +em duas coleções. O segmento pertence ao roteamento do i18n, nunca a uma coleção +— é por isso que mudar de idioma mantém o leitor na mesma página, e por isso uma +tradução em falta é resolvida com mais uma consulta em vez de um +redirecionamento. +:: + +## Quando uma página não tem tradução + +O leitor recebe o idioma mais próximo que a tenha, com um aviso a dizê-lo, e a +página fica fora do índice sob `noindex`, com um `canonical` a apontar para o +idioma que realmente a transporta. A cadeia é a mesma que a camada já aplica aos +textos: + +1. o próprio locale — `pt-PT` lê `pt-PT` +2. a sua língua base — `pt-PT` lê uma árvore `pt`, o que permite a uma pasta + `pt/` servir tanto `pt-PT` como `pt-BR` +3. uma irmã da mesma língua — `pt-BR` lê `pt-PT` antes de sair do português +4. `fallbackLocale` do vue-i18n, o mesmo valor por onde um texto em falta + resolve +5. o original por traduzir + +Um 404 fica deliberadamente de fora: uma lacuna numa tradução pertence a quem +escreve, e respondê-la escondendo a página castiga o leitor por isso. diff --git a/docs/pt/2.concepts/index.md b/docs/pt/2.concepts/index.md new file mode 100644 index 00000000..2ded7509 --- /dev/null +++ b/docs/pt/2.concepts/index.md @@ -0,0 +1,12 @@ +--- +title: Conceitos +description: Como a camada funciona — o modelo de fontes, o esquema de URL, as coleções e as verificações. +icon: lucide:compass +--- + +As páginas de introdução levam-te do nada até um site a funcionar. Estas +explicam o que são as peças, onde ficam as junções e porque cada uma é feita +como é. + +::page-cards +:: diff --git a/docs/pt/index.md b/docs/pt/index.md new file mode 100644 index 00000000..649d780b --- /dev/null +++ b/docs/pt/index.md @@ -0,0 +1,21 @@ +--- +title: Documentação +description: Uma camada Nuxt que transforma a pasta docs de um repositório num site de documentação versionado e multirrepositório. +icon: lucide:book-open-text +navigation: false +--- + +Tudo o que o duxt faz, em quatro partes. Comece pela esquerda se for novo, ou +escolha a parte que veio procurar. + +::page-cards +:: + +Duas coisas não estão aqui de propósito. Como contribuir para o próprio duxt +está em +[`CONTRIBUTING.md`](https://github.com/kirchDev/duxt/blob/main/CONTRIBUTING.md), +e as decisões por trás da camada estão registadas nas +[Decisões de arquitetura](/adr). + +Aquilo em que a camada assenta, e aquilo de que partiu, está nos +[Créditos](/credits). diff --git a/www/app/app.config.ts b/www/app/app.config.ts index 643458a4..bcf932d8 100644 --- a/www/app/app.config.ts +++ b/www/app/app.config.ts @@ -19,9 +19,19 @@ export default defineAppConfig({ // what `repo` would do, and it would clone the checkout we stand in. { path: 'docs', - origin: { repo: 'kirchDev/duxt', ref: 'main' } + origin: { repo: 'kirchDev/duxt', ref: 'main' }, + + // FOUR languages over one tree, named by LANGUAGE rather than locale. + // `docs/pt/` serves both `pt-PT` and `pt-BR`, and `en-US` reads the + // original through `fallbackLocale` — the same rule the locale FILES in + // nuxt.config already follow, so seven locales need four folders. + // + // `en-GB` is the tree in `docs/` itself and takes no folder of its own, + // which keeps every URL this site already serves where it is. + locales: ['en-GB', 'de', 'es', 'fr', 'pt'] } ], + sourceOptions: { defaultLocale: 'en-GB' }, // The feed, pointed at a section that has dated entries. Off by default in // the layer; this site turns it on so the route is exercised. feed: { path: '/adr', title: 'duxt — decisions' }, From 3d8051610ac198b6b2dce4b0598c23f414bca388 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Sun, 6 Sep 2026 21:35:31 +0200 Subject: [PATCH 08/71] docs: translate the guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight guides in four languages. The checklists translate too — a checklist a reader cannot read is a checklist nobody ticks. --- docs/de/3.guides/1.several-repositories.md | 59 +++++++++ docs/de/3.guides/2.several-versions.md | 76 ++++++++++++ docs/de/3.guides/3.several-languages.md | 127 ++++++++++++++++++++ docs/de/3.guides/4.override-the-theme.md | 71 +++++++++++ docs/de/3.guides/5.move-a-page.md | 55 +++++++++ docs/de/3.guides/6.deploy-a-site.md | 65 ++++++++++ docs/de/3.guides/7.collect-page-feedback.md | 123 +++++++++++++++++++ docs/de/3.guides/index.md | 12 ++ docs/es/3.guides/1.several-repositories.md | 58 +++++++++ docs/es/3.guides/2.several-versions.md | 74 ++++++++++++ docs/es/3.guides/3.several-languages.md | 126 +++++++++++++++++++ docs/es/3.guides/4.override-the-theme.md | 72 +++++++++++ docs/es/3.guides/5.move-a-page.md | 56 +++++++++ docs/es/3.guides/6.deploy-a-site.md | 66 ++++++++++ docs/es/3.guides/7.collect-page-feedback.md | 120 ++++++++++++++++++ docs/es/3.guides/index.md | 12 ++ docs/fr/3.guides/1.several-repositories.md | 59 +++++++++ docs/fr/3.guides/2.several-versions.md | 75 ++++++++++++ docs/fr/3.guides/3.several-languages.md | 127 ++++++++++++++++++++ docs/fr/3.guides/4.override-the-theme.md | 73 +++++++++++ docs/fr/3.guides/5.move-a-page.md | 55 +++++++++ docs/fr/3.guides/6.deploy-a-site.md | 63 ++++++++++ docs/fr/3.guides/7.collect-page-feedback.md | 125 +++++++++++++++++++ docs/fr/3.guides/index.md | 12 ++ docs/pt/3.guides/1.several-repositories.md | 57 +++++++++ docs/pt/3.guides/2.several-versions.md | 73 +++++++++++ docs/pt/3.guides/3.several-languages.md | 126 +++++++++++++++++++ docs/pt/3.guides/4.override-the-theme.md | 71 +++++++++++ docs/pt/3.guides/5.move-a-page.md | 54 +++++++++ docs/pt/3.guides/6.deploy-a-site.md | 65 ++++++++++ docs/pt/3.guides/7.collect-page-feedback.md | 120 ++++++++++++++++++ docs/pt/3.guides/index.md | 12 ++ 32 files changed, 2339 insertions(+) create mode 100644 docs/de/3.guides/1.several-repositories.md create mode 100644 docs/de/3.guides/2.several-versions.md create mode 100644 docs/de/3.guides/3.several-languages.md create mode 100644 docs/de/3.guides/4.override-the-theme.md create mode 100644 docs/de/3.guides/5.move-a-page.md create mode 100644 docs/de/3.guides/6.deploy-a-site.md create mode 100644 docs/de/3.guides/7.collect-page-feedback.md create mode 100644 docs/de/3.guides/index.md create mode 100644 docs/es/3.guides/1.several-repositories.md create mode 100644 docs/es/3.guides/2.several-versions.md create mode 100644 docs/es/3.guides/3.several-languages.md create mode 100644 docs/es/3.guides/4.override-the-theme.md create mode 100644 docs/es/3.guides/5.move-a-page.md create mode 100644 docs/es/3.guides/6.deploy-a-site.md create mode 100644 docs/es/3.guides/7.collect-page-feedback.md create mode 100644 docs/es/3.guides/index.md create mode 100644 docs/fr/3.guides/1.several-repositories.md create mode 100644 docs/fr/3.guides/2.several-versions.md create mode 100644 docs/fr/3.guides/3.several-languages.md create mode 100644 docs/fr/3.guides/4.override-the-theme.md create mode 100644 docs/fr/3.guides/5.move-a-page.md create mode 100644 docs/fr/3.guides/6.deploy-a-site.md create mode 100644 docs/fr/3.guides/7.collect-page-feedback.md create mode 100644 docs/fr/3.guides/index.md create mode 100644 docs/pt/3.guides/1.several-repositories.md create mode 100644 docs/pt/3.guides/2.several-versions.md create mode 100644 docs/pt/3.guides/3.several-languages.md create mode 100644 docs/pt/3.guides/4.override-the-theme.md create mode 100644 docs/pt/3.guides/5.move-a-page.md create mode 100644 docs/pt/3.guides/6.deploy-a-site.md create mode 100644 docs/pt/3.guides/7.collect-page-feedback.md create mode 100644 docs/pt/3.guides/index.md diff --git a/docs/de/3.guides/1.several-repositories.md b/docs/de/3.guides/1.several-repositories.md new file mode 100644 index 00000000..c6fea601 --- /dev/null +++ b/docs/de/3.guides/1.several-repositories.md @@ -0,0 +1,59 @@ +--- +title: Mehrere Repositories +description: Die Dokumentation mehrerer Projekte von einer Seite ausliefern. +icon: lucide:folder-git +--- + +Eine Seite, viele Projekte. Jedes Quell-Repository enthält nichts als Markdown — +kein Nuxt, keine `package.json`, keine Abhängigkeiten — was die einzige +praktikable Anordnung für ein Go-, PHP- oder Rust-Projekt ist. + +::steps +1. **Benenne jedes Repository als Quelle.** Ein Repository-Segment erscheint in + jeder URL, sobald es mehr als eines gibt — gib also jedem einen `slug`, den du + zu behalten bereit bist: Er ist Teil der URL. + + ```ts [app/app.config.ts] + sources: [ + { path: 'docs', slug: 'acme', origin: { repo: 'acme/site', ref: 'main' } }, + { repo: 'acme/api', path: 'docs' }, + { repo: 'acme/cli', path: 'docs' } + ] + ``` + +2. **Nimm `origin`, nicht `repo`, für dieses Repository.** `repo` ist das, was + Content eine Quelle herunterladen lässt — das eigene dort zu nennen, ließe den + Build den Checkout klonen, in dem er bereits steht. `origin` sagt nur, wo die + Seiten liegen, für den Link „Diese Seite bearbeiten". + +3. **Authentifiziere die privaten.** Content liest ein Token aus dem `auth` der + Collection; nichts daran ist duxt-spezifisch. + +4. **Schreibe deine eigenen `sections`.** Die Navigation, die die Ebene + mitbringt, nimmt eine einzelne Quelle ohne Präfix an. Mit einem + Repository-Segment in jedem Pfad lösen ihre Einträge nicht mehr auf — benenne + die Sektionen also selbst und zeige auf `/acme/getting-started` statt auf + `/getting-started`. + +5. **Baue einmal und lies die Meldungen.** Eine leere Collection lässt den Build + scheitern, und das ist der Fehlschlag, den dieses Setup am häufigsten + erzeugt: Ein `path`, den es im anderen Repository nicht gibt, sieht genauso + aus wie ein Repository ohne Dokumentation. +:: + +## Was du kostenlos bekommst + +Die Suche erstreckt sich über jede Quelle und bewertet die Treffer gemeinsam, +wobei die Quelle, in der der Leser gerade ist, zuerst kommt — die Version, die er +liest, oder deren Standard, eine je Repository, damit eine Seite nie einmal pro +Version erscheint. `_partials/` aus jeder Quelle speist eine gemeinsame +Collection, ein Installationshinweis kann also einmal geschrieben und in allen +drei Projekten eingebunden werden. + +## Checkliste + +- [ ] Jede Quelle hat einen `slug`, den du in der URL haben willst +- [ ] Dieses Repository nutzt `origin`, nicht `repo` +- [ ] `sections` zeigen auf Pfade, die das Repository-Segment enthalten +- [ ] Der Build meldet keine leere Collection +- [ ] Die Suche aus einem Repository findet eine Seite in einem anderen diff --git a/docs/de/3.guides/2.several-versions.md b/docs/de/3.guides/2.several-versions.md new file mode 100644 index 00000000..be48432b --- /dev/null +++ b/docs/de/3.guides/2.several-versions.md @@ -0,0 +1,76 @@ +--- +title: Mehrere Versionen +description: Dieselbe Dokumentation an mehreren Refs ausliefern, mit Umschalter und Banner. +icon: lucide:git-compare +--- + +Ein Ref wird zu einer Version. Richte eine Quelle auf Tags, und jeder davon wird +veröffentlicht — mit einem Versionssegment in der URL und einem Eintrag im +Umschalter. + +## Schritte + +::steps +1. **Liste die Refs auf.** Eine bloße Zeichenkette ist ein **Branch**. Ein Tag + muss das sagen — git hält beide in getrennten Namensräumen, und einen Tag + unter `refs/heads` anzufragen lässt den Build scheitern. + + ```ts [app/app.config.ts] + sources: [ + { + repo: 'acme/api', + path: 'docs', + refs: [ + { branch: 'main', status: 'upcoming' }, + { tag: 'v2.0.0' }, + { tag: 'v1.4.0' }, + { tag: 'v0.9.0', status: 'eol' } + ] + } + ], + sourceOptions: { defaultRef: 'v2.0.0' } + ``` + +2. **Benenne den Standard.** `defaultRef` ist der Ref, der ohne Versionspräfix + ausgeliefert wird, und der, auf den Suchmaschinen gelenkt werden. Ungesetzt + ist es der erste in der Liste — was selten das ist, was du willst, wenn die + Liste mit einem Branch beginnt. + +3. **Gib den Lebenszyklus an, wo er sich nicht ableiten lässt.** Semver ordnet + Tags, „älter als der Standard" braucht also keine Hilfe. Einen Branch gegen + einen Tag kann es nicht einordnen: `main` ist `upcoming`, und ohne diese + Angabe wird seinem Leser gesagt, er solle auf eine Version aktualisieren, die + älter ist als das, was er liest. + +4. **Nimm `latest`, wenn du diese Liste nicht bei jedem Release bearbeiten + willst.** Es löst zur Build-Zeit auf den neuesten Semver-Tag dieses + Repositories auf. + + ```ts + refs: [{ branch: 'main', status: 'upcoming' }, 'latest'] + ``` + +5. **Markiere die Versionsanforderung einer Seite im Text**, wo ein Leser, der + eine Version ansieht, die anderen nicht sehen kann: + + ```md + ### Wiederholungen :since{version="v2.0.0"} + ``` + +:: + +## Was mit den alten Versionen passiert + +Eine Nicht-Standardversion trägt `noindex` und ein `canonical`, das auf dieselbe +Seite in der Standardversion zeigt, und eine `eol`-Version verlässt die Sitemap +vollständig. Das hindert eine Suchmaschine daran, `v0.9.0` anzubieten, wo der +Leser die heutige Dokumentation wollte — siehe +[URLs und Versionen](/concepts/urls-and-versions). + +## Checkliste + +- [ ] Jeder Tag ist als `{ tag: … }` geschrieben, nicht als bloße Zeichenkette +- [ ] `defaultRef` benennt die Version, auf der Leser landen sollen +- [ ] Ein Branch in der Liste trägt einen ausdrücklichen `status` +- [ ] Der Umschalter zeigt jede Version, mit einem Abzeichen an den toten +- [ ] Die Seite einer toten Version fehlt in `/sitemap.xml` diff --git a/docs/de/3.guides/3.several-languages.md b/docs/de/3.guides/3.several-languages.md new file mode 100644 index 00000000..342d5f04 --- /dev/null +++ b/docs/de/3.guides/3.several-languages.md @@ -0,0 +1,127 @@ +--- +title: Mehrere Sprachen +description: Die Oberfläche in mehreren Sprachen ausliefern — und die Seiten selbst übersetzen. +icon: lucide:languages +--- + +Die Zeichenketten des Themes sind in sieben Locales übersetzt. Eine Seite +bedient die Teilmenge, die sie will, übersetzt die Zeichenketten, die sie selbst +konfiguriert hat — und, wenn sie mag, auch die Seiten. + +## Schritte + +::steps +1. **Schränke die Liste mit `duxt.locales` ein.** + + ```ts [app/app.config.ts] + locales: ['en-GB', 'de-DE'] + ``` + + Nicht i18ns eigenes `locales`-Array: Das führt über Ebenen hinweg zusammen, + statt zu ersetzen — dort zwei zu deklarieren lässt die anderen fünf geroutet + und in `hreflang` angekündigt. + +2. **Wähle die unpräfigierte** in `nuxt.config.ts` mit `i18n.defaultLocale`. Sie + ist URL-relevant: Sie später zu ändern verschiebt jede Seite in beide + Richtungen zugleich. + +3. **Übersetze deine eigenen Zeichenketten.** Für eine Handvoll kostet die + Datensatzform weniger als eine Locale-Datei je Sprache: + + ```ts + sections: [ + { + label: { 'en-GB': 'Guides', 'de-DE': 'Anleitungen' }, + to: '/guides', + icon: 'lucide:book-open' + } + ] + ``` + + Für mehr als eine Handvoll registriere eigene i18n-Schlüssel und schreibe den + Schlüssel. Greife nicht zu `duxt.defaults.*` — das sind die internen + Schlüssel der Ebene, und eine Umbenennung dort bräche deine Seite lautlos. + +4. **Setze den Ursprung.** `hreflang` ist relativ nicht gültig, es braucht also + `i18n.baseUrl` oder `NUXT_PUBLIC_I18N_BASE_URL`. + +5. **Übersetze die Seiten, wenn du magst.** Ein Ordner je Sprache innerhalb der + Quelle; die Standard-Locale bleibt, wo sie ist, und verschiebt keine URL: + + ```ts [app/app.config.ts] + sources: [{ path: 'docs', locales: ['en-GB', 'de'] }] + ``` + + ::file-tree{title="docs/"} + --- + tree: + - name: index.md + - name: 1.getting-started/ + children: + - name: index.md + - name: de/ + children: + - name: index.md + --- + :: + + Übersetze so wenige Seiten, wie du willst — alles andere fällt zurück, mit + einem Banner, das dem Leser sagt, warum. + +6. **Öffne eine Seite unter jeder Locale** und prüfe, ob die Oberfläche die + Sprache gewechselt hat, der Pfad weiterhin auflöst und keine konfigurierte + Beschriftung als roher Schlüssel erscheint. +:: + +## Nur manche Versionen, oder ein anderes Repository + +`locales` an einem Ref überschreibt das der Quelle, und das ist die übliche +Form: Die aktuelle Version ist übersetzt, die zwei dahinter nicht. + +```ts +{ + path: 'docs', + locales: ['en-GB', 'de'], + refs: [ + { tag: 'v2.0.0' }, // beide Sprachen + { tag: 'v1.0.0', locales: ['en-GB'] } // nur das Original + ] +} +``` + +Die Objektform verschiebt eine Sprache ganz woandershin — in ein eigenes +Repository, mit eigenen Betreuern und eigenem Tempo: + +```ts +locales: ['en-GB', { locale: 'de', repo: 'acme/docs-de', path: 'docs' }] +``` + +Genau das tun React und Vue außerhalb ihres Werkzeugs, mit `de.react.dev` und +der Organisation `vuejs-translations`, weil Übersetzer nach eigenem Zeitplan und +eigenem Review arbeiten. Hier ist es ein Quelleneintrag statt einer zweiten +Website — ein Suchindex, ein Sprachumschalter. + +::callout{type="warning" title="Was eine Übersetzung im Build kostet"} +Grob **zwei Sekunden Build und 0,6 MB Datenbank je Sprache × Version × +Repository**, gemessen linear bis mindestens 200 Collections. Jede Version jedes +Repositories zu übersetzen multipliziert alle drei; nur die aktuelle Version zu +übersetzen ist das, was die meisten Projekte tatsächlich tun. +:: + +## Wenn eine Seite keine Übersetzung hat + +Der Leser bekommt die nächstliegende Sprache, die sie hat, mit einem Banner, und +die Seite trägt `noindex` plus ein Canonical, das auf die Sprache zeigt, in der +sie wirklich ist. Die Kette — Locale, Basissprache, Geschwisterregion, +`fallbackLocale`, Original — steht in [Lokalisierung](/concepts/localisation). + +## Checkliste + +- [ ] `duxt.locales` listet genau die Locales, die du bedienst +- [ ] `/sitemap.xml` enthält einen Eintrag je Seite und Locale, und keine + weiteren +- [ ] `hreflang`-Links sind absolut +- [ ] Keine konfigurierte Beschriftung erscheint als roher Schlüssel +- [ ] Ein Sprachwechsel hält den Leser auf derselben Seite +- [ ] Eine nicht übersetzte Seite zeigt das Rückfall-Banner, keinen 404 +- [ ] `duxt.sourceOptions.defaultLocale` passt zu `i18n.defaultLocale` diff --git a/docs/de/3.guides/4.override-the-theme.md b/docs/de/3.guides/4.override-the-theme.md new file mode 100644 index 00000000..efca459f --- /dev/null +++ b/docs/de/3.guides/4.override-the-theme.md @@ -0,0 +1,71 @@ +--- +title: Das Theme überschreiben +description: Eine Farbe ändern, eine Komponente ersetzen, ein shadcn-vue-Primitiv hinzufügen. +icon: lucide:palette +--- + +Die Ebene liefert das Theme als Dateien, die du überschatten kannst. Drei +Stufen, die kleinste zuerst — und die kleinste, die die Aufgabe erledigt, ist +die richtige, denn eine Komponente, die du ersetzt, ist eine Komponente, die du +nun pflegst. + +## Schritte + +:::steps +1. **Eine Farbe: definiere den Token neu.** Die Palette ist das neutrale + shadcn-Set als CSS-Variablen. Definiere eine in deinem eigenen Stylesheet + neu; ein Fork der Datei der Ebene ist nicht nötig. + + ```css [app/assets/css/main.css] + :root { + --primary: oklch(0.55 0.2 265); + } + .dark { + --primary: oklch(0.7 0.18 265); + } + ``` + + Beide Blöcke sind wichtig: Das Theme schaltet über eine `dark`-**Klasse**, + nicht über eine Media-Query — ein nur einmal definierter Token ist also in + einem der beiden Modi falsch. + + ::callout{type="warning" title="Prüfe den Kontrast, den du gerade geändert hast"} + `--muted-foreground` trägt die Seitenbeschreibungen, das Inhaltsverzeichnis + und den Breadcrumb. Der Wert der Ebene ist bewusst dunkler als shadcns + Standard, der auf den Flächen, auf denen dieser Token tatsächlich landet, + unter 4,5:1 misst. + :: + +2. **Ein Stück des Layouts: lege die Datei an.** Nuxt löst deine Komponente + gleichen Namens zuerst auf — `DuxtFooter` zu ersetzen heißt also, + `app/components/DuxtFooter.vue` in deinem Projekt anzulegen. Die Namen, Props + und Slots stehen in der [Komponentenreferenz](/reference/components) — ein + dokumentierter Name ist Teil der öffentlichen Oberfläche und wird nicht ohne + ein Major-Release umbenannt. + +3. **Ein Slot, wo es einen gibt.** `DuxtPageFeedback` sendet `feedback` und legt + seinen Zustand über einen Slot offen, du kannst dein eigenes Formular also + daran hängen, ohne die Komponente zu ersetzen — durchgespielt in + [Seiten-Feedback sammeln](/guides/collect-page-feedback). + +4. **Ein neues Primitiv: nimm die CLI.** Die Ebene besitzt ihre + shadcn-vue-Komponenten, statt sie zu importieren, und `components.json` + richtet die CLI bereits auf den passenden Alias: + + ```bash + pnpm dlx shadcn-vue@latest add tooltip + ``` + +5. **Eine Komponente für Markdown.** Alles in `app/components/content/` ist aus + einer Seite mit MDC-Blocksyntax aufrufbar. Deine eigene Datei gleichen Namens + ersetzt die der Ebene — siehe [MDC-Referenz](/reference/mdc-components). + +::: + +## Checkliste + +- [ ] Nichts wurde aus der Ebene kopiert, was ein Token hätte ändern können +- [ ] Jeder neu definierte Token hat einen Wert für hell und dunkel +- [ ] Ersetzte Komponenten heißen genau so, wie die Referenz sie benennt +- [ ] `pnpm build` rendert weiterhin jede Seite (ein Override ist die Stelle, an + der SSR bricht) diff --git a/docs/de/3.guides/5.move-a-page.md b/docs/de/3.guides/5.move-a-page.md new file mode 100644 index 00000000..fcf01c33 --- /dev/null +++ b/docs/de/3.guides/5.move-a-page.md @@ -0,0 +1,55 @@ +--- +title: Eine Seite verschieben +description: redirectFrom im Frontmatter, in Routenregeln unter jedem Präfix übersetzt. +icon: lucide:signpost +--- + +Eine verschobene Seite ist der eine tote Link, den sich eine Dokumentationsseite +selbst erzeugt. Schreibe die alte URL in das Frontmatter der neuen Seite, und +die Weiterleitung geht im selben Commit wie die Verschiebung raus. + +## Schritte + +::steps +1. **Verschiebe die Datei** und benenne sie um, wie du es vorhattest. + +2. **Liste die alten Pfade auf der Seite selbst**, ohne jedes Präfix: + + ```md [docs/3.guides/6.deploy-a-site.md] + --- + title: Eine Seite ausliefern + description: Wo eine duxt-Seite laufen kann. + redirectFrom: + - /getting-started/deploy + - /deployment + --- + ``` + +3. **Baue neu.** Der Build macht aus jedem Eintrag Routenregeln — unter jedem + Repository-, Versions- und Locale-Präfix, unter dem die Seite ausgeliefert + wird. + +4. **Prüfe eine davon**, samt Präfix, in der gebauten Seite. + +:: + +## Warum das Frontmatter der richtige Ort ist + +Die Lösung lag immer außerhalb der Seite: eine nginx-Regel, eine +Netlify-`_redirects`-Datei, eine Cloudflare-Liste — geschrieben von dem, der die +Seite ausliefert, und nie von dem, der die Seite verschoben hat. Und nur die +Ebene kann es einmal lösen, weil nur das aufgelöste Manifest weiß, welche +Präfixe es gibt; wer die Regeln von Hand schriebe, müsste das ganze Schema je +Seite neu herausfinden. + +::callout{type="tip" title="Umsortieren braucht nie eine Weiterleitung"} +Das numerische Präfix wird aus der URL entfernt, `3.guides/` in `4.guides/` +umzubenennen verschiebt also nichts. Nur der Teil nach dem Punkt ist die URL. +:: + +## Checkliste + +- [ ] Jeder alte Pfad ist aufgeführt, ohne Präfix geschrieben +- [ ] Der Build meldet keinen toten internen Link +- [ ] Eine alte URL leitet auch unter dem Versionspräfix weiter, nicht nur nackt +- [ ] Der Webserver-Konfiguration wurde nichts hinzugefügt diff --git a/docs/de/3.guides/6.deploy-a-site.md b/docs/de/3.guides/6.deploy-a-site.md new file mode 100644 index 00000000..dff35e67 --- /dev/null +++ b/docs/de/3.guides/6.deploy-a-site.md @@ -0,0 +1,65 @@ +--- +title: Eine Seite ausliefern +description: Wo eine duxt-Seite laufen kann, was sie dort braucht, und warum Inhalte nicht zur Laufzeit aufgefrischt werden. +icon: lucide:cloud-upload +--- + +Eine duxt-Seite ist eine Nuxt-Anwendung. Alles, was Nuxt ausführt, führt sie +aus. + +## Schritte + +:::steps +1. **Bevorzuge statisch.** `nuxt generate` erzeugt einen Ordner HTML — kein + Server, keine Laufzeitkosten, jede Seite am Edge zwischengespeichert. Für + Dokumentation ist das die richtige Vorgabe. + + ::package-managers{command="run generate"} + :: + + `nuxt build` erzeugt einen Node-Server, den du nur brauchst, wenn eine Seite + auf eine Anfrage reagieren muss. + +2. **Gib dem Build, was er braucht.** Node 24 (die Datenbank wird über + `node:sqlite` gelesen, ein nativer Treiber wird also nicht installiert), + Netzzugang zu jeder entfernten Quelle und ein Token für die privaten. + +3. **Setze den Ursprung.** `NUXT_PUBLIC_I18N_BASE_URL` oder `i18n.baseUrl` in + der Konfiguration. Ohne ihn fallen `hreflang`, `canonical`, die Sitemap und + die Social Cards auf relative Ausgabe zurück. + +4. **Setze ein OG-Bild-Geheimnis, wenn du mehr als eine Instanz ausrollst.** + `NUXT_OG_IMAGE_SECRET`, einmal erzeugt. Ohne es signiert jeder Build die + Bild-URLs mit einem eigenen Geheimnis, und eine von einer Instanz signierte + Card wird von der nächsten abgelehnt. + +5. **Plane einen erneuten Build.** Eine Seite, die ein anderes Repository liest, + nimmt dessen Änderungen auf, wenn sie baut, nicht wenn sie gepusht werden. + +::: + +## Warum es keine Auffrischung zur Laufzeit gibt + +Content lädt ein entferntes Repository zur Build-Zeit herunter und übersetzt es +in die Datenbank, die der Build ausliefert. Das zur Laufzeit aufzufrischen +hieße, die Datenbank in einem laufenden Server neu zu bauen — Content bietet +dafür keinen unterstützten Weg, und der einzige verfügbare ist dieselbe +Client-Datenbank, die die Suche liest. Ein geplanter Neubau, oder einer, der vom +Quell-Repository ausgelöst wird, hält das Deployment unveränderlich und die +Inhalte reproduzierbar. + +## Was die Seite außer Seiten ausliefert + +`/llms.txt`, `/llms-full.txt`, den `.md`-Quelltext jeder Seite, `/mcp`, +`/sitemap.xml`, `/robots.txt` und — einmal konfiguriert — `/rss.xml`. Die +vollständige Liste mit ihren Einschränkungen steht in der +[Endpunkt-Referenz](/reference/endpoints). + +## Checkliste + +- [ ] `nuxt generate` läuft ohne Fehler des Validators durch +- [ ] Der Ursprung ist im Deployment gesetzt, nicht nur lokal +- [ ] `/sitemap.xml` listet absolute URLs auf der echten Domain +- [ ] Ein Neubau ist geplant oder wird von jedem Quell-Repository ausgelöst +- [ ] `NUXT_OG_IMAGE_SECRET` ist gesetzt, wenn mehr als eine Instanz die Seite + ausliefert diff --git a/docs/de/3.guides/7.collect-page-feedback.md b/docs/de/3.guides/7.collect-page-feedback.md new file mode 100644 index 00000000..bd2ea703 --- /dev/null +++ b/docs/de/3.guides/7.collect-page-feedback.md @@ -0,0 +1,123 @@ +--- +title: Seiten-Feedback sammeln +description: DuxtPageFeedback umhüllen und die Antwort dort behalten, wo deine Analytik ohnehin liegt. +icon: lucide:thumbs-up +--- + +Jede Dokumentationsseite endet mit „War diese Seite hilfreich?", und die Ebene +tut nichts mit der Antwort: Sie hält sie für die Dauer des Besuchs im eigenen +Zustand der Komponente, sendet ein Ereignis und speichert nichts. Die Komponente +zu umhüllen ist der Weg, auf dem die Antwort bei dir ankommt. + +## Schritte + +::steps + +1. **Umhülle die Komponente der Ebene unter ihrem eigenen Namen.** Nuxt löst + deine Datei zuerst auf — `app/components/DuxtPageFeedback.vue` in deinem + Projekt ersetzt also die der Ebene, und der `@duxt`-Alias erreicht das + Original weiterhin über den Pfad, sodass die Hülle das Markup, die + Übersetzungen und das Zurücksetzen bei Navigation der Ebene behält: + + ```vue [app/components/DuxtPageFeedback.vue] + + + + ``` + +2. **Gib der Antwort einen Ort zum Landen.** Eine eigene Route, damit der + Browser des Lesers nie mit einem Dritten spricht, den du nicht gewählt hast: + + ```ts [server/api/feedback.post.ts] + export default defineEventHandler(async (event) => { + const { path, helpful } = await readBody(event); + + // Dein Speicher, deine Analytik, deine Warteschlange. + await recordFeedback({ path, helpful }); + + return { ok: true }; + }); + ``` + + Ein clientseitiger Analytik-Aufruf funktioniert genauso — ersetze das + `$fetch` in der Hülle durch das, was das SDK deines Anbieters anbietet. + +3. **Baue neu und beantworte die Frage einmal**, dann prüfe, ob die Antwort mit + dem erwarteten Pfad ankam — unter einem Versionspräfix, nicht nur an der + nackten URL. + +:: + +## Ein eigenes Bedienelement rendern + +Das Ereignis behält die Schaltflächen der Ebene. Um sie zu ersetzen, nimm +stattdessen den Standard-Slot: Er legt denselben Zustand offen, aus dem die +Komponente rendert, sodass das Zurücksetzen bei Navigation und der +„Danke"-Zustand Sache der Ebene bleiben. + +```vue [app/components/DuxtPageFeedback.vue] + + +