clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / front.ts
front.ts 6.7K
1// A repo's front matter (the top of its code page): what it is about, its2// topics, and facts read from its default branch at build time, which are3// its license and the languages it is written in.4import { catFileBatch, isBinary } from './git.ts';5import type { TreeEntry } from './types.ts';67// ---------------------------------------------------------------- license89const LICENSE_FILE = /^(licen[cs]e|copying|unlicense)([.-][\w.-]*)?$/i;1011// SPDX ids of the licenses whose text says which it is, by phrases in the12// text, first match wins (more specific first).13const LICENSES: [string, RegExp[]][] = [14 ['AGPL-3.0', [/gnu affero general public license/, /version 3/]],15 ['LGPL-3.0', [/gnu lesser general public license/, /version 3/]],16 ['LGPL-2.1', [/gnu lesser general public license/, /version 2\.1/]],17 ['GPL-3.0', [/gnu general public license/, /version 3/]],18 ['GPL-2.0', [/gnu general public license/, /version 2/]],19 ['Apache-2.0', [/apache license/, /version 2\.0/]],20 ['MPL-2.0', [/mozilla public license,? version 2\.0/]],21 ['EPL-2.0', [/eclipse public license - v 2\.0/]],22 ['BSL-1.0', [/boost software license - version 1\.0/]],23 ['OFL-1.1', [/sil open font license/, /version 1\.1/]],24 ['CC0-1.0', [/cc0 1\.0 universal/]],25 ['Unlicense', [/this is free and unencumbered software released into the public domain/]],26 ['MIT-0', [/permission is hereby granted, free of charge/, /^(?![\s\S]*the above copyright notice and this permission notice shall be included)/]],27 ['MIT', [/permission is hereby granted, free of charge/]],28 ['0BSD', [/permission to use, copy, modify, and\/or distribute this software for any purpose with or without fee is hereby granted/, /^(?![\s\S]*provided that the above copyright notice)/]],29 ['ISC', [/permission to use, copy, modify, and\/or distribute this software for any purpose with or without fee is hereby granted/]],30 ['BSD-3-Clause', [/redistribution and use in source and binary forms/, /neither the name/]],31 ['BSD-2-Clause', [/redistribution and use in source and binary forms/]],32 ['Zlib', [/this software is provided 'as-is'/, /altered source versions must be plainly marked/]],33];3435export function spdx(text: string): string | null {36 const t = text.toLowerCase().replace(/\s+/g, ' ');37 for (const [id, res] of LICENSES) if (res.every((r) => r.test(t))) return id;38 return null;39}4041export interface License {42 // The SPDX id, or null: "see LICENSE".43 id: string | null;44 path: string;45}4647// The root's license file, and what it is.48export function license(dir: string, root: TreeEntry[]): License | null {49 const files = root.filter((e) => e.type === 'blob' && LICENSE_FILE.test(e.name)).sort((a, b) => a.name.length - b.name.length);50 const f = files[0];51 if (!f) return null;52 const data = (f.size ?? 0) <= 1 << 16 ? catFileBatch(dir, [f.oid]).get(f.oid)?.data : null;53 return { id: data && !isBinary(data) ? spdx(data.toString('utf8')) : null, path: f.path };54}5556// ---------------------------------------------------------------- languages5758const EXT: Record<string, string> = {59 ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript',60 js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',61 go: 'Go', rs: 'Rust', py: 'Python', rb: 'Ruby', java: 'Java', kt: 'Kotlin', swift: 'Swift',62 c: 'C', h: 'C', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', cs: 'C#', zig: 'Zig', lua: 'Lua',63 php: 'PHP', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell', ml: 'OCaml', clj: 'Clojure',64 scala: 'Scala', dart: 'Dart', r: 'R', jl: 'Julia', nim: 'Nim', sql: 'SQL',65 sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell',66 html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'SCSS', vue: 'Vue', svelte: 'Svelte',67 njk: 'Nunjucks', liquid: 'Liquid', tex: 'TeX',68 nix: 'Nix', tf: 'HCL', hcl: 'HCL', ps1: 'PowerShell', vim: 'Vim Script', el: 'Emacs Lisp',69};70const NAMES: Record<string, string> = { makefile: 'Makefile', dockerfile: 'Dockerfile', gnumakefile: 'Makefile', justfile: 'Just' };71// What a script's #! line runs.72const SHEBANG: [RegExp, string][] = [73 [/\b(ba|z|da|k)?sh\b/, 'Shell'], [/\bnode\b|\bdeno\b|\bbun\b/, 'JavaScript'], [/\bpython[0-9.]*\b/, 'Python'],74 [/\bruby\b/, 'Ruby'], [/\bperl\b/, 'Perl'], [/\blua\b/, 'Lua'],75];76// Not the repo's own code: dependencies, build output, lock files and77// minified or generated files.78const VENDORED = /(^|\/)(node_modules|vendor|third_party|third-party|bower_components|dist|build|out|target|\.yarn|_site|\.git)\//;79const GENERATED = /(\.min\.(js|css)|\.map|\.lock|-lock\.json|-lock\.yaml|\.pb\.go|_pb2\.py|\.generated\.\w+|\.snap)$|(^|\/)(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|composer\.lock)$/;8081export interface Language {82 name: string;83 bytes: number;84 // Of the counted bytes, rounded to tenths.85 pct: number;86}8788// Bytes of each language in a tree: by extension or name, and for an89// executable without either, its #! line. Vendored, generated and binary90// files (anything the map doesn't know) aren't counted.91export function languages(dir: string, blobs: TreeEntry[]): Language[] {92 const by = new Map<string, number>();93 const scripts: TreeEntry[] = [];94 for (const e of blobs) {95 if (e.type !== 'blob' || e.mode === '120000' || !e.size || VENDORED.test(e.path) || GENERATED.test(e.path)) continue;96 const ext = /\.([A-Za-z0-9]+)$/.exec(e.name)?.[1]?.toLowerCase();97 const lang = (ext && EXT[ext]) || NAMES[e.name.toLowerCase()];98 if (lang) by.set(lang, (by.get(lang) ?? 0) + e.size);99 else if (!ext && e.mode === '100755') scripts.push(e);100 }101 if (scripts.length) {102 const data = catFileBatch(dir, scripts.slice(0, 200).map((e) => e.oid));103 for (const e of scripts.slice(0, 200)) {104 const head = data.get(e.oid)?.data.subarray(0, 128).toString('utf8') ?? '';105 if (!head.startsWith('#!')) continue;106 const line = head.split('\n')[0];107 const lang = SHEBANG.find(([re]) => re.test(line))?.[1];108 if (lang) by.set(lang, (by.get(lang) ?? 0) + (e.size ?? 0));109 }110 }111 const total = [...by.values()].reduce((a, b) => a + b, 0);112 if (!total) return [];113 return [...by].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, bytes]) => ({ name, bytes, pct: Math.round((bytes / total) * 1000) / 10 }));114}115116// ---------------------------------------------------------------- topics117118// Extra topics from the repo's .forge/topics: one a line, lowercase words,119// # for comments, ten at most.120export function extraTopics(text: string): string[] {121 const out: string[] = [];122 for (const raw of text.split('\n')) {123 const t = raw.replace(/#.*/, '').trim().toLowerCase().replace(/\s+/g, ' ');124 if (!t || t.length > 32 || !/^[a-z0-9][a-z0-9 .+-]*$/.test(t) || out.includes(t)) continue;125 out.push(t);126 if (out.length >= 10) break;127 }128 return out;129}