@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
package/sources-cache.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import { readDuxtBuildConfig } from './duxt-app-config';
|
|
4
|
+
import { resolveLatestRefs } from './sources-git';
|
|
5
|
+
import type { DuxtSource, DuxtSourcesOptions } from './sources-resolve';
|
|
6
|
+
import {
|
|
7
|
+
expandSources,
|
|
8
|
+
isLatestRef,
|
|
9
|
+
refIsTag,
|
|
10
|
+
refName,
|
|
11
|
+
repoUrl
|
|
12
|
+
} from './sources-resolve';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a CI run may keep of `.data/content`, and under which key.
|
|
16
|
+
*
|
|
17
|
+
* Content downloads every remote source into `.data/content/` and keys the
|
|
18
|
+
* directory by repository AND ref — `github.com-kirchDev-workflows-v0.7.0` is
|
|
19
|
+
* the shape. That directory is a working-copy artefact: it does not survive a
|
|
20
|
+
* runner, so a site reading five refs re-clones all five on every pull request
|
|
21
|
+
* for content that has not moved.
|
|
22
|
+
*
|
|
23
|
+
* Two questions decide whether caching it is safe, and this file answers both
|
|
24
|
+
* as data rather than as prose in a workflow:
|
|
25
|
+
*
|
|
26
|
+
* THE KEY IS THE RESOLVED SOURCE LIST, nothing else. A key over
|
|
27
|
+
* `pnpm-lock.yaml` is wrong in both directions — it moves when no source did,
|
|
28
|
+
* and it stands still when a `latest` tag moves underneath it. So the digest is
|
|
29
|
+
* taken over the (repository, ref) pairs the resolver produced, `latest`
|
|
30
|
+
* already replaced by the tag it means; passing an unresolved one is refused
|
|
31
|
+
* outright rather than hashed, because the resulting key would restore last
|
|
32
|
+
* release's checkout into this release's build.
|
|
33
|
+
*
|
|
34
|
+
* THE CACHE HOLDS PUBLIC SOURCES ONLY. A GitHub Actions cache written on a
|
|
35
|
+
* branch is readable by every pull request against it, forks included, so a
|
|
36
|
+
* private checkout put there is a private repository handed to anyone who can
|
|
37
|
+
* open a pull request. A list with one authenticated remote in it therefore
|
|
38
|
+
* disables the cache entirely — never partially, which would expose the
|
|
39
|
+
* checkout AND still download it.
|
|
40
|
+
*
|
|
41
|
+
* WHAT THIS DOES NOT HAVE TO GUARD IS STALENESS. `downloadGitRepository` in
|
|
42
|
+
* `@nuxt/content` asks the remote for the ref's hash on every build and
|
|
43
|
+
* compares it with the `.content.cache.json` it wrote beside the checkout; a
|
|
44
|
+
* mismatch re-clones. A restored directory whose branch has moved is therefore
|
|
45
|
+
* re-downloaded rather than served, and an unreachable remote fails the build
|
|
46
|
+
* instead of quietly answering from the cache. That is what makes a key over
|
|
47
|
+
* ref NAMES sufficient: it need only be right often enough to be worth having,
|
|
48
|
+
* because being wrong costs a download rather than a wrong page.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** Where Content puts the downloads, relative to a site's root. */
|
|
52
|
+
export const DUXT_CONTENT_CACHE_DIR = '.data/content';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Bumped when what lands in `.data/content` changes shape rather than content.
|
|
56
|
+
*
|
|
57
|
+
* A cache entry written by an older layer may hold directories a newer one no
|
|
58
|
+
* longer understands, and a restored one of those is worse than no cache. The
|
|
59
|
+
* marker makes that a miss instead of a puzzle.
|
|
60
|
+
*/
|
|
61
|
+
const KEY_VERSION = 'v1';
|
|
62
|
+
|
|
63
|
+
/** The heredoc marker `--github` wraps its one multi-line value in. */
|
|
64
|
+
const OUTPUT_DELIMITER = '__DUXT_CACHE__';
|
|
65
|
+
|
|
66
|
+
/** One repository-and-ref pair Content downloads for the resolved list. */
|
|
67
|
+
export interface DuxtRemoteCacheEntry {
|
|
68
|
+
/** The URL Content clones, exactly as `sources.ts` hands it over. */
|
|
69
|
+
url: string;
|
|
70
|
+
/** `tag:<name>`, `branch:<name>`, or `default` for the repository's HEAD. */
|
|
71
|
+
ref: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DuxtSourcesCacheKey {
|
|
75
|
+
/** Whether `.data/content` may be carried between runs at all. */
|
|
76
|
+
cacheable: boolean;
|
|
77
|
+
/** Why it may not be, in one line fit for a CI log. Absent when it may. */
|
|
78
|
+
reason?: string;
|
|
79
|
+
/** The cache key, or `''` when there is nothing to cache. */
|
|
80
|
+
key: string;
|
|
81
|
+
/** The pairs the key was taken over, sorted. */
|
|
82
|
+
entries: DuxtRemoteCacheEntry[];
|
|
83
|
+
/** The directory the downloads land in, relative to where the command ran. */
|
|
84
|
+
path: string;
|
|
85
|
+
/** `path` and its exclusions, as the glob list a cache action takes. */
|
|
86
|
+
paths: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The directory a cache action should carry, and the part of it it must not.
|
|
91
|
+
*
|
|
92
|
+
* `contents.sqlite` sits INSIDE `.data/content`, so caching the directory whole
|
|
93
|
+
* carries the parse cache along with the downloads — and the parse cache is the
|
|
94
|
+
* one thing this must not keep. It is the OUTPUT of reading the Markdown rather
|
|
95
|
+
* than an input to it, so a restored one answers for a tree that has since
|
|
96
|
+
* changed: the failure mode is a page that silently does not update, where a
|
|
97
|
+
* missed download costs only a download. The layer's own `enableWriteAheadLog`
|
|
98
|
+
* leaves its two sidecar files beside it, so all three are excluded by name.
|
|
99
|
+
*
|
|
100
|
+
* A `!` glob is `@actions/glob`'s exclusion syntax, which `actions/cache` reads
|
|
101
|
+
* for its `path:` — the reason this is a LIST rather than a directory.
|
|
102
|
+
*/
|
|
103
|
+
export function duxtSourcesCachePaths(path: string): {
|
|
104
|
+
path: string;
|
|
105
|
+
paths: string[];
|
|
106
|
+
} {
|
|
107
|
+
return {
|
|
108
|
+
path,
|
|
109
|
+
paths: [
|
|
110
|
+
path,
|
|
111
|
+
`!${path}/contents.sqlite`,
|
|
112
|
+
`!${path}/contents.sqlite-wal`,
|
|
113
|
+
`!${path}/contents.sqlite-shm`
|
|
114
|
+
]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The repositories and refs a resolved source list names, deduplicated.
|
|
120
|
+
*
|
|
121
|
+
* ONE ENTRY PER DOWNLOAD, not per collection: five languages over one tree are
|
|
122
|
+
* ten collections and one clone, because Content keys the checkout by
|
|
123
|
+
* repository and ref and every collection reads the same directory.
|
|
124
|
+
*
|
|
125
|
+
* A source read off disk contributes nothing, `origin` included — `origin`
|
|
126
|
+
* names a repository for the edit links and downloads none of it.
|
|
127
|
+
*
|
|
128
|
+
* Not listed, and deliberately: the `_partials/` collection clones each remote
|
|
129
|
+
* repository once more at its default HEAD. It is a clone of a repository this
|
|
130
|
+
* list already names, so it rides along on the same key; putting a synthetic
|
|
131
|
+
* `default` ref in a list whose purpose is to say which refs were RESOLVED
|
|
132
|
+
* would read as a source nobody declared.
|
|
133
|
+
*/
|
|
134
|
+
export function duxtRemoteCacheEntries(
|
|
135
|
+
sources: DuxtSource[],
|
|
136
|
+
options: DuxtSourcesOptions = {}
|
|
137
|
+
): DuxtRemoteCacheEntry[] {
|
|
138
|
+
const seen = new Map<string, DuxtRemoteCacheEntry>();
|
|
139
|
+
|
|
140
|
+
for (const { ref, effective } of expandSources(sources, options)) {
|
|
141
|
+
if (!effective.repo) continue;
|
|
142
|
+
|
|
143
|
+
const used = effective.ref ?? ref;
|
|
144
|
+
|
|
145
|
+
if (used && isLatestRef(used)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'duxt: a cache key cannot be taken over an unresolved `latest` ref — ' +
|
|
148
|
+
'resolve the source list with `resolveLatestRefs` first.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const entry: DuxtRemoteCacheEntry = {
|
|
153
|
+
url: repoUrl(effective.repo),
|
|
154
|
+
ref: used
|
|
155
|
+
? `${refIsTag(used) ? 'tag' : 'branch'}:${refName(used)}`
|
|
156
|
+
: 'default'
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
seen.set(`${entry.url} ${entry.ref}`, entry);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...seen.values()].sort(
|
|
163
|
+
(left, right) =>
|
|
164
|
+
left.url.localeCompare(right.url) || left.ref.localeCompare(right.ref)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A remote whose checkout may sit in a cache every pull request can read.
|
|
170
|
+
*
|
|
171
|
+
* Public means an unauthenticated `https://` clone and nothing else. An SSH
|
|
172
|
+
* remote is authenticated by definition — it is the form that exists to carry a
|
|
173
|
+
* key — and a URL with userinfo carries the credential in the declaration. Both
|
|
174
|
+
* are refused rather than reasoned about: this decides who may read a
|
|
175
|
+
* repository, and a rule with exceptions in it is one nobody can audit.
|
|
176
|
+
*
|
|
177
|
+
* `owner/name`, the shorthand the vast majority of sources are written in,
|
|
178
|
+
* becomes `https://github.com/owner/name` and is public.
|
|
179
|
+
*/
|
|
180
|
+
function privateRemoteReason(repo: string): string | undefined {
|
|
181
|
+
const named = redactRemote(repo);
|
|
182
|
+
|
|
183
|
+
if (!repo.includes('://')) {
|
|
184
|
+
// `git@github.com:owner/name` — scp syntax, which is not a URL at all and
|
|
185
|
+
// would otherwise be pasted onto the GitHub prefix by `repoUrl`.
|
|
186
|
+
return /^[^/]+@/.test(repo)
|
|
187
|
+
? `remote source ${named} is not a public https:// URL`
|
|
188
|
+
: undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let url: URL;
|
|
192
|
+
try {
|
|
193
|
+
url = new URL(repo);
|
|
194
|
+
} catch {
|
|
195
|
+
return `remote source ${named} is not a URL this can vouch for`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
199
|
+
return `remote source ${named} is not a public https:// URL`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return url.username || url.password
|
|
203
|
+
? `remote source ${named} carries credentials, so its checkout is not public`
|
|
204
|
+
: undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A remote named without whatever made it private.
|
|
209
|
+
*
|
|
210
|
+
* The reason is printed into a CI log, which on a public repository is a public
|
|
211
|
+
* artefact — so the one thing it must never repeat is the token that put the
|
|
212
|
+
* source on the wrong side of this rule.
|
|
213
|
+
*/
|
|
214
|
+
function redactRemote(repo: string): string {
|
|
215
|
+
if (repo.includes('://')) {
|
|
216
|
+
try {
|
|
217
|
+
const url = new URL(repo);
|
|
218
|
+
url.username = '';
|
|
219
|
+
url.password = '';
|
|
220
|
+
return url.toString();
|
|
221
|
+
} catch {
|
|
222
|
+
return repo.replace(/\/\/[^/@]*@/, '//');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return repo.replace(/^[^/]+@/, '');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The cache key for a resolved source list, or the reason there is none.
|
|
231
|
+
*
|
|
232
|
+
* Pure, and takes the list a caller has already run through `resolveLatestRefs`
|
|
233
|
+
* — the same split `sources.ts` and `sources-git.ts` keep, so the rule is
|
|
234
|
+
* testable without git and without a network.
|
|
235
|
+
*/
|
|
236
|
+
export function duxtSourcesCacheKey(
|
|
237
|
+
sources: DuxtSource[],
|
|
238
|
+
options: DuxtSourcesOptions = {}
|
|
239
|
+
): DuxtSourcesCacheKey {
|
|
240
|
+
const entries = duxtRemoteCacheEntries(sources, options);
|
|
241
|
+
const where = duxtSourcesCachePaths(DUXT_CONTENT_CACHE_DIR);
|
|
242
|
+
|
|
243
|
+
if (!entries.length) {
|
|
244
|
+
return {
|
|
245
|
+
cacheable: false,
|
|
246
|
+
reason:
|
|
247
|
+
'no remote sources — nothing is downloaded, so there is nothing to cache',
|
|
248
|
+
key: '',
|
|
249
|
+
entries,
|
|
250
|
+
...where
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const source of sources) {
|
|
255
|
+
for (const repo of [
|
|
256
|
+
source.repo,
|
|
257
|
+
...(source.locales ?? []).map((locale) =>
|
|
258
|
+
typeof locale === 'string' ? undefined : locale.repo
|
|
259
|
+
)
|
|
260
|
+
]) {
|
|
261
|
+
const reason = repo ? privateRemoteReason(repo) : undefined;
|
|
262
|
+
if (reason)
|
|
263
|
+
return { cacheable: false, reason, key: '', entries, ...where };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A SPACE AND A NEWLINE are enough to separate the fields unambiguously: git
|
|
268
|
+
// forbids both in a refname outright, and a URL carrying either is not one
|
|
269
|
+
// `repoUrl` would hand to `git`. So no two different lists can hash the same
|
|
270
|
+
// string, and the digest's input stays something a person can print and read.
|
|
271
|
+
const digest = createHash('sha256')
|
|
272
|
+
.update(entries.map((entry) => `${entry.url} ${entry.ref}`).join('\n'))
|
|
273
|
+
.digest('hex');
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
cacheable: true,
|
|
277
|
+
key: `duxt-content-${KEY_VERSION}-${digest}`,
|
|
278
|
+
entries,
|
|
279
|
+
...where
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface DuxtSourcesCacheOptions {
|
|
284
|
+
/** The site being keyed — where `app.config.ts` and `.data/` live. */
|
|
285
|
+
rootDir?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The key for a site on disk: read its config, resolve `latest`, hash the rest.
|
|
290
|
+
*
|
|
291
|
+
* The one impure function here, and the only one that needs git — `latest` is a
|
|
292
|
+
* tag the remote has to be asked about, and asking is exactly what makes the
|
|
293
|
+
* key describe what will be downloaded rather than what was written down.
|
|
294
|
+
*/
|
|
295
|
+
export function duxtSourcesCache(
|
|
296
|
+
options: DuxtSourcesCacheOptions = {}
|
|
297
|
+
): DuxtSourcesCacheKey {
|
|
298
|
+
// ABSOLUTE, always. `readDuxtBuildConfig` hands the path to a module loader,
|
|
299
|
+
// which resolves a bare `www/app/app.config.ts` as a PACKAGE specifier and
|
|
300
|
+
// reports a missing module — a site whose config was found reads identically
|
|
301
|
+
// to one whose sources are all local, and the cache silently never fires.
|
|
302
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
303
|
+
const config = readDuxtBuildConfig([rootDir, join(rootDir, 'app')]);
|
|
304
|
+
|
|
305
|
+
const result = duxtSourcesCacheKey(
|
|
306
|
+
resolveLatestRefs(config?.sources ?? [{ path: 'docs' }]),
|
|
307
|
+
config?.sourceOptions ?? {}
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Reported relative to where the COMMAND runs, not to the site: a workflow
|
|
311
|
+
// hands this straight to a cache action, whose paths are workspace-relative
|
|
312
|
+
// and whose site usually sits in a subdirectory of it.
|
|
313
|
+
const absolute = join(rootDir, DUXT_CONTENT_CACHE_DIR);
|
|
314
|
+
const here = relative(process.cwd(), absolute);
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
...result,
|
|
318
|
+
...duxtSourcesCachePaths(here && !here.startsWith('..') ? here : absolute)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The command, in the two shapes anyone asks for it.
|
|
324
|
+
*
|
|
325
|
+
* `--github` writes the `name=value` lines a step appends to `$GITHUB_OUTPUT`;
|
|
326
|
+
* bare, it writes what a person needs to see why the key is what it is.
|
|
327
|
+
*
|
|
328
|
+
* ALWAYS EXIT 0. "This site has nothing worth caching" is an answer, not a
|
|
329
|
+
* failure — a repository with no remote sources is an ordinary one, and a job
|
|
330
|
+
* that asked for the key should skip the cache rather than go red.
|
|
331
|
+
*/
|
|
332
|
+
export function runDuxtSourcesCacheCli(
|
|
333
|
+
argv: string[],
|
|
334
|
+
data: DuxtSourcesCacheKey
|
|
335
|
+
): { output: string; exitCode: number } {
|
|
336
|
+
if (argv.includes('--github')) {
|
|
337
|
+
const lines = [
|
|
338
|
+
`cacheable=${data.cacheable}`,
|
|
339
|
+
`path=${data.path}`,
|
|
340
|
+
`key=${data.key}`,
|
|
341
|
+
// `paths` is several lines, and `$GITHUB_OUTPUT` takes a multi-line value
|
|
342
|
+
// only as a heredoc. The delimiter is fixed rather than generated because
|
|
343
|
+
// every value here is a glob this file built out of a filesystem path:
|
|
344
|
+
// none of them can contain a line of its own, let alone this one.
|
|
345
|
+
`paths<<${OUTPUT_DELIMITER}`,
|
|
346
|
+
...data.paths,
|
|
347
|
+
OUTPUT_DELIMITER
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
if (data.reason) lines.push(`reason=${data.reason}`);
|
|
351
|
+
|
|
352
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const lines = data.cacheable
|
|
356
|
+
? [
|
|
357
|
+
`key ${data.key}`,
|
|
358
|
+
`path ${data.path}`,
|
|
359
|
+
...data.paths
|
|
360
|
+
.filter((glob) => glob.startsWith('!'))
|
|
361
|
+
.map((glob) => `skip ${glob.slice(1)}`),
|
|
362
|
+
'',
|
|
363
|
+
'Keyed over:'
|
|
364
|
+
]
|
|
365
|
+
: [`Not cached: ${data.reason}`, '', 'Remote sources:'];
|
|
366
|
+
|
|
367
|
+
// REDACTED here too. The `--github` output never carries a URL, but this
|
|
368
|
+
// listing did, verbatim — and a terminal, a pasted log or a screenshot is as
|
|
369
|
+
// public as the CI log `redactRemote` was written for.
|
|
370
|
+
for (const entry of data.entries) {
|
|
371
|
+
lines.push(` ${redactRemote(entry.url)} ${entry.ref}`);
|
|
372
|
+
}
|
|
373
|
+
if (!data.entries.length) lines.push(' (none)');
|
|
374
|
+
|
|
375
|
+
return { output: lines.join('\n'), exitCode: 0 };
|
|
376
|
+
}
|
package/sources-git.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
DuxtRef,
|
|
4
|
+
DuxtSource,
|
|
5
|
+
DuxtSourceReleases
|
|
6
|
+
} from './sources-resolve';
|
|
3
7
|
import {
|
|
8
|
+
compareVersionTags,
|
|
4
9
|
expandSources,
|
|
5
10
|
isLatestRef,
|
|
6
11
|
newestTag,
|
|
@@ -22,7 +27,21 @@ import {
|
|
|
22
27
|
* `git ls-remote` a second apart could otherwise straddle a release and leave
|
|
23
28
|
* the site with a collection no route points at.
|
|
24
29
|
*/
|
|
25
|
-
const cache = new Map<string,
|
|
30
|
+
const cache = new Map<string, RepoTags>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What one repository's tags came back as.
|
|
34
|
+
*
|
|
35
|
+
* A `git` that could not run and a repository that has never been tagged both
|
|
36
|
+
* leave an empty list, and only the second is a configuration mistake. Keeping
|
|
37
|
+
* the failure beside the list is what lets the callers below say which one
|
|
38
|
+
* happened instead of telling a maintainer to fix a source that is fine.
|
|
39
|
+
*/
|
|
40
|
+
interface RepoTags {
|
|
41
|
+
tags: string[];
|
|
42
|
+
/** Git's own reason, where the command failed outright. */
|
|
43
|
+
failure?: string;
|
|
44
|
+
}
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
47
|
* What a repository URL is allowed to look like before it is handed to `git`.
|
|
@@ -35,50 +54,185 @@ const cache = new Map<string, string[]>();
|
|
|
35
54
|
*/
|
|
36
55
|
const REMOTE_URL = /^(?:https?:\/\/|ssh:\/\/|git@)[\w.~:/?#@!$&'()*+,;=%-]+$/;
|
|
37
56
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The URL a remote's tags are read from, refused unless it is one we will run.
|
|
59
|
+
*
|
|
60
|
+
* Separate from the command below, and called OUTSIDE its try: refusing a URL
|
|
61
|
+
* is this layer's own decision about a value a maintainer wrote, not git
|
|
62
|
+
* failing to read it, so it must reach the build as itself.
|
|
63
|
+
*/
|
|
64
|
+
function remoteUrl(repo: string): string {
|
|
40
65
|
const url = repoUrl(repo);
|
|
41
66
|
|
|
42
67
|
if (!REMOTE_URL.test(url)) {
|
|
43
68
|
throw new Error(`duxt: refusing to read tags from ${JSON.stringify(url)}`);
|
|
44
69
|
}
|
|
45
70
|
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Git's own account of why it failed, as one line. */
|
|
75
|
+
function gitFailure(error: unknown): string {
|
|
76
|
+
const stderr = (error as { stderr?: unknown }).stderr;
|
|
77
|
+
const text =
|
|
78
|
+
typeof stderr === 'string'
|
|
79
|
+
? stderr
|
|
80
|
+
: Buffer.isBuffer(stderr)
|
|
81
|
+
? stderr.toString('utf8')
|
|
82
|
+
: error instanceof Error
|
|
83
|
+
? error.message
|
|
84
|
+
: String(error);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
text
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line) => line.trim())
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.at(-1) ?? 'git produced no output'
|
|
52
92
|
);
|
|
53
93
|
}
|
|
54
94
|
|
|
55
|
-
function tagsOf(repo: string | undefined):
|
|
95
|
+
function tagsOf(repo: string | undefined): RepoTags {
|
|
56
96
|
const key = repo ?? '.';
|
|
57
97
|
const cached = cache.get(key);
|
|
58
98
|
if (cached) return cached;
|
|
59
99
|
|
|
60
|
-
|
|
100
|
+
// Before the try, so a refused URL is never dressed up as a git failure.
|
|
101
|
+
const url = repo ? remoteUrl(repo) : undefined;
|
|
102
|
+
|
|
103
|
+
let found: RepoTags;
|
|
61
104
|
|
|
62
105
|
try {
|
|
63
|
-
const output =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
106
|
+
const output = execFileSync(
|
|
107
|
+
'git',
|
|
108
|
+
url
|
|
109
|
+
? // `--end-of-options` so a URL that survived the test above is still
|
|
110
|
+
// read as a URL and never as an option.
|
|
111
|
+
['ls-remote', '--tags', '--refs', '--end-of-options', url]
|
|
112
|
+
: ['tag', '--list'],
|
|
113
|
+
// stderr is piped rather than dropped: it is the only place git says WHY
|
|
114
|
+
// it could not answer, and that reason is what the errors below carry.
|
|
115
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
found = {
|
|
119
|
+
tags: output
|
|
120
|
+
.split('\n')
|
|
121
|
+
.map((line) => line.trim().split(/\s+/).pop() ?? '')
|
|
122
|
+
.map((ref) => ref.replace(/^refs\/tags\//, ''))
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
76
126
|
// Reported by the caller, which knows which source asked.
|
|
77
|
-
|
|
127
|
+
found = { tags: [], failure: gitFailure(error) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
cache.set(key, found);
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tags a source publishes from, or an error naming git as the reason there
|
|
136
|
+
* are none.
|
|
137
|
+
*
|
|
138
|
+
* `what` is the caller's own words for the thing that asked — the `latest`
|
|
139
|
+
* shorthand or a release selection — so an unreachable remote reads as an
|
|
140
|
+
* unreachable remote in both, and neither sends a maintainer to edit a source
|
|
141
|
+
* that was never wrong.
|
|
142
|
+
*/
|
|
143
|
+
function tagsFor(source: DuxtSource, what: string): string[] {
|
|
144
|
+
const found = tagsOf(source.repo);
|
|
145
|
+
|
|
146
|
+
if (found.failure) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`duxt: ${what} on ${source.repo ?? 'this repository'} could not read ` +
|
|
149
|
+
`its tags — git failed: ${found.failure}`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return found.tags;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface ParsedTag {
|
|
157
|
+
major: number;
|
|
158
|
+
minor: number;
|
|
159
|
+
prerelease: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseTag(tag: string): ParsedTag | undefined {
|
|
163
|
+
const match = /^v?(\d+)\.(\d+)\.\d+(?:-(.+))?$/.exec(tag.trim());
|
|
164
|
+
if (!match) return undefined;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
major: Number(match[1]),
|
|
168
|
+
minor: Number(match[2]),
|
|
169
|
+
prerelease: Boolean(match[3])
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Tags selected by a source's explicit release policy, newest first. */
|
|
174
|
+
function releaseTags(tags: string[], releases: DuxtSourceReleases): string[] {
|
|
175
|
+
const parsed = tags
|
|
176
|
+
.map((tag) => ({ tag, version: parseTag(tag) }))
|
|
177
|
+
.filter(
|
|
178
|
+
(entry): entry is { tag: string; version: ParsedTag } =>
|
|
179
|
+
Boolean(entry.version) &&
|
|
180
|
+
(releases.prereleases || !entry.version!.prerelease)
|
|
181
|
+
)
|
|
182
|
+
.sort((left, right) => compareVersionTags(left.tag, right.tag));
|
|
183
|
+
|
|
184
|
+
if (releases.select === 'all') return parsed.map((entry) => entry.tag);
|
|
185
|
+
|
|
186
|
+
const selected = new Set<string>();
|
|
187
|
+
const result: string[] = [];
|
|
188
|
+
for (const entry of parsed) {
|
|
189
|
+
const line =
|
|
190
|
+
releases.select === 'major'
|
|
191
|
+
? String(entry.version.major)
|
|
192
|
+
: `${entry.version.major}.${entry.version.minor}`;
|
|
193
|
+
if (selected.has(line)) continue;
|
|
194
|
+
selected.add(line);
|
|
195
|
+
result.push(entry.tag);
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Add release-discovered tags to explicit refs without giving discovery control
|
|
202
|
+
* over a tag a maintainer has written out by hand.
|
|
203
|
+
*/
|
|
204
|
+
function resolveReleaseRefs(source: DuxtSource): DuxtSource {
|
|
205
|
+
if (!source.releases) return source;
|
|
206
|
+
|
|
207
|
+
const tags = releaseTags(
|
|
208
|
+
tagsFor(source, `releases selection "${source.releases.select}"`),
|
|
209
|
+
source.releases
|
|
210
|
+
);
|
|
211
|
+
if (!tags.length) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`duxt: releases selection "${source.releases.select}" on ` +
|
|
214
|
+
`${source.repo ?? 'this repository'} found no SemVer tags to publish.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Keep the newest selected stable tag first. `resolveSources` already makes
|
|
219
|
+
// the first ref the default and lets sourceOptions.defaultRef replace that
|
|
220
|
+
// fallback; marking it explicit would make discovery outrank that option.
|
|
221
|
+
const refs: DuxtRef[] = tags.map((tag) => ({ tag }));
|
|
222
|
+
const indexByTag = new Map(tags.map((tag, index) => [tag, index]));
|
|
223
|
+
|
|
224
|
+
for (const ref of source.refs ?? []) {
|
|
225
|
+
if (refIsTag(ref)) {
|
|
226
|
+
const index = indexByTag.get(refName(ref));
|
|
227
|
+
if (index !== undefined) {
|
|
228
|
+
refs[index] = ref;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
refs.push(ref);
|
|
78
233
|
}
|
|
79
234
|
|
|
80
|
-
|
|
81
|
-
return tags;
|
|
235
|
+
return { ...source, refs };
|
|
82
236
|
}
|
|
83
237
|
|
|
84
238
|
/**
|
|
@@ -89,11 +243,12 @@ function tagsOf(repo: string | undefined): string[] {
|
|
|
89
243
|
*/
|
|
90
244
|
export function resolveLatestRefs(sources: DuxtSource[]): DuxtSource[] {
|
|
91
245
|
// Reject local refs before a missing tag can hide the configuration error.
|
|
92
|
-
|
|
93
|
-
|
|
246
|
+
const discovered = sources.map(resolveReleaseRefs);
|
|
247
|
+
expandSources(discovered);
|
|
248
|
+
return discovered.map((source) => {
|
|
94
249
|
if (!source.refs?.some(isLatestRef)) return source;
|
|
95
250
|
|
|
96
|
-
const newest = newestTag(
|
|
251
|
+
const newest = newestTag(tagsFor(source, "refs: ['latest']"));
|
|
97
252
|
|
|
98
253
|
if (!newest) {
|
|
99
254
|
throw new Error(
|