|
#!/usr/bin/env node |
|
|
|
const fs = require('fs'); |
|
const path = require('path'); |
|
|
|
// Tailwind CSS の5つのブレイクポイント |
|
const BREAKPOINTS = ['2xl', 'xl', 'lg', 'md', 'sm']; |
|
|
|
// 抽出器のロジック(Rust実装に基づく) |
|
class TailwindExtractor { |
|
constructor(input) { |
|
this.input = input; |
|
this.pos = 0; |
|
} |
|
|
|
isAlphaLower(char) { |
|
return char >= 'a' && char <= 'z'; |
|
} |
|
|
|
isAlphaUpper(char) { |
|
return char >= 'A' && char <= 'Z'; |
|
} |
|
|
|
isNumber(char) { |
|
return char >= '0' && char <= '9'; |
|
} |
|
|
|
isValidStart(char) { |
|
return ( |
|
this.isAlphaLower(char) || |
|
this.isNumber(char) || |
|
char === '@' || |
|
char === '*' |
|
); |
|
} |
|
|
|
isValidUtilityChar(char) { |
|
return ( |
|
this.isAlphaLower(char) || |
|
this.isAlphaUpper(char) || |
|
this.isNumber(char) || |
|
char === '-' || |
|
char === '_' || |
|
char === '.' || |
|
char === '/' || |
|
char === '(' || |
|
char === ')' || |
|
char === '[' || |
|
char === ']' || |
|
char === ':' || |
|
char === '%' || |
|
char === '#' || |
|
char === '+' || |
|
char === '&' || |
|
char === '=' || |
|
char === '>' || |
|
char === '<' || |
|
char === ',' || |
|
char === ';' |
|
); |
|
} |
|
|
|
isWhitespace(char) { |
|
return /\s/.test(char); |
|
} |
|
|
|
extractCandidates() { |
|
const candidates = []; |
|
this.pos = 0; |
|
|
|
while (this.pos < this.input.length) { |
|
this.skipWhitespace(); |
|
if (this.pos >= this.input.length) break; |
|
|
|
const candidate = this.extractCandidate(); |
|
if (candidate) { |
|
candidates.push(candidate); |
|
} else { |
|
this.pos++; |
|
} |
|
} |
|
|
|
return candidates; |
|
} |
|
|
|
skipWhitespace() { |
|
while ( |
|
this.pos < this.input.length && |
|
this.isWhitespace(this.input[this.pos]) |
|
) { |
|
this.pos++; |
|
} |
|
} |
|
|
|
extractCandidate() { |
|
const start = this.pos; |
|
let candidate = ''; |
|
|
|
// 開始文字の検証 |
|
if (!this.isValidStart(this.input[this.pos])) { |
|
return null; |
|
} |
|
|
|
// 候補を構築 |
|
while (this.pos < this.input.length) { |
|
const char = this.input[this.pos]; |
|
|
|
if ( |
|
this.isWhitespace(char) || |
|
char === '"' || |
|
char === "'" || |
|
char === '`' |
|
) { |
|
break; |
|
} |
|
|
|
if (!this.isValidUtilityChar(char)) { |
|
break; |
|
} |
|
|
|
candidate += char; |
|
this.pos++; |
|
} |
|
|
|
// 有効な候補かチェック |
|
if (candidate.length === 0) { |
|
return null; |
|
} |
|
|
|
// バリアントとユーティリティの分離 |
|
return this.validateCandidate(candidate) ? candidate : null; |
|
} |
|
|
|
validateCandidate(candidate) { |
|
// 基本的な検証 |
|
if (candidate.length === 0) return false; |
|
if (candidate.startsWith(':') || candidate.endsWith(':')) return false; |
|
|
|
// HTMLタグの除外 |
|
if (candidate.match(/^<[^>]*>?$/)) return false; |
|
|
|
// 無効なパターンの除外 |
|
if (candidate.includes('//')) return false; |
|
if (candidate.match(/[!]{2,}/)) return false; |
|
|
|
return true; |
|
} |
|
} |
|
|
|
// ブレイクポイント置換ロジック |
|
class BreakpointReplacer { |
|
constructor() { |
|
this.breakpoints = BREAKPOINTS; |
|
} |
|
|
|
replaceBreakpoints(candidates) { |
|
return candidates.map((candidate) => this.replaceCandidate(candidate)); |
|
} |
|
|
|
replaceCandidate(candidate) { |
|
// バリアントとユーティリティを分離 |
|
const parts = candidate.split(':'); |
|
if (parts.length < 2) { |
|
return candidate; // バリアントがない場合はそのまま |
|
} |
|
|
|
const utility = parts.pop(); // 最後の部分がユーティリティ |
|
const variants = parts; |
|
|
|
// 各バリアントを処理 |
|
const processedVariants = variants.map((variant) => |
|
this.processVariant(variant), |
|
); |
|
|
|
return [...processedVariants, utility].join(':'); |
|
} |
|
|
|
processVariant(variant) { |
|
// 既に max- プレフィックスがある場合はスキップ |
|
if (variant.startsWith('max-')) { |
|
return variant; |
|
} |
|
|
|
// @ から始まるコンテナクエリはスキップ |
|
if (variant.startsWith('@')) { |
|
return variant; |
|
} |
|
|
|
// ブレイクポイントバリアントかチェック |
|
for (const bp of this.breakpoints) { |
|
if (variant === bp) { |
|
return `max-${bp}`; |
|
} |
|
} |
|
|
|
// 複雑なバリアント(modifier付きなど)の処理 |
|
const modifierMatch = variant.match(/^(.+)\/(.+)$/); |
|
if (modifierMatch) { |
|
const [, baseVariant, modifier] = modifierMatch; |
|
for (const bp of this.breakpoints) { |
|
if (baseVariant === bp) { |
|
return `max-${bp}/${modifier}`; |
|
} |
|
} |
|
} |
|
|
|
return variant; |
|
} |
|
} |
|
|
|
// HTMLファイルの処理 |
|
function processHtmlFile(filePath) { |
|
console.log(`Processing: ${filePath}`); |
|
|
|
const content = fs.readFileSync(filePath, 'utf8'); |
|
const extractor = new TailwindExtractor(content); |
|
const candidates = extractor.extractCandidates(); |
|
|
|
console.log(`Found ${candidates.length} candidates`); |
|
|
|
const replacer = new BreakpointReplacer(); |
|
const replacedCandidates = replacer.replaceBreakpoints(candidates); |
|
|
|
// 置換が必要な候補のみを表示 |
|
const changedCandidates = []; |
|
for (let i = 0; i < candidates.length; i++) { |
|
if (candidates[i] !== replacedCandidates[i]) { |
|
changedCandidates.push({ |
|
original: candidates[i], |
|
replaced: replacedCandidates[i], |
|
}); |
|
} |
|
} |
|
|
|
if (changedCandidates.length > 0) { |
|
console.log('\nBreakpoint replacements:'); |
|
changedCandidates.forEach(({ original, replaced }) => { |
|
console.log(` ${original} → ${replaced}`); |
|
}); |
|
|
|
// 実際にファイルを置換 |
|
let newContent = content; |
|
|
|
// 置換を一度にまとめて実行して重複を避ける |
|
const replacementMap = new Map(); |
|
changedCandidates.forEach(({ original, replaced }) => { |
|
replacementMap.set(original, replaced); |
|
}); |
|
|
|
// class属性とclassName属性内でのみ置換 |
|
newContent = newContent.replace( |
|
/(class(?:Name)?\s*=\s*["'])([^"']*?)(["'])/g, |
|
(match, prefix, classString, suffix) => { |
|
let updatedClassString = classString; |
|
|
|
// 各置換を適用(長いものから順に処理して部分マッチを防ぐ) |
|
const sortedReplacements = Array.from(replacementMap.entries()).sort( |
|
([a], [b]) => b.length - a.length, |
|
); |
|
|
|
sortedReplacements.forEach(([original, replaced]) => { |
|
// Tailwindクラス用の境界を考慮した置換 |
|
// 前後にスペースまたは行頭行末がある場合のみ置換 |
|
const regex = new RegExp( |
|
`(^|\\s)${escapeRegex(original)}(?=\\s|$)`, |
|
'g', |
|
); |
|
updatedClassString = updatedClassString.replace( |
|
regex, |
|
`$1${replaced}`, |
|
); |
|
}); |
|
|
|
return prefix + updatedClassString + suffix; |
|
}, |
|
); |
|
|
|
if (newContent !== content) { |
|
fs.writeFileSync(filePath, newContent, 'utf8'); |
|
console.log(`✅ File updated: ${filePath}`); |
|
} |
|
} else { |
|
console.log('No breakpoint variants to replace'); |
|
} |
|
|
|
console.log('---'); |
|
} |
|
|
|
function escapeRegex(string) { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
// ディレクトリ内のファイルを再帰的に検索 |
|
function findFilesInDirectory(dirPath) { |
|
const targetExtensions = ['.html', '.jsx', '.tsx', '.vue', '.js', '.ts']; |
|
const files = []; |
|
|
|
function searchRecursively(currentPath) { |
|
const items = fs.readdirSync(currentPath); |
|
|
|
for (const item of items) { |
|
const fullPath = path.join(currentPath, item); |
|
const stat = fs.statSync(fullPath); |
|
|
|
if (stat.isDirectory()) { |
|
// node_modules, .git などの隠しディレクトリをスキップ |
|
if ( |
|
!item.startsWith('.') && |
|
item !== 'node_modules' && |
|
item !== 'dist' && |
|
item !== 'build' |
|
) { |
|
searchRecursively(fullPath); |
|
} |
|
} else if (stat.isFile()) { |
|
// 対象の拡張子をチェック |
|
const ext = path.extname(fullPath); |
|
if (targetExtensions.includes(ext)) { |
|
files.push(fullPath); |
|
} |
|
} |
|
} |
|
} |
|
|
|
searchRecursively(dirPath); |
|
return files; |
|
} |
|
|
|
// メイン処理 |
|
function main() { |
|
const args = process.argv.slice(2); |
|
|
|
if (args.length === 0) { |
|
console.log( |
|
'Usage: tailwind-breakpoint-replacer <html-file-1> [html-file-2] ...', |
|
); |
|
console.log(''); |
|
console.log( |
|
'This script replaces Tailwind CSS breakpoint variants from sm:/md:/lg:/xl:/2xl: to max-sm:/max-md:/etc.', |
|
); |
|
console.log(''); |
|
console.log('Examples:'); |
|
console.log(' tailwind-breakpoint-replacer index.html'); |
|
console.log(' tailwind-breakpoint-replacer src/'); |
|
console.log(' tailwind-breakpoint-replacer src/**/*.html'); |
|
console.log(' npx tailwind-breakpoint-replacer index.html'); |
|
process.exit(1); |
|
} |
|
|
|
console.log('Tailwind CSS Breakpoint Replacer'); |
|
console.log( |
|
'Replacing breakpoint variants: sm: → max-sm:, md: → max-md:, etc.', |
|
); |
|
console.log(''); |
|
|
|
args.forEach((arg) => { |
|
if (!fs.existsSync(arg)) { |
|
console.error(`Error: File not found: ${arg}`); |
|
return; |
|
} |
|
|
|
const stat = fs.statSync(arg); |
|
if (stat.isDirectory()) { |
|
console.log(`Processing directory: ${arg}`); |
|
const files = findFilesInDirectory(arg); |
|
console.log(`Found ${files.length} files in directory`); |
|
|
|
files.forEach((filePath) => { |
|
try { |
|
processHtmlFile(filePath); |
|
} catch (error) { |
|
console.error(`Error processing ${filePath}:`, error.message); |
|
} |
|
}); |
|
return; |
|
} |
|
try { |
|
processHtmlFile(arg); |
|
} catch (error) { |
|
console.error(`Error processing ${arg}:`, error.message); |
|
} |
|
}); |
|
|
|
console.log('Done!'); |
|
} |
|
|
|
if (require.main === module) { |
|
main(); |
|
} |
|
|
|
module.exports = { TailwindExtractor, BreakpointReplacer }; |