mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 18:11:01 +00:00
659 lines
24 KiB
JavaScript
659 lines
24 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
import { extname, join, relative, resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const README_NAMES = ["README.md", "readme.md", "Readme.md", "README", "readme", "README.rst", "README.txt"];
|
|
const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "LICENCE.txt"];
|
|
const SOURCE_LANGUAGES = new Map([
|
|
[".cs", "C#"],
|
|
[".fs", "F#"],
|
|
[".vb", "VB.NET"],
|
|
[".py", "Python"],
|
|
[".rs", "Rust"],
|
|
[".go", "Go"],
|
|
[".ts", "TypeScript"],
|
|
[".tsx", "TypeScript"],
|
|
[".js", "JavaScript"],
|
|
[".jsx", "JavaScript"],
|
|
[".mjs", "JavaScript"],
|
|
[".java", "Java"],
|
|
[".kt", "Kotlin"],
|
|
[".swift", "Swift"],
|
|
[".rb", "Ruby"],
|
|
[".cpp", "C++"],
|
|
[".cc", "C++"],
|
|
[".c", "C"],
|
|
[".zig", "Zig"],
|
|
[".lua", "Lua"],
|
|
[".php", "PHP"],
|
|
[".dart", "Dart"],
|
|
[".ex", "Elixir"],
|
|
[".exs", "Elixir"],
|
|
[".hs", "Haskell"],
|
|
[".scala", "Scala"],
|
|
[".r", "R"],
|
|
[".jl", "Julia"],
|
|
[".ps1", "PowerShell"],
|
|
[".sh", "Shell"],
|
|
]);
|
|
const SKIPPED_DIRECTORIES = new Set([
|
|
".git",
|
|
".next",
|
|
".nuxt",
|
|
".output",
|
|
".turbo",
|
|
"bin",
|
|
"build",
|
|
"coverage",
|
|
"dist",
|
|
"node_modules",
|
|
"obj",
|
|
"out",
|
|
"target",
|
|
"vendor",
|
|
]);
|
|
const BAD_IMAGE_HOSTS = new Set([
|
|
"avatars.githubusercontent.com",
|
|
"badge.fury.io",
|
|
"contrib.rocks",
|
|
"img.shields.io",
|
|
"opencollective.com",
|
|
]);
|
|
const THEMES = [
|
|
"None (site default)",
|
|
"terminal",
|
|
"neon",
|
|
"minimal",
|
|
"pastel",
|
|
"matrix",
|
|
"sunset",
|
|
"ocean",
|
|
"forest",
|
|
"candy",
|
|
"synthwave",
|
|
"newspaper",
|
|
"retro",
|
|
];
|
|
|
|
async function run(file, args, cwd, timeout = 15000) {
|
|
const { stdout } = await execFileAsync(file, args, {
|
|
cwd,
|
|
timeout,
|
|
maxBuffer: 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function runOptional(file, args, cwd, timeout) {
|
|
try {
|
|
return await run(file, args, cwd, timeout);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function pathExists(path) {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function findFirst(root, names) {
|
|
let rootEntries;
|
|
for (const name of names) {
|
|
if (name.startsWith("*.")) {
|
|
rootEntries ??= await readdir(root, { withFileTypes: true });
|
|
const suffix = name.slice(1).toLowerCase();
|
|
const match = rootEntries.find((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix));
|
|
if (match) {
|
|
return join(root, match.name);
|
|
}
|
|
continue;
|
|
}
|
|
const path = join(root, name);
|
|
if (await pathExists(path)) {
|
|
return path;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function parseJson(value, fallback = null) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function normalizeGitUrl(value) {
|
|
const url = String(value || "").trim();
|
|
if (url.startsWith("git@github.com:")) {
|
|
return `https://github.com/${url.slice("git@github.com:".length).replace(/\.git$/i, "")}`;
|
|
}
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname.toLowerCase() !== "github.com") return url;
|
|
const segments = parsed.pathname.replace(/\.git$/i, "").split("/").filter(Boolean);
|
|
if (segments.length < 2) return url;
|
|
return `https://github.com/${segments[0]}/${segments[1]}`;
|
|
} catch {
|
|
return url.replace(/\.git$/i, "").replace(/\/$/, "");
|
|
}
|
|
}
|
|
|
|
function githubCoordinates(githubUrl) {
|
|
const match = String(githubUrl || "").match(/^https:\/\/github\.com\/([^/]+)\/([^/#?]+)$/i);
|
|
return match ? { owner: match[1], repo: match[2] } : null;
|
|
}
|
|
|
|
function cleanMarkdown(text) {
|
|
return String(text || "")
|
|
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
.replace(/!\[[^\]]*]\([^)]*\)/g, " ")
|
|
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
|
|
.replace(/`{1,3}[^`]*`{1,3}/g, " ")
|
|
.replace(/[*_>#|~-]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function firstReadmeParagraph(readme) {
|
|
for (const block of String(readme || "").split(/\r?\n\s*\r?\n/)) {
|
|
const trimmed = block.trim();
|
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("![") || trimmed.startsWith("[![")) {
|
|
continue;
|
|
}
|
|
const cleaned = cleanMarkdown(trimmed);
|
|
if (cleaned.length >= 30) {
|
|
return cleaned.slice(0, 600);
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function sentenceDescription(name, source) {
|
|
const cleaned = cleanMarkdown(source);
|
|
if (!cleaned) {
|
|
return `${name} is an open source tool built to make a focused task simpler and more delightful. Explore the repository for installation and usage details.`;
|
|
}
|
|
const first = /[.!?]$/.test(cleaned) ? cleaned : `${cleaned}.`;
|
|
return `${first} The project is free and open source, with its source and usage details available on GitHub.`;
|
|
}
|
|
|
|
function truncate(value, maxLength) {
|
|
const text = cleanMarkdown(value);
|
|
if (text.length <= maxLength) {
|
|
return text;
|
|
}
|
|
return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
|
}
|
|
|
|
function detectLicenseText(content) {
|
|
const text = String(content || "").toLowerCase();
|
|
if (text.includes("mit license") || text.includes("licensed under the mit")) return "MIT";
|
|
if (text.includes("apache license") || text.includes("apache-2.0")) return "Apache-2.0";
|
|
if (text.includes("gnu general public license") || text.includes("gpl-3.0") || text.includes("gplv3")) return "GPL-3.0";
|
|
if (text.includes("gpl-2.0") || text.includes("gplv2")) return "GPL-2.0";
|
|
if (text.includes("bsd 2-clause")) return "BSD-2-Clause";
|
|
if (text.includes("bsd 3-clause")) return "BSD-3-Clause";
|
|
if (text.includes("mozilla public license") || text.includes("mpl-2.0")) return "MPL-2.0";
|
|
if (text.includes("isc license")) return "ISC";
|
|
if (text.includes("the unlicense")) return "Unlicense";
|
|
return "";
|
|
}
|
|
|
|
async function scanLanguages(root) {
|
|
const counts = new Map();
|
|
let visited = 0;
|
|
|
|
async function visit(directory, depth) {
|
|
if (depth > 6 || visited > 12000) return;
|
|
let entries;
|
|
try {
|
|
entries = await readdir(directory, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (visited > 12000) break;
|
|
if (entry.isDirectory()) {
|
|
if (!SKIPPED_DIRECTORIES.has(entry.name)) {
|
|
await visit(join(directory, entry.name), depth + 1);
|
|
}
|
|
continue;
|
|
}
|
|
visited += 1;
|
|
const language = SOURCE_LANGUAGES.get(extname(entry.name).toLowerCase());
|
|
if (language) {
|
|
counts.set(language, (counts.get(language) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
await visit(root, 0);
|
|
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "";
|
|
}
|
|
|
|
function isLikelyBadge(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
const pathname = parsed.pathname.toLowerCase();
|
|
return BAD_IMAGE_HOSTS.has(hostname)
|
|
|| hostname.endsWith(".githubusercontent.com") && hostname.startsWith("avatars.")
|
|
|| pathname.endsWith(".svg")
|
|
|| pathname.includes("/contributors/")
|
|
|| pathname.includes("/sponsors/");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeImageUrl(rawUrl, githubUrl, defaultBranch, readmePath, root) {
|
|
const value = String(rawUrl || "").trim().replace(/^<|>$/g, "").split(/\s+["']/)[0];
|
|
if (!value || value.startsWith("data:")) return "";
|
|
if (/^https?:\/\//i.test(value)) return isLikelyBadge(value) ? "" : value;
|
|
if (!githubUrl || value.startsWith("#")) return "";
|
|
|
|
const relativeReadmeDirectory = value.startsWith("/")
|
|
? ""
|
|
: relative(root, resolve(readmePath, "..")).replaceAll("\\", "/");
|
|
const imagePath = [relativeReadmeDirectory, value]
|
|
.filter(Boolean)
|
|
.join("/")
|
|
.replace(/^\.\//, "")
|
|
.replace(/^\/+/, "")
|
|
.replaceAll("\\", "/");
|
|
return `${githubUrl.replace("github.com", "raw.githubusercontent.com")}/${defaultBranch}/${imagePath}`;
|
|
}
|
|
|
|
function detectThumbnail(readme, githubUrl, defaultBranch, readmePath, root) {
|
|
const candidates = [];
|
|
const imagePattern = /!\[(?<alt>[^\]]*)]\((?<url>[^)]+)\)/g;
|
|
for (const match of readme.matchAll(imagePattern)) {
|
|
candidates.push({
|
|
index: match.index,
|
|
url: match.groups?.url,
|
|
alt: match.groups?.alt,
|
|
});
|
|
}
|
|
const htmlImagePattern = /<img\b[^>]*>/gi;
|
|
for (const match of readme.matchAll(htmlImagePattern)) {
|
|
const tag = match[0];
|
|
const srcMatch = tag.match(/\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i);
|
|
const altMatch = tag.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
|
if (srcMatch) {
|
|
candidates.push({
|
|
index: match.index,
|
|
url: srcMatch[1] || srcMatch[2] || srcMatch[3],
|
|
alt: altMatch ? altMatch[1] || altMatch[2] || altMatch[3] || "" : "",
|
|
});
|
|
}
|
|
}
|
|
candidates.sort((left, right) => left.index - right.index);
|
|
for (const candidate of candidates) {
|
|
const url = normalizeImageUrl(candidate.url, githubUrl, defaultBranch, readmePath, root);
|
|
if (url) {
|
|
return { url, alt: String(candidate.alt || "").trim() };
|
|
}
|
|
}
|
|
return { url: "", alt: "" };
|
|
}
|
|
|
|
function detectWebsite(readme, githubUrl, packageJson, githubInfo) {
|
|
const candidates = [packageJson?.homepage, githubInfo?.homepageUrl];
|
|
const markdownLinks = [...String(readme || "").matchAll(/\[(?<label>[^\]]+)]\((?<url>https?:\/\/[^)\s]+)\)/g)];
|
|
for (const match of markdownLinks) {
|
|
const label = String(match.groups?.label || "").toLowerCase();
|
|
if (/(demo|website|live|try|preview|play)/.test(label)) {
|
|
candidates.push(match.groups?.url);
|
|
}
|
|
}
|
|
for (const candidate of candidates) {
|
|
const value = String(candidate || "").trim().replace(/\/$/, "");
|
|
if (value && value !== githubUrl && !value.startsWith(`${githubUrl}#`)) {
|
|
return value;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function uniqueTags(values) {
|
|
const tags = [];
|
|
for (const value of values.flatMap((item) => Array.isArray(item) ? item : String(item || "").split(","))) {
|
|
const tag = String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
if (tag && !tags.includes(tag)) tags.push(tag);
|
|
if (tags.length === 6) break;
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
function makeRecommendation(id, severity, title, detail, prompt, detected = false) {
|
|
return { id, severity, title, detail, prompt, detected };
|
|
}
|
|
|
|
export function validateSubmission(metadata, facts) {
|
|
const errors = [];
|
|
const required = [
|
|
["name", "Tool name"],
|
|
["tagline", "One-line description"],
|
|
["description", "Tool description"],
|
|
["githubUrl", "GitHub repository URL"],
|
|
["author", "Author name"],
|
|
["authorGitHub", "GitHub username"],
|
|
["tags", "Tags"],
|
|
];
|
|
for (const [field, label] of required) {
|
|
if (!String(metadata?.[field] || "").trim()) errors.push(`${label} is required.`);
|
|
}
|
|
if (String(metadata?.tagline || "").length > 100) errors.push("One-line description must be 100 characters or fewer.");
|
|
const coordinates = githubCoordinates(metadata?.githubUrl);
|
|
if (!coordinates || metadata.githubUrl !== `https://github.com/${coordinates.owner}/${coordinates.repo}`) {
|
|
errors.push("GitHub repository URL must be a canonical https://github.com/owner/repository URL.");
|
|
} else if (facts?.repoSlug && `${coordinates.owner}/${coordinates.repo}`.toLowerCase() !== facts.repoSlug.toLowerCase()) {
|
|
errors.push(`GitHub repository URL must match the inspected repository (${facts.repoSlug}).`);
|
|
}
|
|
if (metadata?.websiteUrl) {
|
|
try {
|
|
new URL(metadata.websiteUrl);
|
|
} catch {
|
|
errors.push("Website or demo URL must be valid.");
|
|
}
|
|
}
|
|
if (metadata?.thumbnailUrl) {
|
|
try {
|
|
new URL(metadata.thumbnailUrl);
|
|
} catch {
|
|
errors.push("Thumbnail URL must be valid.");
|
|
}
|
|
}
|
|
if (metadata?.theme && !THEMES.includes(metadata.theme)) errors.push("Choose a supported page theme.");
|
|
if (!metadata?.confirmations?.freeOpenSource) errors.push("Confirm that the tool is free and open source.");
|
|
if (!metadata?.confirmations?.notEnterpriseSaas) errors.push("Confirm that the tool is not enterprise software or paid SaaS.");
|
|
if (!metadata?.confirmations?.publicAndWorks) errors.push("Confirm that the repository is public and the tool works.");
|
|
if (facts?.isPrivate === true) errors.push("The repository is private; Tiny Tool Town requires a public repository.");
|
|
if (facts?.isArchived === true) errors.push("The repository is archived and should be made active before submission.");
|
|
return errors;
|
|
}
|
|
|
|
export function buildIssueBody(metadata) {
|
|
const optional = (value) => String(value || "").trim() || "N/A";
|
|
return [
|
|
"### Tool Name",
|
|
"",
|
|
metadata.name.trim(),
|
|
"",
|
|
"### One-line description",
|
|
"",
|
|
metadata.tagline.trim(),
|
|
"",
|
|
"### Tell us about your tool",
|
|
"",
|
|
metadata.description.trim(),
|
|
"",
|
|
"### GitHub Repository URL",
|
|
"",
|
|
metadata.githubUrl.trim(),
|
|
"",
|
|
"### Website or Demo URL (optional)",
|
|
"",
|
|
optional(metadata.websiteUrl),
|
|
"",
|
|
"### Thumbnail image URL (optional)",
|
|
"",
|
|
optional(metadata.thumbnailUrl),
|
|
"",
|
|
"### Your Name",
|
|
"",
|
|
metadata.author.trim(),
|
|
"",
|
|
"### Your GitHub Username",
|
|
"",
|
|
metadata.authorGitHub.trim(),
|
|
"",
|
|
"### Tags (comma-separated)",
|
|
"",
|
|
metadata.tags.trim(),
|
|
"",
|
|
"### Primary Programming Language",
|
|
"",
|
|
optional(metadata.language),
|
|
"",
|
|
"### License",
|
|
"",
|
|
optional(metadata.license),
|
|
"",
|
|
"### Page Theme (optional)",
|
|
"",
|
|
optional(metadata.theme),
|
|
"",
|
|
"### Checklist",
|
|
"",
|
|
"- [x] This tool is free and open source",
|
|
"- [x] This tool is not enterprise software or paid SaaS",
|
|
"- [x] The GitHub repo is public and the tool works",
|
|
].join("\n");
|
|
}
|
|
|
|
export async function resolveRepoRoot(candidate) {
|
|
const start = resolve(candidate || process.cwd());
|
|
const root = await runOptional("git", ["-C", start, "rev-parse", "--show-toplevel"], start);
|
|
if (!root) throw new Error(`No Git repository was found at ${start}.`);
|
|
return resolve(root);
|
|
}
|
|
|
|
export async function inspectRepository(candidate) {
|
|
const root = await resolveRepoRoot(candidate);
|
|
const readmePath = await findFirst(root, README_NAMES);
|
|
const licensePath = await findFirst(root, LICENSE_NAMES);
|
|
const readme = readmePath ? await readFile(readmePath, "utf8") : "";
|
|
const licenseText = licensePath ? await readFile(licensePath, "utf8") : "";
|
|
const packagePath = join(root, "package.json");
|
|
const packageJson = await pathExists(packagePath)
|
|
? parseJson(await readFile(packagePath, "utf8"), {})
|
|
: {};
|
|
const remote = await runOptional("git", ["remote", "get-url", "origin"], root);
|
|
const githubUrl = normalizeGitUrl(remote);
|
|
const coordinates = githubCoordinates(githubUrl);
|
|
const repoSlug = coordinates ? `${coordinates.owner}/${coordinates.repo}` : "";
|
|
const githubRaw = repoSlug
|
|
? await runOptional("gh", [
|
|
"repo",
|
|
"view",
|
|
repoSlug,
|
|
"--json",
|
|
"name,description,homepageUrl,isArchived,isPrivate,primaryLanguage,repositoryTopics,owner,url,defaultBranchRef",
|
|
], root)
|
|
: "";
|
|
const githubInfo = parseJson(githubRaw, {});
|
|
const defaultBranch = githubInfo?.defaultBranchRef?.name
|
|
|| await runOptional("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], root)
|
|
.then((value) => value.replace(/^origin\//, ""))
|
|
|| "main";
|
|
const authorLogin = githubInfo?.owner?.login || coordinates?.owner || "";
|
|
const authorName = authorLogin
|
|
? await runOptional("gh", ["api", `users/${authorLogin}`, "--jq", ".name // .login"], root)
|
|
: await runOptional("git", ["config", "user.name"], root);
|
|
const language = githubInfo?.primaryLanguage?.name || await scanLanguages(root);
|
|
const detectedLicense = detectLicenseText(licenseText)
|
|
|| String(packageJson?.license || "").trim()
|
|
|| detectLicenseText(readme);
|
|
const thumbnail = detectThumbnail(readme, githubUrl, defaultBranch, readmePath, root);
|
|
const name = String(packageJson?.displayName || packageJson?.name || githubInfo?.name || coordinates?.repo || root.split(/[\\/]/).pop())
|
|
.replace(/^@[^/]+\//, "");
|
|
const sourceDescription = packageJson?.description || githubInfo?.description || firstReadmeParagraph(readme);
|
|
const tags = uniqueTags([
|
|
packageJson?.keywords || [],
|
|
githubInfo?.repositoryTopics?.map((topic) => topic.name) || [],
|
|
language,
|
|
"open-source",
|
|
]);
|
|
while (tags.length < 3) {
|
|
for (const fallback of ["developer-tools", "productivity", "utility"]) {
|
|
if (!tags.includes(fallback)) tags.push(fallback);
|
|
if (tags.length >= 3) break;
|
|
}
|
|
}
|
|
|
|
const facts = {
|
|
root,
|
|
repoSlug,
|
|
readmePath,
|
|
licensePath,
|
|
hasReadme: Boolean(readmePath),
|
|
hasInstallDocs: /(^|\n)#{1,4}\s+(install|installation|getting started|quick start)\b/i.test(readme),
|
|
hasUsageDocs: /(^|\n)#{1,4}\s+(usage|examples?|how to use)\b/i.test(readme),
|
|
hasThumbnail: Boolean(thumbnail.url),
|
|
thumbnailAlt: thumbnail.alt,
|
|
isPrivate: typeof githubInfo?.isPrivate === "boolean" ? githubInfo.isPrivate : null,
|
|
isArchived: typeof githubInfo?.isArchived === "boolean" ? githubInfo.isArchived : null,
|
|
githubReachable: Boolean(githubRaw),
|
|
hasBuildSignal: Boolean(
|
|
packageJson?.scripts?.test
|
|
|| packageJson?.scripts?.build
|
|
|| await findFirst(root, ["Makefile", "Cargo.toml", "go.mod", "pyproject.toml", "requirements.txt", "*.csproj"]),
|
|
),
|
|
};
|
|
|
|
const metadata = {
|
|
name,
|
|
tagline: truncate(sourceDescription || `${name}, a focused open source tool.`, 100),
|
|
description: sentenceDescription(name, sourceDescription || firstReadmeParagraph(readme)),
|
|
githubUrl: githubInfo?.url || githubUrl,
|
|
websiteUrl: detectWebsite(readme, githubUrl, packageJson, githubInfo),
|
|
thumbnailUrl: thumbnail.url,
|
|
author: authorName || authorLogin,
|
|
authorGitHub: authorLogin,
|
|
tags: tags.slice(0, 6).join(", "),
|
|
language,
|
|
license: detectedLicense,
|
|
theme: "None (site default)",
|
|
confirmations: {
|
|
freeOpenSource: false,
|
|
notEnterpriseSaas: false,
|
|
publicAndWorks: false,
|
|
},
|
|
};
|
|
|
|
const recommendations = [];
|
|
if (!facts.hasReadme) {
|
|
recommendations.push(makeRecommendation(
|
|
"add-readme",
|
|
"blocking",
|
|
"Add a project README",
|
|
"Tiny Tool Town needs enough public documentation to understand, install, and showcase the tool.",
|
|
"Create a clear README.md with a short product overview, installation steps, usage examples, and contribution guidance.",
|
|
));
|
|
} else {
|
|
if (!facts.hasInstallDocs) {
|
|
recommendations.push(makeRecommendation(
|
|
"document-installation",
|
|
"recommended",
|
|
"Document installation",
|
|
"The README was found, but it does not have a clearly labeled installation or quick-start section.",
|
|
"Add an Installation or Quick Start section with copy-pasteable commands and prerequisites.",
|
|
));
|
|
}
|
|
if (!facts.hasUsageDocs) {
|
|
recommendations.push(makeRecommendation(
|
|
"document-usage",
|
|
"recommended",
|
|
"Add a usage example",
|
|
"A concise example helps reviewers verify that the tool works and helps visitors try it quickly.",
|
|
"Add a Usage or Examples section showing the smallest successful workflow and expected output.",
|
|
));
|
|
}
|
|
}
|
|
if (!facts.licensePath) {
|
|
recommendations.push(makeRecommendation(
|
|
"add-license",
|
|
"blocking",
|
|
"Add an open source license file",
|
|
metadata.license
|
|
? `The project declares ${metadata.license}, but no root LICENSE file was found.`
|
|
: "No recognizable open source license was found.",
|
|
"Add a root LICENSE file using the intended SPDX-compatible open source license and ensure package metadata matches it.",
|
|
));
|
|
}
|
|
if (!facts.hasThumbnail) {
|
|
recommendations.push(makeRecommendation(
|
|
"add-thumbnail",
|
|
"recommended",
|
|
"Add a showcase image",
|
|
"No suitable screenshot or product image was detected in the README. Tiny Tool Town recommends at least 960x540.",
|
|
"Create a polished 16:9 screenshot or product image (at least 960x540), commit it under docs or assets, and feature it near the top of README.md with descriptive alt text.",
|
|
));
|
|
} else if (!facts.thumbnailAlt) {
|
|
recommendations.push(makeRecommendation(
|
|
"improve-image-alt",
|
|
"suggestion",
|
|
"Add descriptive image alt text",
|
|
"A showcase image was found, but its README alt text is empty.",
|
|
"Update the README image syntax with concise alt text that describes the tool UI or output.",
|
|
));
|
|
}
|
|
if (facts.isPrivate === true) {
|
|
recommendations.push(makeRecommendation(
|
|
"make-public",
|
|
"blocking",
|
|
"Make the repository public",
|
|
"Tiny Tool Town only accepts public repositories.",
|
|
"Review the repository for secrets and private material, then change its GitHub visibility to public.",
|
|
));
|
|
} else if (!facts.githubReachable) {
|
|
recommendations.push(makeRecommendation(
|
|
"verify-public-repo",
|
|
"recommended",
|
|
"Verify GitHub repository access",
|
|
"The canvas could not verify repository visibility with GitHub CLI.",
|
|
"Authenticate GitHub CLI, confirm the origin points to GitHub, and verify that the repository is public.",
|
|
));
|
|
}
|
|
if (facts.isArchived === true) {
|
|
recommendations.push(makeRecommendation(
|
|
"unarchive-repository",
|
|
"blocking",
|
|
"Unarchive the repository",
|
|
"Archived repositories cannot demonstrate an actively working tool.",
|
|
"Unarchive the repository and publish any pending fixes before submitting.",
|
|
));
|
|
}
|
|
recommendations.push(makeRecommendation(
|
|
"verify-working-release",
|
|
"suggestion",
|
|
"Verify a clean install and run",
|
|
"The required checklist asks you to confirm that the tool works; this remains a manual release-readiness check.",
|
|
"From a clean environment, follow the README exactly, run the primary workflow, and fix any missing prerequisites or stale commands.",
|
|
true,
|
|
));
|
|
|
|
return {
|
|
repoPath: root,
|
|
scannedAt: new Date().toISOString(),
|
|
metadata,
|
|
facts,
|
|
recommendations,
|
|
themes: THEMES,
|
|
};
|
|
}
|
|
|
|
export async function fileSummary(path) {
|
|
try {
|
|
const details = await stat(path);
|
|
return { path, bytes: details.size };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|