@grove-dev/astro 0.2.13 → 0.2.16

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.
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@astrojs/check": "^0.9.9",
28
- "@grove-dev/astro": "0.2.13",
29
- "@grove-dev/cli": "0.2.13",
30
- "@grove-dev/core": "0.2.13",
28
+ "@grove-dev/astro": "0.2.16",
29
+ "@grove-dev/cli": "0.2.16",
30
+ "@grove-dev/core": "0.2.16",
31
31
  "@tailwindcss/vite": "^4.3.0",
32
32
  "astro": "^6.4.4",
33
33
  "marked": "^18.0.0",
@@ -1,6 +1,6 @@
1
1
  # example.com — full directory
2
2
 
3
- Generated: 2026-06-13T10:16:37.910Z
3
+ Generated: 2026-06-15T13:49:39.319Z
4
4
  Records: 8
5
5
 
6
6
  ---
@@ -2,61 +2,61 @@
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
3
  <url>
4
4
  <loc>https://example.com/</loc>
5
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
5
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
6
6
  <changefreq>daily</changefreq>
7
7
  <priority>1.0</priority>
8
8
  </url>
9
9
  <url>
10
10
  <loc>https://example.com/projects</loc>
11
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
11
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
12
12
  <changefreq>daily</changefreq>
13
13
  <priority>0.9</priority>
14
14
  </url>
15
15
  <url>
16
16
  <loc>https://example.com/projects/coolify</loc>
17
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
17
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
18
18
  <changefreq>weekly</changefreq>
19
19
  <priority>0.7</priority>
20
20
  </url>
21
21
  <url>
22
22
  <loc>https://example.com/projects/gitea</loc>
23
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
23
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
24
24
  <changefreq>weekly</changefreq>
25
25
  <priority>0.7</priority>
26
26
  </url>
27
27
  <url>
28
28
  <loc>https://example.com/projects/hoppscotch</loc>
29
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
29
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
30
30
  <changefreq>weekly</changefreq>
31
31
  <priority>0.7</priority>
32
32
  </url>
33
33
  <url>
34
34
  <loc>https://example.com/projects/inventree</loc>
35
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
35
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
36
36
  <changefreq>weekly</changefreq>
37
37
  <priority>0.7</priority>
38
38
  </url>
39
39
  <url>
40
40
  <loc>https://example.com/projects/label-studio</loc>
41
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
41
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
42
42
  <changefreq>weekly</changefreq>
43
43
  <priority>0.7</priority>
44
44
  </url>
45
45
  <url>
46
46
  <loc>https://example.com/projects/logseq</loc>
47
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
47
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
48
48
  <changefreq>weekly</changefreq>
49
49
  <priority>0.7</priority>
50
50
  </url>
51
51
  <url>
52
52
  <loc>https://example.com/projects/private-gpt</loc>
53
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
53
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
54
54
  <changefreq>weekly</changefreq>
55
55
  <priority>0.7</priority>
56
56
  </url>
57
57
  <url>
58
58
  <loc>https://example.com/projects/wakapi</loc>
59
- <lastmod>2026-06-13T10:16:37.466Z</lastmod>
59
+ <lastmod>2026-06-15T13:49:38.754Z</lastmod>
60
60
  <changefreq>weekly</changefreq>
61
61
  <priority>0.7</priority>
62
62
  </url>
@@ -67,6 +67,9 @@ interface SiteConfigPayload {
67
67
  kind?: "project" | "resource" | "entity";
68
68
  routeSlug?: string;
69
69
  itemSlug?: string;
70
+ /** V1 canonical field name for the record detail slug
71
+ * (replaces the V0 `itemSlug` field). */
72
+ recordSlug?: string;
70
73
  labelSingular?: string;
71
74
  labelPlural?: string;
72
75
  };
@@ -226,10 +229,11 @@ export function indexSlug(): string {
226
229
  return blueprintConfig.routeSlug ?? "projects";
227
230
  }
228
231
 
229
- /** URL slug for a single item detail page (the dynamic
230
- * `[itemSlug]` segment). */
231
- export function itemSlug(): string {
232
- return blueprintConfig.itemSlug ?? "project";
232
+ /** URL slug for a single record detail page (the dynamic
233
+ * `[recordSlug]` segment). Default "project" for backwards-compat
234
+ * with V0-published configs that exposed the field as `itemSlug`. */
235
+ export function recordSlugConfig(): string {
236
+ return blueprintConfig.recordSlug ?? blueprintConfig.itemSlug ?? "project";
233
237
  }
234
238
 
235
239
  /** Singular human label, e.g. "project", "resource", "entity". */
@@ -17,7 +17,7 @@
17
17
  * Generic across the 3 record kinds (project / resource / entity) —
18
18
  * non-project fields simply render nothing. The URL is dynamic on
19
19
  * both `[slug]` (blueprint route, e.g. `projects`/`resources`/
20
- * `entities`) and `[itemSlug]` (the record slug), so a single page
20
+ * `entities`) and `[recordSlug]` (the record slug), so a single page
21
21
  * file serves every blueprint and every record.
22
22
  */
23
23
  import siteConfig from "../../../data/generated/site-config.json";
@@ -54,12 +54,12 @@ export async function getStaticPaths() {
54
54
  /* not present */
55
55
  }
56
56
  return fullRecords.map((record) => ({
57
- params: { slug: dirSlug, itemSlug: record.slug },
57
+ params: { slug: dirSlug, recordSlug: record.slug },
58
58
  }));
59
59
  }
60
60
 
61
61
  const slug = Astro.params.slug ?? indexSlug();
62
- const recordSlug = Astro.params.itemSlug ?? "";
62
+ const recordSlug = Astro.params.recordSlug ?? "";
63
63
  const project = projectBySlug(recordSlug);
64
64
  const resource = resourceBySlug(recordSlug);
65
65
  const entity = entityBySlug(recordSlug);
@@ -162,20 +162,51 @@ const languages = extras.github?.languages
162
162
  const totalLanguageBytes = Math.max(1, languages.reduce((sum, [, b]) => sum + b, 0));
163
163
 
164
164
  // ── Kind-aware JSON-LD ─────────────────────────────────────────
165
+ // Mirrors the openapps field set (10+ fields for projects):
166
+ // name, headline, description, url, codeRepository, sameAs,
167
+ // programmingLanguage, license, applicationCategory,
168
+ // operatingSystem, keywords, dateCreated, dateModified,
169
+ // interactionStatistic, author, isAccessibleForFree.
165
170
  let jsonLd: Record<string, unknown>;
166
171
  if (isProject && proj) {
172
+ const sameAs = [repoUrl, homepageUrl].filter((value): value is string =>
173
+ typeof value === "string" && value.length > 0,
174
+ );
175
+ const keywords = (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined;
176
+ const operatingSystem = (proj.platforms ?? []).length > 0
177
+ ? (proj.platforms ?? []).join(", ")
178
+ : undefined;
179
+ const applicationCategory = (record.category ?? "") || undefined;
180
+ const dateCreated = (record as { curation?: { reviewedAt?: string } }).curation?.reviewedAt;
181
+ const dateModified = pushedAt ?? dateCreated;
182
+ const interactionStatistic = stars > 0
183
+ ? {
184
+ "@type": "InteractionCounter",
185
+ interactionType: { "@type": "LikeAction" },
186
+ userInteractionCount: stars,
187
+ }
188
+ : undefined;
167
189
  jsonLd = {
168
190
  "@context": "https://schema.org",
169
191
  "@type": "SoftwareSourceCode",
170
192
  name,
193
+ headline: name,
171
194
  description: record.description ?? undefined,
172
195
  url: repoUrl || homepageUrl || undefined,
173
196
  codeRepository: repoUrl || undefined,
197
+ sameAs: sameAs.length > 0 ? sameAs : undefined,
174
198
  programmingLanguage: language ?? undefined,
175
199
  license: licenseSpdx ?? undefined,
200
+ applicationCategory,
201
+ operatingSystem,
202
+ keywords,
203
+ dateCreated: dateCreated ?? undefined,
204
+ dateModified: dateModified ?? undefined,
205
+ interactionStatistic,
176
206
  author: ownerRepo
177
207
  ? { "@type": "Organization", name: ownerRepo.owner, url: `https://github.com/${ownerRepo.owner}` }
178
208
  : undefined,
209
+ isAccessibleForFree: true,
179
210
  };
180
211
  } else if (record.kind === "resource") {
181
212
  const resourceSchemaType: Record<string, string> = {
@@ -189,20 +220,25 @@ if (isProject && proj) {
189
220
  "@context": "https://schema.org",
190
221
  "@type": resourceSchemaType[record.type] ?? "CreativeWork",
191
222
  name,
223
+ headline: name,
192
224
  description: record.description || undefined,
193
225
  url: homepageUrl || undefined,
194
226
  author: record.author ? { "@type": "Person", name: record.author } : undefined,
195
227
  datePublished: record.publishedAt || undefined,
228
+ keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
229
+ isAccessibleForFree: true,
196
230
  };
197
231
  } else {
198
232
  jsonLd = {
199
233
  "@context": "https://schema.org",
200
234
  "@type": record.type === "person" ? "Person" : "Organization",
201
235
  name,
236
+ headline: name,
202
237
  description: record.description || undefined,
203
238
  url: homepageUrl || undefined,
204
239
  foundingDate: record.founded || undefined,
205
240
  location: record.location || undefined,
241
+ keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
206
242
  };
207
243
  }
208
244
  ---
@@ -264,6 +300,17 @@ if (isProject && proj) {
264
300
  {labelDisplay(l) ?? prettySlug(l)}
265
301
  </span>
266
302
  ))}
303
+ {record.curation?.reviewed && (
304
+ <span
305
+ class="inline-flex items-center gap-1 rounded border border-emerald-300/60 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300"
306
+ title="A human curator reviewed this record. (No reviewer name is shown — the framework is reviewer-agnostic; consumers can wire the curation.by field into their own badge component.)"
307
+ >
308
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
309
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z" />
310
+ </svg>
311
+ Curator reviewed
312
+ </span>
313
+ )}
267
314
  {healthLabel && (
268
315
  <span class="grove-badge grove-badge-stale">{healthLabel}</span>
269
316
  )}
@@ -5,7 +5,8 @@
5
5
  * Mirrors the openapps list structure:
6
6
  * - SmartLensTabs (All / Trending / Recently added / Established / Featured)
7
7
  * - RefinePanel (Stack / Platform / Category / License / Sort)
8
- * - ItemSection rows (full record cards via RecordSection)
8
+ * - RecordSection rows (full record cards; the `ItemSection` V0
9
+ * name was renamed to `RecordSection` in V1)
9
10
  * - ExploreByCategory / ExploreByStack (compact pill lists)
10
11
  *
11
12
  * Generic: the URL slug (e.g. `/projects/`, `/resources/`,
@@ -15,11 +16,12 @@
15
16
  */
16
17
  import siteConfig from "../../../data/generated/site-config.json";
17
18
  import { items, itemLabel, itemLabelPlural } from "../../data/records";
18
- import type { AppsFilters } from "@grove-dev/astro";
19
+ import type { IndexFilters } from "@grove-dev/astro";
19
20
  import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
20
21
  import SmartLensTabs from "@grove-dev/astro/components/SmartLensTabs.astro";
21
22
  import RefinePanel from "@grove-dev/astro/components/RefinePanel.astro";
22
- import AppsIndexRow from "@grove-dev/astro/components/AppsIndexRow.astro";
23
+ import IndexRow from "@grove-dev/astro/components/IndexRow.astro";
24
+ import Pagination from "@grove-dev/astro/components/Pagination.astro";
23
25
  import ExploreByCategory from "@grove-dev/astro/components/ExploreByCategory.astro";
24
26
  import ExploreByStack from "@grove-dev/astro/components/ExploreByStack.astro";
25
27
  import {
@@ -30,11 +32,12 @@ import {
30
32
  countByStack,
31
33
  effectivePage,
32
34
  effectiveSort,
33
- filterApps,
35
+ filterRecords,
34
36
  filtersFromSearchParams,
35
37
  paginate,
36
38
  searchParamsFromFilters,
37
39
  sortDisplay,
40
+ SORT_OPTIONS,
38
41
  totalPages,
39
42
  } from "@grove-dev/astro";
40
43
 
@@ -57,7 +60,7 @@ const filters = filtersFromSearchParams(Astro.url.searchParams);
57
60
  const facets = buildFacets(items);
58
61
  const sort = effectiveSort(filters);
59
62
  const requestedPage = effectivePage(filters);
60
- const filtered = filterApps(items, filters);
63
+ const filtered = filterRecords(items, filters);
61
64
  const sorted = applySort(filtered, sort);
62
65
  const pages = totalPages(sorted.length);
63
66
  const page = Math.min(requestedPage, pages);
@@ -106,15 +109,15 @@ const stackEntries = [...countByStack(items as Parameters<typeof countByStack>[0
106
109
  .map(([name, count]) => ({ name, slug: name, count }));
107
110
 
108
111
  function hrefForPage(target: number): string {
109
- const next: AppsFilters = { ...filters, page: target };
112
+ const next: IndexFilters = { ...filters, page: target };
110
113
  const query = searchParamsFromFilters(next).toString();
111
114
  return query ? `/${slug}?${query}` : `/${slug}`;
112
115
  }
113
116
 
114
- function hrefForRemove(key: keyof AppsFilters, value: string): string {
115
- const params = new URLSearchParams(Astro.url.searchParams);
116
- params.delete("page");
117
- const urlKey: Partial<Record<keyof AppsFilters, string>> = {
117
+ function hrefForRemove(key: keyof IndexFilters, value: string): string {
118
+ const params = new URLSearchParams();
119
+ const next: IndexFilters = { ...filters, page: 1 };
120
+ const urlKey: Partial<Record<keyof IndexFilters, string>> = {
118
121
  stacks: "stack",
119
122
  platforms: "platform",
120
123
  categories: "category",
@@ -164,6 +167,7 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
164
167
  <form
165
168
  method="get"
166
169
  action={`/${slug}`}
170
+ id="search-form"
167
171
  class="mt-6 flex flex-col gap-2 sm:flex-row"
168
172
  role="search"
169
173
  >
@@ -177,9 +181,10 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
177
181
  class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-400"
178
182
  fill="currentColor"
179
183
  >
180
- <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.75.75 0 0 1-1.06 1.06l-3.04-3.04ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
184
+ <path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
181
185
  </svg>
182
186
  <input
187
+ id="search-input"
183
188
  type="search"
184
189
  name="q"
185
190
  value={filters.q ?? ""}
@@ -187,7 +192,28 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
187
192
  class="h-10 w-full rounded-md border border-ink-200 bg-white py-2 pl-9 pr-3 text-sm text-ink-900 placeholder:text-ink-400 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-100"
188
193
  />
189
194
  </label>
190
- {filters.sort && <input type="hidden" name="sort" value={filters.sort} />}
195
+ <label class="relative">
196
+ <span class="sr-only">{`Sort ${itemLabelPlural()}`}</span>
197
+ <select
198
+ id="sort-select"
199
+ name="sort"
200
+ class="h-10 cursor-pointer appearance-none rounded-md border border-ink-200 bg-white py-1.5 pl-3 pr-8 text-sm font-medium text-ink-700 transition-colors hover:border-ink-300 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300 dark:hover:border-ink-700 dark:focus:border-ink-100 dark:focus:ring-ink-100/15"
201
+ >
202
+ {SORT_OPTIONS.map((o) => (
203
+ <option value={o.value} selected={o.value === sort}>{o.label}</option>
204
+ ))}
205
+ </select>
206
+ <svg
207
+ viewBox="0 0 16 16"
208
+ width="11"
209
+ height="11"
210
+ aria-hidden="true"
211
+ class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-ink-400"
212
+ fill="currentColor"
213
+ >
214
+ <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z" />
215
+ </svg>
216
+ </label>
191
217
  <button type="submit" class="grove-btn grove-btn-primary">Search</button>
192
218
  </form>
193
219
 
@@ -266,8 +292,8 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
266
292
 
267
293
  <ul id="results-list" class="m-0 flex list-none flex-col gap-3 p-0">
268
294
  {sorted.map((item) => (
269
- <li class="m-0 p-0" data-item-slug={item.slug}>
270
- <AppsIndexRow record={item as Parameters<typeof AppsIndexRow>[0]["record"]} detailHref={`/${slug}/${item.slug}`} />
295
+ <li class="m-0 p-0" data-record-slug={item.slug}>
296
+ <IndexRow record={item as Parameters<typeof IndexRow>[0]["record"]} detailHref={`/${slug}/${item.slug}`} />
271
297
  </li>
272
298
  ))}
273
299
  </ul>
@@ -277,7 +303,28 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
277
303
  </p>
278
304
  <a href={`/${slug}`} class="grove-btn grove-btn-outline mt-4">Clear filters</a>
279
305
  </div>
280
- <nav id="client-pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination"></nav>
306
+ <nav class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination">
307
+ <Pagination current={page} total={pages} hrefFor={hrefForPage} />
308
+ </nav>
309
+ </div>
310
+ </section>
311
+
312
+ <section class="border-t border-ink-200 dark:border-ink-800">
313
+ <div class="grove-container-wide mx-auto px-5 sm:px-7">
314
+ <div class="flex flex-col items-center gap-3 py-12 text-center">
315
+ <h2 class="m-0 text-xl font-semibold tracking-tight text-ink-900 dark:text-ink-100">
316
+ {`Know a real ${itemLabel()} that belongs here?`}
317
+ </h2>
318
+ <p class="m-0 max-w-2xl text-sm text-ink-500 dark:text-ink-400">
319
+ {`Open a pull request with a YAML file. We review every submission against the bar — no marketing, no tutorials.`}
320
+ </p>
321
+ <a
322
+ href={siteConfig.submitUrl ?? "/submit"}
323
+ class="grove-btn grove-btn-primary mt-2"
324
+ >
325
+ {`Submit ${itemLabel()}`}
326
+ </a>
327
+ </div>
281
328
  </div>
282
329
  </section>
283
330
 
@@ -287,6 +334,49 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
287
334
  var list = document.getElementById("results-list");
288
335
  if (!list) return;
289
336
 
337
+ // Sort dropdown: navigate on change so the server re-renders
338
+ // the sorted list (URL is the source of truth for filters/sort).
339
+ var sortSelect = document.getElementById("sort-select");
340
+ if (sortSelect) {
341
+ sortSelect.addEventListener("change", function () {
342
+ var params = new URLSearchParams(location.search);
343
+ if (sortSelect.value && sortSelect.value !== "recently-updated") {
344
+ params.set("sort", sortSelect.value);
345
+ } else {
346
+ params.delete("sort");
347
+ }
348
+ params.delete("page");
349
+ var qs = params.toString();
350
+ location.assign(qs ? "?" + qs : location.pathname);
351
+ });
352
+ }
353
+
354
+ // Search: 250ms debounce; pressing Enter still submits
355
+ // immediately via the form's normal submit handler.
356
+ var searchInput = document.getElementById("search-input");
357
+ var searchForm = document.getElementById("search-form");
358
+ if (searchInput && searchForm) {
359
+ var debounceTimer = null;
360
+ var submit = function () {
361
+ var params = new URLSearchParams(location.search);
362
+ var term = searchInput.value.trim();
363
+ if (term) params.set("q", term);
364
+ else params.delete("q");
365
+ params.delete("page");
366
+ var qs = params.toString();
367
+ location.assign(qs ? "?" + qs : location.pathname);
368
+ };
369
+ searchInput.addEventListener("input", function () {
370
+ if (debounceTimer) window.clearTimeout(debounceTimer);
371
+ debounceTimer = window.setTimeout(submit, 250);
372
+ });
373
+ searchForm.addEventListener("submit", function (event) {
374
+ event.preventDefault();
375
+ if (debounceTimer) window.clearTimeout(debounceTimer);
376
+ submit();
377
+ });
378
+ }
379
+
290
380
  function values(params, key) {
291
381
  return params.getAll(key).filter(Boolean);
292
382
  }
@@ -369,11 +459,11 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
369
459
  var order = new Map(filtered.map(function (item, index) { return [item.slug, index]; }));
370
460
  Array.from(list.children)
371
461
  .sort(function (a, b) {
372
- return (order.get(a.dataset.itemSlug) ?? Number.MAX_SAFE_INTEGER) -
373
- (order.get(b.dataset.itemSlug) ?? Number.MAX_SAFE_INTEGER);
462
+ return (order.get(a.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER) -
463
+ (order.get(b.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER);
374
464
  })
375
465
  .forEach(function (node) {
376
- node.hidden = !visible.has(node.dataset.itemSlug);
466
+ node.hidden = !visible.has(node.dataset.recordSlug);
377
467
  list.appendChild(node);
378
468
  });
379
469
 
@@ -395,21 +485,9 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
395
485
  });
396
486
  });
397
487
 
398
- var pager = document.getElementById("client-pagination");
399
- if (pager) {
400
- pager.innerHTML = "";
401
- if (totalPages > 1) {
402
- for (var number = 1; number <= totalPages; number += 1) {
403
- var link = document.createElement("a");
404
- link.href = pageHref(params, number);
405
- link.textContent = String(number);
406
- link.className = "grove-btn min-w-9 px-2 py-1.5 text-2xs " +
407
- (number === page ? "grove-btn-primary" : "grove-btn-outline");
408
- if (number === page) link.setAttribute("aria-current", "page");
409
- pager.appendChild(link);
410
- }
411
- }
412
- }
488
+ // Pagination is server-rendered (see <Pagination /> in the
489
+ // template) so the static HTML contains the page-number
490
+ // links for SEO. No client-side rendering loop here.
413
491
 
414
492
  var chipHost = document.getElementById("active-filters");
415
493
  if (chipHost) {
@@ -0,0 +1,33 @@
1
+ ---
2
+ /**
3
+ * /apps/<slug> — V0-published alias URL → V1 canonical.
4
+ *
5
+ * The canonical V1 detail URL is `/<blueprint>/<recordSlug>` (e.g.
6
+ * `/projects/coolify`). The V0 published Astro template exposed
7
+ * `/apps/<slug>` as a static alias to keep the openapps-style URL
8
+ * working for project-directory blueprints. Now that the V1
9
+ * template uses the blueprint-aware dynamic route everywhere,
10
+ * this page is a pure redirect: any `/apps/<slug>` request is
11
+ * sent to the blueprint-canonical path.
12
+ */
13
+ import { indexSlug, fullRecords, projectBySlug, recordBySlug } from "../../data/records";
14
+
15
+ export async function getStaticPaths() {
16
+ return fullRecords.map((r) => ({ params: { recordSlug: r.slug } }));
17
+ }
18
+
19
+ const slug = indexSlug();
20
+ const recordSlug = Astro.params.recordSlug ?? "";
21
+
22
+ if (slug !== "projects") {
23
+ return Astro.redirect(`/${slug}/${recordSlug}`);
24
+ }
25
+
26
+ const record = recordBySlug(recordSlug);
27
+ if (!record) {
28
+ return Astro.redirect(`/${slug}`);
29
+ }
30
+
31
+ // Canonical V1 URL.
32
+ return Astro.redirect(`/${slug}/${recordSlug}`, 301);
33
+ ---