@kirchdev/duxt 0.10.1-dev-main.5-62e738f → 0.10.1-dev-main.10-e43ad25

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.
@@ -42,7 +42,8 @@ const props = defineProps<{
42
42
 
43
43
  const page = computed(() => props.page);
44
44
 
45
- const { source } = useDuxtCollection();
45
+ const { source, sources } = useDuxtCollection();
46
+ const path = useDuxtPath();
46
47
  const { locale, t } = useI18n();
47
48
  const duxt = useDuxtConfig();
48
49
 
@@ -73,6 +74,52 @@ const link = computed(() =>
73
74
  : undefined
74
75
  );
75
76
 
77
+ /**
78
+ * Where a reader of a TAG edits this page: the same page on the branch the
79
+ * source also publishes — see `editableEdition` — and only where that edition
80
+ * still has it, so a page renamed since the tag gets no form on a 404.
81
+ *
82
+ * The stem is read off the branch's own row rather than reused from this one:
83
+ * a numbered folder can move between the two, and the stem is the only thing
84
+ * that names the file.
85
+ *
86
+ * Nothing is read after the `await` but values captured before it — the rule
87
+ * `tests/async-data-context.test.ts` holds.
88
+ */
89
+ const { data: editLink } = useAsyncData(
90
+ () => `duxt-edit-link-${path.value}`,
91
+ async () => {
92
+ const own = source.value;
93
+ const branch = editableEdition(sources.value, own);
94
+ if (!own || !branch) return null;
95
+
96
+ const counterpart = versionPath(
97
+ path.value,
98
+ own.prefix,
99
+ branch.prefix || '/'
100
+ );
101
+ const hit = await queryCollection(branch.collection as DuxtCollectionArg)
102
+ .path(counterpart)
103
+ .select('stem')
104
+ .first();
105
+ if (!hit) return null;
106
+
107
+ const target = branch.generated
108
+ ? branch.path
109
+ : sourceFilePath(
110
+ hit.stem,
111
+ branch.prefix,
112
+ branch.originPath ?? branch.path
113
+ );
114
+
115
+ return (
116
+ sourceLink(branch.repositoryUrl, branch.ref, branch.refKind, target) ??
117
+ null
118
+ );
119
+ },
120
+ { default: () => null, watch: [path] }
121
+ );
122
+
76
123
  /**
77
124
  * The day this page names, where it names one.
78
125
  *
@@ -130,6 +177,18 @@ const actions = computed(() => {
130
177
  });
131
178
  }
132
179
 
180
+ // A tag has no edit form of its own, but its branch may — see `editLink`.
181
+ // Drawn first, beside the tag's own source rather than instead of it: the
182
+ // reader may want either the text as released or the place to fix it.
183
+ if (editLink.value) {
184
+ entries.push({
185
+ icon: 'lucide:pencil',
186
+ label: t('duxt.page.edit'),
187
+ to: editLink.value.url,
188
+ external: true
189
+ });
190
+ }
191
+
133
192
  // A tag has no edit form — see `sourceLink`. The label follows the kind
134
193
  // rather than promising a form that answers with a 404.
135
194
  if (link.value) {
@@ -287,7 +287,7 @@ declare global {
287
287
  * `duxt-typesense@v0.1.0`. Applies to `latest` and `releases`; plain
288
288
  * `vX.Y.Z` tags still count.
289
289
  */
290
- tagComponent?: string;
290
+ tagComponent?: string | readonly string[];
291
291
  /**
292
292
  * The repository a source read off disk lives in, for links back to it.
293
293
  * Not `repo`, which is what makes Content download a source.
@@ -1,3 +1,5 @@
1
+ import { sameArtefact } from './version-choices';
2
+
1
3
  /**
2
4
  * From a page back to the file it was written in.
3
5
  *
@@ -82,3 +84,34 @@ export function sourceLink(
82
84
 
83
85
  return undefined;
84
86
  }
87
+
88
+ /**
89
+ * The edition a reader of a TAG can edit instead: the branch the same source
90
+ * also publishes.
91
+ *
92
+ * `sourceLink` refuses an edit form on a tag, and rightly — but a reader of the
93
+ * default version is on a tag nearly always, so the refusal alone left the
94
+ * whole default edition without an edit link. The branch is where a fix to the
95
+ * page actually lands. Whether the page still EXISTS there is the caller's
96
+ * question, answered against that edition's collection before the link is
97
+ * drawn: a file renamed since the tag would otherwise be an edit form on a 404.
98
+ *
99
+ * Same repository, same artefact and same language, so a German page of
100
+ * v0.9.0 is edited as the German page on `main` and an API reference as the
101
+ * API reference. The first such branch in manifest order wins.
102
+ */
103
+ export function editableEdition(
104
+ sources: DuxtResolvedSource[],
105
+ current: DuxtResolvedSource | undefined
106
+ ): DuxtResolvedSource | undefined {
107
+ if (current?.refKind !== 'tag' || !current.repositoryUrl) return undefined;
108
+
109
+ return sources.find(
110
+ (source) =>
111
+ source.refKind === 'branch' &&
112
+ Boolean(source.ref) &&
113
+ source.repositoryUrl === current.repositoryUrl &&
114
+ source.locale === current.locale &&
115
+ sameArtefact(source, current)
116
+ );
117
+ }
@@ -159,28 +159,54 @@ function tagsFor(source: DuxtSource, what: string): string[] {
159
159
  /**
160
160
  * The version tags a source may publish from: every tag that reads as a
161
161
  * version and belongs to the source's `tagComponent`, when it names one.
162
+ *
163
+ * A family of components shares one version line, so one release is often
164
+ * tagged once per member — `duxt@v0.10.0` beside `duxt-typesense@v0.10.0`.
165
+ * Those are one version, kept once, from the member listed first.
162
166
  */
163
167
  function versionTags(
164
168
  tags: string[],
165
- component: string | undefined
169
+ component: string | readonly string[] | undefined
166
170
  ): { tag: string; version: DuxtVersionTag }[] {
167
- return tags
171
+ const matching = tags
168
172
  .map((tag) => ({ tag, version: parseVersionTag(tag) }))
169
173
  .filter(
170
174
  (entry): entry is { tag: string; version: DuxtVersionTag } =>
171
175
  Boolean(entry.version) && tagBelongsTo(entry.version!, component)
172
176
  );
177
+ if (typeof component === 'string' || !component) return matching;
178
+
179
+ const rank = (entry: { version: DuxtVersionTag }) =>
180
+ entry.version.component
181
+ ? component.indexOf(entry.version.component)
182
+ : component.length;
183
+ const byVersion = new Map<string, { tag: string; version: DuxtVersionTag }>();
184
+ for (const entry of matching) {
185
+ const key = versionKey(entry.version);
186
+ const kept = byVersion.get(key);
187
+ if (!kept || rank(entry) < rank(kept)) byVersion.set(key, entry);
188
+ }
189
+ return [...byVersion.values()];
173
190
  }
174
191
 
192
+ /** One version regardless of who tagged it or whether it carries a `v`. */
193
+ const versionKey = (version: DuxtVersionTag) =>
194
+ version.numbers.join('.') + (version.pre ? `-${version.pre}` : '');
195
+
175
196
  /** How a source's tag restriction reads in an error, or nothing without one. */
176
- const componentClause = (source: DuxtSource) =>
177
- source.tagComponent ? ` for the component "${source.tagComponent}"` : '';
197
+ const componentClause = (source: DuxtSource) => {
198
+ const names = source.tagComponent;
199
+ if (!names || names.length === 0) return '';
200
+ return typeof names === 'string'
201
+ ? ` for the component "${names}"`
202
+ : ` for the components ${names.map((name) => `"${name}"`).join(', ')}`;
203
+ };
178
204
 
179
205
  /** Tags selected by a source's explicit release policy, newest first. */
180
206
  function releaseTags(
181
207
  tags: string[],
182
208
  releases: DuxtSourceReleases,
183
- component: string | undefined
209
+ component: string | readonly string[] | undefined
184
210
  ): string[] {
185
211
  const parsed = versionTags(tags, component)
186
212
  .filter((entry) => releases.prereleases || !entry.version.pre)
@@ -60,8 +60,13 @@ export interface DuxtSource {
60
60
  * component tags, and dropping them would unpublish every earlier release.
61
61
  *
62
62
  * Unset, every tag that reads as a version is a candidate, prefixed or not.
63
+ *
64
+ * A LIST names a family released on one version line — the layer and its
65
+ * companion layers under `versioning: linked` — so `latest` and `releases`
66
+ * follow whichever of them released last. One version carried by several
67
+ * of them is one release, published from the tag of the name listed first.
63
68
  */
64
- tagComponent?: string;
69
+ tagComponent?: string | readonly string[];
65
70
  /**
66
71
  * Languages this source is available in, beyond the one written in `path`.
67
72
  *
@@ -964,9 +969,12 @@ export const versionTagName = (tag: string): string =>
964
969
  */
965
970
  export function tagBelongsTo(
966
971
  tag: DuxtVersionTag,
967
- component: string | undefined
972
+ component: string | readonly string[] | undefined
968
973
  ): boolean {
969
- return !component || !tag.component || tag.component === component;
974
+ if (!component || !tag.component) return true;
975
+ return typeof component === 'string'
976
+ ? tag.component === component
977
+ : component.includes(tag.component);
970
978
  }
971
979
 
972
980
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "@kirchdev/duxt",
4
- "version": "0.10.1-dev-main.5-62e738f",
4
+ "version": "0.10.1-dev-main.10-e43ad25",
5
5
  "description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.",
6
6
  "keywords": [
7
7
  "content-v3",
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Keeps a request alive on Cloudflare until it has produced its response, even
3
+ * after the reader has gone.
4
+ *
5
+ * Content restores a collection into D1 the first time a request reads it
6
+ * after a deploy: it writes the collection's `_content_info` row with
7
+ * `ready = 0`, replays the dump one statement at a time — about ten seconds a
8
+ * collection on duxt.app — and flips the row to ready as the last statement.
9
+ * A Worker CANCELS a request's work the moment its client disconnects. A
10
+ * reader who gives up, a crawler with a timeout, a prefetch the router drops:
11
+ * any of them stops that replay halfway, and the row is never flipped. Every
12
+ * later read of that collection then waits on `ready` for 90 seconds and
13
+ * fails, on every isolate, until `content-restore-recovery.ts` clears the row —
14
+ * and a docs page reads every version's collection to draw its version
15
+ * switcher, so one stuck collection hangs every page of every version.
16
+ * duxt.app ran into exactly that after the v0.10.0 deploy.
17
+ *
18
+ * `waitUntil` is the one thing a Worker lets outlive a disconnect, so every
19
+ * request hands it a promise that settles when the request is done. Anything
20
+ * it started — the restore included — then runs to completion inside the
21
+ * platform's grace period rather than being cut off. Other runtimes do not
22
+ * cancel on disconnect, so this only arms where `cloudflare` is in the context.
23
+ */
24
+ const settle = Symbol('duxt.settle');
25
+
26
+ type Settling = { [settle]?: () => void };
27
+
28
+ export default defineNitroPlugin((nitroApp) => {
29
+ nitroApp.hooks.hook('request', (event) => {
30
+ if (!event.context.cloudflare || !event.waitUntil) return;
31
+
32
+ event.waitUntil(
33
+ new Promise<void>((resolve) => {
34
+ (event.context as Settling)[settle] = resolve;
35
+ })
36
+ );
37
+ });
38
+
39
+ const done = (event: { context: Settling } | undefined) => {
40
+ event?.context[settle]?.();
41
+ };
42
+
43
+ nitroApp.hooks.hook('afterResponse', (event) => done(event));
44
+ nitroApp.hooks.hook('error', (_error, { event }) => done(event));
45
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ duxtRecoverContentRestores,
3
+ type DuxtD1
4
+ } from '../utils/content-restore';
5
+
6
+ /**
7
+ * Clears Content restores a cancelled Worker left half done, from inside the
8
+ * Worker, on D1.
9
+ *
10
+ * `content-import-lifetime.ts` keeps a restore running past a disconnect, but
11
+ * only for the platform's grace period, and a row that was stranded before it
12
+ * — or by a deploy that cut a running restore off — stays unready for good:
13
+ * every read of that collection then waits 90 seconds and fails, and a docs
14
+ * page reads every version's collection to draw its switcher. duxt.app sat
15
+ * like that after the v0.10.0 deploy until the row was removed by hand.
16
+ *
17
+ * It runs here rather than in the deploy because the Worker is the one thing
18
+ * that already holds the binding: a cleanup step in CI would need a token with
19
+ * D1 rights for every repository that reads it, and would help no site but the
20
+ * one whose workflow carries it.
21
+ *
22
+ * At most once a minute per isolate, after the response, so no reader waits on
23
+ * it. A failure is logged and dropped: this is a repair, and a missing table
24
+ * on a fresh database is not an error.
25
+ */
26
+ const INTERVAL_MS = 60_000;
27
+
28
+ let last = 0;
29
+
30
+ export default defineNitroPlugin((nitroApp) => {
31
+ nitroApp.hooks.hook('request', (event) => {
32
+ const env = event.context.cloudflare?.env as
33
+ | Record<string, unknown>
34
+ | undefined;
35
+ if (!env || !event.waitUntil) return;
36
+
37
+ const database = useRuntimeConfig(event).content?.database as
38
+ | { type?: string; bindingName?: string; binding?: string }
39
+ | undefined;
40
+ if (database?.type !== 'd1') return;
41
+
42
+ const db = env[database.bindingName || database.binding || 'DB'] as
43
+ | DuxtD1
44
+ | undefined;
45
+ if (!db) return;
46
+
47
+ const now = Date.now();
48
+ if (now - last < INTERVAL_MS) return;
49
+ last = now;
50
+
51
+ event.waitUntil(
52
+ duxtRecoverContentRestores(db, now)
53
+ .then((removed) => {
54
+ if (removed.length) {
55
+ console.warn(
56
+ `[duxt] cleared abandoned Content restores: ${removed.join(', ')}`
57
+ );
58
+ }
59
+ })
60
+ .catch((error) => {
61
+ console.warn('[duxt] Content restore check failed', error);
62
+ })
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The part of a D1 binding this file uses — written out rather than imported,
3
+ * because the layer does not depend on Cloudflare's types and no other
4
+ * runtime ever reaches this code.
5
+ */
6
+ export interface DuxtD1 {
7
+ prepare(sql: string): DuxtD1Statement;
8
+ batch(statements: DuxtD1Statement[]): Promise<unknown>;
9
+ }
10
+
11
+ export interface DuxtD1Statement {
12
+ bind(...values: unknown[]): DuxtD1Statement;
13
+ all<T>(): Promise<{ results: T[] }>;
14
+ run(): Promise<unknown>;
15
+ }
16
+
17
+ /**
18
+ * Twice the 90 seconds Content waits for another restore before giving up.
19
+ * A restore takes about ten seconds a collection on duxt.app, so a row still
20
+ * unready after this has nobody left writing it.
21
+ */
22
+ export const DUXT_STALE_RESTORE_MS = 180_000;
23
+
24
+ /** Where a row was first seen unready. Content owns `_content_info`; this is ours. */
25
+ const SEEN = '_duxt_content_unready';
26
+
27
+ /**
28
+ * Removes every Content restore that was abandoned halfway, so the next read
29
+ * of that collection restores it again instead of waiting on it forever.
30
+ *
31
+ * Content marks a collection `ready = 0` in `_content_info`, replays its dump
32
+ * and flips the row as the dump's last statement. A Worker cancelled before
33
+ * that statement leaves the row unready for good, and every later read waits
34
+ * 90 seconds and fails — see `server/plugins/content-restore-recovery.ts`.
35
+ *
36
+ * The row carries no timestamp, and an unready row is also exactly what a
37
+ * restore IN PROGRESS looks like, so a single look cannot tell the two apart.
38
+ * Two looks can: the first records when a row was seen unready, and a later
39
+ * one removes it only if the SAME version is still unready past the threshold.
40
+ * A new deploy writes a new version, which starts the clock again.
41
+ *
42
+ * Returns the ids it removed.
43
+ */
44
+ export async function duxtRecoverContentRestores(
45
+ db: DuxtD1,
46
+ now: number,
47
+ staleMs = DUXT_STALE_RESTORE_MS
48
+ ): Promise<string[]> {
49
+ const unready = await db
50
+ .prepare('SELECT id, version FROM _content_info WHERE ready = 0')
51
+ .all<{ id: string; version: string }>();
52
+
53
+ await db.batch([
54
+ db.prepare(
55
+ `CREATE TABLE IF NOT EXISTS ${SEEN} (id TEXT PRIMARY KEY, version TEXT NOT NULL, since INTEGER NOT NULL)`
56
+ ),
57
+ // A row that finished, or that Content replaced, is forgotten — otherwise
58
+ // its old clock would condemn the next restore of the same version.
59
+ db.prepare(
60
+ `DELETE FROM ${SEEN} WHERE NOT EXISTS (SELECT 1 FROM _content_info AS info WHERE info.id = ${SEEN}.id AND info.version = ${SEEN}.version AND info.ready = 0)`
61
+ ),
62
+ ...unready.results.map((row) =>
63
+ db
64
+ .prepare(
65
+ `INSERT INTO ${SEEN} (id, version, since) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING`
66
+ )
67
+ .bind(row.id, row.version, now)
68
+ )
69
+ ]);
70
+
71
+ const stale = await db
72
+ .prepare(`SELECT id, version FROM ${SEEN} WHERE since <= ?`)
73
+ .bind(now - staleMs)
74
+ .all<{ id: string; version: string }>();
75
+ if (!stale.results.length) return [];
76
+
77
+ // `ready = 0` and the version in the condition: a restore that finished, or
78
+ // a deploy that replaced the row, since the look above keeps its row.
79
+ await db.batch(
80
+ stale.results.flatMap((row) => [
81
+ db
82
+ .prepare(
83
+ 'DELETE FROM _content_info WHERE id = ? AND version = ? AND ready = 0'
84
+ )
85
+ .bind(row.id, row.version),
86
+ db.prepare(`DELETE FROM ${SEEN} WHERE id = ?`).bind(row.id)
87
+ ])
88
+ );
89
+
90
+ return stale.results.map((row) => row.id);
91
+ }