diff --git a/packages/meta-css/src/convert.ts b/packages/meta-css/src/convert.ts index e5418a44bc..c668d4c3dc 100644 --- a/packages/meta-css/src/convert.ts +++ b/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 { @@ -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"; @@ -23,6 +28,7 @@ interface ConvertOpts extends CommonOpts { include?: string[]; pretty: boolean; noHeader: boolean; + watch: boolean; } interface Scope { @@ -57,124 +63,104 @@ export const CONVERT: Command> = { }), 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 ? [] : [""], - 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, 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>( + map( + ({ input }) => <[string, Stream]>[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>, specs: CompiledSpecs) => - Object.entries(rules).map(([path, ids]) => - buildDeclsForPath(path, ids, specs) - ); - -const buildDeclsForPath = ( - selectorPath: string, - ids: Iterable, - specs: CompiledSpecs +const processInputs = ( + { logger, opts: { include, noHeader, out, pretty } }: AppCtx, + 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) => { - 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, query: string) => - query - .split(QUERY_SEP) - .reduce((acc, id) => Object.assign(acc, mediaQueryDefs[id]), {}); - const processMediaQueries = ( result: string[], { logger, specs, format, mediaQueryRules }: ProcessOpts @@ -265,3 +251,85 @@ const processSpec = ( } } }; + +const QUERY_SEP = ":"; +const PATH_SEP = "///"; + +const defScope = (parent?: Scope): Scope => ({ + state: "sel", + sel: parent ? [] : [""], + 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>, specs: CompiledSpecs) => + Object.entries(rules).map(([path, ids]) => + buildDeclsForPath(path, ids, specs) + ); + +const buildDeclsForPath = ( + selectorPath: string, + ids: Iterable, + 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) => { + 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, query: string) => + query + .split(QUERY_SEP) + .reduce((acc, id) => Object.assign(acc, mediaQueryDefs[id]), {});