@kaathewise/ssg 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@kaathewise/ssg",
3
+ "description": "Djot, Typst & Vite components static site generator",
4
+ "version": "0.1.0-alpha.0",
5
+ "license": "MPL",
6
+ "author": "Andrej Kolčin",
7
+ "type": "module",
8
+ "scripts": {
9
+ "lint": "biome check --fix --unsafe",
10
+ "check": "biome check",
11
+ "smoke": "cd examples/basic/; node ../../src/index.js"
12
+ },
13
+ "dependencies": {
14
+ "@djot/djot": "^0.3.2",
15
+ "@tailwindcss/postcss": "^4.1.11",
16
+ "chokidar": "^4.0.3",
17
+ "handlebars": "^4.7.8",
18
+ "postcss": "^8.5.6",
19
+ "vite": "^7.0.2"
20
+ },
21
+ "files": [
22
+ "src/"
23
+ ]
24
+ }
package/src/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+
5
+ import { find_dir_with_file } from "./util.js"
6
+
7
+ export default {
8
+ root: undefined,
9
+
10
+ async init() {
11
+ const [root, config_path] = await find_dir_with_file(
12
+ process.cwd(),
13
+ "ssg.config.js",
14
+ )
15
+ if (config_path === null) {
16
+ throw new Error("No config file")
17
+ }
18
+ const module = await import(config_path)
19
+ this.options = module.default
20
+
21
+ this.root = path.resolve(root)
22
+ },
23
+
24
+ get pages() {
25
+ return path.join(this.root, "pages/")
26
+ },
27
+
28
+ get target() {
29
+ return path.join(this.root, "target/")
30
+ },
31
+
32
+ get tree() {
33
+ return path.join(this.target, "tree/")
34
+ },
35
+
36
+ get assets() {
37
+ return path.join(this.tree, "assets/")
38
+ },
39
+
40
+ get gen_assets() {
41
+ return path.join(this.assets, "generated/")
42
+ },
43
+ }
package/src/djot.js ADDED
@@ -0,0 +1,91 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import djot from "@djot/djot"
4
+
5
+ import config from "./config.js"
6
+ import { CACHE } from "./typst.js"
7
+
8
+ export function parse(src) {
9
+ return djot.parse(src)
10
+ }
11
+
12
+ export function metadata(doc) {
13
+ const block = doc.children[0]
14
+
15
+ if (block.tag === "raw_block" && block.format === "metadata") {
16
+ return JSON.parse(block.text)
17
+ }
18
+
19
+ return null
20
+ }
21
+
22
+ export async function render(doc) {
23
+ await apply_filter(doc, typst_filter)
24
+ await apply_filter(doc, classes_filter)
25
+
26
+ return `<!doctype html>\n ${djot.renderHTML(doc)}`
27
+ }
28
+
29
+ async function apply_filter(element, filter) {
30
+ if (element.children) {
31
+ for (let i = 0; i < element.children.length; i += 1) {
32
+ const result = await apply_filter(
33
+ element.children[i],
34
+ filter,
35
+ )
36
+ if (result) {
37
+ element.children[i] = result
38
+ }
39
+ }
40
+ }
41
+
42
+ if (filter instanceof Function) {
43
+ return await filter(element)
44
+ }
45
+
46
+ for (const [tag, func] of Object.entries(filter)) {
47
+ if (tag === element.tag) {
48
+ return await func(element)
49
+ }
50
+ }
51
+ }
52
+
53
+ async function apply_func(element, func) {
54
+ const output = await func(element)
55
+ if (output) {
56
+ for (const [key, value] of Object.entries(output)) {
57
+ element[key] = value
58
+ }
59
+ }
60
+ }
61
+
62
+ const typst_filter = {
63
+ inline_math: async el => {
64
+ const formula = el.text
65
+ const path = await CACHE.insert(formula)
66
+ return {
67
+ tag: "raw_inline",
68
+ format: "html",
69
+ text: `<img src="/${path}" class=math-inline>`,
70
+ }
71
+ },
72
+
73
+ display_math: async el => {
74
+ const formula = el.text
75
+ const path = await CACHE.insert(formula)
76
+ return {
77
+ tag: "raw_inline",
78
+ format: "html",
79
+ text: `<p class=math-container><img src="/${path}" class=math-display></p>`,
80
+ }
81
+ },
82
+ }
83
+
84
+ async function classes_filter(element) {
85
+ if (element.attributes?.class) {
86
+ await fs.appendFile(
87
+ path.join(config.target, "classes"),
88
+ `${element.attributes.class}\n`,
89
+ )
90
+ }
91
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { fork } from "node:child_process"
2
+ import { promises as fs } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import Proc from "./proc.js"
6
+
7
+ import config from "./config.js"
8
+ await config.init()
9
+
10
+ const vite = Proc.launch("vite.js")
11
+ const tailwind = Proc.launch("tailwind.js")
12
+ const pages = Proc.launch("render.js")
package/src/page.js ADDED
@@ -0,0 +1,73 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import Handlebars from "handlebars"
4
+
5
+ import config from "./config.js"
6
+ import * as djot from "./djot.js"
7
+ import { find_dir_with_file } from "./util.js"
8
+
9
+ export class Page {
10
+ constructor(src) {
11
+ this.src = src
12
+
13
+ const parsed = path.parse(path.relative("pages/", src))
14
+ parsed.base = null // it overrules ext and name
15
+
16
+ if (parsed.ext === ".dj") {
17
+ this.type = "djot"
18
+
19
+ if (parsed.name === "index") {
20
+ parsed.ext = "html"
21
+ } else {
22
+ parsed.ext = null
23
+ }
24
+ } else if (parsed.ext === ".html") {
25
+ this.type = "html"
26
+ // left as-is
27
+ } else {
28
+ throw new Error(
29
+ `The file must be HTML or Djot, got ${src}`,
30
+ )
31
+ }
32
+ this.dst = path.join(config.tree, path.format(parsed))
33
+ }
34
+
35
+ async raw() {
36
+ return fs.readFile(this.src, "utf8")
37
+ }
38
+
39
+ async template() {
40
+ const [_, template_path] = await find_dir_with_file(
41
+ path.dirname(this.src),
42
+ "template.html.hbs",
43
+ )
44
+ const template_raw = await fs.readFile(template_path, "utf8")
45
+ const template = Handlebars.compile(template_raw)
46
+ return template
47
+ }
48
+
49
+ async render() {
50
+ const raw = await this.raw()
51
+
52
+ switch (this.type) {
53
+ case "djot": {
54
+ return await this.#render_djot(raw)
55
+ }
56
+ case "html":
57
+ return raw
58
+ }
59
+ }
60
+
61
+ async #render_djot(raw) {
62
+ const template = await this.template()
63
+ const doc = djot.parse(raw)
64
+ const metadata = djot.metadata(doc)
65
+ const body = await djot.render(doc)
66
+ return template({ body, metadata })
67
+ }
68
+
69
+ async write() {
70
+ await fs.mkdir(path.dirname(this.dst), { recursive: true })
71
+ await fs.writeFile(this.dst, await this.render())
72
+ }
73
+ }
package/src/proc.js ADDED
@@ -0,0 +1,56 @@
1
+ // Main process behavior handling
2
+
3
+ import { fork } from "node:child_process"
4
+ import path from "node:path"
5
+ import readline from "node:readline"
6
+
7
+ import { dirname } from "./util.js"
8
+
9
+ function is_shutdown_key(key) {
10
+ if (!key) {
11
+ return false
12
+ }
13
+
14
+ return key.name.toLowerCase() === "q" || (key.name === "c" && key.ctrl)
15
+ }
16
+
17
+ const Proc = {
18
+ children: [],
19
+
20
+ init() {
21
+ readline.emitKeypressEvents(process.stdin)
22
+ process.stdin.setRawMode(true)
23
+
24
+ process.stdin.on("keypress", (chunk, key) => {
25
+ if (is_shutdown_key(key)) {
26
+ this.shutdown()
27
+ }
28
+ })
29
+
30
+ process.stdin.on("data", key => {
31
+ if (key === "q" || key === "Q") {
32
+ this.shutdown()
33
+ }
34
+ })
35
+
36
+ process.on("SIGINT", () => this.shutdown())
37
+ },
38
+
39
+ launch(name) {
40
+ const location = path.join(dirname(), name)
41
+ const child = fork(location)
42
+ this.children.push(child)
43
+ return child
44
+ },
45
+
46
+ shutdown() {
47
+ for (const child of this.children) {
48
+ child.kill()
49
+ }
50
+ process.exit()
51
+ },
52
+ }
53
+
54
+ Proc.init()
55
+
56
+ export default Proc
package/src/render.js ADDED
@@ -0,0 +1,42 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import chokidar from "chokidar"
4
+
5
+ import config from "./config.js"
6
+ import { Page } from "./page.js"
7
+ import { silence_warning } from "./util.js"
8
+
9
+ async function update(file) {
10
+ const page = new Page(file)
11
+ await page.write()
12
+ }
13
+
14
+ async function* pages() {
15
+ const pattern = path.join(config.pages, "**/*.dj")
16
+ const files = fs.glob(pattern)
17
+
18
+ for await (const djot_path of files) {
19
+ yield new Page(djot_path)
20
+ }
21
+ }
22
+
23
+ async function update_all() {
24
+ for await (const page of pages()) {
25
+ await page.write()
26
+ }
27
+ }
28
+
29
+ silence_warning("ExperimentalWarning")
30
+ await config.init()
31
+ await fs.rm(config.target, { recursive: true, force: true })
32
+ await update_all()
33
+
34
+ const watcher = chokidar.watch("pages/", {
35
+ persistent: true,
36
+ ignoreInitial: true,
37
+ })
38
+
39
+ watcher.on("all", async (event, file) => {
40
+ await update(file)
41
+ console.log(`${file} updated`)
42
+ })
@@ -0,0 +1,32 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import process from "node:process"
4
+ import tailwindcss from "@tailwindcss/postcss"
5
+ import chokidar from "chokidar"
6
+ import postcss from "postcss"
7
+
8
+ import config from "./config.js"
9
+
10
+ async function run() {
11
+ const css = await fs.readFile("style.css", "utf8")
12
+ const result = postcss([tailwindcss]).process(css, {
13
+ from: "style.css",
14
+ to: path.join(config.gen_assets, "style.css"),
15
+ })
16
+ await result
17
+
18
+ fs.writeFile(path.join(config.gen_assets, "style.css"), result.css)
19
+ }
20
+
21
+ await config.init()
22
+ await run()
23
+
24
+ const watcher = chokidar.watch(["style.css", "pages/", "target/classes"], {
25
+ persistent: true,
26
+ ignoreInitial: true,
27
+ })
28
+
29
+ watcher.on("all", async (event, file_path) => {
30
+ await run()
31
+ console.log("TailwindCSS updated")
32
+ })
package/src/typst.js ADDED
@@ -0,0 +1,59 @@
1
+ import { spawn } from "node:child_process"
2
+ import { createHash } from "node:crypto"
3
+ import { promises as fs } from "node:fs"
4
+ import path from "node:path"
5
+
6
+ import config from "./config.js"
7
+ import { file_exists } from "./util.js"
8
+
9
+ function dir() {
10
+ return path.join(config.gen_assets, "typst/")
11
+ }
12
+
13
+ function svg_file_path(formula) {
14
+ return path.format({ dir: dir(), name: hash(formula), ext: "svg" })
15
+ }
16
+
17
+ export const CACHE = {
18
+ async contains(formula) {
19
+ return file_exists(svg_file_path(formula))
20
+ },
21
+
22
+ // Returns the relative path to the file
23
+ async insert(formula) {
24
+ if (!(await this.contains(formula))) {
25
+ render(formula)
26
+ }
27
+ return path.relative(config.tree, svg_file_path(formula))
28
+ },
29
+ }
30
+
31
+ function hash(text) {
32
+ return createHash("sha256").update(text).digest("hex")
33
+ }
34
+
35
+ export async function render(formula) {
36
+ // TODO: this should probably go elsewhere
37
+ await fs.mkdir(dir(), { recursive: true })
38
+
39
+ const typst = `\
40
+ #set page(width: auto, height: auto, margin: (x: 0pt, y: 5pt))
41
+ #set text(size: 16pt)
42
+
43
+ $${formula}$
44
+ `
45
+
46
+ const dst = svg_file_path(formula)
47
+ const child = spawn("typst", ["compile", "--format", "svg", "-", dst], {
48
+ stdio: ["pipe", null, null],
49
+ })
50
+
51
+ child.stderr.on("data", data => {
52
+ console.log(data.toString("utf8"))
53
+ })
54
+
55
+ child.stdin.write(typst)
56
+ child.stdin.end()
57
+
58
+ await child
59
+ }
package/src/util.js ADDED
@@ -0,0 +1,48 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import url from "node:url"
4
+
5
+ export async function file_exists(file_path) {
6
+ try {
7
+ await fs.access(file_path)
8
+ return true
9
+ } catch (error) {
10
+ return false
11
+ }
12
+ }
13
+
14
+ export async function find_dir_with_file(start_dir, name) {
15
+ let current_dir = start_dir
16
+
17
+ while (true) {
18
+ const file_path = path.join(current_dir, name)
19
+ if (await file_exists(file_path)) {
20
+ return [current_dir, file_path]
21
+ }
22
+
23
+ const parent_dir = path.dirname(current_dir)
24
+ if (parent_dir === current_dir) {
25
+ return [null, null]
26
+ }
27
+ current_dir = parent_dir
28
+ }
29
+ }
30
+
31
+ export function filename() {
32
+ return url.fileURLToPath(import.meta.url)
33
+ }
34
+
35
+ export function dirname() {
36
+ return path.dirname(filename())
37
+ }
38
+
39
+ export function silence_warning(name) {
40
+ const originalEmitWarning = process.emitWarning
41
+
42
+ process.emitWarning = (warning, type, code, ctor) => {
43
+ if (type === name) {
44
+ return
45
+ }
46
+ originalEmitWarning(warning, type, code, ctor)
47
+ }
48
+ }
package/src/vite.js ADDED
@@ -0,0 +1,34 @@
1
+ import path from "node:path"
2
+ import { build } from "vite"
3
+
4
+ import config from "./config.js"
5
+
6
+ async function run() {
7
+ await build(vite_config(["js/main.js", "js/other.js"]))
8
+ }
9
+
10
+ function vite_config(files) {
11
+ const entries = files.reduce((acc, file) => {
12
+ const name = path.parse(file).name
13
+ acc[name] = file
14
+ return acc
15
+ }, {})
16
+
17
+ return {
18
+ build: {
19
+ minify: false,
20
+ lib: {
21
+ entry: entries,
22
+ // only build ES
23
+ fileName: (format, entry) => `${entry}.js`,
24
+ formats: ["es"],
25
+ },
26
+ rollupOptions: {},
27
+ outDir: path.join(config.gen_assets, "vite/"),
28
+ watch: "./js/",
29
+ },
30
+ }
31
+ }
32
+
33
+ await config.init()
34
+ await run()