Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
'use client';

import { useEffect, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ExternalLink, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { AppDetailConfig } from './app-detail-utils';

Expand All @@ -20,10 +16,10 @@ type ReadmeCandidate = {
blobBaseUrl: string;
};

type ReadmeState =
| { status: 'idle' | 'loading' }
| { status: 'ready'; markdown: string; source: ReadmeCandidate }
| { status: 'error' };
export type LoadedReadmeMarkdown = {
markdown: string;
source: ReadmeCandidate;
};

const README_FILENAMES = ['README.md', 'README.MD', 'Readme.md', 'readme.md'];
const README_DIRECTORIES = ['', 'docs'];
Expand Down Expand Up @@ -132,6 +128,58 @@ function buildReadmeCandidates({
);
}

function buildDirectReadmeSource(readmeUrl: string): ReadmeSource | null {
if (isUnsafeUrl(readmeUrl)) return null;

try {
const url = new URL(readmeUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
const rawAssetBaseUrl = new URL('./', url).toString();

let repoLabel = url.hostname;
let repoUrl = readmeUrl;
let blobBaseUrl = rawAssetBaseUrl;

if (
url.hostname.toLowerCase() === 'raw.githubusercontent.com' &&
pathParts.length >= 4
) {
const [owner, repo, branch, ...repoPathParts] = pathParts;
const encodedOwner = encodeURIComponent(owner);
const encodedRepo = encodeURIComponent(repo);
const encodedBranch = encodeURIComponent(branch);
const encodedDirectoryPath = encodePathSegments(repoPathParts.slice(0, -1));

repoLabel = `${owner}/${repo}`;
repoUrl = `https://github.com/${encodedOwner}/${encodedRepo}`;
blobBaseUrl = [
repoUrl,
'blob',
encodedBranch,
encodedDirectoryPath,
]
.filter(Boolean)
.join('/');
blobBaseUrl = `${blobBaseUrl}/`;
}

return {
repoLabel,
repoUrl,
candidates: [
{
sourcePath: 'README.md',
url: readmeUrl,
rawAssetBaseUrl,
blobBaseUrl,
},
],
};
} catch {
return null;
}
}

export function normalizeGitHubMarkdown(markdown: string) {
return markdown
.replace(/<br\s*\/?>/gi, '\n')
Expand All @@ -150,8 +198,14 @@ export function normalizeGitHubMarkdown(markdown: string) {
}

export function getReadmeSource(
app: Pick<AppDetailConfig, 'github' | 'website'>,
app: Pick<AppDetailConfig, 'readme' | 'github' | 'website'>,
): ReadmeSource | null {
const readmeUrl = app.readme || undefined;
if (readmeUrl) {
const directSource = buildDirectReadmeSource(readmeUrl);
if (directSource) return directSource;
}
Comment on lines +203 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to GitHub source when configured README fails

This change makes app.readme authoritative: if the URL parses, getReadmeSource returns it immediately and loadReadmeMarkdown later converts any fetch failure into null, so the statically generated page permanently renders the screenshot fallback for that app build. In the app-store detail flow (page.tsx + loadReadmeMarkdown), a typo/404/transient outage on the configured README URL now suppresses the previously available GitHub/website candidate resolution path instead of falling back to it.

Useful? React with 👍 / 👎.


const githubUrl = isGithubUrl(app.github)
? app.github
: isGithubUrl(app.website)
Expand Down Expand Up @@ -233,10 +287,10 @@ function resolveMarkdownUrl(
}
}

async function fetchReadme(source: ReadmeSource, signal: AbortSignal) {
async function fetchReadme(source: ReadmeSource) {
for (const candidate of source.candidates) {
const response = await fetch(candidate.url, {
signal,
cache: 'force-cache',
headers: {
Accept: 'text/plain,text/markdown,*/*',
},
Expand All @@ -256,6 +310,24 @@ async function fetchReadme(source: ReadmeSource, signal: AbortSignal) {
throw new Error('README not found.');
}

export async function loadReadmeMarkdown(
app: Pick<AppDetailConfig, 'readme' | 'github' | 'website'>,
): Promise<LoadedReadmeMarkdown | null> {
const readmeSource = getReadmeSource({
readme: app.readme,
github: app.github,
website: app.website,
});

if (!readmeSource) return null;

try {
return await fetchReadme(readmeSource);
} catch {
return null;
}
}

function ReadmeFallback({ app }: { app: AppDetailConfig }) {
const screenshot = app.screenshots?.[0];

Expand All @@ -280,85 +352,23 @@ function ReadmeFallback({ app }: { app: AppDetailConfig }) {
);
}

function ReadmeLoading() {
return (
<div className="flex aspect-video min-h-[260px] items-center justify-center bg-[#0d1117] text-sm text-zinc-400">
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading README from GitHub...
</span>
</div>
);
}

export default function ReadmeMarkdownWindow({
app,
readme,
}: {
app: AppDetailConfig;
readme: LoadedReadmeMarkdown | null;
}) {
const readmeSource = useMemo(
() => getReadmeSource({ github: app.github, website: app.website }),
[app.github, app.website],
);
const [readme, setReadme] = useState<ReadmeState>({ status: 'idle' });

useEffect(() => {
if (!readmeSource) {
setReadme({ status: 'error' });
return;
}

const controller = new AbortController();
setReadme({ status: 'loading' });

fetchReadme(readmeSource, controller.signal)
.then(({ markdown, source }) => {
setReadme({ status: 'ready', markdown, source });
})
.catch(() => {
if (controller.signal.aborted) return;

setReadme({ status: 'error' });
});

return () => controller.abort();
}, [readmeSource]);

if (!readmeSource || readme.status === 'error') {
if (!readme) {
return <ReadmeFallback app={app} />;
}

if (readme.status !== 'ready') {
return <ReadmeLoading />;
}

return (
<div
aria-label={`${app.name} README content`}
className="aspect-video min-h-[260px] overflow-y-auto overscroll-contain bg-[#0d1117] px-5 py-6 text-sm text-zinc-300 [scrollbar-color:#335f91_#0d1117] [scrollbar-width:thin] sm:px-8"
role="region"
>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-white/10 pb-4 text-xs text-zinc-500">
<a
href={readmeSource.repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-zinc-300 transition hover:text-[#69a3ff]"
>
{readmeSource.repoLabel}
<ExternalLink className="h-3 w-3" />
</a>
<a
href={readme.source.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 transition hover:text-[#69a3ff]"
>
Rendered from {readme.source.sourcePath}
<ExternalLink className="h-3 w-3" />
</a>
</div>

<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { figmaDetailHeadingClassName } from './SectionHeading';
import type { AppDetailConfig } from './app-detail-utils';
import ReadmeMarkdownWindow from './ReadmeMarkdownWindow';
import ReadmeMarkdownWindow, {
type LoadedReadmeMarkdown,
} from './ReadmeMarkdownWindow';

interface ReadmePreviewProps {
app: AppDetailConfig;
readme: LoadedReadmeMarkdown | null;
}

export default function ReadmePreview({ app }: ReadmePreviewProps) {
export default function ReadmePreview({ app, readme }: ReadmePreviewProps) {
return (
<section
id="readme"
Expand Down Expand Up @@ -35,7 +38,7 @@ export default function ReadmePreview({ app }: ReadmePreviewProps) {
<span className="w-[42px]" aria-hidden="true" />
</div>

<ReadmeMarkdownWindow app={app} />
<ReadmeMarkdownWindow app={app} readme={readme} />
</div>
</div>
</section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const heroSource = readFileSync(
join(componentDir, 'AppDetailHero.tsx'),
'utf8',
);
const pageSource = readFileSync(join(componentDir, '../page.tsx'), 'utf8');
const appPreviewSource = readFileSync(
join(componentDir, 'AppPreviewPanel.tsx'),
'utf8',
Expand Down Expand Up @@ -76,10 +77,6 @@ test('README markdown preview resolves GitHub images and relative links', () =>
test('README markdown preview tries docs README before falling back to the screenshot', () => {
assert.match(readmeWindowSource, /README_DIRECTORIES = \['', 'docs'\]/);
assert.match(readmeWindowSource, /sourcePath: pathParts\.join\('\/'\)/);
assert.match(
readmeWindowSource,
/Rendered from \{readme\.source\.sourcePath\}/,
);
assert.match(
readmeWindowSource,
/const screenshot = app\.screenshots\?\.\[0\]/,
Expand All @@ -92,6 +89,35 @@ test('README markdown preview tries docs README before falling back to the scree
);
});

test('README markdown preview prefers the readme URL stored on the app config', () => {
assert.match(readmeWindowSource, /buildDirectReadmeSource/);
assert.match(
readmeWindowSource,
/Pick<AppDetailConfig, 'readme' \| 'github' \| 'website'>/,
);
assert.match(
readmeWindowSource,
/getReadmeSource\(\{[\s\S]*readme: app\.readme,[\s\S]*github: app\.github,[\s\S]*website: app\.website,[\s\S]*\}\)/,
);
assert.match(readmeWindowSource, /export async function loadReadmeMarkdown/);
});

test('README markdown preview does not render the source metadata row', () => {
assert.doesNotMatch(readmeWindowSource, /Rendered from/);
assert.doesNotMatch(readmeWindowSource, /readmeSource\.repoLabel/);
assert.doesNotMatch(readmeWindowSource, /readmeSource\.repoUrl/);
});

test('README markdown is loaded before rendering the static app detail page', () => {
assert.doesNotMatch(readmeWindowSource, /'use client'/);
assert.doesNotMatch(readmeWindowSource, /useEffect/);
assert.doesNotMatch(readmeWindowSource, /useState/);
assert.doesNotMatch(readmeWindowSource, /Loading README from GitHub/);
assert.match(readmeWindowSource, /export async function loadReadmeMarkdown/);
assert.match(pageSource, /const readme = await loadReadmeMarkdown\(app\)/);
assert.match(pageSource, /<ReadmePreview app=\{app\} readme=\{readme\} \/>/);
});

test('README browser bar is labeled README.md', () => {
assert.doesNotMatch(readmeSource, /import \{ Globe \} from 'lucide-react'/);
assert.doesNotMatch(
Expand Down
4 changes: 3 additions & 1 deletion app/[lang]/products/app-store/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { generatePageMetadata } from '@/lib/utils/metadata';
import { LANGUAGES, languagesType } from '@/lib/i18n';
import AppDetailHero from './components/AppDetailHero';
import ReadmePreview from './components/ReadmePreview';
import { loadReadmeMarkdown } from './components/ReadmeMarkdownWindow';
import RelatedTemplates from './components/RelatedTemplates';
import WholeStackSection from './components/WholeStackSection';
import WhyDeployOnSealos from './components/WhyDeployOnSealos';
Expand Down Expand Up @@ -77,6 +78,7 @@ export default async function AppDeployPage({ params }: AppDeployPageProps) {
currentApp: app,
limit: 3,
});
const readme = await loadReadmeMarkdown(app);

return (
<div
Expand All @@ -92,7 +94,7 @@ export default async function AppDeployPage({ params }: AppDeployPageProps) {
<AppDetailHero app={app} lang={params.lang} />
<WhyDeployOnSealos />
<WholeStackSection />
<ReadmePreview app={app} />
<ReadmePreview app={app} readme={readme} />
<RelatedTemplates apps={relatedApps} lang={params.lang} />
</main>

Expand Down
1 change: 1 addition & 0 deletions config/apps-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface AppConfig {
gradient: string;
github?: string;
website?: string;
readme?: string;
tags: string[];
deployUrl?: string;
source?: {
Expand Down
Loading