mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* chore: unify product name display to OpenDesign repo-wide Rename the user-facing product name from "Open Design" to "OpenDesign" across documentation, web/desktop UI strings, i18n locales, prompts, plugin/skill/design-system content, changelogs, and specs, matching the landing-page rename already in flight. Deliberately excluded so installed-app identity stays untouched: - packages/release PRODUCT_NAME and everything derived from it (app bundle names, installer/registry identity, updater feeds) - tools/pack, tools/release, apps/packaged, landing page (PR #6983) - mocks/ recorded traces and LICENSE - test fixtures and comments that encode real on-disk install paths (Open Design.app, Program Files\Open Design, AppData paths) Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host test runs over the renamed strings. * fix(web): keep two-letter author monogram for CamelCase brand names authorInitials split words on whitespace only, so the renamed single-token author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper CamelCase boundaries as word breaks so the avatar fallback stays "OD". * fix(release): restore spaced Windows source artifact names in win.ps1 tools/pack still derives builder outputs from the unchanged PRODUCT_NAME ("Open Design"), so the asset script must look for the spaced filenames. Matches the mac/mac-intel/linux asset scripts, which were already kept on the original names. --------- Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
241 lines
9.3 KiB
TypeScript
241 lines
9.3 KiB
TypeScript
/**
|
|
* Plugin source / author / contribute link derivation.
|
|
*
|
|
* Turns the raw `InstalledPluginRecord.source` install string + the
|
|
* manifest's `author` / `homepage` fields into a small bag of
|
|
* renderable URLs and labels. Used by the Home plugin grid (small
|
|
* "by <author>" byline) and by the PluginDetailsModal (rich author
|
|
* + source + contribute block).
|
|
*
|
|
* Why a separate module:
|
|
* - the parsing rules (`github:owner/repo[@ref][/sub]`, https URL,
|
|
* local path, bundled relpath) are non-trivial enough to warrant
|
|
* isolated unit tests,
|
|
* - the modal and the card both need the same shape, so deriving
|
|
* twice would invite drift,
|
|
* - the host React surface needs a single safe-URL gate (only
|
|
* http(s) URLs become clickable) to keep `javascript:` /
|
|
* `data:` payloads in a manifest from rendering as live links.
|
|
*/
|
|
import type { InstalledPluginRecord } from '@open-design/contracts';
|
|
|
|
export interface PluginSourceLinks {
|
|
/** Browseable URL for the install source, or null when the source
|
|
* is a local path / bundled relpath / unknown shape. */
|
|
sourceUrl: string | null;
|
|
/** Friendly label for the source — github slug, hostname, or path
|
|
* basename. Always present, never null. */
|
|
sourceLabel: string;
|
|
/** Display label for the source kind — "GitHub", "Official",
|
|
* "Marketplace", etc. */
|
|
sourceKindLabel: string;
|
|
/** manifest.author.name trimmed, or null. */
|
|
authorName: string | null;
|
|
/** manifest.author.url when http(s), or null. */
|
|
authorProfileUrl: string | null;
|
|
/** Avatar URL when author.url points at a github profile/org, or
|
|
* null. Uses github.com/<user>.png which works for both users and
|
|
* orgs without an authenticated API call. */
|
|
authorAvatarUrl: string | null;
|
|
/** manifest.homepage when http(s), or null. */
|
|
homepageUrl: string | null;
|
|
/** Issues URL when source/homepage points at github, or null.
|
|
* Encourages contributors by giving them a one-click "report an
|
|
* issue / open a PR" entry point. */
|
|
contributeUrl: string | null;
|
|
/** True when the contribute URL points at a github repo. The UI
|
|
* uses this to choose the right verb / icon ("Contribute on
|
|
* GitHub" vs. a generic external-link). */
|
|
contributeOnGithub: boolean;
|
|
}
|
|
|
|
const OPEN_DESIGN_REPO_URL = 'https://github.com/nexu-io/open-design';
|
|
const OPEN_DESIGN_REPO_LABEL = 'nexu-io/open-design';
|
|
|
|
const GITHUB_SOURCE_RE = /^github:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)(?:@([A-Za-z0-9._/-]+))?(?:\/(.+))?$/;
|
|
const GITHUB_PROFILE_RE = /^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?)(?:[\/?#].*)?$/;
|
|
const GITHUB_REPO_RE = /^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?(?:[\/?#].*)?$/;
|
|
|
|
/** Only http(s) URLs are safe to render as clickable links. */
|
|
function safeHttpUrl(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (!/^https?:\/\//i.test(trimmed)) return null;
|
|
try {
|
|
const url = new URL(trimmed);
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
return url.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** github.com/owner/repo[/...] → "owner/repo" or null. */
|
|
function githubRepoSlug(url: string): { owner: string; repo: string } | null {
|
|
const match = GITHUB_REPO_RE.exec(url);
|
|
if (!match) return null;
|
|
return { owner: match[1]!, repo: match[2]! };
|
|
}
|
|
|
|
/** github.com/<user> → "<user>" or null. Distinguishes single-segment
|
|
* profile/org URLs from multi-segment repo URLs. */
|
|
function githubUsername(url: string): string | null {
|
|
const match = GITHUB_PROFILE_RE.exec(url);
|
|
if (!match) return null;
|
|
// Reject multi-segment paths: a profile URL has nothing after the
|
|
// username (other than query/hash). Repo URLs go through the repo
|
|
// matcher instead.
|
|
try {
|
|
const parsed = new URL(url);
|
|
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
if (segments.length !== 1) return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
return match[1]!;
|
|
}
|
|
|
|
function basename(filesystemPath: string): string {
|
|
const parts = filesystemPath.split(/[\\/]/).filter(Boolean);
|
|
return parts[parts.length - 1] ?? filesystemPath;
|
|
}
|
|
|
|
const SOURCE_KIND_LABELS: Record<InstalledPluginRecord['sourceKind'], string> = {
|
|
bundled: 'Official',
|
|
user: 'User',
|
|
project: 'Project',
|
|
marketplace: 'Marketplace',
|
|
github: 'GitHub',
|
|
url: 'URL',
|
|
local: 'Local',
|
|
};
|
|
|
|
/** Derive everything the UI needs about a plugin's source + author
|
|
* in one call. Pure — safe to call from render. */
|
|
export function derivePluginSourceLinks(
|
|
record: Pick<
|
|
InstalledPluginRecord,
|
|
'source' | 'sourceKind' | 'pinnedRef' | 'fsPath' | 'manifest'
|
|
>,
|
|
): PluginSourceLinks {
|
|
const manifest = record.manifest ?? {};
|
|
const author = (manifest as { author?: { name?: unknown; url?: unknown } }).author ?? {};
|
|
const homepageRaw = (manifest as { homepage?: unknown }).homepage;
|
|
const officialBundled = record.sourceKind === 'bundled';
|
|
|
|
const authorName = typeof author.name === 'string' && author.name.trim().length > 0
|
|
? author.name.trim()
|
|
: null;
|
|
const authorProfileUrl = officialBundled ? OPEN_DESIGN_REPO_URL : safeHttpUrl(author.url);
|
|
const homepageUrl = officialBundled ? OPEN_DESIGN_REPO_URL : safeHttpUrl(homepageRaw);
|
|
|
|
// Source URL + label resolution. The github:owner/repo case wins
|
|
// because we can produce a deep `tree/<ref>/<sub>` URL when the
|
|
// source string carries a ref; the https case just uses the URL
|
|
// verbatim so users can click through to their tarball mirror.
|
|
let sourceUrl: string | null = null;
|
|
let sourceLabel: string;
|
|
let sourceContributeUrl: string | null = null;
|
|
|
|
if (record.sourceKind === 'github') {
|
|
const match = GITHUB_SOURCE_RE.exec(record.source);
|
|
if (match) {
|
|
const [, owner, repo, ref, subpath] = match;
|
|
const ref0 = ref || record.pinnedRef;
|
|
// Refs can contain `/` (branches like `release/1.0`, or
|
|
// installer refs that absorbed a subpath) — encode each
|
|
// segment so spaces/special chars are escaped but the
|
|
// separators stay.
|
|
const refSegment = ref0 && ref0 !== 'HEAD'
|
|
? `/tree/${ref0.split('/').map(encodeURIComponent).join('/')}`
|
|
: '';
|
|
const subSegment = subpath ? `/${subpath.split('/').map(encodeURIComponent).join('/')}` : '';
|
|
sourceUrl = `https://github.com/${owner}/${repo}${refSegment}${subSegment}`;
|
|
sourceLabel = `${owner}/${repo}${ref0 ? ` @${ref0}` : ''}${subpath ? `/${subpath}` : ''}`;
|
|
sourceContributeUrl = `https://github.com/${owner}/${repo}/issues/new`;
|
|
} else {
|
|
sourceLabel = record.source;
|
|
}
|
|
} else if (record.sourceKind === 'url') {
|
|
const safe = safeHttpUrl(record.source);
|
|
if (safe) {
|
|
sourceUrl = safe;
|
|
try {
|
|
sourceLabel = new URL(safe).hostname + new URL(safe).pathname.replace(/\/$/, '');
|
|
} catch {
|
|
sourceLabel = safe;
|
|
}
|
|
} else {
|
|
sourceLabel = record.source;
|
|
}
|
|
} else if (record.sourceKind === 'marketplace') {
|
|
sourceLabel = record.source;
|
|
} else if (officialBundled) {
|
|
sourceUrl = OPEN_DESIGN_REPO_URL;
|
|
sourceLabel = OPEN_DESIGN_REPO_LABEL;
|
|
} else {
|
|
// user / project / local — the source string is a filesystem
|
|
// path. Show just the basename for compactness; the
|
|
// full path stays available via the existing fsPath dt/dd.
|
|
sourceLabel = basename(record.source) || record.source;
|
|
}
|
|
|
|
// Contribute link: prefer the source's github repo, fall back to
|
|
// the homepage when it points at github (covers bundled plugins
|
|
// whose `source` is a local relpath but `homepage` is the upstream
|
|
// repo URL).
|
|
let contributeUrl = sourceContributeUrl;
|
|
let contributeOnGithub = sourceContributeUrl !== null;
|
|
if (!contributeUrl && homepageUrl) {
|
|
const repo = githubRepoSlug(homepageUrl);
|
|
if (repo) {
|
|
contributeUrl = `https://github.com/${repo.owner}/${repo.repo}/issues/new`;
|
|
contributeOnGithub = true;
|
|
}
|
|
}
|
|
|
|
// Avatar derivation: github.com/<user>.png returns the avatar for
|
|
// both user and organisation accounts without authentication. The
|
|
// fallback also works when author.url is a repo URL — we extract
|
|
// the owner segment for the avatar.
|
|
let authorAvatarUrl: string | null = null;
|
|
if (authorProfileUrl) {
|
|
const username = githubUsername(authorProfileUrl);
|
|
if (username) {
|
|
authorAvatarUrl = `https://github.com/${username}.png?size=80`;
|
|
} else {
|
|
const repo = githubRepoSlug(authorProfileUrl);
|
|
if (repo) authorAvatarUrl = `https://github.com/${repo.owner}.png?size=80`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
sourceUrl,
|
|
sourceLabel,
|
|
sourceKindLabel: SOURCE_KIND_LABELS[record.sourceKind] ?? record.sourceKind,
|
|
authorName,
|
|
authorProfileUrl,
|
|
authorAvatarUrl,
|
|
homepageUrl,
|
|
contributeUrl,
|
|
contributeOnGithub,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Deterministic two-letter monogram for the avatar fallback. CamelCase
|
|
* boundaries count as word breaks so single-token brand names like
|
|
* "OpenDesign" still yield a two-letter monogram ("OD").
|
|
*/
|
|
export function authorInitials(name: string | null): string {
|
|
if (!name) return '??';
|
|
const parts = name
|
|
.trim()
|
|
.split(/\s+|(?<=[a-z])(?=[A-Z])/)
|
|
.filter(Boolean)
|
|
.slice(0, 2);
|
|
if (parts.length === 0) return '??';
|
|
return parts.map((p) => p[0]!.toUpperCase()).join('');
|
|
}
|