gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit0369106e0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre0369106e/lib/markdown.hl

15.2 KB

  1. // markdown.hl — MARKDOWN IN TEXT FIELDS (ticket tickets.worldapi.org#6, mission 014; CONCEPT.md
  2. // "Data formats": Markdown inside text fields). The summary, comment texts and state notes are
  3. // Markdown; the ticket page renders them. Statics only, server realm.
  4. //
  5. // SAFE BY CONSTRUCTION: the text is parsed HERE into plain data — blocks of spans — and the
  6. // View (components/markdown.hl) builds elements from it. Every character of the source ends
  7. // up in a TEXT node (hl:web escapes it); there is no HTML string anywhere, so raw HTML in the
  8. // source (`<script>`, `<img onerror>`) is shown as the text it is. A link's href is taken only
  9. // if `safeHref` accepts it: http(s):, mailto:, a same-site path (`/…`, not `//…`) or `#…`;
  10. // anything else (`javascript:`, `data:`, `vbscript:`, relative `foo`, quotes, spaces, control
  11. // characters) stays plain text.
  12. //
  13. // WHAT IS UNDERSTOOD (the subset ticket #6 names — headings, lists, code, links, emphasis):
  14. // blocks: `#`…`######` headings, `-`/`*`/`+` and `1.`/`1)` list items (one level; deeper
  15. // indentation is flattened; a list may interrupt a paragraph — the import writes
  16. // them that way), ``` / ~~~ fenced code, paragraphs (blank-line separated; their
  17. // single line breaks are KEPT — the page showed them before, `white-space: pre-wrap`)
  18. // inline: `code` (any backtick run), [text](url), <https://…>, bare http(s):// URLs,
  19. // *em* _em_ **strong** __strong__ ***both***, backslash escapes of punctuation
  20. // Not understood (shown as typed): block quotes, tables, images, nested lists, HTML.
  21. //
  22. // A block: { isP | isH1 | isH2 | isH3 | isUl | isOl | isCode, spans, items: [{ spans }],
  23. // start (ol), code (text of a code block) }
  24. // A span: { text, isText | isStrong | isEm | isStrongEm | isCode | isLink, href }
  25. static NL = "
  26. "
  27. static isSpaceC = (c) => { return c == 32 || c == 9 || c == 10 || c == 13 }
  28. static isAlnumC = (c) => { return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c > 127 }
  29. static isPunctC = (c) => { return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126) }
  30. // ---- links ------------------------------------------------------------------------------
  31. // the href of a link, or null when it is not one we let through
  32. static safeHref = (u) => {
  33. if (u == null) { return null }
  34. t = ('' + u).trim()
  35. if (t == '' || t.length > 2000) { return null }
  36. let i = 0
  37. while (i < t.length) {
  38. let c = t.charCodeAt(i)
  39. // no spaces / control characters, no quotes, no < > ` and no backslash
  40. if (c <= 32 || c == 127 || c == 34 || c == 39 || c == 60 || c == 62 || c == 96 || c == 92) { return null }
  41. i = i + 1
  42. }
  43. low = t.toLowerCase()
  44. if (low.startsWith('https://') && t.length > 8) { return t }
  45. if (low.startsWith('http://') && t.length > 7) { return t }
  46. if (low.startsWith('mailto:') && t.length > 7) { return t }
  47. if (t.startsWith('/') && !t.startsWith('//')) { return t }
  48. if (t.startsWith('#')) { return t }
  49. return null
  50. }
  51. static isUrlStart = (s, i) => {
  52. low = s.slice(i, i + 8).toLowerCase()
  53. return low.startsWith('https://') || low.startsWith('http://')
  54. }
  55. // where a bare URL starting at i ends: up to whitespace or < > " `, minus trailing punctuation
  56. // (and a closing parenthesis that has no opening one inside the URL)
  57. static isUrlStop = (c) => { return isSpaceC(c) || c == 60 || c == 62 || c == 34 || c == 96 }
  58. static urlEnd = (s, i) => {
  59. let end = i
  60. while (end < s.length && !isUrlStop(s.charCodeAt(end))) { end = end + 1 }
  61. let trimming = true
  62. while (trimming && end > i) {
  63. let c = s.charCodeAt(end - 1)
  64. if (c == 46 || c == 44 || c == 59 || c == 58 || c == 33 || c == 63 || c == 39 || c == 42 || c == 95) {
  65. end = end - 1
  66. } else if (c == 41) {
  67. let opens = 0
  68. let closes = 0
  69. let k = i
  70. while (k < end) {
  71. if (s.charCodeAt(k) == 40) { opens = opens + 1 }
  72. if (s.charCodeAt(k) == 41) { closes = closes + 1 }
  73. k = k + 1
  74. }
  75. if (closes > opens) { end = end - 1 } else { trimming = false }
  76. } else {
  77. trimming = false
  78. }
  79. }
  80. return end
  81. }
  82. // `[label](url)` at i → { label, href (null = not allowed), end } or null (not a link)
  83. static linkAt = (s, i) => {
  84. let j = i + 1
  85. let found = -1
  86. while (j < s.length && found < 0) {
  87. let c = s.charCodeAt(j)
  88. if (c == 91) { return null }
  89. if (c == 93) { found = j }
  90. j = j + 1
  91. }
  92. if (found < 0 || found == i + 1) { return null }
  93. if (found + 1 >= s.length || s.charCodeAt(found + 1) != 40) { return null }
  94. k = s.indexOf(')', found + 2)
  95. if (k < 0) { return null }
  96. let target = s.slice(found + 2, k).trim()
  97. // an optional title after a space is dropped: [x](url "title")
  98. sp = target.indexOf(' ')
  99. if (sp > 0) { target = target.slice(0, sp) }
  100. return { label = s.slice(i + 1, found) href = safeHref(target) end = k + 1 }
  101. }
  102. // ---- spans --------------------------------------------------------------------------------
  103. static textSpan = (t, strong, em) => {
  104. return { text = t isText = !strong && !em isStrong = strong && !em isEm = em && !strong isStrongEm = strong && em isCode = false isLink = false href = '' }
  105. }
  106. static codeSpan = (t) => {
  107. return { text = t isText = false isStrong = false isEm = false isStrongEm = false isCode = true isLink = false href = '' }
  108. }
  109. static linkSpan = (t, h) => {
  110. return { text = t isText = false isStrong = false isEm = false isStrongEm = false isCode = false isLink = true href = h }
  111. }
  112. // the index of a run of EXACTLY n characters c at or after `at`, or -1. `esc`: a character
  113. // after a backslash does not count (emphasis; inside `code` a backslash is just a backslash)
  114. static runAt = (s, at, c, n, esc) => {
  115. let i = at
  116. while (i < s.length) {
  117. if (esc && s.charCodeAt(i) == 92) {
  118. i = i + 2
  119. } else if (s.charCodeAt(i) == c) {
  120. let k = i
  121. while (k < s.length && s.charCodeAt(k) == c) { k = k + 1 }
  122. if (k - i == n) { return i }
  123. i = k
  124. } else {
  125. i = i + 1
  126. }
  127. }
  128. return -1
  129. }
  130. // is there a CLOSING delimiter run (exactly n of c, not after a space; `_` not before a
  131. // letter/digit) after `at`?
  132. static hasCloser = (s, at, c, n) => {
  133. let i = at
  134. while (i < s.length) {
  135. let r = runAt(s, i, c, n, true)
  136. if (r < 0) { return false }
  137. let prev = r > 0 ? s.charCodeAt(r - 1) : 32
  138. let next = r + n < s.length ? s.charCodeAt(r + n) : 32
  139. if (r > at && !isSpaceC(prev) && (c != 95 || !isAlnumC(next))) { return true }
  140. i = r + n
  141. }
  142. return false
  143. }
  144. static inlineSpans = (src) => {
  145. out = []
  146. if (src == null || src == '') { return out }
  147. s = '' + src
  148. n = s.length
  149. let buf = ''
  150. let strong = false
  151. let em = false
  152. let strongC = 0
  153. let emC = 0
  154. let i = 0
  155. while (i < n) {
  156. let c = s.charCodeAt(i)
  157. if (c == 92 && i + 1 < n && isPunctC(s.charCodeAt(i + 1))) {
  158. // a backslash escape: the next punctuation character as itself
  159. buf = buf + s[i + 1]
  160. i = i + 2
  161. } else if (c == 96) {
  162. // `code`: a run of backticks up to the same run
  163. let k = i
  164. while (k < n && s.charCodeAt(k) == 96) { k = k + 1 }
  165. let close = runAt(s, k, 96, k - i, false)
  166. if (close < 0) {
  167. buf = buf + s.slice(i, k)
  168. i = k
  169. } else {
  170. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  171. buf = ''
  172. let code = s.slice(k, close).replaceAll(NL, ' ')
  173. if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() != '') { code = code.slice(1, code.length - 1) }
  174. out.push(codeSpan(code))
  175. i = close + (k - i)
  176. }
  177. } else if (c == 91) {
  178. // [label](url)
  179. let l = linkAt(s, i)
  180. if (l == null) {
  181. buf = buf + '['
  182. i = i + 1
  183. } else if (l.href == null) {
  184. buf = buf + s.slice(i, l.end)
  185. i = l.end
  186. } else {
  187. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  188. buf = ''
  189. out.push(linkSpan(l.label, l.href))
  190. i = l.end
  191. }
  192. } else if (c == 60 && i + 1 < n && isUrlStart(s, i + 1)) {
  193. // <https://…>
  194. let e = s.indexOf('>', i + 1)
  195. let url = e > 0 ? s.slice(i + 1, e) : ''
  196. let h = safeHref(url)
  197. if (e < 0 || h == null) {
  198. buf = buf + '<'
  199. i = i + 1
  200. } else {
  201. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  202. buf = ''
  203. out.push(linkSpan(url, h))
  204. i = e + 1
  205. }
  206. } else if ((c == 104 || c == 72) && (i == 0 || !isAlnumC(s.charCodeAt(i - 1))) && isUrlStart(s, i)) {
  207. // a bare http(s):// URL
  208. let e = urlEnd(s, i)
  209. let url = s.slice(i, e)
  210. let h = safeHref(url)
  211. if (h == null) {
  212. buf = buf + s[i]
  213. i = i + 1
  214. } else {
  215. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  216. buf = ''
  217. out.push(linkSpan(url, h))
  218. i = e
  219. }
  220. } else if (c == 42 || c == 95) {
  221. // * and _: emphasis (1), strong (2), both (3)
  222. let k = i
  223. while (k < n && s.charCodeAt(k) == c) { k = k + 1 }
  224. let run = k - i
  225. let prev = i > 0 ? s.charCodeAt(i - 1) : 32
  226. let next = k < n ? s.charCodeAt(k) : 32
  227. let wantStrong = run == 2 || run == 3
  228. let wantEm = run == 1 || run == 3
  229. let canOpen = !isSpaceC(next) && (c == 42 || !isAlnumC(prev))
  230. let canClose = !isSpaceC(prev) && (c == 42 || !isAlnumC(next))
  231. let closes = canClose && run <= 3 && (!wantStrong || (strong && strongC == c)) && (!wantEm || (em && emC == c))
  232. let opens = !closes && canOpen && run <= 3 && (!wantStrong || !strong) && (!wantEm || !em) && hasCloser(s, k, c, run)
  233. if (closes || opens) {
  234. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  235. buf = ''
  236. if (wantStrong) {
  237. strong = opens
  238. strongC = c
  239. }
  240. if (wantEm) {
  241. em = opens
  242. emC = c
  243. }
  244. } else {
  245. buf = buf + s.slice(i, k)
  246. }
  247. i = k
  248. } else {
  249. buf = buf + s[i]
  250. i = i + 1
  251. }
  252. }
  253. if (buf != '') { out.push(textSpan(buf, strong, em)) }
  254. return out
  255. }
  256. // ---- blocks -------------------------------------------------------------------------------
  257. static noCr = (line) => { return line.length > 0 && line.charCodeAt(line.length - 1) == 13 ? line.slice(0, line.length - 1) : line }
  258. static indentOf = (line) => {
  259. let i = 0
  260. while (i < line.length && (line.charCodeAt(i) == 32 || line.charCodeAt(i) == 9)) { i = i + 1 }
  261. return i
  262. }
  263. // a heading line (≤ 3 spaces in, 1–6 '#', a space or the end) → { level, text } or null
  264. static headingOf = (line) => {
  265. if (indentOf(line) > 3) { return null }
  266. t = line.trim()
  267. let k = 0
  268. while (k < t.length && t.charCodeAt(k) == 35) { k = k + 1 }
  269. if (k == 0 || k > 6) { return null }
  270. if (k < t.length && t.charCodeAt(k) != 32 && t.charCodeAt(k) != 9) { return null }
  271. let body = t.slice(k).trim()
  272. // a closing run of '#' after a space goes
  273. let e = body.length
  274. while (e > 0 && body.charCodeAt(e - 1) == 35) { e = e - 1 }
  275. if (e == 0) { body = '' } else if (e < body.length && (body.charCodeAt(e - 1) == 32 || body.charCodeAt(e - 1) == 9)) { body = body.slice(0, e).trim() }
  276. return { level = k text = body }
  277. }
  278. // a list item line → { ordered, start, text } or null
  279. static itemOf = (line) => {
  280. t = line.trim()
  281. if (t.length >= 2 && (t.charCodeAt(0) == 45 || t.charCodeAt(0) == 42 || t.charCodeAt(0) == 43) && (t.charCodeAt(1) == 32 || t.charCodeAt(1) == 9)) {
  282. return { ordered = false start = 1 text = t.slice(2).trim() }
  283. }
  284. if (t.length == 1 && (t == '-' || t == '*' || t == '+')) { return { ordered = false start = 1 text = '' } }
  285. let k = 0
  286. while (k < t.length && k < 9 && t.charCodeAt(k) >= 48 && t.charCodeAt(k) <= 57) { k = k + 1 }
  287. if (k == 0 || k >= t.length) { return null }
  288. d = t.charCodeAt(k)
  289. if (d != 46 && d != 41) { return null }
  290. if (k + 1 < t.length && t.charCodeAt(k + 1) != 32 && t.charCodeAt(k + 1) != 9) { return null }
  291. return { ordered = true start = toNumber(t.slice(0, k)) text = t.slice(k + 1).trim() }
  292. }
  293. // a code fence line (``` or ~~~, ≥ 3) → its run (e.g. '````') or null
  294. static fenceOf = (line) => {
  295. if (indentOf(line) > 3) { return null }
  296. t = line.trim()
  297. if (t.length < 3) { return null }
  298. c = t.charCodeAt(0)
  299. if (c != 96 && c != 126) { return null }
  300. let k = 0
  301. while (k < t.length && t.charCodeAt(k) == c) { k = k + 1 }
  302. if (k < 3) { return null }
  303. // an info string after ``` must not contain a backtick
  304. if (c == 96 && t.slice(k).includes('`')) { return null }
  305. return t.slice(0, k)
  306. }
  307. static paraBlock = (lines) => {
  308. let spans = inlineSpans(lines.join(NL))
  309. return { isP = true isH1 = false isH2 = false isH3 = false isUl = false isOl = false isCode = false spans = spans items = [] start = 1 code = '' }
  310. }
  311. static headingBlock = (level, text) => {
  312. let spans = inlineSpans(text)
  313. return { isP = false isH1 = level == 1 isH2 = level == 2 isH3 = level >= 3 isUl = false isOl = false isCode = false spans = spans items = [] start = 1 code = '' }
  314. }
  315. static codeBlock = (lines) => {
  316. text = lines.join(NL)
  317. return { isP = false isH1 = false isH2 = false isH3 = false isUl = false isOl = false isCode = true spans = [] items = [] start = 1 code = text }
  318. }
  319. static listBlock = (ordered, first, itemLines) => {
  320. let items = []
  321. for (lines of itemLines) {
  322. let spans = inlineSpans(lines.join(NL))
  323. items.push({ spans = spans })
  324. }
  325. return { isP = false isH1 = false isH2 = false isH3 = false isUl = !ordered isOl = ordered isCode = false spans = [] items = items start = first code = '' }
  326. }
  327. // the Markdown text → blocks (see the header)
  328. static markdownBlocks = (text) => {
  329. out = []
  330. if (text == null || text == '') { return out }
  331. lines = ('' + text).split(NL)
  332. let para = []
  333. let inList = false
  334. let listOrdered = false
  335. let listStart = 1
  336. let items = []
  337. let blankBefore = false
  338. let i = 0
  339. while (i < lines.length) {
  340. let line = noCr(lines[i])
  341. let fence = fenceOf(line)
  342. let h = fence == null ? headingOf(line) : null
  343. let it = fence == null && h == null ? itemOf(line) : null
  344. if (fence != null) {
  345. if (para.length > 0) { out.push(paraBlock(para)) }
  346. para = []
  347. if (inList) { out.push(listBlock(listOrdered, listStart, items)) }
  348. inList = false
  349. items = []
  350. let code = []
  351. let j = i + 1
  352. let closed = false
  353. let fc = fence.charCodeAt(0)
  354. while (j < lines.length && !closed) {
  355. let l = noCr(lines[j])
  356. let f = fenceOf(l)
  357. if (f != null && f.charCodeAt(0) == fc && f.length >= fence.length && l.trim() == f) {
  358. closed = true
  359. } else {
  360. code.push(l)
  361. }
  362. j = j + 1
  363. }
  364. out.push(codeBlock(code))
  365. i = j
  366. blankBefore = false
  367. } else if (line.trim() == '') {
  368. if (para.length > 0) { out.push(paraBlock(para)) }
  369. para = []
  370. blankBefore = true
  371. i = i + 1
  372. } else if (h != null) {
  373. if (para.length > 0) { out.push(paraBlock(para)) }
  374. para = []
  375. if (inList) { out.push(listBlock(listOrdered, listStart, items)) }
  376. inList = false
  377. items = []
  378. out.push(headingBlock(h.level, h.text))
  379. blankBefore = false
  380. i = i + 1
  381. } else if (it != null) {
  382. if (para.length > 0) { out.push(paraBlock(para)) }
  383. para = []
  384. if (inList && listOrdered != it.ordered) {
  385. out.push(listBlock(listOrdered, listStart, items))
  386. inList = false
  387. items = []
  388. }
  389. if (!inList) {
  390. inList = true
  391. listOrdered = it.ordered
  392. listStart = it.start
  393. items = []
  394. }
  395. items.push([it.text])
  396. blankBefore = false
  397. i = i + 1
  398. } else if (inList && (!blankBefore || indentOf(line) >= 2)) {
  399. // a wrapped item line (or an indented paragraph of the item): onto the last item
  400. let last = items[items.length - 1]
  401. last.push(line.trim())
  402. items[items.length - 1] = last
  403. blankBefore = false
  404. i = i + 1
  405. } else {
  406. if (inList) { out.push(listBlock(listOrdered, listStart, items)) }
  407. inList = false
  408. items = []
  409. para.push(line.trim())
  410. blankBefore = false
  411. i = i + 1
  412. }
  413. }
  414. if (para.length > 0) { out.push(paraBlock(para)) }
  415. if (inList) { out.push(listBlock(listOrdered, listStart, items)) }
  416. return out
  417. }

Branches

Latest commits

  • 0369106emission 010 (code order) 4/4: README file map + import order + 'Same output' test, STATUS (entry, lessons, how to verify), LOG, report; tests/realdata-baseline.mjs + realdata-compare*.py (a cleanup answers the same on live data), tests/letcount.pymre
  • a75e0279mission 010 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (456 lets → plain declarations; Hybriel refuses a plain declaration inside a loop on its 2nd pass). gate 249/0, connect 60/0, real-data reads identical, a 50-step write sequence (API + faces) identical to the old codemre
  • e9d5c618mission 010 (code order) 2/4: one lib/ file per topic — store.hl split into projects / tickets (+ relations) / events / tickets-helpers, util.hl shared helpers (env, storage dir, URLs, sorts, Vienna time), the function routes out of project.hl into lib/api.hl (thin; auth/filters/Accept in api-helpers.hl), invite + member-removal logic out of the faces/routes into invites.hl / tickets.hl; project.hl is the map. /login/callback gets req + the session store by reference. gate 249/0, connect 60/0, real-data reads identicalmre
  • 97e269b5mission 010 (code order) 1/4: .hl files out of the root — lib/ (store, users, connections, invites, migrate, markdown, mdview, import = ticketfile, util = localtime, jsoncheck, api-helpers = api), tools/import.hl, components/styles.hl; import paths only. gate 249/0, connect 60/0, real-data reads identicalmre
  • 38f9d10ftickets: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gate 249/0, connect 60/0mre
  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre