Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@ static React SPA with per-app prerendered pages, built with Vite and served from
Read **[SPEC.md](SPEC.md)** (§7 has the checklist), add `apps/<your-slug>/`, and open a PR. CI
enforces the schema, exact asset dimensions, and the store's quality gates automatically.

## Browsing the store

- `/` — the featured shelf + filterable/searchable grid.
- `/apps` — the **Apps** tab: every listing as a phone-home-screen icon grid (tap an icon to open
the dApp). This is the mobile-first browse/launch surface, and what the dig-chrome-extension's
"Explore DIG Network" action surfaces.

## Machine consumption

- `https://explore.dig.net/catalog.json` — every listing's full metadata + asset URLs; consume
this instead of scraping HTML.
- `https://explore.dig.net/llms.txt` — the agent-facing map of the store.
- Per-app pages at `/app/<slug>` ship `SoftwareApplication` JSON-LD; the home page ships
`WebSite` + `ItemList`.
`WebSite` + `ItemList`; `/apps` ships its own title/canonical/OG card.

## Development

Expand Down
18 changes: 15 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ Every build regenerates, and every deploy serves:
- `/app/<slug>` — a prerendered HTML page per listing carrying its own title, meta description,
canonical URL, OG/Twitter tags (including the app's OWN `og:image` — sharing a detail page
unfurls that app's card, never the generic store card), and `SoftwareApplication` JSON-LD.
- `/sitemap.xml`, `/robots.txt`, `/llms.txt` — kept in sync with the catalog on every build.
- `/apps` — a prerendered HTML page for the Apps home-screen tab (§6), carrying its own title,
meta description, canonical URL, and OG/Twitter tags (the store's own `og.png`, since the tab has
no per-listing art of its own).
- `/sitemap.xml`, `/robots.txt`, `/llms.txt` — kept in sync with the catalog on every build; the
sitemap and `llms.txt` both list `/apps` alongside home and every `/app/<slug>` page.
- The store's own icon set: `/favicon.svg`, `/apple-touch-icon.png` (180×180), `/icon-192.png`,
`/icon-512.png`, `/site.webmanifest`, and the store's social card `/og.png` (1200×630).
- **Build gate** (`scripts/check-dist.mjs`): a build fails unless every file above exists, the
Expand Down Expand Up @@ -225,8 +229,16 @@ be renamed or repurposed.
`<meta name="app-version">`, and at `window.__APP_VERSION__`.
- **Bug reporting:** the shared `@dignetwork/components` `<BugReportButton repo="explore.dig.net">`
is mounted at the shell; its API host `api.bugreport.dig.net` is allowed in the CSP.
- **Routing:** `/` (home), `/app/<slug>` (detail), anything else renders the not-found state. The
filter state mirrors to the URL as `?category=<cat>&q=<text>`.
- **Routing:** `/` (home), `/apps` (the Apps home-screen tab), `/app/<slug>` (detail), anything else
renders the not-found state. The filter state mirrors to the URL as `?category=<cat>&q=<text>`
(home only — the Apps tab below has no filter/search).
- **Apps home-screen tab (`/apps`):** every listed dApp renders as a phone-home-screen icon — the
listing's `assets.icon` plus its `name` label beneath, in a responsive grid (rows of rounded icons
on mobile; the same grid scales up on desktop). Tapping a tile's icon+label opens the dApp's `url`
directly in a new tab (the same action as the Store tab's "Open dApp" CTA); a small, separate "i"
affordance on each tile links to that listing's `/app/<slug>` detail page. A `<nav>` labelled view
switcher ("Store" / "Apps", `aria-current="page"` on the active one) appears on both the home and
Apps views so a visitor can move between the two presentations of the same catalog.

## 7. Submission checklist (author-facing)

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "explore-dig-net",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"type": "module",
"description": "explore.dig.net — the curated dApp store for the DIG Network / Chia ecosystem",
Expand Down
9 changes: 9 additions & 0 deletions public/site.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,14 @@
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{
"name": "Apps",
"short_name": "Apps",
"description": "Browse every DIG Network dApp as a home-screen icon grid.",
"url": "/apps",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
4 changes: 3 additions & 1 deletion scripts/build-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ export function buildCatalog(apps, { generatedAt, storeVersion }) {
return { generatedAt, storeVersion, siteUrl: SITE_URL, count: entries.length, apps: entries };
}

/** Render sitemap.xml — home + one entry per app detail page (pure). */
/** Render sitemap.xml — home + the Apps home-screen tab + one entry per app detail page (pure). */
export function renderSitemap(catalog) {
const lastmod = catalog.generatedAt.slice(0, 10);
const urls = [
{ loc: `${SITE_URL}/`, priority: "1.0" },
{ loc: `${SITE_URL}/apps`, priority: "0.9" },
...catalog.apps.map((a) => ({ loc: a.detailUrl, priority: "0.8" })),
];
const body = urls
Expand All @@ -74,6 +75,7 @@ export function renderLlmsTxt(catalog) {
## Key pages

- [Store home](${SITE_URL}/) — featured + all apps, category filter, search
- [Apps](${SITE_URL}/apps) — every listing as a phone-home-screen icon grid (tap an icon to open the dApp) — the mobile-first browse/launch surface
- [Machine catalog (JSON)](${SITE_URL}/catalog.json) — every listing's full metadata + asset URLs; consume this instead of scraping HTML
- [Submission spec](https://github.com/DIG-Network/explore.dig.net/blob/main/SPEC.md) — the exact metadata schema + asset checklist to get listed
- [Metadata JSON Schema](https://github.com/DIG-Network/explore.dig.net/blob/main/apps/app.schema.json)
Expand Down
23 changes: 23 additions & 0 deletions scripts/check-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const DIST = join(ROOT, "dist");
const SITE_URL = "https://explore.dig.net";

Check failure

Code scanning / CodeQL

Incomplete regular expression for hostnames High

This string, which is used as a regular expression
here
, has an unescaped '.' before 'dig.net', so it might match more hosts than expected.
This string, which is used as a regular expression
here
, has an unescaped '.' before 'dig.net', so it might match more hosts than expected.
This string, which is used as a regular expression
here
, has an unescaped '.' before 'dig.net', so it might match more hosts than expected.

/** Files every deploy MUST ship (agent files + the store's icon/OG set). */
export const REQUIRED_DIST_FILES = Object.freeze([
Expand Down Expand Up @@ -60,6 +60,20 @@
return HOME_HEAD_TAGS.filter(([, re]) => !re.test(html)).map(([label]) => `home: missing ${label}`);
}

/** The Apps home-screen tab's (#51) mandatory head tags — its own canonical + card, not home's. */
const APPS_PAGE_HEAD_TAGS = [
["<title>", /<title>[^<]{5,}<\/title>/],
["meta description", /<meta\s+name="description"\s+content="[^"]{20,}"/],
["canonical = /apps", new RegExp(`<link rel="canonical" href="${SITE_URL}/apps"`)],
["og:url = /apps", new RegExp(`property="og:url" content="${SITE_URL}/apps"`)],
["twitter:card summary_large_image", /name="twitter:card" content="summary_large_image"/],
];

/** Audit the built Apps tab's head (#51). Returns the missing tags' labels ([] when complete). */
export function auditAppsPageHead(html) {
return APPS_PAGE_HEAD_TAGS.filter(([, re]) => !re.test(html)).map(([label]) => `apps: missing ${label}`);
}

/**
* Audit one prerendered app page's head: it must carry the app's OWN social card — its canonical
* detail URL and its own og image — plus a large-image Twitter card. Returns missing[] labels.
Expand Down Expand Up @@ -108,6 +122,15 @@
/* index.html already reported missing above */
}

// The Apps home-screen tab (#51): its own prerendered page + head carrying its own card.
try {
const s = statSync(join(DIST, "apps", "index.html"));
if (!s.isFile() || s.size === 0) missing.push("apps/index.html (empty)");
else missing.push(...auditAppsPageHead(readFileSync(join(DIST, "apps", "index.html"), "utf-8")));
} catch {
missing.push("apps/index.html (missing)");
}

// Every cataloged app: prerendered page with its OWN card + copied assets + a true-size og.
let catalog = { apps: [] };
try {
Expand Down
43 changes: 41 additions & 2 deletions scripts/prerender-apps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
// 1. writes dist/app/<slug>/index.html for every listed app — the built SPA shell with the SEO
// block (title/description/canonical/OG/Twitter/JSON-LD) swapped for that app's own; a
// CloudFront function rewrites the extensionless /app/<slug> request to this object;
// 2. injects the store-wide ItemList JSON-LD into dist/index.html so the home page's structured
// 2. writes dist/apps/index.html — the Apps home-screen tab's own SEO head (#51); a CloudFront
// function rewrites the extensionless /apps request to this object;
// 3. injects the store-wide ItemList JSON-LD into dist/index.html so the home page's structured
// data enumerates the catalog.
//
// index.html carries `<!-- SEO:BEGIN -->…<!-- SEO:END -->` markers around the swappable block.
Expand Down Expand Up @@ -59,6 +61,38 @@ export function appSeoBlock(app) {
].join("\n ");
}

/**
* The Apps home-screen tab's SEO head block (#51, pure — unit-testable). It carries its own
* title/canonical rather than the generic store card, so sharing /apps unfurls the right page.
*/
export function appsPageSeoBlock() {
const title = "Apps — explore.dig.net";
const description =
"Every DIG Network dApp, one tap away — a phone-home-screen icon grid for browsing and " +
"launching dApps built on DIG Network and Chia.";
const pageUrl = `${SITE_URL}/apps`;
const ogImage = `${SITE_URL}/og.png`;
return [
`<title>${esc(title)}</title>`,
`<meta name="description" content="${esc(description)}" />`,
`<link rel="canonical" href="${pageUrl}" />`,
`<meta name="robots" content="index, follow" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:site_name" content="explore.dig.net" />`,
`<meta property="og:title" content="${esc(title)}" />`,
`<meta property="og:description" content="${esc(description)}" />`,
`<meta property="og:url" content="${pageUrl}" />`,
`<meta property="og:image" content="${ogImage}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:alt" content="${esc(title)}" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${esc(title)}" />`,
`<meta name="twitter:description" content="${esc(description)}" />`,
`<meta name="twitter:image" content="${ogImage}" />`,
].join("\n ");
}

/** The home page's ItemList JSON-LD (pure — unit-testable). */
export function homeItemListLd(catalog) {
return {
Expand Down Expand Up @@ -95,9 +129,14 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
writeFileSync(join(outDir, "index.html"), swapSeoBlock(shell, appSeoBlock(app)));
}

// The Apps home-screen tab (#51) gets its own prerendered page + card, same as a per-app page.
const appsOutDir = join(ROOT, "dist", "apps");
mkdirSync(appsOutDir, { recursive: true });
writeFileSync(join(appsOutDir, "index.html"), swapSeoBlock(shell, appsPageSeoBlock()));

// Home: append the ItemList JSON-LD alongside the static WebSite block.
const itemList = `<script type="application/ld+json">${JSON.stringify(homeItemListLd(catalog))}</script>`;
writeFileSync(join(ROOT, "dist", "index.html"), shell.replace("</head>", ` ${itemList}\n </head>`));

console.log(`[prerender-apps] OK — ${catalog.count} app page(s) + home ItemList JSON-LD.`);
console.log(`[prerender-apps] OK — ${catalog.count} app page(s) + the Apps tab + home ItemList JSON-LD.`);
}
25 changes: 25 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ describe("<App>", () => {
expect(screen.getByTestId("not-found")).toBeInTheDocument();
});

it("renders the Apps home-screen tab at /apps with every listing as a tile", () => {
renderWithIntl(<App pathname="/apps" search="" />);
expect(screen.getByTestId("app-home-grid")).toBeInTheDocument();
expect(screen.getByTestId("app-tile-xchtip")).toBeInTheDocument();
expect(document.title).toBe("Apps — explore.dig.net");
});

it("shows the Store/Apps view tabs on the home view, marking Store active", () => {
renderWithIntl(<App pathname="/" search="" />);
expect(screen.getByTestId("view-tab-store")).toHaveAttribute("aria-current", "page");
expect(screen.getByTestId("view-tab-apps")).not.toHaveAttribute("aria-current");
});

it("shows the Store/Apps view tabs on the Apps view, marking Apps active", () => {
renderWithIntl(<App pathname="/apps" search="" />);
expect(screen.getByTestId("view-tab-apps")).toHaveAttribute("aria-current", "page");
expect(screen.getByTestId("view-tab-store")).not.toHaveAttribute("aria-current");
});

it("does not show the view tabs on a detail page or the not-found state", () => {
renderWithIntl(<App pathname="/app/xchtip" search="" />);
expect(screen.queryByTestId("view-tab-store")).not.toBeInTheDocument();
expect(screen.queryByTestId("view-tab-apps")).not.toBeInTheDocument();
});

it("shows the build version in the footer (§6.7 human-readable form)", () => {
renderWithIntl(<App pathname="/" search="" />);
expect(screen.getByTestId("app-version")).toHaveTextContent(`v${APP_VERSION}`);
Expand Down
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { parseRoute } from "@/lib/route";
import { APP_VERSION } from "@/lib/version";
import { LanguageSelector } from "@/components/LanguageSelector";
import { ThemeToggle } from "@/components/ThemeToggle";
import { ViewTabs } from "@/components/ViewTabs";
import { HomePage } from "@/features/store/HomePage";
import { AppsHomeScreen } from "@/features/apps/AppsHomeScreen";
import { AppDetailPage, NotFound } from "@/features/detail/AppDetailPage";
import { useT } from "@/i18n/useT";

Expand Down Expand Up @@ -41,6 +43,8 @@ export function App({ pathname, search }: AppProps) {
if (route.kind === "app") {
const app = catalog.apps.find((a) => a.slug === route.slug);
if (app) document.title = `${app.name} — ${app.tagline} · explore.dig.net`;
} else if (route.kind === "apps") {
document.title = "Apps — explore.dig.net";
}
}, [route, catalog]);

Expand Down Expand Up @@ -79,7 +83,11 @@ export function App({ pathname, search }: AppProps) {
</header>

<main id="main" className="shell site-main">
{(route.kind === "home" || route.kind === "apps") && (
<ViewTabs active={route.kind === "apps" ? "apps" : "store"} />
)}
{route.kind === "home" && <HomePage apps={catalog.apps} search={search} />}
{route.kind === "apps" && <AppsHomeScreen apps={catalog.apps} />}
{route.kind === "app" && <AppDetailPage apps={catalog.apps} slug={route.slug} />}
{route.kind === "not-found" && <NotFound />}
</main>
Expand Down
47 changes: 47 additions & 0 deletions src/components/AppTile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// AppTile tests — one dApp rendered as a phone-home-screen icon (#51). Two sibling links (same
// pattern as AppCard, App.tsx comment): the tile opens the dApp directly; a small "i" affordance
// opens the detail page. Neither is nested inside the other (invalid HTML + broken a11y tree).

import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithIntl } from "@/test/renderWithIntl";
import { makeApp } from "@/test/fixtures";
import { AppTile } from "./AppTile";

describe("<AppTile>", () => {
it("renders the app's icon + name label", () => {
const app = makeApp({ slug: "demo-app", name: "Demo App" });
renderWithIntl(<AppTile app={app} />);
expect(screen.getByTestId("app-tile-demo-app")).toBeInTheDocument();
expect(screen.getByText("Demo App")).toBeInTheDocument();
});

it("tapping the tile opens the dApp directly, in a new tab", () => {
const app = makeApp({ slug: "demo-app", name: "Demo App", url: "https://demo.example.com/" });
renderWithIntl(<AppTile app={app} />);
const link = screen.getByTestId("app-tile-link-demo-app");
expect(link).toHaveAttribute("href", "https://demo.example.com/");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link).toHaveAttribute("aria-label", "Open Demo App");
});

it("offers a distinguishable info affordance to the detail page (never nested inside the app link)", () => {
const app = makeApp({ slug: "demo-app", name: "Demo App" });
renderWithIntl(<AppTile app={app} />);
const info = screen.getByTestId("app-tile-info-demo-app");
expect(info.tagName).toBe("A");
expect(info).toHaveAttribute("href", "/app/demo-app");
expect(info).toHaveAttribute("aria-label", "View details for Demo App");
// Sibling, not a descendant, of the app-opening link.
const link = screen.getByTestId("app-tile-link-demo-app");
expect(link.contains(info)).toBe(false);
expect(info.contains(link)).toBe(false);
});

it("is announced as a list item for a coherent grid landmark", () => {
const app = makeApp({ slug: "demo-app" });
renderWithIntl(<AppTile app={app} />);
expect(screen.getByTestId("app-tile-demo-app")).toHaveAttribute("role", "listitem");
});
});
43 changes: 43 additions & 0 deletions src/components/AppTile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// AppTile — one dApp rendered as a phone-home-screen icon: a rounded icon + a short name label
// beneath it (the "Apps" tab's signature layout, #51). Reuses AppIcon as-is (its fan-out across
// Store/Detail is high — CLAUDE.md §2.0 blast-radius — so this only re-styles it via CSS descendant
// selectors, never edits it). Two sibling links, same pattern as AppCard: the tile itself opens the
// dApp directly (the primary phone-home-screen action — tap an icon, the app opens); a small corner
// "i" affordance is a SEPARATE link to the detail page for anyone who wants more before committing.

import type { CatalogApp } from "@/catalog/types";
import { AppIcon } from "@/components/AppIcon";
import { useT } from "@/i18n/useT";

export interface AppTileProps {
app: CatalogApp;
}

export function AppTile({ app }: AppTileProps) {
const t = useT();
return (
<div className="app-tile" role="listitem" data-testid={`app-tile-${app.slug}`}>
<a
className="app-tile-link"
href={app.url}
target="_blank"
rel="noopener noreferrer"
aria-label={t("openAppNamed", { name: app.name })}
data-testid={`app-tile-link-${app.slug}`}
>
<span className="app-tile-icon-frame">
<AppIcon app={app} size={72} />
</span>
<span className="app-tile-name">{app.name}</span>
</a>
<a
className="app-tile-info"
href={`/app/${app.slug}`}
aria-label={t("viewDetailsFor", { name: app.name })}
data-testid={`app-tile-info-${app.slug}`}
>
<span aria-hidden="true">i</span>
</a>
</div>
);
}
Loading
Loading