Folders and files
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Repository files navigation
#!/usr/bin/env -S deno run --node-modules-dir=false --allow-write --allow-read --allow-run=bash,git,cargo --allow-net=docs.rs:443,github.com:443 --allow-env --allow-sys --no-lock
// NOTE: Pin the versions of the packages because the script runs without a lock file
import {
CargoMetadataSchema as CargoMetadataBaseSchema,
CargoPackageSchema as CargoPackageBaseSchema,
PackageMetadataSchema,
ReadmeSchema,
selectWorkspacePackages,
z,
} from "./CargoMetadata.ts"
import * as zx from "npm:zx@8.3.2"
import { ProcessPromise, Shell } from "npm:zx@8.3.2"
import { assert, assertEquals } from "jsr:@std/assert@1.0.0"
import { dirname, join, relative } from "jsr:@std/path@1.1.4"
import { parse as parseToml } from "jsr:@std/toml@1.0.5"
const CargoUrlSchema = z.union([z.string().url(), z.literal("")]).nullable()
const OptionalUrlSchema = CargoUrlSchema.optional()
// Each package must define its own README.md. Cargo autodetects README.md if `package.readme` is omitted. So, `package.readme` must be omitted
const PackageManifestSchema = z.object({
package: z.object({
name: z.string().min(1),
readme: z.undefined().optional(),
metadata: PackageMetadataSchema,
}),
})
const WorkspaceManifestSchema = z.object({
workspace: z.object({
package: z.object({
description: z.undefined().optional(),
readme: z.undefined().optional(),
homepage: OptionalUrlSchema,
repository: OptionalUrlSchema,
}).passthrough().optional(),
metadata: z.object({
details: z.object({
name: z.string().min(1).optional(),
title: z.string().nullable().optional(),
readme: ReadmeSchema,
}).default({}),
}).default({}),
}).optional(),
package: PackageManifestSchema.shape.package.optional(),
})
const CargoTargetSchema = z.object({
name: z.string().min(1),
kind: z.array(z.string()).min(1),
})
const CargoPackageSchema = CargoPackageBaseSchema.extend({
description: z.string().nullable(),
homepage: CargoUrlSchema,
repository: CargoUrlSchema,
license: z.string().nullable(),
metadata: PackageMetadataSchema,
targets: z.array(CargoTargetSchema).min(1),
})
const CargoMetadataSchema = CargoMetadataBaseSchema.extend({
packages: z.array(CargoPackageSchema),
})
type CargoPackage = z.infer<typeof CargoPackageSchema>
type Badge = Record<"name" | "image" | "url", string>
type Section = Record<"title" | "body", string>
const badge = (name: string, image: string, url: string): Badge => ({ name, image, url })
const pushSection = (sections: Section[], title: string, body: string) => sections.push({ title, body })
// Nested sections not supported
const renderSection = ({ title, body }: Section) => `## ${title}\n\n${body}`
const renderNonEmptySections = (sections: Section[]) => sections.filter((value) => value.body).map(renderSection).join("\n\n")
const autogeneratedHeader = `
<!-- DO NOT EDIT -->
<!-- This file is automatically generated by README.ts. -->
<!-- Edit README.ts if you want to make changes. -->
`.trim()
const crateDocsPlaceholder = `
<!-- crate documentation start -->
<!-- crate documentation end -->
`.trim()
/**
* Examples:
*
* `normalizeGitRemoteUrl("git@github.com:DenisGorbachev/rust-private-template.git") == "https://github.com/DenisGorbachev/rust-private-template"`
* `normalizeGitRemoteUrl("https://github.com/DenisGorbachev/rust-private-template.git") == "https://github.com/DenisGorbachev/rust-private-template"`
*
* @param url
*/
const normalizeGitRemoteUrl = (url: string) => {
const parseableUrl = url.replace(/^git@([^:]+):/, "ssh://git@$1/")
if (!URL.canParse(parseableUrl)) return url
const remote = new URL(parseableUrl)
return remote.hostname === "github.com" ? `https://github.com${remote.pathname.replace(/\/$/, "").replace(/\.git$/, "")}` : url
}
const scriptDir = import.meta.dirname
if (!scriptDir) throw new Error("Cannot determine the current script dirname")
const $: Shell<false, ProcessPromise> = zx.$({ cwd: scriptDir })
const rootManifestPath = await Deno.realPath(join(scriptDir, "Cargo.toml"))
const parseTomlFile = async <Output>(path: string, schema: z.ZodType<Output>, errorMessage: string) => {
try {
return schema.parse(parseToml(await Deno.readTextFile(path)))
} catch (cause) {
throw new Error(`${errorMessage}: '${path}'`, { cause })
}
}
const parsePackageManifest = async (cargoPackage: CargoPackage) => {
const { manifest_path: manifestPath, name } = cargoPackage
const manifest = await parseTomlFile(manifestPath, PackageManifestSchema, `Package '${name}' manifest is invalid`)
assertEquals(manifest.package.name, name)
}
const [cargoMetadataOutput, originUrlOutput, workspaceManifest] = await Promise.all([
$`cargo metadata --format-version 1 --no-deps --manifest-path ${rootManifestPath}`,
$`git remote get-url origin`,
parseTomlFile(rootManifestPath, WorkspaceManifestSchema, "Workspace manifest is invalid"),
])
const cargoMetadata = CargoMetadataSchema.parse(JSON.parse(cargoMetadataOutput.stdout))
const originUrl = normalizeGitRemoteUrl(originUrlOutput.stdout.trim())
const workspaceRoot = await Deno.realPath(cargoMetadata.workspace_root)
assertEquals(workspaceRoot, await Deno.realPath(scriptDir))
const workspacePackages = selectWorkspacePackages(cargoMetadata)
.sort((left, right) => left.manifest_path.localeCompare(right.manifest_path))
assertEquals(workspacePackages.length, new Set(cargoMetadata.workspace_members).size)
await Promise.all(workspacePackages.map(parsePackageManifest))
const rootPackage = workspacePackages.find((cargoPackage) => cargoPackage.manifest_path === rootManifestPath)
const repository = workspaceManifest.workspace?.package?.repository ?? rootPackage?.repository
if (repository) assertEquals(originUrl, repository)
for (const { repository: packageRepository } of workspacePackages) if (packageRepository) assertEquals(packageRepository, originUrl)
const checkIsPublicGitHubRepo = async () => {
if (!URL.canParse(originUrl) || new URL(originUrl).hostname !== "github.com") return false
const response = await fetch(originUrl, { method: "GET" })
if (response.status === 200) return true
if (response.status === 404) return false
throw new Error(`Unexpected response status while checking GitHub repo visibility: ${response.status} ${response.statusText}`)
}
const licenseNameFileMap: Record<string, string> = {
"Apache-2.0": "LICENSE-APACHE",
"MIT": "LICENSE-MIT",
}
const getLicenseFile = (name: string) => {
const file = licenseNameFileMap[name]
if (file === undefined) throw new Error(`licenseNameFileMap is missing the following key: \`${name}\``)
return file
}
const renderMarkdownList = (items: string[]) => items.map((item) => `* ${item}`).join("\n")
const renderShellCode = (code: string) => `\`\`\`shell\n${code}\n\`\`\``
const markdownRelativePath = (from: string, to: string) => relative(from, to).replaceAll("\\", "/")
const packageReadmeLink = (fromDirectory: string, cargoPackage: CargoPackage) => `[\`${cargoPackage.name}\`](${markdownRelativePath(fromDirectory, join(dirname(cargoPackage.manifest_path), "README.md"))})`
const packageLinks = (fromDirectory: string, packages: CargoPackage[]) =>
renderMarkdownList(
packages.map((cargoPackage) => packageReadmeLink(fromDirectory, cargoPackage)),
)
const temporaryPaths = new Set<string>()
type RenderedReadme = Record<"destinationPath" | "temporaryPath", string>
const writeTemporaryReadme = async (destinationPath: string, content: string) => {
const temporaryPath = await Deno.makeTempFile({
dir: dirname(destinationPath),
prefix: ".README.",
suffix: ".tmp",
})
temporaryPaths.add(temporaryPath)
await Deno.writeTextFile(temporaryPath, `${content}\n`)
return temporaryPath
}
const renderPackageReadme = async (cargoPackage: CargoPackage, isPublicGitHubRepo: boolean): Promise<RenderedReadme> => {
const { license, name, targets } = cargoPackage
try {
const packageDirectory = dirname(cargoPackage.manifest_path)
const destinationPath = join(packageDirectory, "README.md")
const title = cargoPackage.metadata.details.title?.trim() || name
const peers = cargoPackage.metadata.details.peers
const primaryTarget = targets.at(0)
assert(primaryTarget, `Could not find primary target for package '${name}'`)
const primaryBinTarget = targets.find((target) => target.kind.includes("bin"))
const secondaryBinTargets = targets.filter(
(target) => target !== primaryTarget && target !== primaryBinTarget && target.kind.includes("bin"),
)
const docsUrl = `https://docs.rs/${name}`
const docsUrlPromise = fetch(docsUrl, { method: "HEAD" })
const helpPromise = primaryBinTarget ? $`cargo run --quiet --manifest-path ${cargoPackage.manifest_path} --package ${name} --bin ${primaryBinTarget.name} -- --help` : undefined
const docsUrlHead = await docsUrlPromise
const badges: Badge[] = []
if (isPublicGitHubRepo) badges.push(badge("Build", `${originUrl}/actions/workflows/ci.yml/badge.svg`, originUrl))
if (docsUrlHead.status === 200) badges.push(badge("Documentation", `https://docs.rs/${name}/badge.svg`, docsUrl))
const badgesText = badges.map(({ name: badgeName, image, url }) => `[](${url})`).join("\n")
const titleSectionBody = [badgesText, crateDocsPlaceholder].filter((value) => value.length > 0).join("\n\n")
const sections: Section[] = []
const installationSectionBodyParts: string[] = []
const installationSectionUseExpandedFormat = primaryBinTarget && primaryTarget !== primaryBinTarget
if (primaryBinTarget) {
const command = renderShellCode(`cargo install --locked ${name}`)
installationSectionBodyParts.push(installationSectionUseExpandedFormat ? `Install as executable:\n\n${command}` : command)
}
if (primaryTarget !== primaryBinTarget) {
const command = renderShellCode(`cargo add ${[name, ...peers].join(" ")}`)
installationSectionBodyParts.push(
installationSectionUseExpandedFormat ? `Install as library dependency in your package:\n\n${command}` : command,
)
}
pushSection(sections, "Installation", installationSectionBodyParts.join("\n\n"))
if (helpPromise) {
const help = await helpPromise
pushSection(sections, "Usage", renderShellCode(help.stdout.trim()))
}
if (secondaryBinTargets.length > 0) {
pushSection(
sections,
"Additional binaries",
renderMarkdownList(secondaryBinTargets.map((target) => `\`${target.name}\``)),
)
}
if (rootPackage?.id === cargoPackage.id) {
const otherPackages = workspacePackages.filter((candidate) => candidate.id !== cargoPackage.id)
const body = otherPackages.length > 0 ? packageLinks(packageDirectory, otherPackages) : "This workspace has no other packages."
pushSection(sections, "Other packages", body)
}
if (isPublicGitHubRepo) pushSection(sections, "Gratitude", `Like the project? [⭐ Star this repo](${originUrl}) on GitHub!`)
const licenseNames = license ? license.split("OR").map((licenseName) => licenseName.trim()) : []
if (licenseNames.length > 0) {
const licenseLinks = licenseNames.map((licenseName) => {
const file = join(workspaceRoot, getLicenseFile(licenseName))
return `[${licenseName}](${markdownRelativePath(packageDirectory, file)})`
})
pushSection(
sections,
"License",
`
${licenseLinks.join(" or ")}.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, shall be licensed as above, without any additional terms or conditions.
`.trim(),
)
}
const body = renderNonEmptySections(sections)
const content = [autogeneratedHeader, `# ${title}`, titleSectionBody, body]
.filter((value) => value.length > 0)
.join("\n\n")
const temporaryPath = await writeTemporaryReadme(destinationPath, content)
await $`cargo insert-docs crate-into-readme --allow-dirty --link-to-latest --shrink-headings 0 --manifest-path ${cargoPackage.manifest_path} --package ${name} --readme-path ${temporaryPath}`
await Deno.chmod(temporaryPath, 0o644)
return { destinationPath, temporaryPath }
} catch (cause) {
throw new Error(`Failed to render README.md for package '${name}'`, { cause })
}
}
const renderVirtualWorkspaceReadme = async (): Promise<RenderedReadme> => {
try {
const destinationPath = join(workspaceRoot, "README.md")
const workspaceDetails = workspaceManifest.workspace?.metadata?.details
const packageManifest = workspaceManifest.package
const title = workspaceDetails?.title?.trim() || packageManifest?.metadata?.details?.title?.trim() || workspaceDetails?.name || packageManifest?.name
assert(title, "The root manifest must define workspace.metadata.details.name or package.name")
const content = [autogeneratedHeader, `# ${title}`, packageLinks(workspaceRoot, workspacePackages)].join("\n\n")
const temporaryPath = await writeTemporaryReadme(destinationPath, content)
await Deno.chmod(temporaryPath, 0o644)
return { destinationPath, temporaryPath }
} catch (cause) {
throw new Error("Failed to render the virtual workspace README.md", { cause })
}
}
/// PRUNING: Removes only temporary README render files after commit or failure because they contain no user-owned data.
const removeTemporaryReadme = async (path: string) => {
try {
await Deno.remove(path)
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error
}
}
/// PRUNING: Replaces obsolete generated README contents only after every replacement has rendered successfully.
const replaceReadme = async ({ destinationPath, temporaryPath }: RenderedReadme) => {
await Deno.rename(temporaryPath, destinationPath)
temporaryPaths.delete(temporaryPath)
}
try {
const generateWorkspaceReadme = workspaceManifest.workspace?.metadata?.details?.readme?.generate ?? true
const packagesToRender = workspacePackages.filter(({ metadata }) => metadata.details.readme.generate ?? generateWorkspaceReadme)
const isPublicGitHubRepo = packagesToRender.length > 0 ? await checkIsPublicGitHubRepo() : false
const renderPromises = packagesToRender.map((cargoPackage) => renderPackageReadme(cargoPackage, isPublicGitHubRepo))
if (!rootPackage && generateWorkspaceReadme) renderPromises.push(renderVirtualWorkspaceReadme())
const results = await Promise.allSettled(renderPromises)
const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [])
if (failures.length > 0) throw new AggregateError(failures, `Failed to render ${failures.length} README target(s)`)
const renderedReadmes = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : [])
assertEquals(new Set(renderedReadmes.map((target) => target.destinationPath)).size, renderedReadmes.length)
// There is a minor risk of partial README replacement failure. This is acceptable because files will be regenerated during the next successful run of fix:readme.
await Promise.all(renderedReadmes.map(replaceReadme))
} finally {
await Promise.all([...temporaryPaths].map(removeTemporaryReadme))
}