mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

own-list-view.ts

Language TypeScript23.4 KB568 lines

apps/dashboard/src/own-list-view.ts

  1. /**
  2. * The private list, rendered: the lines one person logs for themselves.
  3. *
  4. * What this file is careful about: every other surface on this board turns five
  5. * live sources into numbers, and its hardest job is keeping an absent number
  6. * from rendering as a zero. This one has exactly one reader (its author) and
  7. * two hard jobs instead.
  8. *
  9. * The first is that "empty" means something different here. Everywhere else an
  10. * empty list is a claim about the world, and the prime invariant exists so that
  11. * "we looked and there is nothing" cannot be confused with "no source covers
  12. * this" or "the fetch failed". Here an empty list is a claim about the owner:
  13. * it means they have not written anything. That is a true zero and must read as
  14. * one, while a store that is missing or unreadable must still read as neither.
  15. * Four facts, four sentences, and none of them silence.
  16. *
  17. * The second is that none of it may reach anybody else. The pages this board
  18. * serves are pre-rendered per viewer by a cron and read back from KV; this block
  19. * is stamped into the viewer's own page at serve time, so no private text is
  20. * ever written to KV at all. `ownListBlock` re-derives the person from the
  21. * viewer and refuses to render a store belonging to anybody else.
  22. *
  23. * It is also why "overdue" is computed here and now. A pre-rendered overdue
  24. * flag would be decided by the 6 a.m. cron, would be wrong by lunchtime, and
  25. * would be silently wrong, which is the worst kind. Every load re-decides.
  26. *
  27. * Runtime-neutral (rule 7). The Worker imports this to stamp the block into a
  28. * pre-rendered page; the D1 access that feeds it lives in
  29. * apps/worker/src/own-list.ts, which is the only part that needs Worker types.
  30. */
  31. import { MERIDIAN_TZ, calendarDate, localDay, localStamp } from '@meridian/ninety-client'
  32. import {
  33. OWN_OPEN_CAP,
  34. OWN_PRIORITIES,
  35. OWN_PRIORITY_LABEL,
  36. OWN_TEXT_CAP,
  37. type OwnPriority,
  38. hasOwnList,
  39. ownListPersonFor,
  40. ownPriorityOrder,
  41. } from '../../../config/own-list.js'
  42. import { esc, join, map } from './html.js'
  43. import { parseWorkKey, workKeyLabel } from './work-key.js'
  44. import type { Viewer } from './viewer.js'
  45. /** One stored line. Mirrors the `own_item` table, camel-cased. */
  46. export interface OwnItem {
  47. id: number
  48. text: string
  49. /** YYYY-MM-DD, or null for "no date", which is not the same as overdue. */
  50. due: string | null
  51. priority: OwnPriority
  52. /** `order:MD2639582` / `task:41837`, or null. */
  53. linkKey: string | null
  54. createdAt: string
  55. updatedAt: string
  56. edits: number
  57. /** ISO 8601 UTC when it was completed, or null while it is open. */
  58. doneAt: string | null
  59. }
  60. /**
  61. * What a read of the store came back with.
  62. *
  63. * `unconfigured` and `failed` stay distinct for the same reason they do in
  64. * CheckinStore: collapsing them into one "unavailable" would print the same
  65. * sentence in the two cases where the next action differs: one is a deploy
  66. * step, the other is a database that answered and refused.
  67. */
  68. export type OwnStore =
  69. | {
  70. state: 'ok'
  71. /** Whose list this is. Checked against the viewer before anything renders. */
  72. person: string
  73. open: OwnItem[]
  74. done: OwnItem[]
  75. /** More open lines exist than were fetched (rule 5). Should be impossible. */
  76. truncated: boolean
  77. /** Completed lines beyond the folded block's window. */
  78. doneHidden: number
  79. }
  80. | { state: 'unconfigured' }
  81. | { state: 'failed'; error: string }
  82. export type OwnOutcome =
  83. | 'added'
  84. | 'edited'
  85. | 'unchanged'
  86. | 'completed'
  87. | 'reopened'
  88. | 'empty'
  89. | 'toolong'
  90. | 'badaction'
  91. | 'badid'
  92. | 'baddate'
  93. | 'badlink'
  94. | 'badpriority'
  95. | 'notfound'
  96. | 'stale'
  97. | 'full'
  98. | 'failed'
  99. const OUTCOMES: readonly OwnOutcome[] = [
  100. 'added',
  101. 'edited',
  102. 'unchanged',
  103. 'completed',
  104. 'reopened',
  105. 'empty',
  106. 'toolong',
  107. 'badaction',
  108. 'badid',
  109. 'baddate',
  110. 'badlink',
  111. 'badpriority',
  112. 'notfound',
  113. 'stale',
  114. 'full',
  115. 'failed',
  116. ]
  117. /**
  118. * Narrow a query-string value to an outcome, or null.
  119. *
  120. * An allow-list rather than a cast, so only our own words reach the page; the
  121. * value arrives in a URL anybody can type. Anything else renders no banner at
  122. * all, which is the safe answer: a banner is a claim about what just happened.
  123. */
  124. export function parseOwnOutcome(raw: string | null | undefined): OwnOutcome | null {
  125. const v = (raw ?? '').trim()
  126. return (OUTCOMES as readonly string[]).includes(v) ? (v as OwnOutcome) : null
  127. }
  128. // --- dates --------------------------------------------------------------------------
  129. export type DueState = 'overdue' | 'today' | 'later' | 'none'
  130. /**
  131. * Where a line sits relative to today, in Meridian time.
  132. *
  133. * A line with no date is `none` and never becomes `overdue`. That distinction is
  134. * the prime invariant on this surface: "I did not give this a date" and "this
  135. * was due on Friday" are different facts, and only one of them is a reproach.
  136. */
  137. export function dueState(due: string | null, today: string): DueState {
  138. if (!due) return 'none'
  139. if (due < today) return 'overdue'
  140. if (due === today) return 'today'
  141. return 'later'
  142. }
  143. /**
  144. * Open lines in the order the card reads them.
  145. *
  146. * Dated before undated, soonest first, priority breaking ties, then id so the
  147. * order is stable across loads. The store's SQL asks for the same order; this
  148. * function is what the card actually trusts, because a page whose grouping and
  149. * whose sort came from two different definitions would eventually disagree with
  150. * itself and there would be no way to tell which was right.
  151. */
  152. export function sortOpen(items: OwnItem[]): OwnItem[] {
  153. return [...items].sort((a, b) => {
  154. if ((a.due === null) !== (b.due === null)) return a.due === null ? 1 : -1
  155. if (a.due && b.due && a.due !== b.due) return a.due < b.due ? -1 : 1
  156. const p = ownPriorityOrder(a.priority) - ownPriorityOrder(b.priority)
  157. return p !== 0 ? p : a.id - b.id
  158. })
  159. }
  160. // --- rendering ----------------------------------------------------------------------
  161. const GROUPS: { state: DueState; label: string }[] = [
  162. { state: 'overdue', label: 'Overdue' },
  163. { state: 'today', label: 'Due today' },
  164. { state: 'later', label: 'Dated' },
  165. { state: 'none', label: 'No date' },
  166. ]
  167. /** The store's own words and no diagnosis (rule 1). */
  168. function storeBanner(store: OwnStore): string {
  169. if (store.state === 'unconfigured') {
  170. return `<p class="own-banner own-bad"><b>Your own list is not set up on this board.</b>
  171. There is no D1 binding for <code>CHECKINS</code>, so nothing can be read and nothing can
  172. be saved. That is a deploy step, not an empty list. See
  173. <code>apps/worker/migrations/0002_own_list.sql</code>.</p>`
  174. }
  175. if (store.state === 'failed') {
  176. return `<p class="own-banner own-bad"><b>Your own list could not be read.</b>
  177. ${esc(store.error)}. So this is missing, not empty. Anything you have added is
  178. still in the database.</p>`
  179. }
  180. if (store.truncated) {
  181. // Rule 5: what we have, plus the fact that there was more. This should be
  182. // unreachable (the add route refuses past the cap), so it says so rather
  183. // than presenting a clipped list as a whole one.
  184. return `<p class="own-banner own-bad">Showing the first ${OWN_OPEN_CAP} open lines. There are
  185. more than that, which should not be possible: the add form refuses past the cap. If
  186. you are seeing this, tell Sam.</p>`
  187. }
  188. return ''
  189. }
  190. function outcomeBanner(outcome: OwnOutcome | null): string {
  191. if (!outcome) return ''
  192. switch (outcome) {
  193. case 'added':
  194. return `<p class="own-banner own-good">Added. It stays on your list until you complete it.</p>`
  195. case 'edited':
  196. return `<p class="own-banner own-good">Changed.</p>`
  197. case 'unchanged':
  198. return `<p class="own-banner">Nothing had changed, so nothing was written.</p>`
  199. case 'completed':
  200. return `<p class="own-banner own-good">Done. It is folded under <b>Done</b> below.
  201. Completing something never throws it away, and reopening is one click.</p>`
  202. case 'reopened':
  203. return `<p class="own-banner own-good">Back on your open list.</p>`
  204. case 'empty':
  205. return `<p class="own-banner own-bad">A line with no text is a bullet you cannot act on
  206. later, so nothing was saved. Even three words is enough.</p>`
  207. case 'toolong':
  208. return `<p class="own-banner own-bad">That was longer than ${OWN_TEXT_CAP} characters, so
  209. nothing was saved rather than saving part of it. Shorten it and try again; your
  210. text is still in the box if you go back.</p>`
  211. case 'full':
  212. return `<p class="own-banner own-bad">Your list is at its cap of open lines, so this was
  213. <b>not</b> added. Complete something first; nothing was trimmed to make room.</p>`
  214. case 'notfound':
  215. return `<p class="own-banner own-bad">That line is not on your list. It may have been
  216. changed from another tab. Nothing was altered.</p>`
  217. case 'stale':
  218. return `<p class="own-banner own-bad">That page was open from before this line last changed,
  219. so nothing was done rather than done to the wrong version. Reload and try again.</p>`
  220. case 'baddate':
  221. return `<p class="own-banner own-bad">That date was not a real one, so nothing was saved.
  222. A line can have no date at all, which is different from being overdue.</p>`
  223. case 'badlink':
  224. return `<p class="own-banner own-bad">A link has to be an order number or a task id, like
  225. <code>MD2639582</code> or <code>task:41837</code>. Nothing was saved.</p>`
  226. case 'badpriority':
  227. case 'badaction':
  228. case 'badid':
  229. return `<p class="own-banner own-bad">That form did not arrive in a shape this page
  230. recognises, so nothing was changed. Reload and try again.</p>`
  231. case 'failed':
  232. return `<p class="own-banner own-bad">It was <b>not</b> saved: the store refused the
  233. write. Copy what you typed somewhere before you reload.</p>`
  234. }
  235. }
  236. /** The link chip: the id the rest of the board already uses for that work. */
  237. function linkChip(linkKey: string | null): string {
  238. const parsed = parseWorkKey(linkKey)
  239. if (!parsed) return ''
  240. // A label, not an anchor. The board has no per-order page to link to, and a
  241. // link that scrolled somewhere plausible but wrong is worse than a label.
  242. return `<span class="own-link">${esc(workKeyLabel(parsed))}</span>`
  243. }
  244. function priorityChip(p: OwnPriority): string {
  245. // Normal is the default and says nothing; only the two ends earn a chip.
  246. if (p === 'normal') return ''
  247. return `<span class="own-pri own-pri-${esc(p)}">${esc(OWN_PRIORITY_LABEL[p])}</span>`
  248. }
  249. function dueLabel(item: OwnItem, today: string): string {
  250. if (!item.due) return `<span class="own-when own-nodate">no date</span>`
  251. const state = dueState(item.due, today)
  252. const word = state === 'overdue' ? 'was due' : state === 'today' ? 'due today' : 'due'
  253. const shown = state === 'today' ? '' : ` ${esc(calendarDate(item.due))}`
  254. return `<span class="own-when own-when-${state}">${word}${shown}</span>`
  255. }
  256. /** The hidden fields every mutation form carries. */
  257. function rowTokens(item: OwnItem): string {
  258. return `<input type="hidden" name="id" value="${esc(String(item.id))}">
  259. <input type="hidden" name="seen" value="${esc(item.updatedAt)}">`
  260. }
  261. function openRow(item: OwnItem, today: string, writable: boolean): string {
  262. const state = dueState(item.due, today)
  263. return `<li class="own-row own-row-${state}">
  264. <div class="own-row-main">
  265. <p class="own-text">${esc(item.text)}</p>
  266. <div class="own-meta">
  267. ${dueLabel(item, today)}
  268. ${priorityChip(item.priority)}
  269. ${linkChip(item.linkKey)}
  270. ${item.edits ? `<span class="own-edited">edited</span>` : ''}
  271. </div>
  272. </div>
  273. ${
  274. writable
  275. ? `<div class="own-row-acts">
  276. <form method="post" action="/own" class="own-act">
  277. ${rowTokens(item)}
  278. <input type="hidden" name="action" value="done">
  279. <button type="submit" class="own-btn">Done</button>
  280. </form>
  281. <details class="own-edit">
  282. <summary class="own-btn own-btn-quiet">Edit</summary>
  283. ${itemForm(item, today, 'edit')}
  284. </details>
  285. </div>`
  286. : ''
  287. }
  288. </li>`
  289. }
  290. function doneRow(item: OwnItem, writable: boolean): string {
  291. return `<li class="own-row own-row-done">
  292. <div class="own-row-main">
  293. <p class="own-text">${esc(item.text)}</p>
  294. <div class="own-meta">
  295. <span class="own-when">done ${esc(item.doneAt ? localStamp(item.doneAt) : '')} CT</span>
  296. ${linkChip(item.linkKey)}
  297. </div>
  298. </div>
  299. ${
  300. writable
  301. ? `<div class="own-row-acts">
  302. <form method="post" action="/own" class="own-act">
  303. ${rowTokens(item)}
  304. <input type="hidden" name="action" value="reopen">
  305. <button type="submit" class="own-btn own-btn-quiet">Reopen</button>
  306. </form>
  307. </div>`
  308. : ''
  309. }
  310. </li>`
  311. }
  312. /**
  313. * The add form, and the edit form, which are the same fields.
  314. *
  315. * One function because they must stay the same fields: an edit box missing a
  316. * control the add box has would silently drop that value on every edit.
  317. */
  318. function itemForm(item: OwnItem | null, today: string, mode: 'add' | 'edit'): string {
  319. const id = mode === 'add' ? 'own-new' : `own-edit-${item?.id ?? 0}`
  320. const parsed = parseWorkKey(item?.linkKey ?? null)
  321. return `<form method="post" action="/own" class="own-form">
  322. <input type="hidden" name="action" value="${mode === 'add' ? 'add' : 'edit'}">
  323. ${item ? rowTokens(item) : ''}
  324. <label class="own-label" for="${esc(id)}-text">What needs doing</label>
  325. <textarea id="${esc(id)}-text" name="text" class="own-input" rows="2"
  326. maxlength="${OWN_TEXT_CAP}"
  327. placeholder="Call the gym back about the 10 ft backdrop proof">${esc(item?.text ?? '')}</textarea>
  328. <div class="own-form-row">
  329. <span class="own-field">
  330. <label class="own-label" for="${esc(id)}-due">Due</label>
  331. <input id="${esc(id)}-due" type="date" name="due" class="own-date"
  332. value="${esc(item?.due ?? '')}">
  333. </span>
  334. <span class="own-field">
  335. <label class="own-label" for="${esc(id)}-pri">Priority</label>
  336. <select id="${esc(id)}-pri" name="priority" class="own-select">
  337. ${map(
  338. OWN_PRIORITIES,
  339. (p) =>
  340. `<option value="${esc(p)}"${
  341. (item?.priority ?? 'normal') === p ? ' selected' : ''
  342. }>${esc(OWN_PRIORITY_LABEL[p])}</option>`,
  343. )}
  344. </select>
  345. </span>
  346. <span class="own-field">
  347. <label class="own-label" for="${esc(id)}-link">Order or task <span class="own-opt">optional</span></label>
  348. <input id="${esc(id)}-link" type="text" name="link" class="own-text-in"
  349. value="${esc(parsed?.key ?? '')}" placeholder="MD2639582">
  350. </span>
  351. </div>
  352. <div class="own-form-acts">
  353. <button type="submit" class="own-btn own-btn-go">${mode === 'add' ? 'Add to my list' : 'Save changes'}</button>
  354. ${
  355. mode === 'add'
  356. ? `<span class="own-hint">Today is ${esc(calendarDate(today))}. Leave the date blank if it does not have one.</span>`
  357. : ''
  358. }
  359. </div>
  360. </form>`
  361. }
  362. /**
  363. * The reconciliation sentence: what the numbers above mean and what they do not.
  364. *
  365. * Rule 9, and the version of it that actually bites on this card. The risk here
  366. * is not a number above an empty list; it is a reader seeing "on my board 4" at
  367. * the top of the card and "3 open" here and having no way to know whether the 3
  368. * is inside the 4. It is not, and saying so is one sentence.
  369. */
  370. function reconciliation(open: number, overdue: number, done: number, loadIsNull: boolean): string {
  371. const bits = [
  372. `${open} open, and ${open === 1 ? 'it is' : 'all of them are'} listed above.`,
  373. overdue
  374. ? `${overdue} of ${open === 1 ? 'them' : 'those'} ${overdue === 1 ? 'is' : 'are'} past its date and counted in both numbers.`
  375. : '',
  376. done ? `${done} completed ${done === 1 ? 'line is' : 'lines are'} folded below and in neither.` : '',
  377. loadIsNull
  378. ? `&ldquo;On my board&rdquo; above reads &ndash; because no source covers your work
  379. individually. Your own list is not a source, and adding to it does not change that.`
  380. : `Nothing here is counted in &ldquo;on my board&rdquo; at the top of this card:
  381. that number is orders, tasks, to-dos and tracker items. This list is yours and is not
  382. a source.`,
  383. ]
  384. return `<p class="own-reconcile">${bits.filter(Boolean).join(' ')}</p>`
  385. }
  386. /**
  387. * The whole block, stamped over the placeholder at serve time.
  388. *
  389. * Nothing is the default. A viewer with no list, or a store belonging to
  390. * somebody else, gets an empty string, not a hidden div. Hidden markup is not
  391. * hidden, which is why the board renders a variant per viewer at all.
  392. */
  393. export function ownListBlock(
  394. viewer: Viewer,
  395. store: OwnStore,
  396. now: Date = new Date(),
  397. outcome: OwnOutcome | null = null,
  398. loadIsNull = false,
  399. ): string {
  400. const person = ownListPersonFor(viewer)
  401. if (!person || !hasOwnList(viewer)) return ''
  402. // The store must be the one that was asked for. This cannot happen through
  403. // any current call path (the Worker reads with this same name), and it is
  404. // checked anyway, because the cost of the refactor that breaks it is one
  405. // person reading another's private list, and the cost of the check is a
  406. // string comparison.
  407. if (store.state === 'ok' && store.person !== person) {
  408. return card(
  409. person,
  410. '',
  411. `<p class="own-banner own-bad"><b>Your own list could not be shown.</b> The board read a
  412. list that is not yours and refused to render it. Nothing was disclosed and nothing was
  413. changed. Tell Sam.</p>`,
  414. )
  415. }
  416. const today = localDay(now, MERIDIAN_TZ)
  417. const writable = store.state !== 'unconfigured'
  418. const open = store.state === 'ok' ? sortOpen(store.open) : []
  419. const done = store.state === 'ok' ? store.done : []
  420. const overdue = open.filter((i) => dueState(i.due, today) === 'overdue').length
  421. const chip =
  422. store.state === 'ok'
  423. ? `<span class="own-chip ${overdue ? 'own-chip-due' : ''}">${open.length} open${
  424. overdue ? ` &middot; ${overdue} overdue` : ''
  425. }</span>`
  426. : // Not a zero. A store that could not be read has no count, and printing
  427. // "0 open" here would be the prime invariant broken on the one surface
  428. // where the reader is the only person who could notice.
  429. `<span class="own-chip own-chip-dark">not read</span>`
  430. const body = join([
  431. outcomeBanner(outcome),
  432. storeBanner(store),
  433. store.state !== 'ok'
  434. ? // The banner said what happened. A list here would be a claim.
  435. ''
  436. : open.length
  437. ? join([
  438. ...GROUPS.map((g) => {
  439. const rows = open.filter((i) => dueState(i.due, today) === g.state)
  440. if (!rows.length) return ''
  441. return `<div class="own-group">
  442. <div class="own-group-head">${esc(g.label)} <span class="own-group-n">${rows.length}</span></div>
  443. <ul class="own-rows">${map(rows, (i) => openRow(i, today, writable))}</ul>
  444. </div>`
  445. }),
  446. reconciliation(open.length, overdue, done.length, loadIsNull),
  447. ])
  448. : done.length
  449. ? `<p class="own-empty">Nothing open. ${done.length} completed
  450. ${done.length === 1 ? 'line is' : 'lines are'} kept below; completing
  451. something never removes it.</p>`
  452. : // A true zero, and it must read as one. Everywhere else on this board
  453. // an empty list is a claim about the world and has to be kept apart
  454. // from "not tracked"; here it is a claim about the owner, and saying
  455. // so is what stops it reading like every other absence on the page.
  456. `<p class="own-empty"><b>Nothing on your own list.</b> This one is empty because you
  457. have not added anything yet. It is the only list on this card that is not
  458. read from a source, so an empty one means exactly that.</p>`,
  459. writable
  460. ? `<details class="own-add"${open.length || done.length ? '' : ' open'}>
  461. <summary class="own-btn own-btn-go">Add a line</summary>
  462. ${
  463. store.state === 'failed'
  464. ? `<p class="own-hint own-hint-warn">The store would not answer a read just now, so
  465. this may not save either. Keep a copy of anything long until you see it land.</p>`
  466. : ''
  467. }
  468. ${itemForm(null, today, 'add')}
  469. </details>`
  470. : // No form at all without a binding: a box that could only lose what was
  471. // typed into it is worse than no box, which is the same call `bootNotes`
  472. // makes when the notes tag is absent.
  473. '',
  474. done.length
  475. ? `<details class="own-done">
  476. <summary class="own-btn own-btn-quiet">Done (${done.length})</summary>
  477. <ul class="own-rows">${map(done, (i) => doneRow(i, writable))}</ul>
  478. ${
  479. store.state === 'ok' && store.doneHidden
  480. ? `<p class="own-hint">${store.doneHidden} older completed
  481. ${store.doneHidden === 1 ? 'line is' : 'lines are'} kept in the database and not
  482. listed here. Nothing was deleted.</p>`
  483. : ''
  484. }
  485. </details>`
  486. : '',
  487. ])
  488. return card(person, chip, body)
  489. }
  490. function card(person: string, chip: string, body: string): string {
  491. return `<div class="own-wrap">
  492. <div class="own-head">
  493. <div class="own-title">My own list</div>
  494. ${chip}
  495. </div>
  496. <p class="own-sub">Only you. Nobody else who signs into this board can read these:
  497. there is no reader list and no leadership view, and no count from here appears on any
  498. shared page. Nothing here chases you either: it is waiting when you open the board, and
  499. the dates are checked fresh each time you load it.</p>
  500. ${body}
  501. <p class="own-foot">Filed under ${esc(person)}.</p>
  502. </div>`
  503. }
  504. // --- markers ------------------------------------------------------------------------
  505. /**
  506. * Stamped into a pre-rendered page at serve time, like the theme, the notes
  507. * JSON, the sync line and the end-of-day note.
  508. *
  509. * It has to be stamped rather than rendered for two independent reasons, and
  510. * the second is the one that matters: a line added a minute ago must show on
  511. * the next load without waiting for a cron, and no private text may ever be
  512. * written into the KV page at all. What sits in KV is the placeholder below.
  513. */
  514. export const OWN_START = '<!--ownlist:start-->'
  515. export const OWN_END = '<!--ownlist:end-->'
  516. /**
  517. * Not empty, and rule 8 is why. An empty div here is a feature that silently is
  518. * not there: visible in a variant sitting in KV before the Worker stamps it,
  519. * in a variant dumped to a file, and above all in a deploy where the Worker
  520. * stopped stamping. The last sentence is for whoever is looking at one of those
  521. * and needs to know at a glance that no private text is in it.
  522. */
  523. export const ownListPlaceholder = (): string =>
  524. `${OWN_START}<div class="own-wrap"><p class="own-empty"><b>Your own list lives on the live
  525. board.</b> This copy was rendered to a file, which has no way to read your list or accept
  526. the form. Open the deployed board to add a line.
  527. <b>Nothing of yours is in this file.</b></p></div>${OWN_END}`
  528. /** Swap the placeholder for the real block. A page without markers is untouched. */
  529. export function stampOwnList(html: string, block: string): string {
  530. const from = html.indexOf(OWN_START)
  531. if (from === -1) return html
  532. const to = html.indexOf(OWN_END, from)
  533. if (to === -1) return html
  534. return html.slice(0, from) + block + html.slice(to + OWN_END.length)
  535. }