Skip to content

Commit 9da008d

Browse files
martrappCopilot
andcommitted
Refactor RSS feed update process: enhance automation, add validation scripts, and improve documentation
Co-authored-by: Copilot <copilot@github.com>
1 parent 94b647b commit 9da008d

6 files changed

Lines changed: 280 additions & 71 deletions

File tree

.github/skills/update-rss/SKILL.md

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,42 @@ disable-model-invocation: false
1111
- User asks to update the RSS feed
1212

1313
## Input
14-
none
14+
- No input required from the user beyond confirming that git is up to date.
15+
16+
## High-value automation in this skill
17+
- Use scripts for the repetitive, easy-to-get-wrong parts of the workflow: mapping RSS entries back to source files, restoring selected `pubDate` values from `HEAD`, and re-sorting `<item>` blocks after those restores.
18+
- Keep the human review focused on classification only: decide whether each changed page is minor or substantive, then let the scripts apply the mechanical updates.
1519

1620
## Procedure
1721
0. Ask the user whether git is up to date. Stop immediately if the user says no. This is important because the process relies on commit dates for time stamps.
1822

1923
1. Run `npm run build` to update last update dates for all pages. Stop immediately if the build fails.
2024

21-
2. Run `node bin/rss-update.ts` to update the `public/rss.xml` file. The script also creates `src/content/docs/rss.md`, but that file is intentionally out of scope for this workflow and must be left untouched. Only `public/rss.xml` is reviewed and corrected manually. The script reassigns dates to all entries based on file modification times, even if the actual content changes are minor.
25+
2. Run `node bin/rss-update.ts` to update the `public/rss.xml` file. The script also updates `src/content/docs/rss.md`, and that generated file should be kept as produced by the script. Do not restore `src/content/docs/rss.md` to `HEAD`, and do not manually edit it in this workflow. Only `public/rss.xml` is reviewed and corrected manually. The script reassigns dates to all entries based on file modification times, even if the actual content changes are minor.
2226

23-
3. Run `node .github/skills/update-rss/diff-rss.cjs` to identify which existing entries have changed `pubDate` values versus `HEAD`, and which are brand new.
27+
3. Run `node .github/skills/update-rss/diff-rss.ts` to identify which existing entries have changed `pubDate` values versus `HEAD`, and which are brand new. The output now includes the source content path for each entry, so do not derive the path manually from the URL unless the script reports an unresolved fallback path.
2428

25-
4. For each entry listed as CHANGED: the `guid` value is the URL of the page; derive the source file path from it (e.g. `https://vtbag.dev/basics/api/``src/content/docs/basics/api.mdx`). Check whether the change since the old `pubDate` is minor:
29+
4. For each entry listed as CHANGED, use the source file path reported by `node .github/skills/update-rss/diff-rss.ts`. Check whether the change since the old `pubDate` is minor:
2630
- Find the commit that was current as of the committed `pubDate`: `git log --before="<committedPubDate>" -1 --format="%H" -- <sourceFile>`
2731
- Get the diff to the current file: `git --no-pager diff <hash> -- <sourceFile>`
2832
- A diff is **minor** if the page keeps the same user-facing purpose and technical takeaways, even when wording is expanded or reorganized for clarity.
2933
- Treat as **minor**: grammar/style fixes, formatting, link updates, readability rewrites, sentence/paragraph reshuffling, and examples or clarifications that do not change recommendations or factual claims.
3034
- A diff is **substantive** only if it changes what the reader should do or believe: new/removed features, changed compatibility/support statements, changed API semantics, changed recommended patterns, or added/removed constraints/caveats.
3135
- If uncertain, apply this test: if an experienced reader would keep the same implementation decisions after reading both versions, classify as **minor**.
32-
- If the change is minor, replace the new `pubDate` in `public/rss.xml` with the old committed values. Do not make any matching edit in `src/content/docs/rss.md`.
36+
- Keep a list of CHANGED entry `guid` values that you classify as **minor**.
3337
- If the change is substantive, keep the new `pubDate` as is.
3438

3539
5. ADDED entries (new `guid` not in `HEAD`) always keep their new `pubDate` without review.
3640

37-
6. After all `pubDate` adjustments are done, ensure that the `<item>` entries in `public/rss.xml` are sorted by `pubDate` in descending order, with the newest entries first. If reverting a minor change makes an entry older than the items around it, move the whole `<item>` block to its correct position. Do not reorder `src/content/docs/rss.md`.
41+
6. After classifying all CHANGED entries, restore the old dates for the minor ones with the script instead of editing XML by hand:
42+
- Inline GUIDs: `node .github/skills/update-rss/apply-rss-date-overrides.ts <guid> <guid> ...`
43+
- Or use a file with one GUID per line: `node .github/skills/update-rss/apply-rss-date-overrides.ts --guid-file /tmp/minor-rss-guids.txt`
44+
- The script restores `pubDate` values from `HEAD` only for the selected GUIDs and automatically re-sorts all `<item>` entries in `public/rss.xml` by descending `pubDate`.
45+
- Do not make any matching edit in `src/content/docs/rss.md`, and do not restore that file to `HEAD`.
3846

3947
7. Validate the final result:
40-
- Run `node .github/skills/update-rss/diff-rss.cjs` again and confirm that only the intended substantive entries still appear under CHANGED.
41-
- Run `node .github/skills/update-rss/check-rss-sort.cjs` and confirm it prints `RSS_SORT_OK`.
48+
- Run `node .github/skills/update-rss/diff-rss.ts` again and confirm that only the intended substantive entries still appear under CHANGED.
49+
- Run `node .github/skills/update-rss/check-rss-sort.ts` and confirm it prints `RSS_SORT_OK`.
4250

4351
## URL to source file mapping
4452
- `https://vtbag.dev/<path>/``src/content/docs/<path>.mdx` (or `.md`)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import fs from 'node:fs';
2+
import {
3+
itemsByGuid,
4+
parseRss,
5+
readHeadRssFile,
6+
readRssFile,
7+
replacePubDate,
8+
serializeRss,
9+
sortItemsByPubDate,
10+
} from './rss-utils.ts';
11+
12+
function usage(): void {
13+
console.log('Usage: node .github/skills/update-rss/apply-rss-date-overrides.ts [--guid-file <path>] [--dry-run] <guid> [<guid> ...]');
14+
}
15+
16+
function parseArgs(argv: string[]): { dryRun: boolean; guids: string[] } {
17+
const guids: string[] = [];
18+
let guidFile: string | undefined;
19+
let dryRun = false;
20+
21+
for (let index = 0; index < argv.length; index += 1) {
22+
const arg = argv[index];
23+
24+
if (arg === '--guid-file') {
25+
guidFile = argv[index + 1];
26+
index += 1;
27+
continue;
28+
}
29+
30+
if (arg === '--dry-run') {
31+
dryRun = true;
32+
continue;
33+
}
34+
35+
if (arg === '--help' || arg === '-h') {
36+
usage();
37+
process.exit(0);
38+
}
39+
40+
guids.push(arg);
41+
}
42+
43+
if (guidFile) {
44+
const fileGuids = fs.readFileSync(guidFile, 'utf8')
45+
.split(/\r?\n/)
46+
.map(line => line.trim())
47+
.filter(Boolean);
48+
guids.push(...fileGuids);
49+
}
50+
51+
return {
52+
dryRun,
53+
guids: [...new Set(guids)],
54+
};
55+
}
56+
57+
const { dryRun, guids } = parseArgs(process.argv.slice(2));
58+
59+
if (guids.length === 0) {
60+
usage();
61+
console.error('Provide at least one GUID to restore from HEAD.');
62+
process.exit(1);
63+
}
64+
65+
const parsedCurrent = parseRss(readRssFile());
66+
const currentByGuid = itemsByGuid(parsedCurrent.items);
67+
const committedByGuid = itemsByGuid(parseRss(readHeadRssFile()).items);
68+
const restored = [];
69+
70+
for (const guid of guids) {
71+
const currentItem = currentByGuid.get(guid);
72+
if (!currentItem) {
73+
console.error(`GUID missing in current RSS: ${guid}`);
74+
process.exit(1);
75+
}
76+
77+
const committedItem = committedByGuid.get(guid);
78+
if (!committedItem?.pubDate) {
79+
console.error(`GUID missing in HEAD RSS: ${guid}`);
80+
process.exit(1);
81+
}
82+
83+
currentItem.pubDate = committedItem.pubDate;
84+
currentItem.xml = replacePubDate(currentItem.xml, committedItem.pubDate);
85+
restored.push({
86+
guid,
87+
pubDate: committedItem.pubDate,
88+
sourcePath: currentItem.sourcePath,
89+
title: currentItem.title,
90+
});
91+
}
92+
93+
const sortedItems = sortItemsByPubDate(parsedCurrent.items);
94+
const nextXml = serializeRss({
95+
prefix: parsedCurrent.prefix,
96+
items: sortedItems,
97+
suffix: parsedCurrent.suffix,
98+
});
99+
100+
if (!dryRun) {
101+
fs.writeFileSync('public/rss.xml', nextXml);
102+
}
103+
104+
console.log(`${dryRun ? 'WOULD_RESTORE' : 'RESTORED'} ${restored.length}`);
105+
for (const entry of restored) {
106+
console.log(`= ${entry.guid} | ${entry.pubDate} | ${entry.sourcePath ?? 'UNKNOWN_SOURCE'} | ${entry.title}`);
107+
}
108+
console.log(`${dryRun ? 'WOULD_SORT' : 'SORTED'} public/rss.xml`);

.github/skills/update-rss/check-rss-sort.cjs renamed to .github/skills/update-rss/check-rss-sort.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
const fs = require('fs');
1+
import { parseRss, readRssFile } from './rss-utils.ts';
22

3-
const xml = fs.readFileSync('public/rss.xml', 'utf8');
4-
const items = [...xml.matchAll(/<item>[\s\S]*?<\/item>/g)].map((match) => match[0]);
5-
let previousTimestamp = Infinity;
3+
const items = parseRss(readRssFile()).items;
4+
let previousTimestamp = Number.POSITIVE_INFINITY;
65
let isSorted = true;
76

87
for (const item of items) {
9-
const guid = (item.match(/<guid[^>]*>([^<]+)<\/guid>/) || [])[1];
10-
const pubDate = (item.match(/<pubDate>([^<]+)<\/pubDate>/) || [])[1];
11-
const timestamp = Date.parse(pubDate);
8+
const { guid, pubDate } = item;
9+
const timestamp = Date.parse(pubDate ?? '');
1210

1311
if (Number.isNaN(timestamp)) {
1412
console.log(`INVALID_DATE ${guid} | ${pubDate}`);

.github/skills/update-rss/diff-rss.cjs

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
itemsByGuid,
3+
parseRss,
4+
readHeadRssFile,
5+
readRssFile,
6+
} from './rss-utils.ts';
7+
8+
const committedEntries = itemsByGuid(parseRss(readHeadRssFile()).items);
9+
const currentEntries = itemsByGuid(parseRss(readRssFile()).items);
10+
const added = [];
11+
const changed = [];
12+
13+
for (const [guid, entry] of currentEntries) {
14+
if (!committedEntries.has(guid)) {
15+
added.push({ guid, ...entry });
16+
continue;
17+
}
18+
19+
const committedEntry = committedEntries.get(guid);
20+
if (committedEntry?.pubDate !== entry.pubDate) {
21+
changed.push({
22+
guid,
23+
sourcePath: entry.sourcePath,
24+
title: entry.title,
25+
oldPubDate: committedEntry?.pubDate,
26+
newPubDate: entry.pubDate,
27+
});
28+
}
29+
}
30+
31+
console.log(`ADDED ${added.length}`);
32+
for (const entry of added) {
33+
console.log(`+ ${entry.guid} | ${entry.pubDate} | ${entry.sourcePath ?? 'UNKNOWN_SOURCE'} | ${entry.title}`);
34+
}
35+
36+
console.log(`CHANGED ${changed.length}`);
37+
for (const entry of changed) {
38+
console.log(`* ${entry.guid} | ${entry.oldPubDate} => ${entry.newPubDate} | ${entry.sourcePath ?? 'UNKNOWN_SOURCE'} | ${entry.title}`);
39+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import fs from 'node:fs';
2+
import { execSync } from 'node:child_process';
3+
4+
export type RssItem = {
5+
index: number;
6+
xml: string;
7+
guid?: string;
8+
pubDate?: string;
9+
title?: string;
10+
sourcePath?: string;
11+
};
12+
13+
export type ParsedRss = {
14+
prefix: string;
15+
items: RssItem[];
16+
suffix: string;
17+
};
18+
19+
export function readRssFile(filePath = 'public/rss.xml'): string {
20+
return fs.readFileSync(filePath, 'utf8');
21+
}
22+
23+
export function readHeadRssFile(filePath = 'public/rss.xml'): string {
24+
return execSync(`git show HEAD:${filePath}`, {
25+
encoding: 'utf8',
26+
});
27+
}
28+
29+
export function extractTagValue(xml: string, tagName: string): string | undefined {
30+
const match = xml.match(new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`));
31+
return match ? match[1].trim() : undefined;
32+
}
33+
34+
export function extractTitle(xml: string): string | undefined {
35+
const cdataMatch = xml.match(/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>/);
36+
if (cdataMatch) return cdataMatch[1];
37+
return extractTagValue(xml, 'title');
38+
}
39+
40+
export function guidToSourcePath(guid: string): string | undefined {
41+
if (!guid.startsWith('https://vtbag.dev/')) return undefined;
42+
43+
const pathname = new URL(guid).pathname.replace(/\/+$/, '');
44+
const relativePath = pathname === '' ? 'index' : pathname.slice(1);
45+
const mdxPath = `src/content/docs/${relativePath}.mdx`;
46+
const mdPath = `src/content/docs/${relativePath}.md`;
47+
48+
if (fs.existsSync(mdxPath)) return mdxPath;
49+
if (fs.existsSync(mdPath)) return mdPath;
50+
51+
return `${mdxPath} | ${mdPath}`;
52+
}
53+
54+
export function parseRss(xml: string): ParsedRss {
55+
const itemMatches = [...xml.matchAll(/<item>[\s\S]*?<\/item>/g)];
56+
const firstItem = itemMatches[0];
57+
const lastItem = itemMatches.at(-1);
58+
const prefix = firstItem ? xml.slice(0, firstItem.index) : xml;
59+
const suffix = firstItem && lastItem
60+
? xml.slice(lastItem.index + lastItem[0].length)
61+
: '';
62+
63+
const items = itemMatches.map((match, index) => {
64+
const itemXml = match[0];
65+
const guid = extractTagValue(itemXml, 'guid');
66+
const pubDate = extractTagValue(itemXml, 'pubDate');
67+
const title = extractTitle(itemXml);
68+
69+
return {
70+
index,
71+
xml: itemXml,
72+
guid,
73+
pubDate,
74+
title,
75+
sourcePath: guid ? guidToSourcePath(guid) : undefined,
76+
} satisfies RssItem;
77+
});
78+
79+
return { prefix, items, suffix };
80+
}
81+
82+
export function itemsByGuid(items: RssItem[]): Map<string, RssItem> {
83+
return new Map(items.filter(item => item.guid).map(item => [item.guid as string, item]));
84+
}
85+
86+
export function replacePubDate(itemXml: string, pubDate: string): string {
87+
return itemXml.replace(
88+
/<pubDate>[\s\S]*?<\/pubDate>/,
89+
`<pubDate>${pubDate}</pubDate>`,
90+
);
91+
}
92+
93+
export function sortItemsByPubDate(items: RssItem[]): RssItem[] {
94+
return [...items].sort((left, right) => {
95+
const rightTimestamp = Date.parse(right.pubDate ?? '');
96+
const leftTimestamp = Date.parse(left.pubDate ?? '');
97+
98+
if (Number.isNaN(leftTimestamp) || Number.isNaN(rightTimestamp)) {
99+
throw new Error('Cannot sort RSS items with invalid pubDate values.');
100+
}
101+
102+
return rightTimestamp - leftTimestamp || left.index - right.index;
103+
});
104+
}
105+
106+
export function serializeRss({ prefix, items, suffix }: ParsedRss): string {
107+
if (items.length === 0) return prefix + suffix;
108+
const itemIndent = prefix.match(/(?:^|\n)([ \t]*)$/)?.[1] ?? '';
109+
const [firstItem, ...remainingItems] = items;
110+
111+
return `${prefix}${firstItem.xml}${remainingItems.map(item => `\n\n${itemIndent}${item.xml}`).join('')}${suffix}`;
112+
}

0 commit comments

Comments
 (0)