Skip to content

Commit

Permalink
feat(meta-css): add input file watching
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Dec 15, 2023
1 parent 9963480 commit 717dbe1
Showing 1 changed file with 171 additions and 103 deletions.
274 changes: 171 additions & 103 deletions packages/meta-css/src/convert.ts
@@ -1,6 +1,8 @@
// thing:no-export
import type { IObjectOf } from "@thi.ng/api";
import { flag, string, strings, type Command } from "@thi.ng/args";
import { peek } from "@thi.ng/arrays";
import { delayed } from "@thi.ng/compose";
import { assert, illegalArgs, illegalState } from "@thi.ng/errors";
import { readJSON, readText } from "@thi.ng/file-io";
import {
Expand All @@ -11,7 +13,10 @@ import {
type Format,
} from "@thi.ng/hiccup-css";
import { type ILogger } from "@thi.ng/logger";
import { map } from "@thi.ng/transducers";
import { Stream, reactive, sync } from "@thi.ng/rstream";
import { Z3 } from "@thi.ng/strings";
import { assocObj, map } from "@thi.ng/transducers";
import { watch } from "fs";
import { resolve } from "path";
import type { AppCtx, CommonOpts, CompiledSpecs } from "./api.js";
import { maybeWriteText } from "./utils.js";
Expand All @@ -23,6 +28,7 @@ interface ConvertOpts extends CommonOpts {
include?: string[];
pretty: boolean;
noHeader: boolean;
watch: boolean;
}

interface Scope {
Expand Down Expand Up @@ -57,124 +63,104 @@ export const CONVERT: Command<ConvertOpts, CommonOpts, AppCtx<ConvertOpts>> = {
}),
include: strings({
alias: "I",
desc: "Include CSS files (only in 1st input)",
desc: "Include CSS files (prepend)",
}),
pretty: flag({ alias: "p", desc: "Pretty print CSS" }),
noHeader: flag({ desc: "Don't emit generated header comment" }),
watch: flag({
alias: "w",
desc: "Watch input files for changes",
}),
},
fn: async ({
logger,
opts: { specs: $specs, out, pretty, noHeader, include },
inputs,
}) => {
const specs = readJSON(resolve($specs), logger);
const procOpts: ProcessOpts = {
logger,
specs,
format: pretty ? PRETTY : COMPACT,
mediaQueryIDs: new Set(Object.keys(specs.media)),
mediaQueryRules: {},
plainRules: {},
};
const bundle: string[] = include
? include.map((x) => readText(resolve(x), logger).trim())
: [];
if (!noHeader) {
bundle.push(
`/*! generated by thi.ng/meta-css @ ${new Date().toISOString()} */`
fn: async (ctx) => {
const specs = readJSON(resolve(ctx.opts.specs), ctx.logger);
if (ctx.opts.watch) {
await watchInputs(ctx, specs);
} else {
processInputs(
ctx,
specs,
ctx.inputs.map((file) => readText(resolve(file), ctx.logger))
);
}
for (let file of inputs) {
processSpec(readText(resolve(file), logger), procOpts);
}
processPlainRules(bundle, procOpts);
processMediaQueries(bundle, procOpts);
maybeWriteText(out, bundle, logger);
},
};

const QUERY_SEP = ":";
const PATH_SEP = "///";

const defScope = (parent?: Scope): Scope => ({
state: "sel",
sel: parent ? [] : ["<root>"],
path: "",
parent,
});

const endScope = (ctx: ProcessCtx) => {
const isEmpty = !ctx.curr.sel.length;
assert(!!ctx.curr.parent, "stack underflow");
ctx.scopes.pop();
if (ctx.scopes.length > 0) {
ctx.curr = peek(ctx.scopes);
if (!isEmpty && ctx.curr.state === "nest") {
ctx.scopes.push((ctx.curr = defScope(ctx.curr)));
}
} else {
ctx.scopes.push((ctx.curr = defScope(ctx.root)));
const watchInputs = async (ctx: AppCtx<ConvertOpts>, specs: CompiledSpecs) => {
let active = true;
const close = () => {
ctx.logger.info("closing watchers...");
inputs.forEach((i) => i.watcher.close());
active = false;
};
const inputs = ctx.inputs.map((file, i) => {
file = resolve(file);
const input = reactive(readText(file, ctx.logger), {
id: `in${Z3(i)}`,
});
return {
input,
watcher: watch(file, {}, (event) => {
if (event === "change") input.next(readText(file, ctx.logger));
else {
ctx.logger.warn(`input removed:`, file);
close();
}
}),
};
});
sync({
src: assocObj<Stream<string>>(
map(
({ input }) => <[string, Stream<string>]>[input.id, input],
inputs
)
),
}).subscribe({
next(ins) {
processInputs(
ctx,
specs,
Object.keys(ins)
.sort()
.map((k) => ins[k])
);
},
});
// close watchers when Ctrl-C is pressed
process.on("SIGINT", close);
while (active) {
await delayed(null, 1000);
}
};

const buildScopePath = (scopes: Scope[]) =>
scopes.map((x) => x.sel.join(",")).join(PATH_SEP);

const buildDecls = (rules: IObjectOf<Set<string>>, specs: CompiledSpecs) =>
Object.entries(rules).map(([path, ids]) =>
buildDeclsForPath(path, ids, specs)
);

const buildDeclsForPath = (
selectorPath: string,
ids: Iterable<string>,
specs: CompiledSpecs
const processInputs = (
{ logger, opts: { include, noHeader, out, pretty } }: AppCtx<ConvertOpts>,
specs: CompiledSpecs,
inputs: string[]
) => {
const root: any[] = [];
let parent = root;
const parts = selectorPath.split(PATH_SEP);
for (let i = 0; i < parts.length; i++) {
const curr = parts[i].split(",");
if (i == parts.length - 1) {
curr.push(Object.assign({}, ...map((x) => specs.defs[x], ids)));
}
parent.push(curr);
parent = curr;
const procOpts: ProcessOpts = {
logger,
specs,
format: pretty ? PRETTY : COMPACT,
mediaQueryIDs: new Set(Object.keys(specs.media)),
mediaQueryRules: {},
plainRules: {},
};
const bundle: string[] = include
? include.map((x) => readText(resolve(x), logger).trim())
: [];
if (!noHeader) {
bundle.push(
`/*! generated by thi.ng/meta-css @ ${new Date().toISOString()} */`
);
}
return root[0];
};

const parseMediaQueryToken = (token: string, mediaQueries: Set<string>) => {
if (/^::?/.test(token)) return { token };
const idx = token.lastIndexOf(QUERY_SEP);
if (idx < 0) return { token };
const query = token.substring(0, idx);
const parts = query.split(QUERY_SEP);
if (!parts.every((x) => mediaQueries.has(x)))
illegalArgs(`invalid media query in token: ${token}`);
return { token: token.substring(idx + 1), query };
inputs.forEach((input) => processSpec(input, procOpts));
processPlainRules(bundle, procOpts);
processMediaQueries(bundle, procOpts);
maybeWriteText(out, bundle, logger);
};

/**
* Takes an object of media query definitions and a query ID (possibly composed
* of multiple media query IDs, separated by `:`). Splits the query ID into
* components, looks up definition for each sub-query ID and returns merged
* media query definition.
*
* @remarks
* See
* [`at_media()`](https://docs.thi.ng/umbrella/hiccup-css/functions/at_media.html)
* for details
*
* @param mediaQueryDefs
* @param query
* @returns
*/
const mergeMediaQueries = (mediaQueryDefs: IObjectOf<any>, query: string) =>
query
.split(QUERY_SEP)
.reduce((acc, id) => Object.assign(acc, mediaQueryDefs[id]), <any>{});

const processMediaQueries = (
result: string[],
{ logger, specs, format, mediaQueryRules }: ProcessOpts
Expand Down Expand Up @@ -265,3 +251,85 @@ const processSpec = (
}
}
};

const QUERY_SEP = ":";
const PATH_SEP = "///";

const defScope = (parent?: Scope): Scope => ({
state: "sel",
sel: parent ? [] : ["<root>"],
path: "",
parent,
});

const endScope = (ctx: ProcessCtx) => {
const isEmpty = !ctx.curr.sel.length;
assert(!!ctx.curr.parent, "stack underflow");
ctx.scopes.pop();
if (ctx.scopes.length > 0) {
ctx.curr = peek(ctx.scopes);
if (!isEmpty && ctx.curr.state === "nest") {
ctx.scopes.push((ctx.curr = defScope(ctx.curr)));
}
} else {
ctx.scopes.push((ctx.curr = defScope(ctx.root)));
}
};

const buildScopePath = (scopes: Scope[]) =>
scopes.map((x) => x.sel.join(",")).join(PATH_SEP);

const buildDecls = (rules: IObjectOf<Set<string>>, specs: CompiledSpecs) =>
Object.entries(rules).map(([path, ids]) =>
buildDeclsForPath(path, ids, specs)
);

const buildDeclsForPath = (
selectorPath: string,
ids: Iterable<string>,
specs: CompiledSpecs
) => {
const root: any[] = [];
let parent = root;
const parts = selectorPath.split(PATH_SEP);
for (let i = 0; i < parts.length; i++) {
const curr = parts[i].split(",");
if (i == parts.length - 1) {
curr.push(Object.assign({}, ...map((x) => specs.defs[x], ids)));
}
parent.push(curr);
parent = curr;
}
return root[0];
};

const parseMediaQueryToken = (token: string, mediaQueries: Set<string>) => {
if (/^::?/.test(token)) return { token };
const idx = token.lastIndexOf(QUERY_SEP);
if (idx < 0) return { token };
const query = token.substring(0, idx);
const parts = query.split(QUERY_SEP);
if (!parts.every((x) => mediaQueries.has(x)))
illegalArgs(`invalid media query in token: ${token}`);
return { token: token.substring(idx + 1), query };
};

/**
* Takes an object of media query definitions and a query ID (possibly composed
* of multiple media query IDs, separated by `:`). Splits the query ID into
* components, looks up definition for each sub-query ID and returns merged
* media query definition.
*
* @remarks
* See
* [`at_media()`](https://docs.thi.ng/umbrella/hiccup-css/functions/at_media.html)
* for details
*
* @param mediaQueryDefs
* @param query
* @returns
*/
const mergeMediaQueries = (mediaQueryDefs: IObjectOf<any>, query: string) =>
query
.split(QUERY_SEP)
.reduce((acc, id) => Object.assign(acc, mediaQueryDefs[id]), <any>{});

0 comments on commit 717dbe1

Please sign in to comment.