@ultimat3/cli 18.0.0 → 19.1.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.
@@ -0,0 +1,162 @@
1
+ // `sw.js` and its registration script, built from the route table and the island bundle — the one
2
+ // caller of `@ultimat3/pwa`'s `generateServiceWorker`, which had none outside its own package
3
+ // until #390. Beside `pwa-artifacts.ts` and not inside it: that file needs a root and a config
4
+ // file, this one needs a booted app and a finished build.
5
+
6
+ import type { PrecacheAsset, PrecacheManifest, PwaRoute } from '@ultimat3/pwa';
7
+ import { generateServiceWorker } from '@ultimat3/pwa';
8
+ import type { RouteDescriptor } from '@ultimat3/render';
9
+ import type { IslandBundle } from './island-bundle';
10
+ import { msg } from './messages';
11
+ import type { PwaArtifacts } from './pwa-artifacts';
12
+
13
+ /** Root scope, so `/sw.js` and nothing under a directory — `assertScope` refuses the rest. */
14
+ export const SERVICE_WORKER_PATH = '/sw.js';
15
+ export const SW_SCOPE = '/';
16
+
17
+ /**
18
+ * The registration is an EXTERNAL script, never inline, and that is a CSP fact rather than a
19
+ * preference: `startWeb` computes a `script-src` sha256 for each inline script it serves, so an
20
+ * unhashed one is blocked in the container while passing report-only under `x dev` — which is
21
+ * exactly how the hydration runtime shipped broken once already.
22
+ */
23
+ export const SW_REGISTER_PATH = '/x-sw-register.js';
24
+
25
+ export interface ServiceWorkerArtifacts {
26
+ /** `sw.js`, deterministic for identical input. */
27
+ readonly source: string;
28
+ /** `x-sw-register.js`, the four lines that install it. */
29
+ readonly register: string;
30
+ /** The `<script src>` tag, appended to `PwaArtifacts.head` by every surface that serves it. */
31
+ readonly head: string;
32
+ readonly precache: PrecacheManifest;
33
+ /** Precache budget findings — reported by `x build`, so the ceiling is not a designed thing. */
34
+ readonly warnings: readonly string[];
35
+ }
36
+
37
+ export interface ServiceWorkerInput {
38
+ /**
39
+ * What `loadPwaArtifacts` read out of `app.config.ts`. The whole object rather than three loose
40
+ * fields, because a second reader of that file is a second answer to what the app declared.
41
+ */
42
+ readonly pwa: PwaArtifacts;
43
+ readonly buildId: string;
44
+ readonly routes: readonly RouteDescriptor[];
45
+ readonly islands: IslandBundle;
46
+ }
47
+
48
+ /**
49
+ * The route table, as the service worker sees it. `api/` is dropped: an API response is a JSON
50
+ * document whose freshness is the app's business, and precaching one serves a stale answer to a
51
+ * client that had a network. Only the four fields `PwaRoute` reads cross — a descriptor carries
52
+ * budgets and policy flags that a browser has no use for.
53
+ */
54
+ const pwaRoutes = (routes: readonly RouteDescriptor[]): readonly PwaRoute[] =>
55
+ // `flatMap` rather than `filter().map()`: the filter's predicate does not narrow `surface` for
56
+ // the map that follows it, and `PwaRoute` declares the two navigable surfaces only. A cast would
57
+ // hide the day a fifth surface arrives.
58
+ routes.flatMap((route): readonly PwaRoute[] => {
59
+ // `shared/` is dropped with `api/`, and for a stronger reason: it is not a URL at all — the
60
+ // surface exists so two routes can import one module, and a browser can never navigate to it.
61
+ if (route.surface !== 'site' && route.surface !== 'app') return [];
62
+ return [
63
+ {
64
+ path: route.path,
65
+ surface: route.surface,
66
+ mode: route.mode,
67
+ offline: route.offline,
68
+ dynamic: route.dynamic,
69
+ },
70
+ ];
71
+ });
72
+
73
+ /**
74
+ * Every island chunk, precached. They are content-addressed and served `immutable`, so the
75
+ * revision IS the URL's hash and a byte-identical chunk across deploys is never re-downloaded.
76
+ *
77
+ * Sorted by url, because `buildPrecacheManifest` sorts its own entries but the ASSET list is what
78
+ * decides which of two equal urls wins, and `sw.js` must be byte-identical for identical input.
79
+ */
80
+ const islandAssets = (islands: IslandBundle): readonly PrecacheAsset[] =>
81
+ [...islands.chunks]
82
+ .map((chunk) => ({ url: chunk.url, revision: chunk.url, bytes: chunk.bytes }))
83
+ .sort((a, b) => (a.url < b.url ? -1 : a.url > b.url ? 1 : 0));
84
+
85
+ /**
86
+ * Four lines, and every one of them earns it. `load` because registration competes with the page's
87
+ * own first paint for the same network. `scope: '/'` stated rather than inferred, so a change to
88
+ * where the file is served is a build-time refusal (`assertScope`) instead of a worker that
89
+ * silently controls a subdirectory. The `catch` because a registration that throws in a browser
90
+ * with service workers disabled — an incognito profile, an enterprise policy — must not take an
91
+ * otherwise working page down with it.
92
+ */
93
+ const registerSource = (): string =>
94
+ `if ('serviceWorker' in navigator) {
95
+ addEventListener('load', function () {
96
+ navigator.serviceWorker
97
+ .register(${JSON.stringify(SERVICE_WORKER_PATH)}, { scope: ${JSON.stringify(SW_SCOPE)} })
98
+ .catch(function (error) { console.warn('service worker registration failed', error); });
99
+ });
100
+ }
101
+ `;
102
+
103
+ /**
104
+ * Build the worker, or answer `undefined` for an app that declared no PWA.
105
+ *
106
+ * A bad `sw.js` is sticky in a way a manifest is not: a manifest a browser dislikes is ignored, a
107
+ * worker that installs and caches wrong keeps serving wrong bytes until the user clears site data.
108
+ * That is why this landed only once a real browser could be driven —
109
+ * `packages/cli/e2e/service-worker.e2e.test.ts` installs the emitted worker in Chrome, takes the
110
+ * network away and asserts the fallback renders.
111
+ *
112
+ * `pwa.enabled` is the one switch, and it is already spent: `loadPwaArtifacts` answers `undefined`
113
+ * for an app that declared no PWA, so a caller only reaches this with an installable one.
114
+ * `defineConfig` refuses `enabled: true` without an absolute `offline.fallback`, and a
115
+ * hand-written config that lacks one reads as `null` here — so `requireOfflineFallback` inside
116
+ * `generateServiceWorker` can never be the thing that fails, and the app gets no worker rather
117
+ * than a worker caching a path nobody declared.
118
+ */
119
+ export function serviceWorkerArtifacts(
120
+ input: ServiceWorkerInput,
121
+ ): ServiceWorkerArtifacts | undefined {
122
+ const pwa = input.pwa;
123
+ if (pwa.offline.fallback === null) return undefined;
124
+ const output = generateServiceWorker(
125
+ pwaRoutes(input.routes),
126
+ {
127
+ scope: SW_SCOPE,
128
+ swPath: SERVICE_WORKER_PATH,
129
+ offline: {
130
+ fallback: pwa.offline.fallback,
131
+ ...(pwa.offline.image === null ? {} : { image: pwa.offline.image }),
132
+ ...(pwa.offline.font === null ? {} : { font: pwa.offline.font }),
133
+ neverCache: pwa.offline.neverCache,
134
+ },
135
+ capabilities: { backgroundSync: pwa.backgroundSync, push: pwa.push },
136
+ assets: islandAssets(input.islands),
137
+ },
138
+ input.buildId,
139
+ );
140
+ return {
141
+ source: output.source,
142
+ register: registerSource(),
143
+ head: `<script src="${SW_REGISTER_PATH}" defer></script>`,
144
+ precache: output.precache,
145
+ // `output.warnings` IS `output.precache.warnings` — the generator returns the manifest's list
146
+ // verbatim — so it is read once, not twice. The push line is this module's own, and it is the
147
+ // one thing the generator cannot say: `generateServiceWorker` emits a push handler only when a
148
+ // VAPID key comes with the capability, and drops it in SILENCE otherwise. `pwa.push: true` in
149
+ // an `app.config.ts` therefore wires nothing and reports nothing, which is `jobs.driver`'s
150
+ // shape one package over.
151
+ warnings: [...output.warnings, ...pushWarning(pwa)],
152
+ };
153
+ }
154
+
155
+ /**
156
+ * `pwa.push: true` with nothing to sign a subscription with. There is no `pwa.vapid` config key
157
+ * yet, so today this fires for EVERY app that sets the flag — deliberately: a switch that silently
158
+ * does nothing is the defect this framework keeps re-shipping, and a warning naming the missing
159
+ * half is the smallest honest answer until the key exists.
160
+ */
161
+ const pushWarning = (pwa: PwaArtifacts): readonly string[] =>
162
+ pwa.push ? [msg('cli.build.pushUnwired')] : [];
@@ -0,0 +1,53 @@
1
+ // The two GET routes that serve what `sw-artifacts.ts` built, and the cache policy each one needs.
2
+ // Split from the emitter because mounting is HTTP and generation is a build: `prerender.ts` writes
3
+ // both artifacts as files and mounts nothing, and it must not have to import a route table to do it.
4
+
5
+ import type { CacheHint, Route } from '@ultimat3/http';
6
+ import { applyCacheHeaders } from '@ultimat3/http';
7
+ import type { ServiceWorkerArtifacts } from './sw-artifacts';
8
+ import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, SW_SCOPE } from './sw-artifacts';
9
+
10
+ /**
11
+ * `no-store`, and it is the one asset here that must be. A cached `sw.js` is a worker that cannot
12
+ * be replaced: the browser re-fetches it to decide whether an update exists, and an intermediary
13
+ * answering the old bytes pins every client to the deploy that shipped them. Browsers cap SW
14
+ * script caching at 24h on their own; this removes the question.
15
+ */
16
+ const SW_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
17
+
18
+ /** Content-addressed in neither path, so the register script gets the favicon's hour. */
19
+ const REGISTER_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
20
+
21
+ /** `/sw.js` and `/x-sw-register.js`, mounted by `x dev` and by the container alike. */
22
+ export const serviceWorkerRoutes = (artifacts: ServiceWorkerArtifacts): readonly Route[] => [
23
+ {
24
+ method: 'GET',
25
+ path: SERVICE_WORKER_PATH,
26
+ meta: { name: 'pwa.serviceWorker', auth: 'public' },
27
+ handler: () =>
28
+ applyCacheHeaders(
29
+ new Response(artifacts.source, {
30
+ headers: {
31
+ 'content-type': 'text/javascript; charset=utf-8',
32
+ // Without it the browser refuses to let a worker served from `/` control `/`, which
33
+ // is the failure `assertScope` cannot see: the scope a REGISTRATION asks for has to be
34
+ // allowed by the script's own response, not only by its path.
35
+ 'service-worker-allowed': SW_SCOPE,
36
+ },
37
+ }),
38
+ SW_CACHE,
39
+ ),
40
+ },
41
+ {
42
+ method: 'GET',
43
+ path: SW_REGISTER_PATH,
44
+ meta: { name: 'pwa.serviceWorkerRegister', auth: 'public' },
45
+ handler: () =>
46
+ applyCacheHeaders(
47
+ new Response(artifacts.register, {
48
+ headers: { 'content-type': 'text/javascript; charset=utf-8' },
49
+ }),
50
+ REGISTER_CACHE,
51
+ ),
52
+ },
53
+ ];
@@ -27,6 +27,13 @@ import { entity, invariant, money, text, timestamp, uuid } from '@ultimat3/entit
27
27
  export const ${name.camel} = entity('${table}', {
28
28
  // Naming the tenant column is what turns tenancy on: a read without an org predicate then
29
29
  // fails with X_TENANCY_UNSCOPED instead of leaking another org's rows.
30
+ //
31
+ // SINGLE-TENANT APP? There is no flag: \`x g action\`, \`x g query\` and \`x g policy\` decide on
32
+ // \`orgId\` too, so the edit is per slice and it is this: delete \`tenant: 'orgId'\` and the
33
+ // \`orgId: uuid()\` column below, drop \`'orgId'\` from \`indexes\`, and in repo.ts turn
34
+ // \`listByOrg(orgId, limit)\` into \`list(limit)\` with no \`org_id\` predicate and no \`org_id\` in
35
+ // the insert. entity.test.ts then expects \`$tenantColumn\` null and \`orgScoped\` false, and its
36
+ // \`row()\` fixture loses \`orgId\`. Nothing else reads the column.
30
37
  tenant: 'orgId',
31
38
  columns: {
32
39
  id: uuid().primaryKey(),
@@ -190,16 +190,63 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
190
190
  });
191
191
  `;
192
192
 
193
+ const offlineTest =
194
+ (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
195
+ // JavaScript. Every one of those is a config field here, and every one of them rots the moment
196
+ // someone adds an import or a policy — at which point the page the service worker precaches is a
197
+ // page that cannot render when it is finally needed.
198
+ import { metaContextFor, routeDataFor } from '@ultimat3/render';
199
+ import { expect, unitTest } from '@ultimat3/testing';
200
+ import { config } from './page';
201
+
202
+ const ctx = { params: {}, url: 'https://example.test/offline' };
203
+
204
+ unitTest('the offline fallback is static, precached, and ships no JavaScript', async () => {
205
+ expect(config.render).toBe('static');
206
+ // 'precache', or the document that answers a lost network is itself fetched over the network.
207
+ expect(config.offline).toBe('precache');
208
+ expect(config.hydrate).toBe('never');
209
+ expect(config.budget.js).toBe('0kb');
210
+ // A cached error page has nothing to index, and an indexed one outranks the page it stood in for
211
+ // on the day the crawler happened to be offline.
212
+ const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
213
+ expect(meta.robots?.index).toBe(false);
214
+ });
215
+ `;
216
+
193
217
  const offlineFallback = (
194
218
  app: NameSet,
195
- ): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
196
- // tunnel shows the product's own shell instead of the browser's error page.
219
+ ): string => `// The offline fallback, and it is a ROUTE — \`pwa.offline.fallback\` in app.config.ts names this
220
+ // path, the generated sw.js precaches it, and every app/ route with offline: 'runtime' falls back
221
+ // here. So a train tunnel shows the product's own shell instead of the browser's error page.
222
+ //
223
+ // site/ and render: 'static', deliberately: the document that answers a lost network has to render
224
+ // with no network, no session and no database, which is what site/ guarantees and app/ (ssr |
225
+ // stream) cannot. \`offline: 'precache'\` for the same reason one level down — a fallback fetched
226
+ // over the network when the network is gone is not a fallback.
197
227
 
198
228
  // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
199
- import { useT } from '@${app.kebab}/i18n';
200
- import styles from './offline.module.scss';
229
+ ${sortedImports([
230
+ `import { useT } from '@${app.kebab}/i18n';`,
231
+ `import { defineRoute } from '@ultimat3/render';`,
232
+ ])}
233
+ import styles from './page.module.scss';
234
+
235
+ export const config = defineRoute({
236
+ render: 'static',
237
+ offline: 'precache',
238
+ hydrate: 'never',
239
+ budget: { js: '0kb' },
240
+ meta: ({ t }) => ({
241
+ title: t('app.offline.title'),
242
+ description: t('app.offline.description'),
243
+ // A cached error page has nothing to index, and an indexed one outranks the page it stood in
244
+ // for on the day the crawler happened to be offline.
245
+ robots: { index: false },
246
+ }),
247
+ });
201
248
 
202
- export function OfflineFallback() {
249
+ export function OfflinePage() {
203
250
  const t = useT();
204
251
 
205
252
  return (
@@ -390,8 +437,12 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
390
437
  // policy and `shared/roles.ts` declares the grants, and until this file existed nothing
391
438
  // answered "who is this?" — so every one of those routes refused every request.
392
439
  ...authFiles(app),
393
- { path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
394
- { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
440
+ // `site/offline/page.tsx`, not `app/offline.tsx`: the directory is the URL and `<name>.tsx` is
441
+ // not a route file, so the old path shipped a component nothing rendered and left `/offline` a
442
+ // URL the generated service worker could not fall back to.
443
+ { path: 'apps/web/site/offline/page.tsx', contents: offlineFallback(app) },
444
+ { path: 'apps/web/site/offline/page.module.scss', contents: offlineStyle() },
445
+ { path: 'apps/web/site/offline/page.test.ts', contents: offlineTest() },
395
446
  // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
396
447
  ...apiFiles(example),
397
448
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
@@ -187,7 +187,10 @@ export const config = defineConfig({
187
187
  // else in an app.
188
188
  pwa: {
189
189
  enabled: true,
190
- offline: 'runtime',
190
+ // The document an offline navigation gets when the cache has no answer, and the path
191
+ // \`apps/web/site/offline/page.tsx\` serves. Required once \`enabled\` is true: an installable
192
+ // app that shows the browser's error page offline is the failure the block exists to prevent.
193
+ offline: { fallback: '/offline' },
191
194
  name: '${titleCase(app.raw)}',
192
195
  colors: {
193
196
  light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
package/src/ts-scan.ts CHANGED
@@ -30,120 +30,17 @@ export interface CodeScan {
30
30
  readonly unresolved: readonly UnresolvedCodeSite[];
31
31
  }
32
32
 
33
+ // The masking itself — `QUOTES`, `endOfLiteral`, `stripComments`, `maskLiterals` — lives in
34
+ // `@ultimat3/core`'s `source-mask.ts` since 2026-09-05, because `@ultimat3/i18n`'s key extractor
35
+ // (tier 1) needed it and could not reach a tier-5 package. Re-exported here so every scanner in
36
+ // this package, and this package's public API, keep the names they had.
37
+ import { endOfLiteral, maskLiterals, QUOTES, stripComments } from '@ultimat3/core';
38
+
39
+ export { endOfLiteral, maskLiterals, QUOTES, stripComments };
40
+
33
41
  // `ReadonlySet`, so a consumer cannot mutate what every scan in this package reads.
34
- export const QUOTES: ReadonlySet<string> = new Set(["'", '"', '`']);
35
42
  export const OPENERS: ReadonlySet<string> = new Set(['(', '[', '{']);
36
43
  export const CLOSERS: ReadonlySet<string> = new Set([')', ']', '}']);
37
- const WORD = /[\w$]/;
38
-
39
- /** After one of these words a `/` opens a regex; after any other identifier it divides. */
40
- const REGEX_AFTER_WORDS = new Set(
41
- 'await case delete do else in instanceof new of return throw typeof void yield'.split(' '),
42
- );
43
-
44
- /**
45
- * Index just past the closing quote of the literal opening at `from`, or `from + 1` when a `'`/`"`
46
- * does not close on its own line — which makes it text, not a literal. Only a template literal may
47
- * span a newline, so the apostrophe in `<p>Don't panic</p>` is JSX text; read as an opener it ran
48
- * forward to the next `'` in the FILE (the next `fix:` line) and blanked every declaration between,
49
- * silently emptying the `errors` gate for the whole file. Same rule `endOfRegex` applies to a `/`.
50
- * An escaped newline is still a continuation: the escape is consumed before the line test.
51
- */
52
- export function endOfLiteral(text: string, from: number): number {
53
- const quote = text[from] as string;
54
- const spansLines = quote === '`';
55
- for (let i = from + 1; i < text.length; i += 1) {
56
- if (text[i] === '\\') i += 1;
57
- else if (text[i] === quote) return i + 1;
58
- else if (!spansLines && text[i] === '\n') return from + 1;
59
- }
60
- return spansLines ? text.length : from + 1;
61
- }
62
-
63
- /**
64
- * Whether the `/` at `at` opens a regex rather than divides — the call no scanner without a parser
65
- * avoids. A regex cannot follow what ends an expression: an identifier that is not one of the words
66
- * above, a number, `)`, `]`, a string's closing quote. Every other position is an operator's and
67
- * opens one; `</` and `/>` are JSX delimiters. Read from the masked prefix, so a comment is space.
68
- */
69
- function opensRegex(out: readonly string[], at: number): boolean {
70
- if (out[at + 1] === '>') return false;
71
- let i = at - 1;
72
- while (i >= 0 && /\s/.test(out[i] as string)) i -= 1;
73
- if (i < 0) return true;
74
- const ch = out[i] as string;
75
- if (ch === '<' || ch === ')' || ch === ']' || QUOTES.has(ch)) return false;
76
- if (!WORD.test(ch)) return true;
77
- let start = i;
78
- while (start >= 0 && WORD.test(out[start] as string)) start -= 1;
79
- return REGEX_AFTER_WORDS.has(out.slice(start + 1, i + 1).join(''));
80
- }
81
-
82
- /**
83
- * Index just past the closing `/` of the regex opening at `from`, or `from + 1` when it does not
84
- * close on its own line — a literal may not span one, so an unterminated candidate was a division
85
- * or a JSX delimiter after all. A `/` inside a `[…]` class does not close the literal.
86
- */
87
- function endOfRegex(text: string, from: number): number {
88
- let inClass = false;
89
- let escaped = false;
90
- for (let i = from + 1; i < text.length; i += 1) {
91
- const ch = text[i];
92
- if (ch === '\n') break;
93
- if (escaped) escaped = false;
94
- else if (ch === '\\') escaped = true;
95
- else if (inClass) inClass = ch !== ']';
96
- else if (ch === '[') inClass = true;
97
- else if (ch === '/') return i + 1;
98
- }
99
- return from + 1;
100
- }
101
-
102
- /**
103
- * Comments — and optionally string contents — replaced by spaces, newlines kept so line numbers
104
- * survive and quote delimiters kept so the caller can still find where a literal starts and ends.
105
- * A regex body is masked the same way: `/(['"`])/` holds three quotes that delimit nothing, and
106
- * reading one as an opening quote desyncs every literal after it.
107
- */
108
- function blankRegions(text: string, strings: boolean): string {
109
- // `split('')` and NOT `[...text]`: the spread yields one element per CODE POINT while every
110
- // index below runs over UTF-16 units (`text.length`, `text[i]`). One astral character — an emoji
111
- // in a fixture, `piñata 🎉` — and `out` is shorter than `text`, so every write past it lands a
112
- // slot early and the returned mask no longer aligns with the input. Measured: 22 files in this
113
- // tree desynced, shipped source included, and eight rules read this mask.
114
- const out = text.split('');
115
- const blank = (from: number, to: number): void => {
116
- for (let n = from; n < to; n += 1) if (out[n] !== '\n') out[n] = ' ';
117
- };
118
- let i = 0;
119
- while (i < text.length) {
120
- const ch = text[i] as string;
121
- if (ch === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) {
122
- const line = text[i + 1] === '/';
123
- const end = line ? text.indexOf('\n', i) : text.indexOf('*/', i + 2);
124
- const stop = end === -1 ? text.length : line ? end : end + 2;
125
- blank(i, stop);
126
- i = stop;
127
- continue;
128
- }
129
- // Not code: a regex body or a literal. `end === i + 1` blanks nothing and steps one char.
130
- const end =
131
- ch === '/' && opensRegex(out, i)
132
- ? endOfRegex(text, i)
133
- : QUOTES.has(ch)
134
- ? endOfLiteral(text, i)
135
- : i + 1;
136
- if (strings) blank(i + 1, end - 1);
137
- i = end;
138
- }
139
- return out.join('');
140
- }
141
-
142
- /** Comments gone, string literals intact — what a scan for declared codes reads. */
143
- export const stripComments = (text: string): string => blankRegions(text, false);
144
-
145
- /** Comments and string contents gone, delimiters kept — what a scan for code structure reads. */
146
- export const maskLiterals = (text: string): string => blankRegions(text, true);
147
44
 
148
45
  /**
149
46
  * Line numbers for one text, in one pass. Counting newlines per lookup is O(index), which a scan