ネスト記法CSSをフラット記法へ動的変換。iOS16,iOS15の少数派に対応

アルファ版

JavaScript

ネスト記法CSSをフラット記法へ動的変換。iOS16,iOS15の少数派に対応

CSSをネスト記法で書く同志へ。少数派のiOS16,iOS15の為にネスト記法をフラット記法に動的変換する。
コンセプト版、アルファ版

(2024年11月時点)
CSSネスト形式が解禁されてから既に1年が経過している。iOS17の登場は2023-09-18であり、iOS18も登場した。
Scss(Sass)は使わなくても十分構造的なCSSが書ける時代。しかし前々時代のiOS16勢とiOS15勢が居るのも事実。
いずれ無くなる少数派の為にScssをまだ使うのか。これからの未来を見据えてネスト記法CSSを書こう。
このJavaScriptは、アクセスがiOS16以下の場合にネスト形式CSSをフラット形式(旧式)に動的に置き換えるコードである。
コーダーはネスト形式CSSを書くだけで良い。事前にフラット化(トランスパイル)しなくても良い。

例: 用意したCSS

div {
	max-width: 1000px;
	margin-inline: auto;
	> h1 {
		color: blue;
		@media (width < 500px) {
			color: red;
		}
	}
}
		
変換結果が<style>タグで<head>に埋め込まれる
CSSのフラット化と、(width < 500px) 形式が (max-width: 499.98px) に変換される。
background-image: url(../image/aa.svg); などはパスも自動補正される。

<style>
div { max-width: 1000px; }
div { margin-inline: auto; }
div > h1 { color: blue; }
@media (max-width: 499.98px) { div > h1 { color: red; }}
</style>
		
使い方の例:

<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<!-- TODO: content here -->
<script defer src="./js/css_flatten_link.js" type="module"></script>
</body>
</html>
		
この例のファイル構造:

/index.html
/css/style.css
/js/css_flatten_link.js
/js/css_flattener.class.js
		
適用後:

<!doctype html>
<html>
<head>
<style>
/* フラット化されたCSSが配置される */
</style>
</head>
<body>
<!-- TODO: content here -->
<script defer src="./js/css_flatten_link.js" type="module"></script>
</body>
</html>
		
実使用について
ネスト記法で書かれたCSSはiOS16やiOS15では解釈できない為ウェブページのレイアウトが崩れて見ることも出ない。
このJavaScriptを使えば、正常にレイアウトが再現されるか、そうでなくとも見るぐらいはできるようになるだろう。
ポリフィル(Polyfill)と呼べるだけの再現性ないだろうが、ひとつの簡易サイトでは実用が確認できている。
以下はこのページをiOS16・iOS15で表示した例。CSSネスト形式がフラット化されて一応見るに堪えうる表示になる。
CSSがフラット化されたとしても、次々登場する新しい擬似クラス等、例えばhas()はiOS15は対応していない。よって意味をなさない場合が有る。
また、新しく書いたJavaScriptの対応も問題となる。よって、CSSフラット化が全てを解決するものではない。
ちょっとした簡易ページやランディングページなどで使えるのではないか。
適用前 (iOS16 iOS15 同じ)
適用後 (iOS16 iOS15 同じ)
現時点のコードは以下の通り(アルファ版): css_flattener.class.js
CSSの記法は個人差が有りますし、全てを網羅しているものではありません。
/**
 * ネスト記法の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');
	}
}
現時点のコードは以下の通り(アルファ版): css_flatten_link.js
iOS16以下を判定して処理をする。
<link>タグのcssファイルを読み取り、class CSSFlattener でフラット化。
<style>タグを生成して<head>に埋め込む。フラット化されたcssをインサート。
/**
 * CSSファイルを読み込み、CSSをフラット化して<head>内に<style>タグとして挿入する
 * ローカルパス指定のlinkが対象
 *
 * @author ao-system, Inc.
 * @date 2024-11-08
 */

import { CSSFlattener } from './css_flattener.class.js';

(() => {
	new class {
		constructor() {
			if (this.#isAtLeastChrome125()) {
				return;	//iOS17以上の場合は処理しない
			}
			document.addEventListener('DOMContentLoaded', () => { this.#flattenCSSLinks(); });
		}
		#flattenCSSLinks() {
			// すべての <link rel="stylesheet"> 要素を選択
			const allLinks = document.querySelectorAll('link[rel="stylesheet"]');
			// 相対パスの href を持つリンクのみをフィルタリング
			const links = Array.from(allLinks).filter(link => {
				const href = link.getAttribute('href');
				return href && this.#isRelativePath(href);
			});
			links.forEach(link => {
				const href = link.getAttribute('href');
				if (href) {
					// CSSファイルを非同期で取得
					fetch(href)
						.then(response => {
							if (!response.ok) {
								throw new Error(`Failed to load CSS file: ${href}`);
							}
							return response.text();
						})
						.then(nestedCssText => {
							// CSSFlattenerを初期化
							const flattener = new CSSFlattener();
							flattener.setIncludeComments(false);	// コメントを出力しない

							// CSSファイルのパスとHTMLファイルのパスを設定
							const cssFilePath = this.#getAbsolutePath(href);
							const htmlFilePath = window.location.pathname;
							flattener.setFilePaths(cssFilePath, htmlFilePath);

							// CSSをフラット化
							flattener.flatten(nestedCssText);
							const flattenedCSS = flattener.getOutput();

							// <style>タグを作成し、フラット化したCSSを挿入
							const styleElement = document.createElement('style');
							styleElement.textContent = flattenedCSS;
							document.head.appendChild(styleElement);	// <head>内に<style>タグを追加

							// 元の<link>タグを削除
							link.parentNode.removeChild(link);

							// デバッグ用に出力
							// console.log(flattenedCSS);
						})
						.catch(error => {
							console.error(error);
						});
				}
			});
		}

		//Chrome125で導入された重複した名前付きキャプチャグループをテスト chrome125以上ならtrue iOS17以上ならtrue
		#isAtLeastChrome125() {
			try {
				new RegExp('(?<year>[0-9]{4})-[0-9]{2}|[0-9]{2}-(?<year>[0-9]{4})');
				return true;
			} catch (e) {
				return false;
			}
		}

		//相対パスかどうかを判定する
		#isRelativePath(href) {
			// 絶対URL(http://、https://、//)で始まる場合は除外
			return !/^(?:[a-z]+:)?\/\//i.test(href);
		}

		// 絶対パスを取得する
		#getAbsolutePath(href) {
			const link = document.createElement('a');
			link.href = href;
			return link.pathname;
		}
	}
})();
2024年11月初版
このサイトについてのお問い合わせはエーオーシステムまでお願いいたします。
ご使用上の過失の有無を問わず、本プログラムの運用において発生した損害に対するいかなる請求があったとしても、その責任を負うものではありません。