refactor(skills): convert shell scripts to Node.js

Replace bash scripts with cross-platform Node.js equivalents for
mt-add-post and mt-add-tags skills. Update SKILL.md references.
This commit is contained in:
tiennm99 committed 2026-04-02 16:44:48 +07:00
1 parent 17222538a5
commit 134e3a5ff4
8 files changed
+248 -184

No files matched your search

+2 -2
View File
@@ -30,7 +30,7 @@ This skill manages the complete workflow for adding newsletter posts to a Hugo b
For each URL, run the bundled script:
```bash
.claude/skills/mt-add-post/scripts/prepare_url.sh "<url>"
node .claude/skills/mt-add-post/scripts/prepare-url.js "<url>"
```
The script handles:
@@ -53,7 +53,7 @@ Check if `content/post/YYYY/MM/DD/index.md` exists:
Run the bundled script:
```bash
.claude/skills/mt-add-post/scripts/find_newsletter_number.sh
node .claude/skills/mt-add-post/scripts/find-newsletter-number.js
```
This searches backwards from current date to find the most recent newsletter and returns the next number.
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// Find the most recent newsletter number and return the next one
// Usage: node find-newsletter-number.js
// Outputs: The next newsletter number
const fs = require("fs");
const path = require("path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
const CONTENT_DIR = path.join(PROJECT_ROOT, "content", "post");
// Extract newsletter number from file content
function extractNewsletterNumber(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const match = content.match(/Newsletter\s*#(\d+)/);
return match ? parseInt(match[1], 10) : 0;
} catch {
return 0;
}
}
// Get current date in Asia/Ho_Chi_Minh timezone
function getCurrentDate() {
const now = new Date();
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Ho_Chi_Minh",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(now);
return {
year: parts.find((p) => p.type === "year").value,
month: parts.find((p) => p.type === "month").value,
day: parts.find((p) => p.type === "day").value,
};
}
// Scan all year/month/day directories for newsletter posts
function findMostRecentNewsletter() {
let maxNumber = 0;
// List year directories, sorted descending
let years;
try {
years = fs.readdirSync(CONTENT_DIR)
.filter((d) => /^\d{4}$/.test(d))
.sort((a, b) => b.localeCompare(a));
} catch {
return 0;
}
for (const year of years) {
const yearDir = path.join(CONTENT_DIR, year);
let months;
try {
months = fs.readdirSync(yearDir)
.filter((d) => /^\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
} catch {
continue;
}
for (const month of months) {
const monthDir = path.join(yearDir, month);
let days;
try {
days = fs.readdirSync(monthDir)
.filter((d) => /^\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
} catch {
continue;
}
for (const day of days) {
const filePath = path.join(monthDir, day, "index.md");
const num = extractNewsletterNumber(filePath);
if (num > maxNumber) maxNumber = num;
}
}
// Early exit: if we found a newsletter in this year, no need to go further back
if (maxNumber > 0) break;
}
return maxNumber;
}
const mostRecent = findMostRecentNewsletter();
console.log(mostRecent + 1);
@@ -1,82 +0,0 @@
#!/bin/bash
# Find the most recent newsletter number and return the next one
# Usage: find_newsletter_number.sh
# Outputs: The next newsletter number
set -euo pipefail
# Get project root
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
CONTENT_DIR="$PROJECT_ROOT/content/post"
# Get current date in YYYY/MM/DD format
CURRENT_YEAR=$(TZ='Asia/Ho_Chi_Minh' date +%Y)
CURRENT_MONTH=$(TZ='Asia/Ho_Chi_Minh' date +%m)
CURRENT_DAY=$(TZ='Asia/Ho_Chi_Minh' date +%d)
# Function to find newsletter number in a file
extract_newsletter_number() {
local file="$1"
# Look for "Newsletter #N" in front matter title or content
grep -oP 'Newsletter\s*#\K\d+' "$file" 2>/dev/null | head -1 || echo "0"
}
# Function to search for newsletters in date order
find_most_recent_newsletter() {
local max_number=0
# Current month, going backwards by day
for day in {31..1}; do
local day_dir=$(printf "%02d" "$day")
local check_path="$CONTENT_DIR/$CURRENT_YEAR/$CURRENT_MONTH/$day_dir/index.md"
if [[ -f "$check_path" ]]; then
local num=$(extract_newsletter_number "$check_path")
if ((num > max_number)); then
max_number=$num
fi
fi
done
# Previous months in current year
for month in {12..1}; do
if [[ $month -ge $CURRENT_MONTH ]]; then
continue
fi
local month_dir=$(printf "%02d" "$month")
for day in {31..1}; do
local day_dir=$(printf "%02d" "$day")
local check_path="$CONTENT_DIR/$CURRENT_YEAR/$month_dir/$day_dir/index.md"
if [[ -f "$check_path" ]]; then
local num=$(extract_newsletter_number "$check_path")
if ((num > max_number)); then
max_number=$num
fi
fi
done
done
# Previous years (check last 5 years)
for year in $(seq $((CURRENT_YEAR - 1)) -1 $((CURRENT_YEAR - 5))); do
for month in {12..1}; do
local month_dir=$(printf "%02d" "$month")
for day in {31..1}; do
local day_dir=$(printf "%02d" "$day")
local check_path="$CONTENT_DIR/$year/$month_dir/$day_dir/index.md"
if [[ -f "$check_path" ]]; then
local num=$(extract_newsletter_number "$check_path")
if ((num > max_number)); then
max_number=$num
fi
fi
done
done
done
echo $max_number
}
# Find most recent and increment
MOST_RECENT=$(find_most_recent_newsletter)
NEXT_NUMBER=$((MOST_RECENT + 1))
echo "$NEXT_NUMBER"
@@ -0,0 +1,89 @@
#!/usr/bin/env node
// URL preparation script for mt-add-post skill
// Usage: node prepare-url.js "<url>"
// Outputs: JSON with status, clean_url, type, accessible, duplicate
const { execSync } = require("child_process");
const path = require("path");
const url = process.argv[2];
if (!url) {
console.error("Usage: node prepare-url.js <url>");
process.exit(1);
}
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
// Remove common tracking parameters
function cleanUrl(rawUrl) {
try {
const parsed = new URL(rawUrl);
const trackingParams = [
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"fbclid", "gclid", "msclkid", "mc_eid",
];
trackingParams.forEach((p) => parsed.searchParams.delete(p));
return parsed.toString();
} catch {
// If URL parsing fails, do basic string cleanup
return rawUrl.replace(/[?&](utm_[^&]*|fbclid|gclid|msclkid|mc_eid)[^&]*/g, "")
.replace(/[?&]$/, "");
}
}
// Check if URL is accessible (returns HTTP status code)
async function checkAccessibility(targetUrl) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(targetUrl, {
method: "HEAD",
redirect: "follow",
signal: controller.signal,
});
clearTimeout(timeout);
return res.status.toString();
} catch {
return "000";
}
}
// Check if URL already exists in project content
function checkDuplicate(targetUrl) {
try {
const contentDir = path.join(PROJECT_ROOT, "content");
execSync(`grep -rF "${targetUrl}" "${contentDir}"`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}
// Classify URL type by extension
function classifyUrl(targetUrl) {
const lower = targetUrl.toLowerCase();
if (/\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$/.test(lower)) return "image";
if (/\.(mp4|webm|mov|avi)(\?.*)?$/.test(lower)) return "video";
if (/\.(pdf|doc|docx|xls|xlsx)(\?.*)?$/.test(lower)) return "document";
return "article";
}
// Main
async function main() {
const cleanedUrl = cleanUrl(url);
const httpStatus = await checkAccessibility(cleanedUrl);
const accessible = httpStatus === "200";
const duplicate = checkDuplicate(cleanedUrl);
const type = classifyUrl(cleanedUrl);
console.log(JSON.stringify({
original_url: url,
clean_url: cleanedUrl,
http_status: httpStatus,
accessible,
duplicate,
type,
}, null, 2));
}
main();
@@ -1,78 +0,0 @@
#!/bin/bash
# URL preparation script for miti-add-post skill
# Usage: prepare_url.sh "<url>"
# Outputs: JSON with status, clean_url, type, accessible, duplicate
set -euo pipefail
URL="$1"
# Remove tracking parameters
clean_url() {
local url="$1"
# Remove common tracking parameters
url=$(echo "$url" | sed -E 's/[?&](utm_[^&]*|fbclid|gclid|msclkid|mc_eid)[^&]*//g')
# Clean up trailing ? or &
url=$(echo "$url" | sed -E 's/[?&]$//')
echo "$url"
}
# Check if URL is accessible (returns HTTP status code)
check_accessibility() {
local url="$1"
# Use curl to get HTTP status only
curl -s -o /dev/null -w "%{http_code}" -L --max-time 10 "$url" 2>/dev/null || echo "000"
}
# Check if URL is duplicate in project
check_duplicate() {
local url="$1"
local project_root="$2"
if grep -rF "$url" "$project_root/content" 2>/dev/null; then
echo "true"
else
echo "false"
fi
}
# Classify URL type
classify_url() {
local url="$1"
local lower_url=$(echo "$url" | tr '[:upper:]' '[:lower:]')
# Check for direct assets
if echo "$lower_url" | grep -qE '\.(png|jpg|jpeg|gif|webp|svg)(\?.*)?$'; then
echo "image"
elif echo "$lower_url" | grep -qE '\.(mp4|webm|mov|avi)(\?.*)?$'; then
echo "video"
elif echo "$lower_url" | grep -qE '\.(pdf|doc|docx|xls|xlsx)(\?.*)?$'; then
echo "document"
else
echo "article"
fi
}
# Main execution
CLEAN_URL=$(clean_url "$URL")
HTTP_STATUS=$(check_accessibility "$CLEAN_URL")
IS_ACCESSIBLE="false"
if [ "$HTTP_STATUS" = "200" ]; then
IS_ACCESSIBLE="true"
fi
# Get project root (assumes we're in .claude/skills/miti-add-post/scripts)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
IS_DUPLICATE=$(check_duplicate "$CLEAN_URL" "$PROJECT_ROOT")
URL_TYPE=$(classify_url "$CLEAN_URL")
# Output JSON
cat <<EOF
{
"original_url": "$URL",
"clean_url": "$CLEAN_URL",
"http_status": "$HTTP_STATUS",
"accessible": $IS_ACCESSIBLE,
"duplicate": $IS_DUPLICATE,
"type": "$URL_TYPE"
}
EOF
+1 -1
View File
@@ -49,7 +49,7 @@ Read the full post body. Identify:
Before generating, run:
```bash
grep -rh "^tags:" content/post/ | tr ',' '\n' | grep -oP '(?<=")[^"]+(?=")' | sort | uniq -c | sort -rn | head -40
node .claude/skills/mt-add-tags/scripts/list-existing-tags.js
```
When a proposed tag matches an existing one case-insensitively, use the existing casing.
-->
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// List existing tags in the repo ranked by frequency
// Usage: node list-existing-tags.js
// Outputs: tag count and name, sorted most-used first (top 40)
//
// NOTE: This script is NOT currently used by the mt-add-tags skill.
// Tag normalization is disabled until existing posts have standardized tags.
// To enable, uncomment step 4a in SKILL.md.
const fs = require("fs");
const path = require("path");
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
const CONTENT_DIR = path.join(PROJECT_ROOT, "content", "post");
// Recursively find all index.md files
function findIndexFiles(dir) {
const results = [];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findIndexFiles(fullPath));
} else if (entry.name === "index.md") {
results.push(fullPath);
}
}
} catch {
// skip unreadable directories
}
return results;
}
// Extract tags from frontmatter
function extractTags(filePath) {
try {
const content = fs.readFileSync(filePath, "utf-8");
const match = content.match(/^tags:\s*\[([^\]]*)\]/m);
if (!match) return [];
// Extract quoted strings from the tags array
return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
} catch {
return [];
}
}
// Count tag frequency
const tagCounts = new Map();
const files = findIndexFiles(CONTENT_DIR);
for (const file of files) {
for (const tag of extractTags(file)) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
// Sort by frequency descending, take top 40
const sorted = [...tagCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 40);
for (const [tag, count] of sorted) {
console.log(`${String(count).padStart(6)} ${tag}`);
}
@@ -1,21 +0,0 @@
#!/bin/bash
# List existing tags in the repo ranked by frequency.
# Usage: list_existing_tags.sh
# Outputs: tag count and name, sorted most-used first (top 40)
#
# NOTE: This script is NOT currently used by the mt-add-tags skill.
# Tag normalization is disabled until existing posts have standardized tags.
# To enable, uncomment step 4a in SKILL.md.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
CONTENT_DIR="$PROJECT_ROOT/content/post"
grep -rh "^tags:" "$CONTENT_DIR" \
| tr ',' '\n' \
| grep -oP '(?<=")[^"]+(?=")' \
| sort \
| uniq -c \
| sort -rn \
| head -40