/**
* ネスト記法のCSSをフラットなCSSに変換する
* @author ao-system, Inc.
* @date 2024-11-08
*/
export class CSSFlattener {
#cssString = ''; // 元のCSS文字列
#lines = []; // CSS文字列を行ごとに分割した配列
#selectorStack = []; // 現在のセレクタのスタック(各レベルでのセレクタ配列)
#outputLines = []; // 変換後のCSS行を格納する配列
#blockStack = []; // 現在のブロック(セレクタやat-rule)のスタック
#inCommentBlock = false; // コメントブロック内か否かのフラグ
#inKeyframesBlock = false; // @keyframesブロック内か否かのフラグ
#keyframesBraceCount = 0; // @keyframesブロック内の波括弧のカウント
#includeComments = true; // コメントを出力するか否かのフラグ
#cssFilePath = ''; // CSSファイルのパス
#htmlFilePath = ''; // HTMLファイルのパス
constructor() {
}
// コメントを出力するか否かのフラグを設定する
setIncludeComments(includeComments) {
this.#includeComments = includeComments;
}
// CSSファイルのパスとHTMLファイルのパスを設定する
setFilePaths(cssFilePath, htmlFilePath) {
this.#cssFilePath = cssFilePath;
this.#htmlFilePath = htmlFilePath;
}
// セレクタを処理して、親セレクタとの組み合わせを行い、フルセレクタを生成
#processSelector(selector) {
let parentSelectors = this.#selectorStack[this.#selectorStack.length - 1] || [''];
// カンマ区切りのセレクタを分割
let selectors = selector.split(',').map(s => s.trim());
// 親セレクタとの組み合わせを行う
let combinedSelectors = [];
for (let parent of parentSelectors) {
for (let sel of selectors) {
let fullSelector = '';
if (sel.startsWith('>')) {
// 直下の子セレクタの場合
sel = sel.slice(1).trim();
fullSelector = parent ? `${parent} > ${sel}` : `> ${sel}`;
} else if (sel.includes('&')) {
// 親セレクタの参照を置換
fullSelector = parent ? sel.replace(/&/g, parent) : sel.replace(/&/g, '');
} else {
// 通常の子孫セレクタの場合
fullSelector = parent ? `${parent} ${sel}` : sel;
}
combinedSelectors.push(fullSelector.trim());
}
}
return combinedSelectors;
}
// カスタムなメディアクエリの条件を標準の形式に変換
#convertMediaQuery(condition) {
// (width < 550px) を (max-width: 549.98px) に変換
condition = condition.replace(/\(width\s*<\s*(\d+\.?\d*)px\)/g, (match, p1) => {
let value = parseFloat(p1) - 0.02;
return `(max-width: ${value}px)`;
});
// (width <= 550px) を (max-width: 550px) に変換
condition = condition.replace(/\(width\s*<=\s*(\d+\.?\d*)px\)/g, (match, p1) => {
return `(max-width: ${p1}px)`;
});
// (width > 550px) を (min-width: 550.02px) に変換
condition = condition.replace(/\(width\s*>\s*(\d+\.?\d*)px\)/g, (match, p1) => {
let value = parseFloat(p1) + 0.02;
return `(min-width: ${value}px)`;
});
// (width >= 550px) を (min-width: 550px) に変換
condition = condition.replace(/\(width\s*>=\s*(\d+\.?\d*)px\)/g, (match, p1) => {
return `(min-width: ${p1}px)`;
});
return condition;
}
// @keyframesブロック内の不要なスペースを削除して、1行の文字列に結合
#minifyKeyframes(lines) {
return lines.map(line => line.trim()).join('');
}
// CSS 内の url() を正しいパスに置換する
#adjustUrls(cssText) {
const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/g; //'
const cssDir = this.#cssFilePath.substring(0, this.#cssFilePath.lastIndexOf('/'));
const htmlDir = this.#htmlFilePath.substring(0, this.#htmlFilePath.lastIndexOf('/'));
return cssText.replace(urlPattern, (match, quote, url) => {
// 絶対URLの場合はそのまま
if (/^(data:|http:\/\/|https:\/\/|\/\/)/.test(url)) {
return `url(${quote}${url}${quote})`;
}
// 相対パスを解決
const resolvedUrl = this.#relativePath(cssDir, htmlDir, url);
return `url(${quote}${resolvedUrl}${quote})`;
});
}
// 相対パスを計算するヘルパーメソッド
#relativePath(fromDir, toDir, relativeUrl) {
// CSSファイル内のURLを絶対パスに変換
const absoluteUrl = this.#resolvePath(relativeUrl, fromDir + '/');
// HTMLファイルからの相対パスを計算
const relativePath = this.#getRelativePath(toDir + '/', absoluteUrl);
return relativePath;
}
// パスを解決するヘルパーメソッド
#resolvePath(relativePath, basePath) {
const stack = basePath.split('/');
const parts = relativePath.split('/');
stack.pop(); // 現在のファイル名(または空文字)を削除
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '.' || parts[i] === '') continue;
if (parts[i] === '..') {
if (stack.length > 1) stack.pop();
} else {
stack.push(parts[i]);
}
}
return stack.join('/');
}
// from から to への相対パスを計算する
#getRelativePath(from, to) {
const fromParts = from.split('/').filter(Boolean);
const toParts = to.split('/').filter(Boolean);
while (fromParts.length && toParts.length && fromParts[0] === toParts[0]) {
fromParts.shift();
toParts.shift();
}
const up = fromParts.map(() => '..');
return up.concat(toParts).join('/');
}
// メインの処理関数。入力されたCSSを解析し、フラットなCSSに変換
flatten(cssString) {
this.#cssString = this.#adjustUrls(cssString); // URL を調整
this.#lines = this.#cssString.split('\n');
this.#selectorStack = [];
this.#outputLines = [];
this.#blockStack = [];
this.#inCommentBlock = false;
this.#inKeyframesBlock = false;
this.#keyframesBraceCount = 0;
let keyframesLines = [];
let selectorBuffer = '';
let collectingSelectors = false;
let i = 0;
while (i < this.#lines.length) {
let line = this.#lines[i];
let trimmedLine = line.trim();
// コメントブロック内の場合
if (this.#inCommentBlock) {
if (this.#includeComments) {
this.#outputLines.push(line);
}
if (trimmedLine.endsWith('*/')) {
this.#inCommentBlock = false;
}
i++;
continue;
}
// コメントブロックの開始
if (trimmedLine.startsWith('/*')) {
this.#inCommentBlock = true;
if (this.#includeComments) {
this.#outputLines.push(line);
}
if (trimmedLine.endsWith('*/')) {
// 同じ行でコメントが終了
this.#inCommentBlock = false;
}
i++;
continue;
}
// @keyframesブロックの開始
if (trimmedLine.startsWith('@keyframes')) {
this.#inKeyframesBlock = true;
this.#keyframesBraceCount = 0;
keyframesLines = [];
if (trimmedLine.endsWith('{')) {
this.#keyframesBraceCount++;
}
keyframesLines.push(trimmedLine);
i++;
continue;
}
// @keyframesブロック内の処理
if (this.#inKeyframesBlock) {
// 行を収集し、波括弧の数をカウント
if (trimmedLine.includes('{')) {
this.#keyframesBraceCount += (trimmedLine.match(/{/g) || []).length;
}
if (trimmedLine.includes('}')) {
this.#keyframesBraceCount -= (trimmedLine.match(/}/g) || []).length;
}
keyframesLines.push(trimmedLine);
if (this.#keyframesBraceCount === 0) {
// ブロックの終了、不要なスペースを削除して出力
let minifiedKeyframes = this.#minifyKeyframes(keyframesLines);
this.#outputLines.push(minifiedKeyframes);
this.#inKeyframesBlock = false;
}
i++;
continue;
}
// 空行はスキップ
if (trimmedLine.length === 0) {
i++;
continue;
}
// 既にフラット化されたCSSルールを検出
if (this.#isFlatCSSRule(trimmedLine)) {
// 複数行に渡る可能性があるので、ブロックの終わりまで読み込む
let flatCSSLines = [];
let braceCount = 0;
do {
flatCSSLines.push(this.#lines[i]);
if (this.#lines[i].includes('{')) {
braceCount += (this.#lines[i].match(/{/g) || []).length;
}
if (this.#lines[i].includes('}')) {
braceCount -= (this.#lines[i].match(/}/g) || []).length;
}
i++;
} while (braceCount > 0 && i < this.#lines.length);
// そのまま出力
this.#outputLines.push(flatCSSLines.join('\n'));
continue;
}
// セレクタ行の処理
if (collectingSelectors) {
// セレクタリストの続き
selectorBuffer += ' ' + trimmedLine;
if (trimmedLine.endsWith('{')) {
// セレクタリストの終了
selectorBuffer = selectorBuffer.slice(0, -1).trim(); // '{' を削除
let combinedSelectors = this.#processSelector(selectorBuffer);
this.#selectorStack.push(combinedSelectors);
this.#blockStack.push({ type: 'selector', name: combinedSelectors });
selectorBuffer = '';
collectingSelectors = false;
}
i++;
continue;
}
if (trimmedLine.endsWith(',')) {
// セレクタリストの開始(複数行にわたる)
collectingSelectors = true;
selectorBuffer = trimmedLine.slice(0, -1).trim() + ',';
i++;
continue;
}
if (trimmedLine.endsWith('{')) {
// セレクタリストが一行で完結している場合
let selector = trimmedLine.slice(0, -1).trim();
let combinedSelectors = this.#processSelector(selector);
this.#selectorStack.push(combinedSelectors);
this.#blockStack.push({ type: 'selector', name: combinedSelectors });
i++;
continue;
}
// ブロックの終了
if (trimmedLine === '}') {
let lastBlock = this.#blockStack.pop();
if (lastBlock && lastBlock.type === 'selector') {
this.#selectorStack.pop();
}
i++;
continue;
}
// プロパティ行の処理
if (this.#selectorStack.length > 0) {
let currentSelectors = this.#selectorStack[this.#selectorStack.length - 1];
let atRules = this.#blockStack.filter(block => block.type === 'atRule').map(block => block.name);
let propertyLine = trimmedLine;
if (atRules.length > 0) {
// at-rule内のプロパティ
let atRulePrefix = atRules.join(' { ') + ' { ';
let closingBraces = ' }'.repeat(atRules.length);
for (let selector of currentSelectors) {
this.#outputLines.push(`${atRulePrefix}${selector} { ${propertyLine} }${closingBraces}`);
}
} else {
// 通常のプロパティ
for (let selector of currentSelectors) {
this.#outputLines.push(`${selector} { ${propertyLine} }`);
}
}
}
i++;
}
}
// 既にフラット化されているCSSルールを検出する
#isFlatCSSRule(line) {
// 単純な正規表現でフラットなCSSルールを検出
const flatCSSRulePattern = /^[^\{\}]+\{\s*[^{}\n]+\s*\}$/;
return flatCSSRulePattern.test(line);
}
// 変換後のCSSを取得
getOutput() {
return this.#outputLines.join('\n');
}
}