◆ 単純な altcss っぽいのを作って loader を使ってロードしてみる

webpack の loader の仕組みは自由度高めなので 自分で独自のフォーマットのファイルを作って何かの処理をするというのが簡単にできそうです
今回は単純に altcss っぽいのを作ってみることにしました
JavaScript は複雑になりすぎて作るのは大変ですけど CSS は単純なので簡単ですから

ccss

カスタム css ってことで ccss って名前にします
機能は とりあえずありがち?なインデントベースで書けるものにしました
ついでにネスト対応と 再利用できる形で定義しておいて好きな場所に埋め込める機能も入れておきました

サンプルなこんな感じ

@@fragment abc
.p
.q
a
color: blue
.r
b
font-size: 20px
p
margin: 0
padding: 3px

.z
@@insert abc
.s &
.t &
color: purple
button
color: red
&:hover
color: green

だいたい想像どおりかと思いますが これを css 化するとこうなります

.z .p .q a{
color: blue;
}
.z .p .r b{
font-size: 20px;
}
.z .p .r p{
margin: 0;
padding: 3px;
}
.t .s .z{
color: purple;
}
.z button{
color: red;
}
.z button:hover{
color: green;
}

変換処理は これです

const nestParser = () => {
const tree = {indent: -1, list: [{}]}
const cptr = {
stack: [tree],
get list(){
return this.stack[this.stack.length - 1].list
},
get last(){
return this.list[this.list.length - 1]
},
add(...a){
this.list.push(...a)
},
nest(indent){
const new_list = []
this.last.children = new_list
this.stack.push({indent, list: new_list})
},
unnest(indent){
while(this.stack.length){
this.stack.pop()
if(this.stack[this.stack.length - 1].indent === indent){
return
}
}
throw new Error("indent not matched")
},
}
return {cptr, getRoot(){return tree.list[0].children}}
}

const detectType = str => {
if(str.includes("@@")){
return "special"
}else if(str.includes(": ")){
return "style"
}else{
return "selector"
}
}

const parse = str => {
let pre_indent = -1
const lines = str.split("\n")
const {cptr, getRoot} = nestParser()
for(const [index, line] of Object.entries(lines)){
const [, space, text] = line.match(/^(\s*)(.*)$/)
const content = text.trim()
if(!content) continue
const indent = space.length
const data = {type: detectType(content), content}
if(indent - pre_indent > 0){
cptr.nest(indent)
cptr.add(data)
}else if(indent === pre_indent){
cptr.add(data)
}else{
cptr.unnest(indent)
cptr.add(data)
}
pre_indent = indent
}
return getRoot()
}

const build = parsed => {
const styles = {}
const rec = (items, selector, ctx) => {
const context = Object.create(ctx)
while(items.length){
const item = items.shift()
if(item.type === "style"){
const sel = selector || "*"
const selector_styles = styles[sel] = styles[sel] || []
selector_styles.push(item.content)
}else if(item.type === "selector"){
if(item.children){
const new_selector = selector
? item.content.includes("&")
? item.content.replace(/&/g, selector)
: selector + " " + item.content
: item.content
rec(item.children, new_selector, context)
}
}else if(item.type === "special"){
if(item.content.startsWith("@@fragment")){
const [op, name] = item.content.split(" ")
context.fragment = context.fragment || {}
context.fragment[name] = item.children
}else if(item.content.startsWith("@@insert")){
const [op, name] = item.content.split(" ")
if(context.fragment && context.fragment[name]){
items.unshift(...context.fragment[name])
}
}
}
}
}
rec(parsed, "", null)
return Object.entries(styles).map(([selector, ss]) => {
return `${selector}{\n${ss.map(e => `\t${e};`).join("\n")}\n}`
}).join("\n")
}

console.log(build(parse(ccss_string)))

あとはこれを webpack の loader にします

loader

loader は関数で引数にファイルの中身を受け取ります
処理した結果を return で返せばいいです
返す文字列は JavaScript のコードになってる必要があります
その文字列が bundle 中に埋め込まれます

複数 loader を繋げる場合は前の loader の結果を受け取って 次の loader に渡すことになります
そのまま bundle じゃなくて 次にまだ loader が来る前提なら その loader に渡すフォーマットになってれば良いです

ccss-loader では css-loader と同じ出力にして style-loader に渡そうかとも考えましたが ccss-loader で style タグ埋め込みまでやってしまうことにしました
css-loader に合わせたほうが style-loader を使ったり css ファイルを別に作る loader を使ったりと既存 loader と組み合わせて動作を変えれたりしますが 今回は実用性あるものでもないですからね

ccss-loader のファイルはこうなります

module.exports = (src) => {
const style = build(parse(src))
return `
const s = document.createElement("style")
s.textContent = ${JSON.stringify(style)}
document.head.append(s)
`
}

// 略

上にも書いたやつなので parse とか build とかの関数は省略しました
引数の文字列から parse して build した style を埋め込みます

実際 bundle したファイル中の出力はこうなってます

/***/ "./index.ccss":
/*!********************!*\
!*** ./index.ccss ***!
\********************/
/*! no static exports found */
/***/ (function(module, exports) {


const s = document.createElement("style")
s.textContent = ".z .p .q a{\n\tcolor: blue;\n}\n.z .p .r b{\n\tfont-size: 20px;\n}\n.z .p .r p{\n\tmargin: 0;\n\tpadding: 3px;\n}\n.t .s .z{\n\tcolor: purple;\n}\n.z button{\n\tcolor: red;\n}\n.z button:hover{\n\tcolor: green;\n}"
document.head.append(s)


/***/ }),

ちゃんとスタイルが影響してることを確認するようの簡単な例です

index.js

ccss ファイルをロードして html を作るだけです

import "./index.ccss"

document.body.innerHTML = `
<div class="p">
<div class="q">
<a>p q a</a>
</div>
</div>
<div class="z">
<div class="p">
<div class="q">
<a>z p q a</a>
</div>
</div>
<button>z button</button>
</div>
<div class="t">
<div class="s">
<div class="z">t s z</div>
</div>
</div>
`

index.ccss

ccss ファイルは最初に書いた例と同じものです

@@fragment abc
.p
.q
a
color: blue
.r
b
font-size: 20px
p
margin: 0
padding: 3px

.z
@@insert abc
.s &
.t &
color: purple
button
color: red
&:hover
color: green

webpack.config.js

use で指定する loader の名前は require に入れるものと一緒です
node_modules じゃないので直接 ccss-loader の絶対パスを指定します

module.exports = {
mode: "development",
entry: {
index: "./index.js",
},
output: {
path: __dirname,
filename: "[name]-bundle.js",
},
devtool: false,
module: {
rules: [
{
test: /\.ccss$/,
use: [
__dirname + "/ccss-loader.js",
],
},
],
},
}

ccss-loader.js

ccss-loader は上に書いたものと同じですが省略せず全部書いておきます

module.exports = (src) => {
const style = build(parse(src))
return `
const s = document.createElement("style")
s.textContent = ${JSON.stringify(style)}
document.head.append(s)
`
}

const nestParser = () => {
const tree = {indent: -1, list: [{}]}
const cptr = {
stack: [tree],
get list(){
return this.stack[this.stack.length - 1].list
},
get last(){
return this.list[this.list.length - 1]
},
add(...a){
this.list.push(...a)
},
nest(indent){
const new_list = []
this.last.children = new_list
this.stack.push({indent, list: new_list})
},
unnest(indent){
while(this.stack.length){
this.stack.pop()
if(this.stack[this.stack.length - 1].indent === indent){
return
}
}
throw new Error("indent not matched")
},
}
return {cptr, getRoot(){return tree.list[0].children}}
}

const detectType = str => {
if(str.includes("@@")){
return "special"
}else if(str.includes(": ")){
return "style"
}else{
return "selector"
}
}

const parse = str => {
let pre_indent = -1
const lines = str.split("\n")
const {cptr, getRoot} = nestParser()
for(const [index, line] of Object.entries(lines)){
const [, space, text] = line.match(/^(\s*)(.*)$/)
const content = text.trim()
if(!content) continue
const indent = space.length
const data = {type: detectType(content), content}
if(indent - pre_indent > 0){
cptr.nest(indent)
cptr.add(data)
}else if(indent === pre_indent){
cptr.add(data)
}else{
cptr.unnest(indent)
cptr.add(data)
}
pre_indent = indent
}
return getRoot()
}

const build = parsed => {
const styles = {}
const rec = (items, selector, ctx) => {
const context = Object.create(ctx)
while(items.length){
const item = items.shift()
if(item.type === "style"){
const sel = selector || "*"
const selector_styles = styles[sel] = styles[sel] || []
selector_styles.push(item.content)
}else if(item.type === "selector"){
if(item.children){
const new_selector = selector
? item.content.includes("&")
? item.content.replace(/&/g, selector)
: selector + " " + item.content
: item.content
rec(item.children, new_selector, context)
}
}else if(item.type === "special"){debugger
if(item.content.startsWith("@@fragment")){
const [op, name] = item.content.split(" ")
context.fragment = context.fragment || {}
context.fragment[name] = item.children
}else if(item.content.startsWith("@@insert")){
const [op, name] = item.content.split(" ")
if(context.fragment && context.fragment[name]){
items.unshift(...context.fragment[name])
}
}
}
}
}
rec(parsed, "", null)
return Object.entries(styles).map(([selector, ss]) => {
return `${selector}{\n${ss.map(e => `\t${e};`).join("\n")}\n}`
}).join("\n")
}

index.html

webpack コマンドでバンドルした結果の index-bundle.js ファイルを単純にロードする HTML ファイルを用意します

<!doctype html>
<script src="index-bundle.js" defer></script>

結果

結果はこうなりました

ccss-loader-demo

blue, red, purple が反映されています
button にマウスを乗せると green になります

まとめ

結構簡単でしたね

use に指定する loader のところに require で指定する名前を書きます
そこでロードされるモジュールでは関数をエクスポートして
関数ではファイルの中身を受け取って JavaScript のテキストを return するだけです

簡単なものならこれで十分ですが 非同期が入ったり ソースマップも使うとなると this.callback を使ったり バイナリファイルの場合は raw を true にしたり もっと複雑なものもあります
詳しいことは公式リファレンス
https://webpack.js.org/api/loaders/