Files
nexu-io__open-design/packages/contracts/tests/plugin-url.test.ts
lefarcen 9a4816b101 feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger (#2999)
* feat(plugins): site-wide plugin detail pages, share-to-site links, landing deploy trigger

Why: a merged plugin PR didn't redeploy the landing site (plugins/** was missing from the deploy paths), and the desktop Share menu copied a local/404 link instead of the public marketplace URL. The landing plugin routing left by the detail-page rework also 404'd: the locale listing's cards used a multi-segment href while detail pages were single-segment, and only 388 bundled _official plugins had pages.

What changed:
- Deploy: landing-page deploy/ci trigger on plugins/**, and skip the slow previews step on an exact cache hit (cache key aligned across both workflows so a PR-built cache is reused by main).
- Share URL: packages/contracts/plugin-url.ts owns the single-segment plugin URL scheme; the web Share menu and the landing site both derive links from it. Web links now point at https://open-design.ai/plugins/<slug>/.
- Full detail coverage: detail pages now cover all 403 local plugins (_official incl. atoms + community), each rendered from its local manifest. Fixes the locale-listing 404s and the community manifest-name/catalog-id (- vs /) mismatch.
- Self-host: daemon exposes OD_SITE_ORIGIN via /api/app-config; web falls back to the canonical origin until the daemon answers.

Validation: pnpm guard, pnpm typecheck (all packages), contracts + web tests green, and a full build E2E confirming all 403 catalog ids and locale-listing cards resolve to built detail pages (0 missing).

* chore: retrigger CI

* ci(landing): carry plugins/** trigger + previews cache-hit into #2994 split workflows

Merged origin/main, which split landing deploy into staging + manual production (#2994). git auto-migrated my landing-page-deploy.yml changes into landing-page-staging.yml via rename detection (plugins/** path, fallback-preview-card.ts cache key, cache-hit skip all carried). The new manual landing-page-production.yml didn't have them, so add the previews cache-key alignment + cache-hit skip there too (plugins/** path is N/A — production is workflow_dispatch only).

* fix(ci): wrangler-action uses pnpm so it tolerates landing's workspace dep

This PR added @open-design/contracts (workspace:*) to apps/landing-page/package.json so the landing site can share the plugin-url slug rules. But the landing deploy/preview steps run cloudflare/wrangler-action with packageManager: npm in workingDirectory apps/landing-page, and 'npm i wrangler' chokes on the workspace: protocol (EUNSUPPORTEDPROTOCOL), failing 'Validate landing page'. Switch all three landing wrangler-action steps (staging / ci preview / production) to packageManager: pnpm, which is workspace-aware.

* test(e2e): bundled plugins now offer the README badge

After this branch, buildPluginShareUrl returns a public open-design.ai link for bundled plugins (not just official-marketplace ones), so the home-starter share menu now shows 'Copy README badge'. Update the assertion from toHaveCount(0) to toBeVisible().

* fix(landing): drop @open-design/contracts dep, use a landing-local slug helper

Per review on #2999: the marketing site must not import @open-design/contracts (AGENTS.md boundary — it's the web/daemon product-runtime contract layer). Move the slug/path helpers into landing-local app/_lib/plugin-slug.ts; the web client keeps contracts' plugin-url. The two derive the same scheme and are verified in lockstep by the e2e route check (403 share URLs -> 403 detail pages, 0 missing). landing no longer has a workspace dep, so revert the wrangler-action packageManager back to npm.

* fix(landing): include plugins/_official in previews cache key

Per review on #2999: generate-previews.ts builds bundled-plugin preview jobs from plugins/_official/**/open-design.json and renders fallback cards from manifest fields (title/description/mode/scenario/tags). With plugins/** now triggering the workflow but the cache key not hashing plugin inputs, a plugin-only PR/merge could exact-hit an old cache and skip the preview regen, shipping with a stale or missing /previews/plugins/<manifest-id>.png. Add plugins/_official/** to the cache key in all three landing workflows (ci, staging, production). community is not currently covered by generate-previews so its glob is omitted.

* fix(plugins): include community marketplace installs in share gate

hasPublicPage now covers sourceMarketplaceId === 'community' so the
README badge and public detail link surface for community installs.
Community manifest names carry a community- prefix that diverges from
the landing-page route slug, so URL derivation uses sourceMarketplaceEntryName
(community/<folder>) instead — pluginDetailSlug takes the last segment,
matching the /plugins/<folder>/ route the landing page emits.
Adds component tests for buildPluginShareUrl, badge copy, and the
Open-in-marketplace link for a community/registry-starter record.

Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-28 09:07:12 +00:00

67 lines
2.6 KiB
TypeScript

// plugin-url — single source of truth for the public marketplace URL scheme.
// Web (PluginShareMenu) and the landing site both derive links here; this
// pins the slug rules so the two surfaces can't drift into a 404 against the
// SSG site. Detail/share routes are SINGLE-segment (last id segment); the
// preview route keeps the namespace.
import { describe, expect, it } from 'vitest';
import {
OPEN_DESIGN_SITE_ORIGIN,
pluginSlug,
pluginSlugSegment,
pluginDetailSlug,
pluginDetailPath,
pluginPreviewPath,
pluginShareUrl,
} from '../src/plugins/plugin-url.js';
describe('pluginSlugSegment', () => {
it('lowercases, collapses unsafe runs, trims dashes', () => {
expect(pluginSlugSegment('Hero Deck')).toBe('hero-deck');
expect(pluginSlugSegment(' Wild!!Name ')).toBe('wild-name');
});
it('keeps url-safe punctuation and falls back to "plugin"', () => {
expect(pluginSlugSegment('keep.dots_and-dashes')).toBe('keep.dots_and-dashes');
expect(pluginSlugSegment('!!!')).toBe('plugin');
});
});
describe('pluginDetailSlug (single segment = last id segment)', () => {
it('takes the slugified last segment, dropping any namespace', () => {
expect(pluginDetailSlug('open-design/Hero Deck')).toBe('hero-deck');
expect(pluginDetailSlug('community/registry-starter')).toBe('registry-starter');
expect(pluginDetailSlug('live-dashboard')).toBe('live-dashboard');
});
});
describe('pluginSlug (multi-segment, namespace preserved)', () => {
it('slugifies each segment and keeps / as a separator', () => {
expect(pluginSlug('open-design/Hero Deck')).toBe('open-design/hero-deck');
});
});
describe('pluginDetailPath / pluginPreviewPath', () => {
it('detail path is single-segment with trailing slash', () => {
expect(pluginDetailPath('open-design/Hero Deck')).toBe('/plugins/hero-deck/');
expect(pluginDetailPath('live-dashboard')).toBe('/plugins/live-dashboard/');
});
it('preview path keeps the namespace', () => {
expect(pluginPreviewPath('open-design/Hero Deck')).toBe(
'/plugins/previews/open-design/hero-deck/',
);
});
});
describe('pluginShareUrl', () => {
it('defaults to the public open-design.ai origin, single-segment path', () => {
expect(OPEN_DESIGN_SITE_ORIGIN).toBe('https://open-design.ai');
expect(pluginShareUrl('open-design/live-dashboard')).toBe(
'https://open-design.ai/plugins/live-dashboard/',
);
});
it('honours an explicit origin and trims a trailing slash on it', () => {
expect(pluginShareUrl('x', 'https://self.host')).toBe('https://self.host/plugins/x/');
expect(pluginShareUrl('x', 'https://self.host/')).toBe('https://self.host/plugins/x/');
});
});