4 Commits

Author SHA1 Message Date
a5ba746625 reorganize project 2025-08-03 23:55:47 -05:00
6d21b8958b clean up email address markdown 2025-08-02 10:48:41 -05:00
4c2441b9e5 track source 2025-08-01 17:01:36 -05:00
494b4b239d allow preamble for md output 2025-08-01 16:59:29 -05:00
8 changed files with 341 additions and 251 deletions

1
.gitignore vendored
View File

@@ -103,4 +103,5 @@ distribution/*
mirrorlist
mirrors*.json
mirrors.md
head.md
.env

11
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "artix-mlg",
"version": "0.2.7",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artix-mlg",
"version": "0.2.7",
"version": "0.2.8",
"license": "MIT",
"dependencies": {
"country-code-lookup": "0.1.3",
"email-addresses": "5.0.0",
"extract-tld": "1.1.2"
},
"bin": {
@@ -36,6 +37,12 @@
"integrity": "sha512-gLu+AQKHUnkSQNTxShKgi/4tYd0vEEait3JMrLNZgYlmIZ9DJLkHUjzXE9qcs7dy3xY/kUx2/nOxZ0Z3D9JE+A==",
"license": "MIT"
},
"node_modules/email-addresses": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz",
"integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==",
"license": "MIT"
},
"node_modules/extract-tld": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/extract-tld/-/extract-tld-1.1.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "artix-mlg",
"version": "0.2.7",
"version": "0.2.8",
"description": "mirrorlist generator for Artix Linux",
"keywords": [
"artix",
@@ -32,6 +32,7 @@
],
"dependencies": {
"country-code-lookup": "0.1.3",
"email-addresses": "5.0.0",
"extract-tld": "1.1.2"
},
"devDependencies": {

178
src/artix-mlg.ts Normal file
View File

@@ -0,0 +1,178 @@
import path from 'path';
import fsp from 'fs/promises';
import { resolveCountry } from './resolveCountry.js';
import { processUrl, getProtocolId } from './processUrl.js';
import { generateMirrorMd } from './markdown.js';
import { generateMirrorlist } from './mirrorlist.js';
import type { MirrorProfile, MirrorInput } from './mirrorProfile.ts';
import type { UrlComponents } from './processUrl.js';
import type { MirrorProfilesByMirrorName } from './markdown.js';
const inputFile = process.env['INPUT'] || path.join(process.cwd(), 'mirrors.json');
const fixtureFile = process.env['FIXTURE'] || path.join(process.cwd(), 'mirrors.fixture.json');
const mirrorList = process.env['MIRRORLIST'] || path.join(process.cwd(), 'mirrorlist');
const mirrorMd = process.env['MIRRORMD'] || path.join(process.cwd(), 'mirrors.md');
const mdHeadFile = process.env['MDHEADER'] || path.join(process.cwd(), 'head.md');
interface ArtixMlgOptions {
fixture: FixtureObject[];
mirrors?: MirrorMap;
mirrorUrls?: MirrorUrl[];
mirrorProfilesByMirrorName?: MirrorProfilesByMirrorName;
}
interface FixtureObject {
pk: number;
model: string;
fields: unknown;
}
interface Mirror extends FixtureObject {
model: "mirrors.Mirror"
fields: {
name: string;
tier: number;
upstream: null | number;
admin_email: string;
alternate_email: string;
public: boolean;
active: boolean;
isos: boolean;
rsync_user: string;
rsync_password: string;
bug: null;
notes: string;
}
}
interface MirrorUrl extends FixtureObject {
model: "mirrors.MirrorUrl"
fields: {
url: string;
protocol: number;
mirror: number;
country: string;
has_ipv4: boolean;
has_ipv6: boolean;
active: boolean;
}
}
type MirrorMap = { [name: string]: Mirror };
class ArtixMlg {
private mirrorCounter = 0;
private mirrorUrls: MirrorUrl[];
private mirrorProfilesByMirrorName: MirrorProfilesByMirrorName;
private mirrors: MirrorMap;
constructor(options: ArtixMlgOptions) {
this.mirrors = options.mirrors || this.setMirrors(options.fixture);
this.mirrorUrls = options.mirrorUrls || [];
this.mirrorProfilesByMirrorName = options.mirrorProfilesByMirrorName || {};
}
async run() {
const input: MirrorInput = JSON.parse(await fsp.readFile(inputFile, { encoding: 'utf-8' }));
input.mirrors.forEach(this.processMirrorProfile);
input.mirrors.forEach(this.updateUpstream);
await fsp.writeFile(fixtureFile, JSON.stringify(this.composeMirrorFixture(), null, 4));
await fsp.writeFile(mirrorList, generateMirrorlist(input.mirrors?.filter(m => m.public && m.active && !m.suppress) || []));
await fsp.writeFile(mirrorMd, await generateMirrorMd(mdHeadFile, this.mirrorProfilesByMirrorName));
}
setMirrors(fixture: FixtureObject[]) {
const mirrors: MirrorMap = {};
fixture.filter(x => x.model === 'mirrors.Mirror').forEach((mirror: Mirror) => {
this.mirrorCounter = Math.max(this.mirrorCounter, mirror.pk);
mirror.fields.active = false;
mirror.fields.public = false;
mirror.fields.isos = false;
mirrors[mirror.fields.name] = mirror;
});
return mirrors;
}
getMirror(name: string): Mirror {
return this.mirrors[name] = {
pk: this.mirrors[name]?.pk || ++this.mirrorCounter,
model: 'mirrors.Mirror',
fields: {
name: name,
tier: -1,
upstream: null,
admin_email: '',
alternate_email: '',
public: true,
active: false,
isos: false,
rsync_user: '',
rsync_password: '',
bug: null,
notes: ''
}
}
}
updateMirror(m: Mirror, profile: MirrorProfile, url: UrlComponents) {
m.fields.tier = Math.max(m.fields.tier, profile.tier);
m.fields.admin_email = profile.admin_email || m.fields.admin_email;
m.fields.alternate_email = profile.alternate_email || m.fields.alternate_email;
m.fields.notes = profile.notes || m.fields.notes;
m.fields.active ||= profile.active;
m.fields.public &&= profile.public;
m.fields.isos ||= !!profile.stable_isos || !!profile.weekly_isos;
if (url.protocol === 'rsync') {
m.fields.rsync_user = profile.rsync_user || '';
m.fields.rsync_password = profile.rsync_password || '';
}
}
processMirrorProfile = (m: MirrorProfile, index: number) => {
const url: UrlComponents = processUrl(m.url);
const mirror: Mirror = this.getMirror(m.force_mirror_name || url.name);
this.updateMirror(mirror, m, url);
const mirrorUrl: MirrorUrl = {
pk: index + 1,
model: 'mirrors.MirrorUrl',
fields: {
url: url.partial,
protocol: getProtocolId(url.protocol),
mirror: mirror.pk,
country: resolveCountry(m.country)?.iso2 || undefined,
// populate ip fields with `mirrorresolv`
has_ipv4: false,
has_ipv6: false,
active: m.active
}
};
this.mirrorUrls.push(mirrorUrl);
this.pushMirrorProfile(m.force_mirror_name || url.name, m);
}
updateUpstream = (m: MirrorProfile) => {
const url: UrlComponents = processUrl(m.url);
const mirror: Mirror = this.mirrors[m.force_mirror_name || url.name];
mirror.fields.upstream = (m.upstream && this.mirrors[m.upstream]?.pk) || mirror.fields.upstream;
}
pushMirrorProfile(name: string, m: MirrorProfile) {
const list: MirrorProfile[] = this.mirrorProfilesByMirrorName[name] ||= [];
list.push(m);
}
composeMirrorFixture(): FixtureObject[] {
const fixture: FixtureObject[] = [];
for (let mirrorName in this.mirrors) {
fixture.push(this.mirrors[mirrorName]);
}
fixture.push.apply(fixture, this.mirrorUrls);
return fixture;
}
}
export default ArtixMlg;
export { ArtixMlg };
export type { ArtixMlgOptions, FixtureObject, Mirror, MirrorUrl, MirrorMap };

View File

@@ -1,244 +1,11 @@
import path from 'path';
import fsp from 'fs/promises';
import parseUrl from 'extract-tld';
import { resolveCountry } from './resolveCountry.js';
import MirrorsByRegion from './mirrorsByRegion.js';
import type { PathLike } from 'fs';
import type { MirrorProfile, MirrorInput } from './mirrorProfile.ts';
import { ArtixMlg, type ArtixMlgOptions, type FixtureObject } from './artix-mlg.js';
const inputFile = process.env['INPUT'] || path.join(process.cwd(), 'mirrors.json');
const fixtureFile = process.env['FIXTURE'] || path.join(process.cwd(), 'mirrors.fixture.json');
const mirrorList = process.env['MIRRORLIST'] || path.join(process.cwd(), 'mirrorlist');
const mirrorMd = process.env['MIRRORMD'] || path.join(process.cwd(), 'mirrors.md');
const verbose = !!process.env['VERBOSE'];
const protocolId: Record<Protocol, number> = {
http: 1,
rsync: 3,
https: 5,
ftp: 9
}
type Protocol = 'http' | 'https' | 'rsync' | 'ftp';
interface FixtureObject {
pk: number;
model: string;
fields: unknown;
}
interface Mirror extends FixtureObject {
model: "mirrors.Mirror"
fields: {
name: string;
tier: number;
upstream: null | number;
admin_email: string;
alternate_email: string;
public: boolean;
active: boolean;
isos: boolean;
rsync_user: string;
rsync_password: string;
bug: null;
notes: string;
}
}
interface MirrorUrl extends FixtureObject {
model: "mirrors.MirrorUrl"
fields: {
url: string;
protocol: number;
mirror: number;
country: string;
has_ipv4: boolean;
has_ipv6: boolean;
active: boolean;
}
}
interface UrlComponents {
full: string;
name: string;
partial: string;
protocol: Protocol;
mirrorListable: boolean;
}
type MirrorMap = { [name: string]: Mirror };
let mirrorCounter = 0;
function setMirrors(fixture: FixtureObject[]) {
const mirrors: MirrorMap = {};
fixture.filter(x => x.model === 'mirrors.Mirror').forEach((mirror: Mirror) => {
mirrorCounter = Math.max(mirrorCounter, mirror.pk);
mirror.fields.active = false;
mirror.fields.public = false;
mirror.fields.isos = false;
mirrors[mirror.fields.name] = mirror;
});
return mirrors;
}
function processUrl(url: string): UrlComponents {
const mirrorListable: Protocol[] = ['http', 'https'];
const protocol = url.split(':')[0] as Protocol;
return {
name: parseUrl.parseUrl(url)?.domain,
full: url,
protocol,
partial: url.split('$repo')[0],
mirrorListable: mirrorListable.indexOf(protocol) >= 0
}
}
function getMirror(name: string): Mirror {
return mirrors[name] = {
pk: mirrors[name]?.pk || ++mirrorCounter,
model: 'mirrors.Mirror',
fields: {
name: name,
tier: -1,
upstream: null,
admin_email: '',
alternate_email: '',
public: true,
active: false,
isos: false,
rsync_user: '',
rsync_password: '',
bug: null,
notes: ''
}
}
}
function updateMirror(m: Mirror, profile: MirrorProfile, url: UrlComponents) {
m.fields.tier = Math.max(m.fields.tier, profile.tier);
m.fields.admin_email = profile.admin_email || m.fields.admin_email;
m.fields.alternate_email = profile.alternate_email || m.fields.alternate_email;
m.fields.notes = profile.notes || m.fields.notes;
m.fields.active ||= profile.active;
m.fields.public &&= profile.public;
m.fields.isos ||= !!profile.stable_isos || !!profile.weekly_isos;
if (url.protocol === 'rsync') {
m.fields.rsync_user = profile.rsync_user || '';
m.fields.rsync_password = profile.rsync_password || '';
}
}
function processMirrorProfile(m: MirrorProfile, index: number) {
const url: UrlComponents = processUrl(m.url);
const mirror: Mirror = getMirror(m.force_mirror_name || url.name);
updateMirror(mirror, m, url);
const mirrorUrl: MirrorUrl = {
pk: index + 1,
model: 'mirrors.MirrorUrl',
fields: {
url: url.partial,
protocol: protocolId[url.protocol],
mirror: mirror.pk,
country: resolveCountry(m.country)?.iso2 || undefined,
// populate ip fields with `mirrorresolv`
has_ipv4: false,
has_ipv6: false,
active: m.active
}
};
mirrorUrls.push(mirrorUrl);
pushMirrorProfile(m.force_mirror_name || url.name, m);
}
function pushMirrorProfile(name: string, m: MirrorProfile) {
const list: MirrorProfile[] = mirrorProfilesByMirrorName[name] ||= [];
list.push(m);
}
function updateUpstream(m: MirrorProfile) {
const url: UrlComponents = processUrl(m.url);
const mirror: Mirror = mirrors[m.force_mirror_name || url.name];
mirror.fields.upstream = (m.upstream && mirrors[m.upstream]?.pk) || mirror.fields.upstream;
}
function composeMirrorFixture(): FixtureObject[] {
const fixture: FixtureObject[] = [];
for (let mirrorName in mirrors) {
fixture.push(mirrors[mirrorName]);
}
fixture.push.apply(fixture, mirrorUrls);
return fixture;
}
function getDateTime(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}
function generateMirrorlist(mirrors: MirrorProfile[] = []): string {
const httpMirrors = mirrors.filter(m => processUrl(m.url).mirrorListable);
const mirrorsByRegion = new MirrorsByRegion(httpMirrors);
const lines: string[] = [
'##',
'## Artix Linux repository mirrorlist',
`## Generated on ${getDateTime()}`,
'##',
'',
'# Artix mirrors',
'# Use rankmirrors(1) to get a list of the fastest mirrors for your location,',
'# e.g.: rankmirrors -v -n 5 /etc/pacman.d/mirrorlist',
'# Then put the resulting list on top of this file.',
'',
'# Default mirrors'
];
httpMirrors.filter(m => m.default).forEach(m => lines.push(`Server = ${m.url}`));
lines.push('');
mirrorsByRegion.printMirrors(lines);
return lines.join('\n');
}
function generateMirrorMd(): string {
function pushTableRowIfTruthy(lines: string[], label: string, value: string | undefined | null | false) {
if (value) {
lines.push(`| ${label} | ${value} |`);
}
}
function findFirstWithChild<T, K extends keyof T>(profiles: T[], key: K): T[K] | undefined {
return profiles.find(p => !!p[key])?.[key];
}
const lines: string[] = [
'# Mirrors\n\nContact or other information for the mirrors of our repositories and ISOs.\n'
]
for (let mirrorName in mirrorProfilesByMirrorName) {
const profiles: MirrorProfile[] = mirrorProfilesByMirrorName[mirrorName];
const activeProfiles: MirrorProfile[] = profiles.filter(p => p.active);
const urls: string[] = profiles.map(p => {
const url = p.url.split('$repo')[0];
return p.active ? url : `${url} (inactive)`;
});
const upstream = findFirstWithChild(activeProfiles, 'upstream');
lines.push(`### ${mirrorName}`);
lines.push(`| Mirror | ${mirrorName} |`);
lines.push('| ------ | ------------- |');
if (upstream) {
lines.push(`| Sync Source | [${upstream}](#${upstream.replaceAll('.', '')}) |`);
}
lines.push(`| URLs | ${urls.join('<br>')} |`);
pushTableRowIfTruthy(lines, 'Provides Stable ISO', findFirstWithChild(activeProfiles, 'stable_isos'));
pushTableRowIfTruthy(lines, 'Provides Weekly ISO', findFirstWithChild(activeProfiles, 'weekly_isos'));
// pushTableRowIfTruthy(lines, 'Bandwidth', findFirstWithChild(activeProfiles, 'bandwidth'));
// pushTableRowIfTruthy(lines, 'Frequency', findFirstWithChild(activeProfiles, 'frequency'));
// pushTableRowIfTruthy(lines, 'Hosted by', findFirstWithChild(activeProfiles, 'org'));
pushTableRowIfTruthy(lines, 'Location', findFirstWithChild(activeProfiles, 'country') || findFirstWithChild(profiles, 'country'));
pushTableRowIfTruthy(lines, 'Contact Details', findFirstWithChild(activeProfiles, 'admin_email') || findFirstWithChild(profiles, 'admin_email'));
pushTableRowIfTruthy(lines, 'Altenate Contact Details', findFirstWithChild(activeProfiles, 'alternate_email') || findFirstWithChild(profiles, 'alternate_email'));
lines.push('');
}
return lines.join('\n');
}
async function tryReadFileOrDefault<T>(f: PathLike, d: T): Promise<T> {
async function tryReadFileOrDefault<T>(f: PathLike, d: T, verbose: boolean = false): Promise<T> {
try {
return JSON.parse(await fsp.readFile(f, { encoding: 'utf-8' }))
}
@@ -251,20 +18,12 @@ async function tryReadFileOrDefault<T>(f: PathLike, d: T): Promise<T> {
}
}
const fixture: FixtureObject[] = await tryReadFileOrDefault<FixtureObject[]>(fixtureFile, []);
const mirrors: MirrorMap = setMirrors(fixture);
const mirrorUrls: MirrorUrl[] = [];
const mirrorProfilesByMirrorName: { [mirrorName: string]: MirrorProfile[] } = {};
async function main() {
const input: MirrorInput = JSON.parse(await fsp.readFile(inputFile, { encoding: 'utf-8' }));
input.mirrors.forEach(processMirrorProfile);
input.mirrors.forEach(updateUpstream);
await fsp.writeFile(fixtureFile, JSON.stringify(composeMirrorFixture(), null, 4));
await fsp.writeFile(mirrorList, generateMirrorlist(input.mirrors?.filter(m => m.public && m.active && !m.suppress) || []));
await fsp.writeFile(mirrorMd, generateMirrorMd());
const options: ArtixMlgOptions = {
fixture: await tryReadFileOrDefault<FixtureObject[]>(fixtureFile, [], !!process.env['VERBOSE'])
};
const mlg = new ArtixMlg(options);
mlg.run();
}
export default main;

73
src/markdown.ts Normal file
View File

@@ -0,0 +1,73 @@
import fsp from 'fs/promises';
import addrs from "email-addresses";
import type { PathLike } from 'fs';
import type { MirrorProfile } from './mirrorProfile.js';
const verbose = !!process.env['VERBOSE'];
type MirrorProfilesByMirrorName = { [mirrorName: string]: MirrorProfile[] };
async function tryReadHeader(mdHeadFile: PathLike): Promise<string[]> {
try {
return [await fsp.readFile(mdHeadFile, 'utf-8')];
}
catch (err) {
if (verbose) {
console.error(err);
}
return [];
}
}
function pushTableRowIfTruthy(lines: string[], label: string, value: string | undefined | null | false) {
if (value) {
lines.push(`| ${label} | ${value} |`);
}
}
function findFirstWithChild<T, K extends keyof T>(profiles: T[], key: K): T[K] | undefined {
return profiles.find(p => !!p[key])?.[key];
}
function printEmail(email: string | undefined) {
if (!email) {
return email;
}
const addr = addrs.parseOneAddress(email)?.['address'];
return `[${email.replaceAll('<', '&lt;').replaceAll('>', '&gt;')}](mailto:${addr})`;
}
async function generateMirrorMd(mdHeadFile: PathLike, mirrorProfilesByMirrorName: MirrorProfilesByMirrorName): Promise<string> {
const lines: string[] = await tryReadHeader(mdHeadFile);
lines.push('# Mirrors\n\nContact or other information for the mirrors of our repositories and ISOs.\n');
for (let mirrorName in mirrorProfilesByMirrorName) {
const profiles: MirrorProfile[] = mirrorProfilesByMirrorName[mirrorName];
const activeProfiles: MirrorProfile[] = profiles.filter(p => p.active);
const urls: string[] = profiles.map(p => {
const url = p.url.split('$repo')[0];
return p.active ? url : `${url} (inactive)`;
});
const upstream = findFirstWithChild(activeProfiles, 'upstream');
lines.push(`### ${mirrorName}`);
lines.push(`| Mirror | ${mirrorName} |`);
lines.push('| ------ | ------------- |');
if (upstream) {
lines.push(`| Sync Source | [${upstream}](#${upstream.replaceAll('.', '')}) |`);
}
lines.push(`| URLs | ${urls.join('<br>')} |`);
pushTableRowIfTruthy(lines, 'Provides Stable ISO', findFirstWithChild(activeProfiles, 'stable_isos'));
pushTableRowIfTruthy(lines, 'Provides Weekly ISO', findFirstWithChild(activeProfiles, 'weekly_isos'));
// pushTableRowIfTruthy(lines, 'Bandwidth', findFirstWithChild(activeProfiles, 'bandwidth'));
// pushTableRowIfTruthy(lines, 'Frequency', findFirstWithChild(activeProfiles, 'frequency'));
// pushTableRowIfTruthy(lines, 'Hosted by', findFirstWithChild(activeProfiles, 'org'));
pushTableRowIfTruthy(lines, 'Location', findFirstWithChild(activeProfiles, 'country') || findFirstWithChild(profiles, 'country'));
pushTableRowIfTruthy(lines, 'Contact Details', printEmail(findFirstWithChild(activeProfiles, 'admin_email') || findFirstWithChild(profiles, 'admin_email')));
pushTableRowIfTruthy(lines, 'Altenate Contact Details', printEmail(findFirstWithChild(activeProfiles, 'alternate_email') || findFirstWithChild(profiles, 'alternate_email')));
lines.push('');
}
return lines.join('\n');
}
export default generateMirrorMd;
export { generateMirrorMd };
export type { MirrorProfilesByMirrorName };

33
src/mirrorlist.ts Normal file
View File

@@ -0,0 +1,33 @@
import MirrorsByRegion from './mirrorsByRegion.js';
import {processUrl} from './processUrl.js';
import type {MirrorProfile} from './mirrorProfile.js';
function getDateTime(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}
function generateMirrorlist(mirrors: MirrorProfile[] = []): string {
const httpMirrors = mirrors.filter(m => processUrl(m.url).mirrorListable);
const mirrorsByRegion = new MirrorsByRegion(httpMirrors);
const lines: string[] = [
'##',
'## Artix Linux repository mirrorlist',
`## Generated on ${getDateTime()} by artix-mlg`,
'##',
'',
'# Artix mirrors',
'# Use rankmirrors(1) to get a list of the fastest mirrors for your location,',
'# e.g.: rankmirrors -v -n 5 /etc/pacman.d/mirrorlist',
'# Then put the resulting list on top of this file.',
'',
'# Default mirrors'
];
httpMirrors.filter(m => m.default).forEach(m => lines.push(`Server = ${m.url}`));
lines.push('');
mirrorsByRegion.printMirrors(lines);
return lines.join('\n');
}
export default generateMirrorlist;
export { generateMirrorlist };

38
src/processUrl.ts Normal file
View File

@@ -0,0 +1,38 @@
import parseUrl from 'extract-tld';
const protocolId: Record<Protocol, number> = {
http: 1,
rsync: 3,
https: 5,
ftp: 9
}
type Protocol = 'http' | 'https' | 'rsync' | 'ftp';
interface UrlComponents {
full: string;
name: string;
partial: string;
protocol: Protocol;
mirrorListable: boolean;
}
function processUrl(url: string): UrlComponents {
const mirrorListable: Protocol[] = ['http', 'https'];
const protocol = url.split(':')[0] as Protocol;
return {
name: parseUrl.parseUrl(url)?.domain,
full: url,
protocol,
partial: url.split('$repo')[0],
mirrorListable: mirrorListable.indexOf(protocol) >= 0
}
}
function getProtocolId(protocol: Protocol): number {
return protocolId[protocol];
}
export default processUrl;
export { processUrl, getProtocolId };
export type { Protocol, UrlComponents };