stack-site-builder 1.10.0
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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dev/local-samples-client.js +284 -0
- package/dev/local-samples.mjs +594 -0
- package/index.d.ts +12 -0
- package/index.mjs +124 -0
- package/markdown.mjs +394 -0
- package/package.json +52 -0
- package/src/components/ArrowUpRight.astro +24 -0
- package/src/components/ArticleCard.astro +56 -0
- package/src/components/ArticleDetail.astro +100 -0
- package/src/components/ArticleLink.astro +45 -0
- package/src/components/BackToTop.astro +47 -0
- package/src/components/BlogIndex.astro +137 -0
- package/src/components/Breadcrumb.astro +37 -0
- package/src/components/CategoryIndex.astro +115 -0
- package/src/components/ChevronDown.astro +23 -0
- package/src/components/CodeSamples.astro +141 -0
- package/src/components/CollapsibleGrid.astro +50 -0
- package/src/components/ConceptCard.astro +59 -0
- package/src/components/ConceptDetail.astro +102 -0
- package/src/components/ConceptIndex.astro +143 -0
- package/src/components/ConceptLink.astro +43 -0
- package/src/components/ConceptTools.astro +49 -0
- package/src/components/DeckView.astro +772 -0
- package/src/components/DetailTabs.astro +219 -0
- package/src/components/FilterControls.astro +407 -0
- package/src/components/Glossary.astro +264 -0
- package/src/components/Home.astro +90 -0
- package/src/components/LanguageSwitcher.astro +86 -0
- package/src/components/ListControls.astro +182 -0
- package/src/components/MermaidLoader.astro +96 -0
- package/src/components/PricingSection.astro +100 -0
- package/src/components/ProjectFileList.astro +68 -0
- package/src/components/ProjectFileTree.astro +95 -0
- package/src/components/ProjectRelatedTools.astro +70 -0
- package/src/components/ProjectViewer.astro +431 -0
- package/src/components/RelatedArticles.astro +52 -0
- package/src/components/RelatedConcepts.astro +60 -0
- package/src/components/RelatedTools.astro +55 -0
- package/src/components/SampleIndex.astro +464 -0
- package/src/components/SamplePage.astro +95 -0
- package/src/components/SampleProject.astro +56 -0
- package/src/components/Slide.astro +36 -0
- package/src/components/SlidesIndex.astro +60 -0
- package/src/components/StackCard.astro +183 -0
- package/src/components/StackDetail.astro +507 -0
- package/src/components/StarIcon.astro +21 -0
- package/src/components/TagIndex.astro +51 -0
- package/src/components/ThemeToggle.astro +106 -0
- package/src/components/Toc.astro +39 -0
- package/src/components/TocRail.astro +74 -0
- package/src/components/ToolAvatar.astro +133 -0
- package/src/components/ToolMeta.astro +95 -0
- package/src/components/VendorIndex.astro +51 -0
- package/src/content.ts +228 -0
- package/src/i18n/ui.ts +337 -0
- package/src/layouts/BaseLayout.astro +287 -0
- package/src/lib/articles.ts +27 -0
- package/src/lib/assets.ts +19 -0
- package/src/lib/category-tree.ts +64 -0
- package/src/lib/concepts.ts +35 -0
- package/src/lib/dates.ts +21 -0
- package/src/lib/facets.ts +57 -0
- package/src/lib/github.ts +186 -0
- package/src/lib/inline-md.ts +30 -0
- package/src/lib/pricing.ts +22 -0
- package/src/lib/project.ts +309 -0
- package/src/lib/samples.ts +88 -0
- package/src/lib/slides.ts +25 -0
- package/src/lib/stacks.ts +129 -0
- package/src/lib/toc-rail-client.ts +79 -0
- package/src/pages/article/[...id].astro +24 -0
- package/src/pages/article/category/[id].astro +17 -0
- package/src/pages/article/index.astro +12 -0
- package/src/pages/categories/[id].astro +25 -0
- package/src/pages/concept/[...id].astro +24 -0
- package/src/pages/concept/category/[id].astro +17 -0
- package/src/pages/concept/index.astro +12 -0
- package/src/pages/glossary.astro +12 -0
- package/src/pages/index.astro +9 -0
- package/src/pages/ko/article/[...id].astro +24 -0
- package/src/pages/ko/article/category/[id].astro +17 -0
- package/src/pages/ko/article/index.astro +12 -0
- package/src/pages/ko/categories/[id].astro +24 -0
- package/src/pages/ko/concept/[...id].astro +24 -0
- package/src/pages/ko/concept/category/[id].astro +17 -0
- package/src/pages/ko/concept/index.astro +12 -0
- package/src/pages/ko/glossary.astro +12 -0
- package/src/pages/ko/index.astro +9 -0
- package/src/pages/ko/sample/[folder].astro +16 -0
- package/src/pages/ko/sample/index.astro +12 -0
- package/src/pages/ko/slides/[deck].astro +14 -0
- package/src/pages/ko/slides/index.astro +12 -0
- package/src/pages/ko/stack/[...id].astro +51 -0
- package/src/pages/ko/tags/[tag].astro +18 -0
- package/src/pages/ko/vendors/[vendor].astro +18 -0
- package/src/pages/sample/[folder].astro +16 -0
- package/src/pages/sample/index.astro +12 -0
- package/src/pages/slides/[deck].astro +14 -0
- package/src/pages/slides/index.astro +12 -0
- package/src/pages/stack/[...id].astro +51 -0
- package/src/pages/tags/[tag].astro +18 -0
- package/src/pages/vendors/[vendor].astro +18 -0
- package/src/styles/global.css +2350 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the `logo` / `logoDark` frontmatter fields, which accept an
|
|
3
|
+
* absolute URL, a /public path, or an emoji/text glyph (e.g. "🚅").
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** True when `ref` is an image reference — an absolute URL or a /public path —
|
|
7
|
+
* rather than an emoji/text glyph. */
|
|
8
|
+
export function isImageRef(ref?: string): ref is string {
|
|
9
|
+
return !!ref && (/^https?:\/\//.test(ref) || ref.startsWith('/'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Resolve an image reference to a browser-usable URL: absolute URLs pass
|
|
13
|
+
* through; /public paths get the deploy base (a GitHub project-page subpath)
|
|
14
|
+
* prefixed. Call only with image refs — an emoji would resolve to nonsense. */
|
|
15
|
+
export function resolveImageUrl(ref?: string): string | undefined {
|
|
16
|
+
if (!ref) return undefined;
|
|
17
|
+
if (/^https?:\/\//.test(ref)) return ref;
|
|
18
|
+
return import.meta.env.BASE_URL.replace(/\/$/, '') + '/' + ref.replace(/^\//, '');
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Lang } from 'stack-site-builder/i18n/ui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A category node, nestable into subcategories to any depth. Reused for several
|
|
5
|
+
* taxonomies (tools, concepts, articles): each builds its own tree of these and
|
|
6
|
+
* runs it through `buildTree` for the lookup helpers. `id` must be unique within
|
|
7
|
+
* its own tree (it keys the category URL and the content's `category` field).
|
|
8
|
+
*/
|
|
9
|
+
export interface Category {
|
|
10
|
+
id: string;
|
|
11
|
+
label: Record<Lang, string>;
|
|
12
|
+
/** Short one-liner for cards / section headers / subcategory summaries. */
|
|
13
|
+
description: Record<Lang, string>;
|
|
14
|
+
/** Longer blurb for the category's own page (falls back to `description`). */
|
|
15
|
+
detail?: Record<Lang, string>;
|
|
16
|
+
children?: Category[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CategoryTree {
|
|
20
|
+
roots: Category[];
|
|
21
|
+
map: Map<string, Category>;
|
|
22
|
+
allIds: string[];
|
|
23
|
+
/** Root → node chain for an id (its breadcrumb path). Empty if unknown. */
|
|
24
|
+
pathOf(id: string): Category[];
|
|
25
|
+
/** Direct children of a node (empty for leaves). */
|
|
26
|
+
childrenOf(id: string): Category[];
|
|
27
|
+
/** A node's id plus all descendant ids (for subtree roll-up). */
|
|
28
|
+
descendantIds(id: string): string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Index a category tree into id/parent maps + path/children/descendant helpers. */
|
|
32
|
+
export function buildTree(roots: Category[]): CategoryTree {
|
|
33
|
+
const map = new Map<string, Category>();
|
|
34
|
+
const parentOf = new Map<string, string | null>();
|
|
35
|
+
(function index(nodes: Category[], parent: string | null) {
|
|
36
|
+
for (const c of nodes) {
|
|
37
|
+
map.set(c.id, c);
|
|
38
|
+
parentOf.set(c.id, parent);
|
|
39
|
+
if (c.children) index(c.children, c.id);
|
|
40
|
+
}
|
|
41
|
+
})(roots, null);
|
|
42
|
+
|
|
43
|
+
const pathOf = (id: string): Category[] => {
|
|
44
|
+
const out: Category[] = [];
|
|
45
|
+
let cur: string | null = id;
|
|
46
|
+
while (cur) {
|
|
47
|
+
const c = map.get(cur);
|
|
48
|
+
if (!c) break;
|
|
49
|
+
out.unshift(c);
|
|
50
|
+
cur = parentOf.get(cur) ?? null;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
const childrenOf = (id: string): Category[] => map.get(id)?.children ?? [];
|
|
55
|
+
const descendantIds = (id: string): string[] => {
|
|
56
|
+
const node = map.get(id);
|
|
57
|
+
if (!node) return [id];
|
|
58
|
+
const out = [id];
|
|
59
|
+
for (const child of node.children ?? []) out.push(...descendantIds(child.id));
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { roots, map, allIds: [...map.keys()], pathOf, childrenOf, descendantIds };
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import type { Lang } from '../i18n/ui';
|
|
3
|
+
|
|
4
|
+
export type ConceptEntry = CollectionEntry<'concepts'>;
|
|
5
|
+
|
|
6
|
+
/** The url slug of a concept, i.e. its id with the `<lang>/` prefix removed. */
|
|
7
|
+
export function conceptSlugOf(entry: ConceptEntry): string {
|
|
8
|
+
return entry.id.replace(/^[a-z]{2}\//, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Published concepts for one locale, by `order` then title. */
|
|
12
|
+
export async function getConcepts(lang: Lang): Promise<ConceptEntry[]> {
|
|
13
|
+
const all = await getCollection('concepts');
|
|
14
|
+
return all
|
|
15
|
+
.filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
|
|
16
|
+
.sort(
|
|
17
|
+
(a, b) =>
|
|
18
|
+
(a.data.order ?? 999) - (b.data.order ?? 999) ||
|
|
19
|
+
a.data.title.localeCompare(b.data.title),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every tool slug a concept references, flattened across its role groups. */
|
|
24
|
+
export function conceptToolSlugs(entry: ConceptEntry): string[] {
|
|
25
|
+
return entry.data.tools.flatMap((g) => g.tools);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Backlinks: concepts in a locale that use `toolSlug` (in any role group), so a
|
|
30
|
+
* tool's detail page can list the patterns it appears in.
|
|
31
|
+
*/
|
|
32
|
+
export async function getConceptsForTool(lang: Lang, toolSlug: string): Promise<ConceptEntry[]> {
|
|
33
|
+
const concepts = await getConcepts(lang);
|
|
34
|
+
return concepts.filter((c) => conceptToolSlugs(c).includes(toolSlug));
|
|
35
|
+
}
|
package/src/lib/dates.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Lang } from '../i18n/ui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Human-facing date in the reader's locale (e.g. "June 28, 2026" / "2026년 6월
|
|
5
|
+
* 28일"). Use UTC so a YYYY-MM-DD in frontmatter renders as that exact calendar
|
|
6
|
+
* day regardless of the build machine's timezone. Pair with an ISO `datetime`
|
|
7
|
+
* attribute on the surrounding `<time>` for machines.
|
|
8
|
+
*/
|
|
9
|
+
export function formatDate(d: Date, lang: Lang): string {
|
|
10
|
+
return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
|
|
11
|
+
year: 'numeric',
|
|
12
|
+
month: 'long',
|
|
13
|
+
day: 'numeric',
|
|
14
|
+
timeZone: 'UTC',
|
|
15
|
+
}).format(d);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** ISO YYYY-MM-DD, for `datetime` attributes. */
|
|
19
|
+
export function isoDate(d: Date): string {
|
|
20
|
+
return d.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { pricingTags } from './pricing';
|
|
2
|
+
import { pricingLabels, licenseLabel, useTranslations, type Lang } from '../i18n/ui';
|
|
3
|
+
import type { StackEntry } from './stacks';
|
|
4
|
+
|
|
5
|
+
export interface FacetOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Facet {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
options: FacetOption[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Filter facet options (pricing / license / language), derived from a locale's
|
|
17
|
+
* stack entries. Shared by the homepage and the category pages — both feed it
|
|
18
|
+
* the WHOLE catalog so the controls are identical everywhere and a sort/filter
|
|
19
|
+
* carried in from another page can always be restored.
|
|
20
|
+
*/
|
|
21
|
+
export function deriveFacets(entries: StackEntry[], lang: Lang): Facet[] {
|
|
22
|
+
const t = useTranslations(lang);
|
|
23
|
+
|
|
24
|
+
const pricingOrder = ['completely-free', 'open-source', 'free-tier', 'paid', 'free'];
|
|
25
|
+
const usedPricing = new Set<string>(entries.flatMap((e) => pricingTags(e.data.pricing)));
|
|
26
|
+
|
|
27
|
+
const langSet = new Set(
|
|
28
|
+
entries.flatMap((e) =>
|
|
29
|
+
(e.data.language ?? '')
|
|
30
|
+
.split('/')
|
|
31
|
+
.map((s) => s.trim())
|
|
32
|
+
.filter(Boolean),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
key: 'pricing',
|
|
39
|
+
label: t('detail.pricing'),
|
|
40
|
+
options: pricingOrder
|
|
41
|
+
.filter((p) => usedPricing.has(p))
|
|
42
|
+
.map((p) => ({ value: p, label: pricingLabels[lang][p] ?? p })),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'license',
|
|
46
|
+
label: t('detail.license'),
|
|
47
|
+
options: [...new Set(entries.map((e) => e.data.license).filter(Boolean))]
|
|
48
|
+
.sort()
|
|
49
|
+
.map((l) => ({ value: l as string, label: licenseLabel(lang, l as string) })),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: 'language',
|
|
53
|
+
label: t('detail.language'),
|
|
54
|
+
options: [...langSet].sort().map((l) => ({ value: l, label: l })),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { site } from '@aas-data/site';
|
|
2
|
+
/**
|
|
3
|
+
* Build-time GitHub stats. Given a repo URL, fetch star count and the latest
|
|
4
|
+
* release/tag from the GitHub API so cards/detail reflect the project's real
|
|
5
|
+
* state as of the last build — no manual entry.
|
|
6
|
+
*
|
|
7
|
+
* Results are memoized per repo for the process, so the same repo is fetched
|
|
8
|
+
* once even though it appears across locales and on both card + detail. Any
|
|
9
|
+
* failure (offline, rate limit, no repo) degrades gracefully to `null`.
|
|
10
|
+
*
|
|
11
|
+
* In CI, set GITHUB_TOKEN to lift the unauthenticated 60 req/hour limit.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
export interface RepoStats {
|
|
16
|
+
stars: number;
|
|
17
|
+
version?: string;
|
|
18
|
+
releasedAt?: string; // ISO date of the latest release (YYYY-MM-DD usable via slice)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const cache = new Map<string, Promise<RepoStats | null>>();
|
|
22
|
+
|
|
23
|
+
// On-disk cache so values survive rate-limited builds and so we only hit the
|
|
24
|
+
// API ~once a day per repo. A fresh entry (< TTL) is used as-is without
|
|
25
|
+
// fetching; a stale entry is refreshed but kept as a fallback on failure.
|
|
26
|
+
type CachedStats = RepoStats & { fetchedAt: number };
|
|
27
|
+
const ONE_DAY = 24 * 60 * 60 * 1000;
|
|
28
|
+
const CACHE_DIR = '.aas-cache';
|
|
29
|
+
const CACHE_FILE = `${CACHE_DIR}/github.json`;
|
|
30
|
+
let disk: Record<string, CachedStats> = {};
|
|
31
|
+
try {
|
|
32
|
+
disk = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
/* no cache yet */
|
|
35
|
+
}
|
|
36
|
+
function strip(c: CachedStats): RepoStats {
|
|
37
|
+
return { stars: c.stars, version: c.version, releasedAt: c.releasedAt };
|
|
38
|
+
}
|
|
39
|
+
function persist() {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
|
|
42
|
+
writeFileSync(CACHE_FILE, JSON.stringify(disk));
|
|
43
|
+
} catch {
|
|
44
|
+
/* read-only fs — skip */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRepo(url?: string): { owner: string; repo: string } | null {
|
|
49
|
+
if (!url) return null;
|
|
50
|
+
const m = url.match(/github\.com\/([^/]+)\/([^/#?]+)/i);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
return { owner: m[1], repo: m[2].replace(/\.git$/, '') };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function headers(): Record<string, string> {
|
|
56
|
+
const h: Record<string, string> = {
|
|
57
|
+
Accept: 'application/vnd.github+json',
|
|
58
|
+
'User-Agent': site.buildUserAgent,
|
|
59
|
+
};
|
|
60
|
+
const token = process.env.GITHUB_TOKEN;
|
|
61
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
62
|
+
return h;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalize a tag like "0.2.1" or "v0.2.1" to a displayed "v0.2.1". */
|
|
66
|
+
function normalizeVersion(tag?: string): string | undefined {
|
|
67
|
+
if (!tag) return undefined;
|
|
68
|
+
const t = tag.trim();
|
|
69
|
+
return /^v\d/i.test(t) ? t : /^\d/.test(t) ? `v${t}` : t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type FetchedStats = RepoStats & {
|
|
73
|
+
// False when the release/tag lookups errored (rate limit, network) — the
|
|
74
|
+
// version fields are then *unknown*, not known-absent, and must not
|
|
75
|
+
// overwrite a previously cached version.
|
|
76
|
+
versionResolved: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
async function fetchStats(owner: string, repo: string): Promise<FetchedStats | null> {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: headers() });
|
|
82
|
+
if (!res.ok) return null;
|
|
83
|
+
const data = (await res.json()) as { stargazers_count?: number };
|
|
84
|
+
const stars = typeof data.stargazers_count === 'number' ? data.stargazers_count : 0;
|
|
85
|
+
|
|
86
|
+
let version: string | undefined;
|
|
87
|
+
let releasedAt: string | undefined;
|
|
88
|
+
let versionResolved = false;
|
|
89
|
+
const rel = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
|
|
90
|
+
headers: headers(),
|
|
91
|
+
});
|
|
92
|
+
if (rel.ok) {
|
|
93
|
+
const data = await rel.json();
|
|
94
|
+
version = normalizeVersion(data.tag_name);
|
|
95
|
+
releasedAt = data.published_at; // e.g. "2026-05-12T09:00:00Z"
|
|
96
|
+
versionResolved = true;
|
|
97
|
+
} else if (rel.status === 404) {
|
|
98
|
+
// 404 = the repo genuinely has no releases — fall back to the most
|
|
99
|
+
// recent tag. Anything else (403 rate limit, 5xx) means we don't know,
|
|
100
|
+
// so skip the fallback and leave versionResolved false.
|
|
101
|
+
// Tags carry no date, so fetch the commit they point to for the date.
|
|
102
|
+
const tags = await fetch(`https://api.github.com/repos/${owner}/${repo}/tags?per_page=1`, {
|
|
103
|
+
headers: headers(),
|
|
104
|
+
});
|
|
105
|
+
if (tags.ok) {
|
|
106
|
+
versionResolved = true;
|
|
107
|
+
const tag = (await tags.json())[0];
|
|
108
|
+
version = normalizeVersion(tag?.name);
|
|
109
|
+
if (tag?.commit?.url) {
|
|
110
|
+
const commit = await fetch(tag.commit.url, { headers: headers() });
|
|
111
|
+
if (commit.ok) {
|
|
112
|
+
const c = await commit.json();
|
|
113
|
+
releasedAt = c.commit?.committer?.date ?? c.commit?.author?.date;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { stars, version, releasedAt, versionResolved };
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getRepoStats(repoUrl?: string): Promise<RepoStats | null> {
|
|
125
|
+
const slug = parseRepo(repoUrl);
|
|
126
|
+
if (!slug) return Promise.resolve(null);
|
|
127
|
+
const key = `${slug.owner}/${slug.repo}`;
|
|
128
|
+
if (!cache.has(key)) {
|
|
129
|
+
const cached = disk[key];
|
|
130
|
+
if (cached && Date.now() - cached.fetchedAt < ONE_DAY) {
|
|
131
|
+
// Fresh enough — use the cache, skip the network (refreshes ~daily).
|
|
132
|
+
cache.set(key, Promise.resolve(strip(cached)));
|
|
133
|
+
} else {
|
|
134
|
+
cache.set(
|
|
135
|
+
key,
|
|
136
|
+
fetchStats(slug.owner, slug.repo).then((stats) => {
|
|
137
|
+
if (stats) {
|
|
138
|
+
const { versionResolved, ...fresh } = stats;
|
|
139
|
+
if (!versionResolved && cached?.version) {
|
|
140
|
+
// Partial refresh (stars OK, release lookup rate-limited):
|
|
141
|
+
// keep the last known release info instead of clobbering it.
|
|
142
|
+
fresh.version = cached.version;
|
|
143
|
+
fresh.releasedAt = cached.releasedAt;
|
|
144
|
+
}
|
|
145
|
+
disk[key] = { ...fresh, fetchedAt: Date.now() };
|
|
146
|
+
persist();
|
|
147
|
+
return fresh;
|
|
148
|
+
}
|
|
149
|
+
return cached ? strip(cached) : null; // rate-limited/offline → last known
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return cache.get(key)!;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const SIX_MONTHS = 182 * ONE_DAY;
|
|
158
|
+
const ONE_YEAR = 365 * ONE_DAY;
|
|
159
|
+
|
|
160
|
+
/** How long since a project's last release: fresh, aging (6m+), or stale (1y+). */
|
|
161
|
+
export type Staleness = 'fresh' | 'aging' | 'stale';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Tier the given date by age — a "no recent updates" signal. Fed the latest
|
|
165
|
+
* release/tag date (the same date shown in the UI), so the warning is
|
|
166
|
+
* consistent with what users see: `stale` past a year (stronger), `aging` past
|
|
167
|
+
* six months (milder). Unknown or unparseable dates are treated as fresh.
|
|
168
|
+
*/
|
|
169
|
+
export function stalenessOf(date?: string): Staleness {
|
|
170
|
+
if (!date) return 'fresh';
|
|
171
|
+
const t = Date.parse(date);
|
|
172
|
+
if (Number.isNaN(t)) return 'fresh';
|
|
173
|
+
const age = Date.now() - t;
|
|
174
|
+
if (age > ONE_YEAR) return 'stale';
|
|
175
|
+
if (age > SIX_MONTHS) return 'aging';
|
|
176
|
+
return 'fresh';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Compact star count, one decimal: 1234 → "1.2k", 35456 → "35.5k",
|
|
180
|
+
* 1200000 → "1.2M". The k→M cutover sits where k would round to "1000.0k". */
|
|
181
|
+
export function formatStars(n: number): string {
|
|
182
|
+
if (n < 1000) return String(n);
|
|
183
|
+
const k = n / 1000;
|
|
184
|
+
if (k < 999.95) return k.toFixed(1) + 'k';
|
|
185
|
+
return (n / 1_000_000).toFixed(1) + 'M';
|
|
186
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a *tiny* inline-Markdown subset to safe HTML, for short strings shown
|
|
3
|
+
* outside the Markdown pipeline — frontmatter `description` fields in detail
|
|
4
|
+
* headers and cards. Supports `` `code` ``, `**bold**`, and `*italic*` only;
|
|
5
|
+
* everything else (including any HTML in the source) is escaped.
|
|
6
|
+
*
|
|
7
|
+
* Keep `<meta>`/OG tags on the raw string — those must stay plain text.
|
|
8
|
+
*/
|
|
9
|
+
const ESCAPE: Record<string, string> = { '&': '&', '<': '<', '>': '>' };
|
|
10
|
+
const OPEN = '';
|
|
11
|
+
const CLOSE = '';
|
|
12
|
+
|
|
13
|
+
function escapeHtml(s: string): string {
|
|
14
|
+
return s.replace(/[&<>]/g, (c) => ESCAPE[c]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function inlineMd(input: string): string {
|
|
18
|
+
// Pull inline code out first — escape it and shield it from bold/italic with
|
|
19
|
+
// private-use sentinels that can't occur in prose.
|
|
20
|
+
const codes: string[] = [];
|
|
21
|
+
let s = input.replace(/`([^`]+)`/g, (_, code) => {
|
|
22
|
+
codes.push(`<code>${escapeHtml(code)}</code>`);
|
|
23
|
+
return `${OPEN}${codes.length - 1}${CLOSE}`;
|
|
24
|
+
});
|
|
25
|
+
s = escapeHtml(s);
|
|
26
|
+
// Bold before italic so `**` isn't consumed by the single-`*` rule.
|
|
27
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
28
|
+
s = s.replace(/(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)/g, '<em>$1</em>');
|
|
29
|
+
return s.replace(new RegExp(`${OPEN}(\\d+)${CLOSE}`, 'g'), (_, i) => codes[Number(i)]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing tags. The stored `pricing` values are open-source / free-tier / paid /
|
|
3
|
+
* free; on top of those we derive a synthetic "completely free" tag for tools
|
|
4
|
+
* that cost nothing at all — open source with no paid or hosted free-tier
|
|
5
|
+
* gating, or the standalone `free` (non-OSS) marker.
|
|
6
|
+
*/
|
|
7
|
+
export const COMPLETELY_FREE = 'completely-free';
|
|
8
|
+
|
|
9
|
+
/** True when a tool is free to use outright, with no paid option. */
|
|
10
|
+
export function isCompletelyFree(pricing: string[]): boolean {
|
|
11
|
+
if (pricing.includes('free')) return true;
|
|
12
|
+
return (
|
|
13
|
+
pricing.includes('open-source') &&
|
|
14
|
+
!pricing.includes('paid') &&
|
|
15
|
+
!pricing.includes('free-tier')
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Display/filter tokens for a tool: prepend the 완전 무료 tag when it applies. */
|
|
20
|
+
export function pricingTags(pricing: string[]): string[] {
|
|
21
|
+
return isCompletelyFree(pricing) ? [COMPLETELY_FREE, ...pricing] : pricing;
|
|
22
|
+
}
|