@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +42 -10
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- package/tfplugindocs.ts +158 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one extension point duxt opens: what the site holds, as search records.
|
|
3
|
+
*
|
|
4
|
+
* THE DECISION BEHIND THIS FILE, because the file is the smaller half of it.
|
|
5
|
+
* The layer is extended by five things already — a CSS token, a shadowed
|
|
6
|
+
* component, a slot, `app.config`'s `duxt` key and the `sectionTypes` registry
|
|
7
|
+
* — and none of them reaches the build. The question was whether to open a
|
|
8
|
+
* generic hook registry, a `duxt.plugins` API, or neither.
|
|
9
|
+
*
|
|
10
|
+
* NEITHER, and then one hook. A distributable duxt extension is a **Nuxt
|
|
11
|
+
* layer**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries
|
|
12
|
+
* components, `app.config` defaults, `content.config.ts` collections, server
|
|
13
|
+
* routes, MCP tools and a module of its own, in one package with a version.
|
|
14
|
+
* A plugin system would reimplement layer resolution inside a layer, and a
|
|
15
|
+
* registry a plugin writes into at import time walks straight into the
|
|
16
|
+
* two-loader wall that shaped `sectionTypes` — `content.config.ts` and the duxt
|
|
17
|
+
* module are loaded by different loaders, so a mutable registry each wrote into
|
|
18
|
+
* would be two registries (`sections-resolve.ts`). A Nuxt BUILD hook does not:
|
|
19
|
+
* only the module half sees it, and that half has one loader.
|
|
20
|
+
*
|
|
21
|
+
* So there is exactly one hook, it is build-time, and it exists because three
|
|
22
|
+
* things want the same data and none of them can get it today: an external
|
|
23
|
+
* search index — Pagefind, Typesense, Meilisearch — needs every page of every
|
|
24
|
+
* source, version and language as flat text, and the only way to that today is
|
|
25
|
+
* a fork. Every other seam named as a candidate (the navigation tree, the
|
|
26
|
+
* redirect map, `llms.txt`, the MCP tool list, the build checks) gets no hook
|
|
27
|
+
* until a consumer exists that cannot be written without one. A hook is public
|
|
28
|
+
* surface: renaming it is a breaking change, and an unused one can never be
|
|
29
|
+
* removed.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THE PAYLOAD DELIBERATELY IS NOT. No excerpt — a snippet is a decision
|
|
32
|
+
* about a result list, and the provider owns the result list. No collection
|
|
33
|
+
* name — that is duxt's own index bookkeeping, and a document keyed on it would
|
|
34
|
+
* break the day a source gains a language. No display label — `label`, `name`
|
|
35
|
+
* and the version caption are prose for a reader, translated per locale, and a
|
|
36
|
+
* record is not read by a reader. What is left is the address and the words,
|
|
37
|
+
* which is what every index actually stores.
|
|
38
|
+
*
|
|
39
|
+
* Split from `modules/search-records.ts` for the reason `validate-report.ts` is
|
|
40
|
+
* split from `modules/validate.ts`: the module half imports `@nuxt/schema` and
|
|
41
|
+
* Content's sqlite cache, and this half is data in, records out — so a test can
|
|
42
|
+
* run it without a build.
|
|
43
|
+
*/
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
import type { CachedPage } from './content-cache';
|
|
46
|
+
import type { DuxtResolvedSource } from './sources-resolve';
|
|
47
|
+
import { duxtPageSearchable } from './app/utils/page-controls';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One entry of an external search index, as duxt hands it over.
|
|
51
|
+
*
|
|
52
|
+
* PROVIDER-INDEPENDENT by construction: every field is a string duxt can state
|
|
53
|
+
* about the site itself, and nothing here is shaped by what a particular
|
|
54
|
+
* service wants stored. A provider layer maps these onto its own document —
|
|
55
|
+
* Pagefind's custom records, a Typesense collection, a Meilisearch index — and
|
|
56
|
+
* duxt never learns which.
|
|
57
|
+
*/
|
|
58
|
+
export interface DuxtSearchRecord {
|
|
59
|
+
/**
|
|
60
|
+
* The record's identity, and the primary key a provider stores it under.
|
|
61
|
+
*
|
|
62
|
+
* A HASH rather than the URL, and that is the one field here whose shape was
|
|
63
|
+
* forced from outside. Meilisearch accepts a document id of `A-Za-z0-9_-`
|
|
64
|
+
* only, so a URL — which carries `/`, `#` and, on a site whose headings are
|
|
65
|
+
* not Latin, whatever the heading was written in — cannot be one. A hex
|
|
66
|
+
* digest is valid everywhere, the same on every machine, and the same on
|
|
67
|
+
* every build the record survives unchanged, which is what lets a provider
|
|
68
|
+
* upsert rather than rebuild.
|
|
69
|
+
*
|
|
70
|
+
* Derived from `url`, which already carries the locale, the source, the
|
|
71
|
+
* version and the anchor — so two records collide exactly when they address
|
|
72
|
+
* the same place, which the source resolver has already made impossible.
|
|
73
|
+
*/
|
|
74
|
+
id: string;
|
|
75
|
+
/** Where a reader lands: the public path, locale segment and anchor included. */
|
|
76
|
+
url: string;
|
|
77
|
+
/** The heading this record covers, or the page's own title for the first. */
|
|
78
|
+
title: string;
|
|
79
|
+
/** The words under that heading, markup removed and whitespace collapsed. */
|
|
80
|
+
content: string;
|
|
81
|
+
/**
|
|
82
|
+
* Which documentation source this came out of, as its URL segments minus the
|
|
83
|
+
* version — `/` for the documentation served at the root, `/harbour` for a
|
|
84
|
+
* second repository, `/harbour/api` for a generated section inside it.
|
|
85
|
+
*
|
|
86
|
+
* An IDENTITY, not an address, which is why the version is not in it: a facet
|
|
87
|
+
* built on this survives a release, and `version` beside it is what narrows
|
|
88
|
+
* to one.
|
|
89
|
+
*/
|
|
90
|
+
source: string;
|
|
91
|
+
/** The version label, where the source has versions at all. */
|
|
92
|
+
version?: string;
|
|
93
|
+
/**
|
|
94
|
+
* The language the CONTENT is in — not the interface language a reader
|
|
95
|
+
* happens to be browsing in.
|
|
96
|
+
*
|
|
97
|
+
* A site whose sources are untranslated has one language of content and gets
|
|
98
|
+
* one set of records, even when it serves seven interface locales: the same
|
|
99
|
+
* paragraph indexed seven times is one answer repeated, not seven answers.
|
|
100
|
+
*/
|
|
101
|
+
locale: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What the site's routing does to a path, and which languages it serves. */
|
|
105
|
+
export interface DuxtSearchRecordOptions {
|
|
106
|
+
/**
|
|
107
|
+
* The locale codes the site actually ROUTES, after `duxt.locales` has
|
|
108
|
+
* narrowed them — in the order the site declares them.
|
|
109
|
+
*
|
|
110
|
+
* NOT the same code space as a source's own locale, which is the trap this
|
|
111
|
+
* whole file had to be corrected for: `@nuxtjs/i18n` routes `de-DE` while a
|
|
112
|
+
* source is commonly written `locales: ['de']`, because one `de/` folder
|
|
113
|
+
* serves every German region. Matching the two by equality indexed nothing
|
|
114
|
+
* at all on a site with four translations, and said so nowhere. See
|
|
115
|
+
* `routedLocale`.
|
|
116
|
+
*/
|
|
117
|
+
locales: readonly string[];
|
|
118
|
+
/** The language a source with no locale of its own is written in. */
|
|
119
|
+
defaultLocale: string;
|
|
120
|
+
/** `@nuxtjs/i18n`'s own strategy, which decides who gets a locale segment. */
|
|
121
|
+
strategy?:
|
|
122
|
+
| 'no_prefix'
|
|
123
|
+
| 'prefix_except_default'
|
|
124
|
+
| 'prefix'
|
|
125
|
+
| 'prefix_and_default';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One heading's worth of a page, before it is given its coordinates. */
|
|
129
|
+
interface Section {
|
|
130
|
+
/** The heading's anchor; absent for the page's own opening section. */
|
|
131
|
+
anchor?: string;
|
|
132
|
+
title: string;
|
|
133
|
+
content: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const HEADING = /^h[1-6]$/;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The words a minimal node shows, with its markup dropped.
|
|
140
|
+
*
|
|
141
|
+
* Content stores a parsed body as `[tag, props, ...children]` with text as
|
|
142
|
+
* plain strings — the shape `generated-toc.ts` and `openapi.ts` already walk.
|
|
143
|
+
*
|
|
144
|
+
* CHILDREN ARE JOINED WITH NOTHING, exactly as Content's own search sections
|
|
145
|
+
* do it, so `a **bold** word` reads as one sentence rather than as three. The
|
|
146
|
+
* separator between BLOCKS is a space, added by the caller, which is the level
|
|
147
|
+
* at which two paragraphs are two things.
|
|
148
|
+
*/
|
|
149
|
+
function text(node: unknown): string {
|
|
150
|
+
if (typeof node === 'string') return node;
|
|
151
|
+
if (typeof node === 'number') return String(node);
|
|
152
|
+
if (!Array.isArray(node)) return '';
|
|
153
|
+
|
|
154
|
+
return node.slice(2).map(text).join('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Runs of whitespace, including the newlines a Markdown body is full of. */
|
|
158
|
+
const normalise = (value: string) => value.replace(/\s+/g, ' ').trim();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A page, split at its headings.
|
|
162
|
+
*
|
|
163
|
+
* Mirrors `generateSearchSections` in `@nuxt/content` deliberately: a site with
|
|
164
|
+
* duxt's own reader-facing search AND an external index must not have the two
|
|
165
|
+
* disagree about what a page says. The page's own record leads and carries its
|
|
166
|
+
* description, exactly as Content's does.
|
|
167
|
+
*
|
|
168
|
+
* ONE DIVERGENCE, and it is the only one. duxt's reader-facing index is built
|
|
169
|
+
* with `ignoredTags: ['table']`, because a table flattened into one string runs
|
|
170
|
+
* its cells together and reads as gibberish in an EXCERPT. A record carries no
|
|
171
|
+
* excerpt — the provider writes its own — so dropping the table would throw
|
|
172
|
+
* away words a reader may well be searching for, and buy nothing.
|
|
173
|
+
*
|
|
174
|
+
* A HEADING WITH NO ANCHOR IS NOT A SECTION. MDC gives every heading an `id`,
|
|
175
|
+
* so this is the pathological case rather than the normal one; addressing such
|
|
176
|
+
* a section would mean a second record at the page's own URL, and therefore at
|
|
177
|
+
* the page's own id. Its words fold into the section above it instead, where
|
|
178
|
+
* they are still findable.
|
|
179
|
+
*/
|
|
180
|
+
export function duxtSearchSections(page: Record<string, unknown>): Section[] {
|
|
181
|
+
const title = typeof page.title === 'string' ? page.title : '';
|
|
182
|
+
const description =
|
|
183
|
+
typeof page.description === 'string' ? page.description : '';
|
|
184
|
+
|
|
185
|
+
const sections: Section[] = [{ title, content: normalise(description) }];
|
|
186
|
+
|
|
187
|
+
const body = (page.body as { value?: unknown } | undefined)?.value;
|
|
188
|
+
if (!Array.isArray(body)) return sections;
|
|
189
|
+
|
|
190
|
+
for (const node of body) {
|
|
191
|
+
if (!Array.isArray(node)) continue;
|
|
192
|
+
|
|
193
|
+
const [tag, props] = node as [unknown, unknown];
|
|
194
|
+
const anchor =
|
|
195
|
+
props && typeof props === 'object' && !Array.isArray(props)
|
|
196
|
+
? (props as { id?: unknown }).id
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
typeof tag === 'string' &&
|
|
201
|
+
HEADING.test(tag) &&
|
|
202
|
+
typeof anchor === 'string' &&
|
|
203
|
+
anchor
|
|
204
|
+
) {
|
|
205
|
+
sections.push({ anchor, title: normalise(text(node)), content: '' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const current = sections.at(-1)!;
|
|
210
|
+
const words = normalise(text(node));
|
|
211
|
+
if (!words) continue;
|
|
212
|
+
current.content = current.content ? `${current.content} ${words}` : words;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return sections;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The language half of a locale code: `de` for `de`, and `de` for `de-DE`. */
|
|
219
|
+
const language = (code: string) => code.split('-')[0]!;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Which routed locale serves a source written in this language, if any.
|
|
223
|
+
*
|
|
224
|
+
* THE TWO CODE SPACES. A source says which language a folder holds — `de`,
|
|
225
|
+
* because one `de/` tree serves every German region, which is exactly the rule
|
|
226
|
+
* `localeChain` in `sources-resolve.ts` reads it by. `@nuxtjs/i18n` says which
|
|
227
|
+
* URL a reader is on — `de-DE`. Comparing them for equality is the bug this
|
|
228
|
+
* function exists to prevent, and it is a SILENT one: four translations
|
|
229
|
+
* disappeared from the payload and the build said nothing, because "no records
|
|
230
|
+
* for that collection" looks exactly like "no pages in that collection".
|
|
231
|
+
*
|
|
232
|
+
* Exact code first, then the first routed locale of the same language, in the
|
|
233
|
+
* order the site declares them. Nothing where the site routes that language at
|
|
234
|
+
* all — indexing it would offer a reader a URL that 404s.
|
|
235
|
+
*
|
|
236
|
+
* A CONSEQUENCE WORTH STATING: where a site routes two regions of one language
|
|
237
|
+
* — `pt-PT` and `pt-BR` over a single `pt/` tree — the records address the
|
|
238
|
+
* first, because the two URLs carry identical words and indexing both would
|
|
239
|
+
* double the index to say the same thing twice. A provider that wants to send a
|
|
240
|
+
* reader to their own region's spelling swaps the segment; duxt cannot, because
|
|
241
|
+
* it does not know who is searching.
|
|
242
|
+
*/
|
|
243
|
+
function routedLocale(
|
|
244
|
+
locale: string,
|
|
245
|
+
options: DuxtSearchRecordOptions
|
|
246
|
+
): string | undefined {
|
|
247
|
+
if (options.locales.includes(locale)) return locale;
|
|
248
|
+
|
|
249
|
+
const base = language(locale);
|
|
250
|
+
return options.locales.find((code) => language(code) === base);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Does this language get a segment in front of the path?
|
|
255
|
+
*
|
|
256
|
+
* The same question `llms-pages.ts` answers at request time, asked here against
|
|
257
|
+
* the build's own i18n options. `prefix_and_default` is the one that surprises:
|
|
258
|
+
* the default language is served at BOTH spellings, and the prefixed one is the
|
|
259
|
+
* canonical of the pair — so that is the one a search result should open.
|
|
260
|
+
*/
|
|
261
|
+
function localeSegment(
|
|
262
|
+
routed: string,
|
|
263
|
+
options: DuxtSearchRecordOptions
|
|
264
|
+
): string {
|
|
265
|
+
const strategy = options.strategy ?? 'prefix_except_default';
|
|
266
|
+
if (strategy === 'no_prefix') return '';
|
|
267
|
+
if (strategy === 'prefix_except_default' && routed === options.defaultLocale)
|
|
268
|
+
return '';
|
|
269
|
+
return `/${routed}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The source's own identity, as URL segments minus the version.
|
|
274
|
+
*
|
|
275
|
+
* Built from the two segments a source can claim rather than sliced out of
|
|
276
|
+
* `prefix`: a generated section's prefix is `<base><version?>/<slug>`, so the
|
|
277
|
+
* version sits in the MIDDLE of it and no suffix rule would survive a source
|
|
278
|
+
* that gained versions.
|
|
279
|
+
*/
|
|
280
|
+
function identity(entry: DuxtResolvedSource): string {
|
|
281
|
+
const segments = [entry.repo, entry.generated?.slug].filter(Boolean);
|
|
282
|
+
return segments.length ? `/${segments.join('/')}` : '/';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Short enough to read in a log, long enough that a collision is not a risk. */
|
|
286
|
+
const identifier = (url: string) =>
|
|
287
|
+
createHash('sha256').update(url).digest('hex').slice(0, 32);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Every page of every source, version and language, as flat records.
|
|
291
|
+
*
|
|
292
|
+
* DETERMINISTIC ORDER, because the cache is read out of SQLite and SQLite
|
|
293
|
+
* promises none: sources in manifest order, pages by path inside a source, and
|
|
294
|
+
* sections in the order they are written. A provider diffing one build against
|
|
295
|
+
* the last must see a payload that moved only where the site did.
|
|
296
|
+
*
|
|
297
|
+
* FROZEN, because "read-only" is a rule and a rule nobody can break is better
|
|
298
|
+
* than a rule everybody is asked to keep. A consumer that sorts the array in
|
|
299
|
+
* place, or rewrites a title on the way into its own index, would be changing
|
|
300
|
+
* what every OTHER listener is handed — the second provider layer would inherit
|
|
301
|
+
* the first one's edits and nothing would say so.
|
|
302
|
+
*/
|
|
303
|
+
export function duxtSearchRecords(
|
|
304
|
+
sources: readonly DuxtResolvedSource[],
|
|
305
|
+
pages: readonly CachedPage[],
|
|
306
|
+
options: DuxtSearchRecordOptions
|
|
307
|
+
): readonly DuxtSearchRecord[] {
|
|
308
|
+
const records: DuxtSearchRecord[] = [];
|
|
309
|
+
|
|
310
|
+
const byCollection = new Map<string, CachedPage[]>();
|
|
311
|
+
for (const page of pages) {
|
|
312
|
+
const list = byCollection.get(page.collection);
|
|
313
|
+
if (list) list.push(page);
|
|
314
|
+
else byCollection.set(page.collection, [page]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const entry of sources) {
|
|
318
|
+
const locale = entry.locale ?? options.defaultLocale;
|
|
319
|
+
const routed = routedLocale(locale, options);
|
|
320
|
+
if (!routed) continue;
|
|
321
|
+
|
|
322
|
+
const source = identity(entry);
|
|
323
|
+
const segment = localeSegment(routed, options);
|
|
324
|
+
const collected = byCollection.get(entry.collection);
|
|
325
|
+
if (!collected) continue;
|
|
326
|
+
|
|
327
|
+
for (const page of [...collected].sort((a, b) =>
|
|
328
|
+
a.path.localeCompare(b.path)
|
|
329
|
+
)) {
|
|
330
|
+
// The one predicate four other discovery surfaces already share: a page
|
|
331
|
+
// that wrote `search: false` is un-findable, not unpublished, and an
|
|
332
|
+
// external index is exactly the surface it meant.
|
|
333
|
+
if (!duxtPageSearchable(page.content)) continue;
|
|
334
|
+
|
|
335
|
+
for (const section of duxtSearchSections(page.content)) {
|
|
336
|
+
// Nothing to match on and nothing to show — an empty row in somebody
|
|
337
|
+
// else's index, charged for and never returned.
|
|
338
|
+
if (!section.title && !section.content) continue;
|
|
339
|
+
|
|
340
|
+
const url = `${segment}${page.path}${
|
|
341
|
+
section.anchor ? `#${section.anchor}` : ''
|
|
342
|
+
}`;
|
|
343
|
+
|
|
344
|
+
records.push(
|
|
345
|
+
Object.freeze({
|
|
346
|
+
id: identifier(url),
|
|
347
|
+
url,
|
|
348
|
+
title: section.title,
|
|
349
|
+
content: section.content,
|
|
350
|
+
source,
|
|
351
|
+
...(entry.version ? { version: entry.version } : {}),
|
|
352
|
+
locale
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return Object.freeze(records);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
declare module '@nuxt/schema' {
|
|
363
|
+
interface NuxtHooks {
|
|
364
|
+
/**
|
|
365
|
+
* Every page of the site, as flat search records — duxt's one build-time
|
|
366
|
+
* extension point.
|
|
367
|
+
*
|
|
368
|
+
* Fires once per build, after Content has parsed every source, version,
|
|
369
|
+
* locale and generated section, and before Nitro prerenders or bundles
|
|
370
|
+
* anything — so a listener may still write into `public/`.
|
|
371
|
+
*
|
|
372
|
+
* READ-ONLY: the payload is frozen. Build or upload your own index from it;
|
|
373
|
+
* do not rewrite a record, reorder the array, or otherwise change what the
|
|
374
|
+
* next listener is handed.
|
|
375
|
+
*/
|
|
376
|
+
'duxt:search:records': (
|
|
377
|
+
records: readonly DuxtSearchRecord[]
|
|
378
|
+
) => void | Promise<void>;
|
|
379
|
+
}
|
|
380
|
+
}
|
package/section-input.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generated section's artefact, read off disk.
|
|
3
|
+
*
|
|
4
|
+
* The disk half of `DuxtSectionInput`, split out of `sections.ts` for the one
|
|
5
|
+
* reason that file is split from `sections-resolve.ts`: two callers need it and
|
|
6
|
+
* only one of them is allowed to import `@nuxt/content`. `sections.ts` builds
|
|
7
|
+
* the collections, `section-reports.ts` reads the same artefacts again for the
|
|
8
|
+
* manifest the modules hold, and both want the same handle over the same path.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows what an artefact MEANS. It opens a file or walks a tree
|
|
11
|
+
* and hands the result to the type, which is the seam `DuxtSectionInput`
|
|
12
|
+
* documents: a resolver that understood a Bruno collection would have to
|
|
13
|
+
* understand the next type's tree as well.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, relative, sep } from 'node:path';
|
|
17
|
+
import type { DuxtSectionInput, DuxtSectionType } from './sections-resolve';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Directories a walk never descends into.
|
|
21
|
+
*
|
|
22
|
+
* Not a taste call: `bruno.json`'s own `ignore` key defaults to exactly these
|
|
23
|
+
* two, and a docs repository that happens to have installed dependencies beside
|
|
24
|
+
* its collection would otherwise be read file by file into a section.
|
|
25
|
+
*/
|
|
26
|
+
const SKIP = new Set(['.git', 'node_modules']);
|
|
27
|
+
|
|
28
|
+
/** What a type's `input` policy says the declared path is. */
|
|
29
|
+
export function sectionInputKind(type: DuxtSectionType): 'file' | 'directory' {
|
|
30
|
+
return type.input ?? 'file';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether the declared artefact is there, AS THE TYPE EXPECTS IT.
|
|
35
|
+
*
|
|
36
|
+
* A directory where a file was declared is as missing as nothing at all — the
|
|
37
|
+
* type would read it and find nothing — and saying so here is what lets
|
|
38
|
+
* `missingSectionArtefact` keep one message for both.
|
|
39
|
+
*/
|
|
40
|
+
export function sectionArtefactExists(
|
|
41
|
+
absolute: string,
|
|
42
|
+
kind: 'file' | 'directory'
|
|
43
|
+
): boolean {
|
|
44
|
+
if (!existsSync(absolute)) return false;
|
|
45
|
+
|
|
46
|
+
const stats = statSync(absolute);
|
|
47
|
+
|
|
48
|
+
return kind === 'directory' ? stats.isDirectory() : stats.isFile();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The artefact at `absolute`, as the type reads it.
|
|
53
|
+
*
|
|
54
|
+
* LAZY: nothing is opened until the type asks. A directory's listing is walked
|
|
55
|
+
* once and kept, because a type reads it more than once — a Bruno collection
|
|
56
|
+
* asks for `bruno.json`, then for every `folder.bru`, then for every request —
|
|
57
|
+
* and a second walk between two of those could see a different tree.
|
|
58
|
+
*
|
|
59
|
+
* `root` is the checkout `path` resolves against, and is PASSED RATHER THAN
|
|
60
|
+
* DERIVED from `absolute`: the caller already holds it, and subtracting one
|
|
61
|
+
* path from another is string arithmetic that goes wrong on the first symlink.
|
|
62
|
+
* Omitted for a remote checkout, which is what tells a type not to read its
|
|
63
|
+
* history — see `DuxtSectionInput.root`.
|
|
64
|
+
*/
|
|
65
|
+
export function diskSectionInput(
|
|
66
|
+
absolute: string,
|
|
67
|
+
path: string,
|
|
68
|
+
kind: 'file' | 'directory',
|
|
69
|
+
root?: string
|
|
70
|
+
): DuxtSectionInput {
|
|
71
|
+
if (kind === 'file') {
|
|
72
|
+
return {
|
|
73
|
+
path,
|
|
74
|
+
root,
|
|
75
|
+
kind,
|
|
76
|
+
text: () => readFileSync(absolute, 'utf8'),
|
|
77
|
+
files: () => [],
|
|
78
|
+
read: () => ''
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let listing: string[] | undefined;
|
|
83
|
+
|
|
84
|
+
const files = () => (listing ??= walk(absolute));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
path,
|
|
88
|
+
root,
|
|
89
|
+
kind,
|
|
90
|
+
text: () => {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`duxt: "${path}" is a directory, and this type asked it for a file.`
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
files,
|
|
96
|
+
read: (file) => {
|
|
97
|
+
// Read through the LISTING rather than straight off the path handed in:
|
|
98
|
+
// a type composing a name out of its own parsing could otherwise walk out
|
|
99
|
+
// of the section with `../`, and a documentation build that reads an
|
|
100
|
+
// arbitrary file off the machine it runs on is not a thing to leave open.
|
|
101
|
+
if (!files().includes(file)) return '';
|
|
102
|
+
|
|
103
|
+
return readFileSync(join(absolute, ...file.split('/')), 'utf8');
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every file under a directory, as `/`-separated relative paths, sorted.
|
|
110
|
+
*
|
|
111
|
+
* Sorted because a directory listing is not ordered: a section whose page order
|
|
112
|
+
* came from `readdir` would reorder itself on a different filesystem, and the
|
|
113
|
+
* URLs with it.
|
|
114
|
+
*
|
|
115
|
+
* Symlinks are not followed — `withFileTypes` reports one as neither a file nor
|
|
116
|
+
* a directory once `isSymbolicLink` is asked first — for the reason `read`
|
|
117
|
+
* checks its listing: a link out of the collection is a file this build has no
|
|
118
|
+
* business opening.
|
|
119
|
+
*/
|
|
120
|
+
function walk(root: string): string[] {
|
|
121
|
+
const out: string[] = [];
|
|
122
|
+
|
|
123
|
+
const descend = (dir: string) => {
|
|
124
|
+
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
|
125
|
+
if (item.isSymbolicLink()) continue;
|
|
126
|
+
|
|
127
|
+
const full = join(dir, item.name);
|
|
128
|
+
|
|
129
|
+
if (item.isDirectory()) {
|
|
130
|
+
if (SKIP.has(item.name)) continue;
|
|
131
|
+
descend(full);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (item.isFile()) out.push(relative(root, full).split(sep).join('/'));
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
descend(root);
|
|
140
|
+
|
|
141
|
+
return out.sort();
|
|
142
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small pieces of Markdown every generated section writes a page out of.
|
|
3
|
+
*
|
|
4
|
+
* The OpenAPI and Bruno generators each carried their own copy of all three,
|
|
5
|
+
* and the copies had started to differ: Bruno's first line undid inline
|
|
6
|
+
* Markdown with a pass of its own that was one refactor away from disagreeing
|
|
7
|
+
* with the reference's. One copy, so a page title reads the same whichever
|
|
8
|
+
* source produced it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Zero-padded so ten pages still sort the way they read. */
|
|
12
|
+
export function pageOrder(index: number, total: number): string {
|
|
13
|
+
return String(index + 1).padStart(String(total).length, '0');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A URL segment: lowercase, no dots.
|
|
18
|
+
*
|
|
19
|
+
* The dots matter: Content reads a name made of digits and dots as a version
|
|
20
|
+
* and stops refining it, which would leave the `NN.` ordering prefix in the
|
|
21
|
+
* URL. `fallback` is what a name made only of punctuation becomes — empty by
|
|
22
|
+
* default, so a caller can try the next candidate with `||`.
|
|
23
|
+
*/
|
|
24
|
+
export function urlSegment(value: string, fallback = ''): string {
|
|
25
|
+
return (
|
|
26
|
+
value
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '') || fallback
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The first paragraph of a description, as one line of PLAIN text.
|
|
35
|
+
*
|
|
36
|
+
* Plain matters because of where this goes: a page's `description`
|
|
37
|
+
* frontmatter, which becomes the meta description, the og tag and the card in
|
|
38
|
+
* a search result, and the summary on a group card. A CommonMark description
|
|
39
|
+
* carrying `*emphasis*` or a `[link](url)` renders as prose everywhere it is
|
|
40
|
+
* rendered as prose — and as its own punctuation everywhere it is not.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately small. It undoes the four inline constructs a one-line summary
|
|
43
|
+
* actually meets — emphasis, code spans, links and images — and leaves
|
|
44
|
+
* everything else alone rather than growing into a second Markdown parser
|
|
45
|
+
* whose disagreements with the first would be invisible.
|
|
46
|
+
*/
|
|
47
|
+
export function firstLine(value?: string): string | undefined {
|
|
48
|
+
const line = plain(value?.split(/\n\s*\n/)[0] ?? '')
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
.trim();
|
|
51
|
+
|
|
52
|
+
return line || undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Inline Markdown, as the text it renders to. */
|
|
56
|
+
function plain(value: string): string {
|
|
57
|
+
return (
|
|
58
|
+
value
|
|
59
|
+
// An image before a link: `` is a link with a `!` in front, and
|
|
60
|
+
// taking the link first would leave the `!` behind.
|
|
61
|
+
.replaceAll(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
62
|
+
.replaceAll(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
63
|
+
.replaceAll(/`([^`]*)`/g, '$1')
|
|
64
|
+
.replaceAll(/(\*\*|__)(.+?)\1/g, '$2')
|
|
65
|
+
.replaceAll(/(\*|_)(.+?)\1/g, '$2')
|
|
66
|
+
);
|
|
67
|
+
}
|
package/section-reports.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import type { DuxtResolvedSource } from './sources-resolve';
|
|
4
3
|
import type { DuxtSectionTypes } from './sections-resolve';
|
|
@@ -7,6 +6,11 @@ import {
|
|
|
7
6
|
missingSectionArtefact,
|
|
8
7
|
sectionPages
|
|
9
8
|
} from './sections-resolve';
|
|
9
|
+
import {
|
|
10
|
+
diskSectionInput,
|
|
11
|
+
sectionArtefactExists,
|
|
12
|
+
sectionInputKind
|
|
13
|
+
} from './section-input';
|
|
10
14
|
import { repositoryRoot } from './repository-root';
|
|
11
15
|
|
|
12
16
|
/**
|
|
@@ -38,14 +42,16 @@ export function readSectionReports(
|
|
|
38
42
|
|
|
39
43
|
if (!type || entry.generated!.remote) continue;
|
|
40
44
|
|
|
41
|
-
const
|
|
45
|
+
const root = repositoryRoot();
|
|
46
|
+
const file = join(root, entry.path);
|
|
47
|
+
const kind = sectionInputKind(type);
|
|
42
48
|
|
|
43
49
|
// Both calls record the report on the entry; the return value is the
|
|
44
50
|
// collection's business, not this one's. A missing local artefact throws
|
|
45
51
|
// here exactly as it throws while the collections are declared — the same
|
|
46
52
|
// error, from whichever of the two runs first.
|
|
47
|
-
if (
|
|
48
|
-
sectionPages(entry, type,
|
|
53
|
+
if (sectionArtefactExists(file, kind)) {
|
|
54
|
+
sectionPages(entry, type, diskSectionInput(file, entry.path, kind, root));
|
|
49
55
|
} else {
|
|
50
56
|
missingSectionArtefact(entry, file);
|
|
51
57
|
}
|