nattoppet 2.1.1 → 4.0.1

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/common.ymd CHANGED
@@ -1,17 +1,40 @@
1
1
  [require]=
2
- const {args: [file]} = std_call()
2
+ const {opts: [id], args: [file]} = std_call()
3
3
  const content = read(file)
4
4
  switch (extname(file)) {
5
5
  case 'less':
6
- `<style>${render_less(content)}</style>`; break
6
+ `<style ${id ? `id="${id}"` : ''}>${render_less(content)}</style>`; break
7
7
  case 'css':
8
- `<style>${content}</style>`; break
8
+ `<style ${id ? `id="${id}"` : ''}>${content}</style>`; break
9
9
  case 'coffee':
10
- `<script>${render_coffee(content, { bare: true })}</script>`; break
10
+ `<script ${id ? `id="${id}"` : ''}>${render_coffee(content, { bare: true })}</script>`; break
11
11
  case 'js':
12
- `<script>${content}</script>`; break
12
+ `<script ${id ? `id="${id}"` : ''}>${content}</script>`; break
13
13
  case 'md':
14
14
  render_markdown(content); break
15
+ case 'wasm':
16
+ `<script ${id ? `id="${id}"` : ''}>
17
+ const ready = (async () => {
18
+ const raw = await fetch("data:application/octet-stream;base64,${read(file, 'compressed-base64')}")
19
+ const blob = await raw.blob()
20
+ const decompressed = await blob.stream().pipeThrough(new DecompressionStream("deflate-raw"))
21
+ const response = new Response(decompressed, { headers: { 'Content-Type': 'application/wasm' } })
22
+ const x = await WebAssembly.instantiateStreaming(response, {})
23
+ window.${file.match(/([^\/]+).wasm$/)[1]} = x.instance.exports
24
+ })()
25
+ window.wasm_ready = window.wasm_ready ? Promise.all([window.wasm_ready, ready]) : ready
26
+ </script>`; break
27
+ case 'json':
28
+ `<script ${id ? `id="${id}"` : ''}>
29
+ const ready = (async () => {
30
+ const raw = await fetch("data:application/octet-stream;base64,${read(file, 'compressed-base64')}")
31
+ const blob = await raw.blob()
32
+ const decompressed = await blob.stream().pipeThrough(new DecompressionStream("deflate-raw"))
33
+ const response = new Response(decompressed, { headers: { 'Content-Type': 'application/json' } })
34
+ window.${file.match(/([^\/]+).json$/)[1]} = await response.json()
35
+ })()
36
+ window.json_ready = window.json_ready ? Promise.all([window.json_ready, ready]) : ready
37
+ </script>`; break
15
38
  default:
16
39
  content; break
17
40
  }
@@ -57,14 +80,6 @@ const content = text || capture_until('\n').trim() || href
57
80
  const {args: [text]} = std_call()
58
81
  ;`<sup>${interpret(text)}</sup>`
59
82
 
60
- [$]=
61
- const tex = capture_until('$')
62
- render_katex(tex, false)
63
-
64
- [$$]=
65
- const {block} = std_call(true)
66
- render_katex(block, true)
67
-
68
83
  [eval]=
69
84
  const {block} = std_call(true)
70
85
  eval(block)
@@ -72,3 +87,8 @@ eval(block)
72
87
  [eval*]=
73
88
  const {block} = std_call(true)
74
89
  eval(block); ''
90
+
91
+ [cn]=
92
+ interpret(std_call().block)
93
+ .replace(/([\u4E00-\u9FCC\u3400-\u4DB5\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34\udf40-\udfff]|\ud86e[\udc00-\udc1d])\r?\n(?=\S)/g, '$1')
94
+ .replace(/(?<=\S)\r?\n([\u4E00-\u9FCC\u3400-\u4DB5\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\ud840-\ud868][\udc00-\udfff]|\ud869[\udc00-\uded6\udf00-\udfff]|[\ud86a-\ud86c][\udc00-\udfff]|\ud86d[\udc00-\udf34\udf40-\udfff]|\ud86e[\udc00-\udc1d])/g, '$1')
package/compiler.ts ADDED
@@ -0,0 +1,169 @@
1
+ import vm from "node:vm"
2
+ import { extname } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import * as fs from "node:fs"
5
+
6
+ const pattern = /^(?:\[(.+?)\]([:=])|\[(mixin|#slot)\] ?(.*?)\n)/m
7
+
8
+ const fetch_text_file = async (path: string) => {
9
+ const filePath = new URL(path, import.meta.url)
10
+ return fs.readFileSync(fileURLToPath(filePath), 'utf-8')
11
+ }
12
+
13
+ const file_exists = (path: string) => {
14
+ try {
15
+ const filePath = new URL(path, import.meta.url)
16
+ fs.accessSync(fileURLToPath(filePath))
17
+ return true
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ const resolve_mixin_path = (path: string): string => {
24
+ if (extname(path)) return path
25
+ // Compatibility hack: bare name only resolves to .ymd if exact file doesn't exist
26
+ if (file_exists(path)) return path
27
+ const ymdPath = path + ".ymd"
28
+ if (file_exists(ymdPath)) return ymdPath
29
+ throw `mixin not found: ${path}`
30
+ }
31
+
32
+ // tokenize also process mixins
33
+ // mixins are special direvatives
34
+ // by default, they are relative to the root directory of nattoppet, unless they are prefixed with "./"
35
+ export const tokenize = async (str: string) => {
36
+ const tokens: any[] = []
37
+
38
+ while (true) {
39
+ const m = str.match(pattern)
40
+ if (!m || m.index == null) {
41
+ if (str) tokens.push({ type: "code", content: str })
42
+ break
43
+ }
44
+
45
+ if (m.index && m.index > 0) {
46
+ tokens.push({ type: "code", content: str.substring(0, m.index) })
47
+ }
48
+
49
+ if (m[3]) {
50
+ if (m[3] === 'mixin') {
51
+ tokens.push({ type: 'mixin', path: m[4] })
52
+ } else if (m[3] === '#slot') {
53
+ tokens.push({ type: 'slot' })
54
+ }
55
+ str = str.substring(m.index + m[0].length)
56
+ continue
57
+ }
58
+
59
+ const name = m[1]
60
+ const type = m[2] == ':' ? 'ref' : 'fn'
61
+ str = str.substring(m.index + m[0].length)
62
+
63
+ const p1 = str.indexOf('\n\n')
64
+ const p2 = str.search(pattern)
65
+ const p = p1 >= 0 && p2 >= 0 ? Math.min(p1+2, p2) :
66
+ p1 < 0 && p2 < 0 ? str.length : Math.max(p1+2, p2)
67
+
68
+ const content = str.substring(0, p).trim()
69
+ str = str.substring(p)
70
+
71
+ tokens.push({ type, name, content })
72
+ }
73
+
74
+ for (let i = 0; i < tokens.length; i++) if (tokens[i].type == 'mixin') {
75
+ const resolvedPath = resolve_mixin_path(tokens[i].path)
76
+ const ext = extname(resolvedPath)
77
+
78
+ if (ext === ".ymd") {
79
+ const text = await fetch_text_file(resolvedPath)
80
+ const inlineTokens = await tokenize(text)
81
+ const slotIndices = inlineTokens
82
+ .map((t: any, idx: number) => t.type === 'slot' ? idx : -1)
83
+ .filter((idx: number) => idx >= 0)
84
+
85
+ if (slotIndices.length > 1) {
86
+ throw `multiple [#slot] in mixin ${resolvedPath}`
87
+ } else if (slotIndices.length === 1) {
88
+ const slotIdx = slotIndices[0]
89
+ const head = inlineTokens.slice(0, slotIdx)
90
+ const tail = inlineTokens.slice(slotIdx + 1)
91
+ tokens.splice(i, 1, ...head)
92
+ tokens.push(...tail)
93
+ } else {
94
+ tokens.splice(i, 1, ...inlineTokens)
95
+ }
96
+ } else {
97
+ tokens[i] = { type: "raw", content: await fetch_text_file(resolvedPath) }
98
+ }
99
+
100
+ i -= 1 // revisit i since we replaced it
101
+ }
102
+
103
+ return tokens
104
+ }
105
+
106
+ const _interpret = (str: string, env: any, defs: any[]): any => {
107
+ const p1 = str.search(/^ /m)
108
+ const p2 = str.search(/\[(.+?)\]/)
109
+ if (p1 < 0 && p2 < 0) return str
110
+
111
+ if (p2 < 0 || (p1 >= 0 && p1 < p2)) {
112
+ const head = str.substring(0, p1)
113
+ str = str.substring(p1+2)
114
+ let p = str.indexOf('\n\n')
115
+ if (p < 0) p = str.length
116
+ return head + '<p>' + _interpret(str.substring(0, p), env, defs) + '</p>' + _interpret(str.substring(p+2), env, defs)
117
+ }
118
+
119
+ const name = str.match(/\[(.+?)\]/)![1]
120
+ const i = defs.findIndex(x => x.name == name)
121
+ if (i < 0) throw `definition ${name} not found`
122
+
123
+ const def = defs[i]
124
+ switch (def.type) {
125
+ case 'fn':
126
+ env.remaining = str.substring(p2 + name.length + 2)
127
+ env.interpret = (str: string) => { // a "scoped" interpret function capable for interpreting substrings. We preserve the "remaining" property outside.
128
+ const remaining = env.remaining
129
+ const result = _interpret(str, env, defs) // We gaved full defs available on the call site (dynamic scoping) as the text to be interpreted is part of the call site text.
130
+ env.remaining = remaining
131
+ return result
132
+ }
133
+ const result = vm.runInContext('{' + def.content + '}', env)
134
+ return str.substring(0, p2) + result + _interpret(env.remaining, env, defs)
135
+ case 'ref':
136
+ return str.substring(0, p2) +
137
+ _interpret(def.content, env, defs.slice(i+1)) + // Exclude self from scope to prevent infinite recursion, but allow forward references
138
+ _interpret(str.substring(p2 + name.length + 2), env, defs)
139
+ default: throw 'unknown defs type'
140
+ }
141
+ }
142
+
143
+ export const compile = async (str: string, locals: any = {}) => {
144
+ const env = vm.createContext(locals)
145
+ for (const k in env) if (typeof(env[k]) == 'function')
146
+ env[k] = env[k].bind(env)
147
+
148
+ let tokens = await tokenize(str)
149
+ let output = ''
150
+ while (true) {
151
+ const token = tokens.shift()
152
+ if (!token) return output
153
+ switch (token.type) {
154
+ case "code":
155
+ output += _interpret(token.content, env, tokens)
156
+ break
157
+ case "raw":
158
+ output += token.content
159
+ break
160
+ }
161
+ }
162
+ }
163
+
164
+ /*
165
+ TODO:
166
+ 1. support first-class markdown-like nestable lists
167
+ 2. cleanup the spaces, carefully define when to trim
168
+ 3. require a newline before indent to open paragraph?
169
+ */
package/form.css ADDED
@@ -0,0 +1,87 @@
1
+ html {
2
+ font-family: sans-serif;
3
+ font-size: 16px;
4
+ line-height: 1.5;
5
+ text-align: justify;
6
+ hyphens: auto;
7
+ color: #33333d;
8
+ }
9
+ @media (max-width: 425px) {
10
+ html {
11
+ font-size: 15px;
12
+ }
13
+ }
14
+ body {
15
+ max-width: 625px;
16
+ margin: 0 auto;
17
+ padding: 1rem 1rem;
18
+ }
19
+ @media (max-width: 425px) {
20
+ body {
21
+ padding: 0 0.8rem;
22
+ }
23
+ }
24
+ h3 {
25
+ margin: 2rem 0 1rem;
26
+ position: relative;
27
+ border-bottom: #bbb 1px solid;
28
+ }
29
+ label {
30
+ display: block;
31
+ font-weight: 500;
32
+ font-size: 0.9rem;
33
+ }
34
+ label input:not([type]),
35
+ label input[type="text"],
36
+ label input[type="number"] {
37
+ display: block;
38
+ width: 100%;
39
+ margin: 0.1rem 0 1rem;
40
+ padding: 0.4rem;
41
+ border: 1px solid #bbb;
42
+ border-radius: 3px;
43
+ font-size: 0.9rem;
44
+ box-sizing: border-box;
45
+ }
46
+ label input[type="checkbox"] {
47
+ margin: 0.1rem 0.5rem 1rem 0;
48
+ transform: translateY(0.1rem);
49
+ }
50
+ button {
51
+ display: inline-block;
52
+ appearance: none;
53
+ background-color: transparent;
54
+ border: 1px solid #bbb;
55
+ border-radius: 0.375rem;
56
+ cursor: pointer;
57
+ line-height: 100%;
58
+ padding: 0.6rem 0.8rem;
59
+ }
60
+ button:active {
61
+ border-color: #4a4a4a;
62
+ outline: 0;
63
+ }
64
+ button:focus {
65
+ border-color: #485fc7;
66
+ outline: 0;
67
+ }
68
+ button:hover {
69
+ border-color: #b5b5b5;
70
+ }
71
+ button:focus:not(:active) {
72
+ box-shadow: rgba(72, 95, 199, 0.25) 0 0 0 0.125rem;
73
+ }
74
+ pre {
75
+ clear: both;
76
+ margin-top: 1rem;
77
+ font-family: monospace;
78
+ color: #222;
79
+ border: 1px solid #bcd;
80
+ background-color: #ebf1f5;
81
+ padding: 0.8rem;
82
+ overflow-x: auto;
83
+ font-size: 0.8rem;
84
+ }
85
+ .hidden {
86
+ opacity: 0;
87
+ }
package/form.js ADDED
@@ -0,0 +1,64 @@
1
+ function gather_inputs() {
2
+ const args = Object.create(null)
3
+
4
+ for (const e of document.querySelectorAll('input')) {
5
+ switch (e.type) {
6
+ case 'text':
7
+ args[e.name] = e.value
8
+ break;
9
+ case 'number':
10
+ args[e.name] = parseFloat(e.value)
11
+ break;
12
+ case 'checkbox':
13
+ args[e.name] = e.checked
14
+ break;
15
+ }
16
+ }
17
+
18
+ return args
19
+ }
20
+
21
+ window.addEventListener('load', async () => {
22
+ document.querySelector('button').addEventListener('click', async () => {
23
+ document.querySelector('pre').classList.remove('hidden')
24
+ document.querySelector('button').disabled = true
25
+ await new Promise(res => setTimeout(res, 0))
26
+
27
+ const args = gather_inputs()
28
+
29
+ try {
30
+ const result = await run(args)
31
+ if (result)
32
+ document.querySelector('pre').textContent = result
33
+ } catch (e) {
34
+ document.querySelector('pre').textContent = "Error: " + (e.message ?? e)
35
+ throw e
36
+ } finally {
37
+ document.querySelector('button').disabled = false
38
+ await new Promise(res => setTimeout(res, 0))
39
+ }
40
+ })
41
+
42
+ document.body.addEventListener('input', () => {
43
+ const url_params = new URLSearchParams(gather_inputs())
44
+ history.replaceState(null, '', '#' + url_params.toString())
45
+ })
46
+
47
+ if (location.hash.length > 1) {
48
+ const url_params = new URLSearchParams(location.hash.slice(1))
49
+ for (const [name, value] of url_params.entries()) {
50
+ const e = document.querySelector(`input[name="${name}"]`)
51
+ if (!e) continue
52
+
53
+ switch (e.type) {
54
+ case 'text':
55
+ case 'number':
56
+ e.value = value
57
+ break;
58
+ case 'checkbox':
59
+ e.checked = value == 'true'
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ })
package/form.ymd ADDED
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <meta name=viewport content="width=device-width, initial-scale=1.0">
3
+ <meta name=author content="ylxdzsw@gmail.com">
4
+ <meta charset="utf-8">
5
+ <title>[title] | ylxdzsw's blog</title>
6
+
7
+ <style>
8
+ [mixin] form.css
9
+ </style>
10
+
11
+ <script>
12
+ [mixin] form.js
13
+ </script>
14
+
15
+ [#slot]
16
+
17
+ <button>Run</button>
18
+ <pre class="hidden"></pre>
19
+
20
+ [h3]=
21
+ const {block: content} = std_call(true)
22
+ ;`<h3>${content}</h3>`
23
+
24
+ [text]=
25
+ const {opts: [name], args: [attr=""], block: desc} = std_call(true)
26
+ ;`<label>${interpret(desc).trim()}<input name="${name}" ${attr}></input></label>`
27
+
28
+ [number]=
29
+ const {opts: [name], args: [attr=""], block: desc} = std_call(true)
30
+ ;`<label>${interpret(desc).trim()}<input type="number" name="${name}" ${attr}></input></label>`
31
+
32
+ [checkbox]=
33
+ const {opts: [name], args: [attr=""], block: desc} = std_call(true)
34
+ ;`<label><input type="checkbox" name="${name}" ${attr}></input>${interpret(desc).trim()}</label>`
35
+
36
+ [mixin] common.ymd