From 6925d731bf774446f0eb30463879e441d85736f3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 2 Apr 2026 16:44:48 +0700 Subject: [PATCH] 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. --- .claude/skills/mt-add-post/SKILL.md | 4 +- .../scripts/find-newsletter-number.js | 91 +++++++++++++++++++ .../scripts/find_newsletter_number.sh | 82 ----------------- .../skills/mt-add-post/scripts/prepare-url.js | 89 ++++++++++++++++++ .../skills/mt-add-post/scripts/prepare_url.sh | 78 ---------------- .claude/skills/mt-add-tags/SKILL.md | 2 +- .../mt-add-tags/scripts/list-existing-tags.js | 65 +++++++++++++ .../mt-add-tags/scripts/list_existing_tags.sh | 21 ----- 8 files changed, 248 insertions(+), 184 deletions(-) create mode 100644 .claude/skills/mt-add-post/scripts/find-newsletter-number.js delete mode 100644 .claude/skills/mt-add-post/scripts/find_newsletter_number.sh create mode 100644 .claude/skills/mt-add-post/scripts/prepare-url.js delete mode 100644 .claude/skills/mt-add-post/scripts/prepare_url.sh create mode 100644 .claude/skills/mt-add-tags/scripts/list-existing-tags.js delete mode 100644 .claude/skills/mt-add-tags/scripts/list_existing_tags.sh diff --git a/.claude/skills/mt-add-post/SKILL.md b/.claude/skills/mt-add-post/SKILL.md index ef6a2f2..9c4ce8b 100644 --- a/.claude/skills/mt-add-post/SKILL.md +++ b/.claude/skills/mt-add-post/SKILL.md @@ -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 "" +node .claude/skills/mt-add-post/scripts/prepare-url.js "" ``` 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. diff --git a/.claude/skills/mt-add-post/scripts/find-newsletter-number.js b/.claude/skills/mt-add-post/scripts/find-newsletter-number.js new file mode 100644 index 0000000..393426d --- /dev/null +++ b/.claude/skills/mt-add-post/scripts/find-newsletter-number.js @@ -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); diff --git a/.claude/skills/mt-add-post/scripts/find_newsletter_number.sh b/.claude/skills/mt-add-post/scripts/find_newsletter_number.sh deleted file mode 100644 index 09498b0..0000000 --- a/.claude/skills/mt-add-post/scripts/find_newsletter_number.sh +++ /dev/null @@ -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" diff --git a/.claude/skills/mt-add-post/scripts/prepare-url.js b/.claude/skills/mt-add-post/scripts/prepare-url.js new file mode 100644 index 0000000..aa30f67 --- /dev/null +++ b/.claude/skills/mt-add-post/scripts/prepare-url.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// URL preparation script for mt-add-post skill +// Usage: node prepare-url.js "" +// 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 "); + 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(); diff --git a/.claude/skills/mt-add-post/scripts/prepare_url.sh b/.claude/skills/mt-add-post/scripts/prepare_url.sh deleted file mode 100644 index b299e15..0000000 --- a/.claude/skills/mt-add-post/scripts/prepare_url.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -# URL preparation script for miti-add-post skill -# Usage: prepare_url.sh "" -# 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 < 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}`); +} diff --git a/.claude/skills/mt-add-tags/scripts/list_existing_tags.sh b/.claude/skills/mt-add-tags/scripts/list_existing_tags.sh deleted file mode 100644 index d741b31..0000000 --- a/.claude/skills/mt-add-tags/scripts/list_existing_tags.sh +++ /dev/null @@ -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