Format 3, step 8: /create redesigned, and always light

The author found the pages too technical and did not want a dark
background. With the frontend design guidance:

- The site is always light, whatever the system asks.
- A capsule is a letter posted to the future: white paper, blue ink,
  an old-style serif for the voice of the page (Palatino, Iowan) and a
  DIN-like sans for the form (Bahnschrift), all system fonts, as the
  CSP allows no other origin. Blue is what you act on.
- /create asks three questions, with short sentences: what it keeps,
  when it opens, who can open it. The date it opens is set large on an
  airmail envelope with the button that creates the capsule; quick
  dates of 1, 5 and 10 years; sizes in KB and MB; everything technical
  folded in "Detalles técnicos". The messages of the rules no longer
  name R4 or §29.6.

Fixes of the review of the pages:
- /inspect labels the start of a single file as content of the
  creator, unchecked, so that it cannot pass for a verdict; keeps ZWNJ
  and ZWJ in the comment and the author, which R4b allows; and says
  the right thing without files and without a comment.
- /create pins at most 500 more rows with problems and counts the
  rest; shows what happened with a drop, not only to screen readers;
  a comment or an author over its limit is its own problem, not that
  of the files; a writing cannot start after the page is destroyed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 7 days ago
parent 57e0a8a6e3
commit e6cca0b20f

@ -1,22 +1,31 @@
/* DateKeys inspector: the whole stylesheet.
The palette is datekeys.com's: white paper, near-black ink and the brand
orange. Here colour always means something: green is a passed step, red a
rejection, orange a trust caveat (public data, informative views). Human
words are set in the system sans; values read from a capsule (hex,
identifiers, codes, CLI details) in the system monospace, never labels.
No web fonts: the Content-Security-Policy allows nothing but this origin. */
/* DateKeys: the whole stylesheet.
A capsule is a letter posted to the future, and the pages are set like
one: white paper, blue ink, the voice of the page in an old-style serif
and the form in a DIN-like sans, as a postal form; the stripes of an
airmail envelope appear once, around the date a capsule opens. Colour
always means something: blue is what you act on, green a passed step, red
a rejection, orange a trust caveat. Values read from a capsule (hex,
identifiers, codes) are in the system monospace, never labels. Every face
is a system font: the Content-Security-Policy allows nothing but this
origin. The page is always light. */
:root {
color-scheme: light dark;
/* Always light, whatever the system asks: the author does not want a
dark page. */
color-scheme: light;
--paper: #ffffff;
--paper-2: #f4f4f3;
--ink: #0e0e0e;
--ink-muted: #545454;
--ink-faint: #6b6b6b;
--rule: #e2e2e0;
--rule-strong: #8f8f8c;
--paper-2: #f3f5f9;
--ink: #1c2b4b;
--ink-muted: #55627a;
--ink-faint: #6b7890;
--rule: #d8dde6;
--rule-strong: #7d889d;
/* The blue of what you act on, and the stripes of the airmail envelope. */
--accent: #1f4fa3;
--accent-bg: #eef3fb;
--airmail-blue: #1f4fa3;
--airmail-red: #c8323c;
--orange: #f18225;
/* The logo orange is a fill; this deeper orange carries text, at least
4.5:1 on the paper and on the caution tint. */
@ -27,41 +36,24 @@
--fail: #b3261e;
--fail-bg: #fbecea;
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif;
--sans: Bahnschrift, 'DIN Alternate', 'DIN 2014', 'Franklin Gothic Medium', 'Nimbus Sans Narrow', 'Segoe UI', system-ui, sans-serif;
--serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'URW Palladio L', P052, 'Book Antiqua', Georgia, serif;
--mono: ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
/* Type ramp, x1.2 from 16 px. */
--t-small: 0.875rem;
--t-body: 1rem;
--t-lead: 1.2rem;
--t-h3: 1.2rem;
--t-h2: 1.44rem;
--t-date: clamp(1.6rem, 1.2rem + 1.6vw, 2.2rem);
--t-h1: clamp(2rem, 1.4rem + 2.4vw, 3rem);
--t-h3: 1.3rem;
--t-h2: 1.7rem;
--t-date: clamp(2rem, 1.3rem + 2.6vw, 3rem);
--t-h1: clamp(2.3rem, 1.5rem + 3vw, 3.4rem);
--gut: 16px;
--measure: 68ch;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #0c0c0b;
--paper-2: #161615;
--ink: #f2f1ef;
--ink-muted: #b3b0ab;
--ink-faint: #9a9791;
--rule: #2a2a28;
--rule-strong: #6d6a66;
--orange-text: #f39a4e;
--caution-bg: #26190c;
--pass: #74d49c;
--pass-bg: #0f2418;
--fail: #ff8f85;
--fail-bg: #2c1311;
}
}
@media (min-width: 720px) {
:root {
--gut: 32px;
@ -99,13 +91,13 @@ h3,
h4 {
line-height: 1.2;
margin: 0;
font-weight: 650;
letter-spacing: -0.01em;
font-family: var(--serif);
font-weight: 400;
}
h1 {
font-size: var(--t-h1);
letter-spacing: -0.025em;
line-height: 1.08;
}
h2 {
@ -151,7 +143,7 @@ pre,
}
:focus-visible {
outline: 2px solid var(--ink);
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
@ -221,17 +213,17 @@ button {
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.55rem 1.1rem;
border: 1px solid var(--ink);
border-radius: var(--radius);
background: var(--ink);
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--accent);
color: var(--paper);
font-weight: 600;
font-weight: 500;
text-decoration: none;
cursor: pointer;
}
.button:hover {
background: color-mix(in srgb, var(--ink) 86%, var(--paper));
background: color-mix(in srgb, var(--accent) 82%, var(--ink));
}
.button.quiet {
@ -241,8 +233,8 @@ button {
}
.button.quiet:hover {
border-color: var(--ink);
background: var(--paper-2);
border-color: var(--accent);
background: var(--accent-bg);
}
/* The step rail, shared by the landing and the report: the steps of §63

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<meta name="color-scheme" content="light dark" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
</head>

@ -548,16 +548,19 @@
</ul>
{#if f.head.author !== ''}
<p class="creator-label">autor declarado (texto del creador, sin comprobar):</p>
<p class="creator"><bdi dir="auto">{escapeInvisible(f.head.author)}</bdi></p>
<p class="creator"><bdi dir="auto">{escapeInvisible(f.head.author, true)}</bdi></p>
{/if}
{#if f.head.comment !== ''}
<figure class="comment">
<figcaption>Comentario del creador (sin comprobar)</figcaption>
<pre><bdi dir="auto">{escapeInvisible(f.head.comment)}</bdi></pre>
<pre><bdi dir="auto">{escapeInvisible(f.head.comment, true)}</bdi></pre>
</figure>
{/if}
{#if v.shown}
<pre class="plaintext">{v.shown.cut ? `${v.shown.text}\n…` : v.shown.text}</pre>
<figure class="comment">
<figcaption>Principio de <bdi dir="auto">{f.facts[0]?.shown}</bdi> (contenido del creador, sin comprobar)</figcaption>
<pre class="plaintext">{v.shown.cut ? `${v.shown.text}\n…` : v.shown.text}</pre>
</figure>
{#if v.shown.cut}
<p class="muted">Se muestra el principio del fichero, como mucho {formatInteger(SHOWN_TEXT)} caracteres.</p>
{/if}
@ -584,14 +587,14 @@
Están en un fichero temporal privado de este navegador. Se borra cuando lo pides, al abrir o cargar otra
cápsula y al salir de la página.
{:else}
Están en la memoria de esta página, porque el navegador no le deja un fichero temporal privado. Se liberan
al abrir o cargar otra cápsula y al salir de la página.
Están en la memoria de esta página: el navegador no le deja un fichero temporal privado, o no tiene sitio
para ellos. Se liberan al abrir o cargar otra cápsula y al salir de la página.
{/if}
</p>
{/if}
{/if}
{#if f.facts.length === 0}
<p class="muted">La cápsula no guarda ficheros, solo el comentario.</p>
<p class="muted">{f.head.comment === '' ? 'La cápsula no guarda ficheros ni comentario.' : 'La cápsula no guarda ficheros, solo el comentario.'}</p>
{:else}
<h4 class="files-title">Ficheros ({formatInteger(f.facts.length)})</h4>
<ul class="files">

@ -7,46 +7,46 @@ import { compareBytes, utf8Bytes } from '../dkc/bytes.ts';
import { checkPath, checkTree } from '../dkc/pathrule.ts';
import { authorProblem, checkPaths, commentProblem, Memo, pathProblem } from './create-check.ts';
const R7 = 'se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).';
const R7 = 'se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre.';
describe('pathProblem', () => {
it.each([
['', 'Escribe la ruta del fichero (R1).'],
['\ud800', 'La ruta tiene un carácter mal formado (R1).'],
['a'.repeat(1025), 'La ruta ocupa 1.025 bytes en UTF-8, más de 1.024 (R1).'],
[`${'a/'.repeat(32)}a`, 'La ruta tiene 33 niveles, más de 32 (R2).'],
['a//b', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'],
['/a', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'],
['a'.repeat(256), `El nombre «${'a'.repeat(57)}…» ocupa 256 bytes en UTF-8, más de 255 (R3).`],
['.', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
['fotos/..', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
['.\u200d', 'El nombre «.\\u200d», sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).'],
['\u0390'.repeat(127), `El nombre «${'\u0390'.repeat(57)}…», descompuesto en NFD como lo guarda macOS, mide 381 unidades UTF-16, más de 255 (R3).`],
['a\u0001b', 'El nombre «a\\u0001b» lleva el carácter de control U+0001 (R4).'],
['a:b', 'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).'],
['x:y/b', 'La carpeta «x:y» lleva «:», que Windows no admite en un nombre (R4).'],
['a\u2028b', 'El nombre «a\\u2028b» lleva el separador U+2028 (R4).'],
['a\u200bb', 'El nombre «a\\u200bb» lleva el carácter invisible U+200B, con el que dos nombres distintos se ven iguales (R4).'],
['a\uf03ab', 'El nombre «a\\uf03ab» lleva U+F03A, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).'],
['a\u0378', 'El nombre «a\\u0378» lleva U+0378, que Unicode 18.0.0 no asigna (R4).'],
['a\ufe0f', 'El nombre «a\ufe0f» lleva el selector de variante U+FE0F sin un emoji delante que lo admita (R4b).'],
['\u200da', 'El nombre «\\u200da» empieza por el invisible U+200D (R4b).'],
['a\u200c/b', 'La carpeta «a\\u200c» acaba en el invisible U+200C (R4b).'],
['a\u200d\u200cb', 'El nombre «a\\u200d\\u200cb» lleva dos invisibles seguidos, U+200D y U+200C (R4b).'],
[' a', 'El nombre « a» empieza por un espacio (R5).'],
['a ', 'El nombre «a » acaba en un espacio, que Windows quita (R5).'],
['a.', 'El nombre «a.» acaba en punto, que Windows quita (R5).'],
['con.txt', 'El nombre «con.txt» usa CON, un nombre que Windows reserva para un dispositivo (R6).'],
['COM\u00b9.txt', 'El nombre «COM\u00b9.txt» usa COM\u00b9, un nombre que Windows reserva para un dispositivo (R6).'],
['nul .txt', 'El nombre «nul .txt» usa NUL, un nombre que Windows reserva para un dispositivo (R6).'],
['ABCDEF~1.TXT', 'El nombre «ABCDEF~1.TXT» tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).'],
['a\u2236b', 'En la página de códigos 1250 de Windows, el nombre «a\u2236b» se convierte en un nombre con «:» (R6c).'],
['x\u00a5y', 'En la página de códigos 932 de Windows, el nombre «x\u00a5y» se convierte en un nombre con «\\» (R6c).'],
['\uff23\uff2f\uff2e.txt', 'En la página de códigos 874 de Windows, el nombre «\uff23\uff2f\uff2e.txt» se convierte en un nombre que incumple R6 (R6c).'],
['\u3000a', 'En la página de códigos 1250 de Windows, el nombre «\\u3000a» se convierte en un nombre que incumple R5 (R6c).'],
['fotos/\uff0e\uff0e', 'En la página de códigos 874 de Windows, el nombre «\uff0e\uff0e» se convierte en un nombre que incumple R3 (R6c).'],
['.datekeys-x/a', 'La carpeta «.datekeys-x» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'],
['.DATEKEYS-a', 'El nombre «.DATEKEYS-a» empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).'],
['', 'Escribe la ruta del fichero.'],
['\ud800', 'La ruta tiene un carácter mal formado.'],
['a'.repeat(1025), 'La ruta ocupa 1.025 bytes en UTF-8, más de 1.024.'],
[`${'a/'.repeat(32)}a`, 'La ruta tiene 33 niveles, más de 32.'],
['a//b', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'],
['/a', 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'],
['a'.repeat(256), `El nombre «${'a'.repeat(57)}…» ocupa 256 bytes en UTF-8, más de 255.`],
['.', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
['fotos/..', '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
['.\u200d', 'El nombre «.\\u200d», sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..».'],
['\u0390'.repeat(127), `El nombre «${'\u0390'.repeat(57)}…», descompuesto en NFD como lo guarda macOS, mide 381 unidades UTF-16, más de 255.`],
['a\u0001b', 'El nombre «a\\u0001b» lleva el carácter de control U+0001.'],
['a:b', 'El nombre «a:b» lleva «:», que Windows no admite en un nombre.'],
['x:y/b', 'La carpeta «x:y» lleva «:», que Windows no admite en un nombre.'],
['a\u2028b', 'El nombre «a\\u2028b» lleva el separador U+2028.'],
['a\u200bb', 'El nombre «a\\u200bb» lleva el carácter invisible U+200B, con el que dos nombres distintos se ven iguales.'],
['a\uf03ab', 'El nombre «a\\uf03ab» lleva U+F03A, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite.'],
['a\u0378', 'El nombre «a\\u0378» lleva U+0378, que Unicode 18.0.0 no asigna.'],
['a\ufe0f', 'El nombre «a\ufe0f» lleva el selector de variante U+FE0F sin un emoji delante que lo admita.'],
['\u200da', 'El nombre «\\u200da» empieza por el invisible U+200D.'],
['a\u200c/b', 'La carpeta «a\\u200c» acaba en el invisible U+200C.'],
['a\u200d\u200cb', 'El nombre «a\\u200d\\u200cb» lleva dos invisibles seguidos, U+200D y U+200C.'],
[' a', 'El nombre « a» empieza por un espacio.'],
['a ', 'El nombre «a » acaba en un espacio, que Windows quita.'],
['a.', 'El nombre «a.» acaba en punto, que Windows quita.'],
['con.txt', 'El nombre «con.txt» usa CON, un nombre que Windows reserva para un dispositivo.'],
['COM\u00b9.txt', 'El nombre «COM\u00b9.txt» usa COM\u00b9, un nombre que Windows reserva para un dispositivo.'],
['nul .txt', 'El nombre «nul .txt» usa NUL, un nombre que Windows reserva para un dispositivo.'],
['ABCDEF~1.TXT', 'El nombre «ABCDEF~1.TXT» tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero.'],
['a\u2236b', 'En la página de códigos 1250 de Windows, el nombre «a\u2236b» se convierte en un nombre con «:».'],
['x\u00a5y', 'En la página de códigos 932 de Windows, el nombre «x\u00a5y» se convierte en un nombre con «\\».'],
['\uff23\uff2f\uff2e.txt', 'En la página de códigos 874 de Windows, el nombre «\uff23\uff2f\uff2e.txt» se convierte en un nombre que incumple R6.'],
['\u3000a', 'En la página de códigos 1250 de Windows, el nombre «\\u3000a» se convierte en un nombre que incumple R5.'],
['fotos/\uff0e\uff0e', 'En la página de códigos 874 de Windows, el nombre «\uff0e\uff0e» se convierte en un nombre que incumple R3.'],
['.datekeys-x/a', 'La carpeta «.datekeys-x» empieza por «.datekeys-», que la CLI usa al abrir una cápsula.'],
['.DATEKEYS-a', 'El nombre «.DATEKEYS-a» empieza por «.datekeys-», que la CLI usa al abrir una cápsula.'],
])('%j', (path, want) => {
expect(pathProblem(path)).toBe(want);
});
@ -64,14 +64,14 @@ describe('checkPaths', () => {
expect(checkPaths(['\u00e9.txt', 'e\u0301.txt']).problems).toEqual([`Choca con «e\u0301.txt»: ${R7}`, `Choca con «\u00e9.txt»: ${R7}`]);
expect(checkPaths(['ab', 'a\u200cb']).problems).toEqual([`Choca con «a\\u200cb»: ${R7}`, `Choca con «ab»: ${R7}`]);
expect(checkPaths(['Fotos/a', 'fotos/b']).problems).toEqual([`Choca con «fotos/b»: ${R7}`, `Choca con «Fotos/a»: ${R7}`]);
expect(checkPaths(['a', 'a/b']).problems).toEqual(['Choca con «a/b»: «a» sería a la vez un fichero y una carpeta (R7).', 'Choca con «a»: «a» sería a la vez un fichero y una carpeta (R7).']);
expect(checkPaths(['x/y/z', 'x/y']).problems).toEqual(['Choca con «x/y»: «x/y» sería a la vez un fichero y una carpeta (R7).', 'Choca con «x/y/z»: «x/y» sería a la vez un fichero y una carpeta (R7).']);
expect(checkPaths(['a', 'a/b']).problems).toEqual(['Choca con «a/b»: «a» sería a la vez un fichero y una carpeta.', 'Choca con «a»: «a» sería a la vez un fichero y una carpeta.']);
expect(checkPaths(['x/y/z', 'x/y']).problems).toEqual(['Choca con «x/y»: «x/y» sería a la vez un fichero y una carpeta.', 'Choca con «x/y/z»: «x/y» sería a la vez un fichero y una carpeta.']);
expect(checkPaths(['x', 'x']).problems).toEqual(['Repite la ruta de otro fichero de la lista.', 'Repite la ruta de otro fichero de la lista.']);
// The first path keeps its first collision; the third repeats the first.
expect(checkPaths(['a', 'A', 'a']).problems).toEqual([`Choca con «A»: ${R7}`, `Choca con «a»: ${R7}`, 'Repite la ruta de otro fichero de la lista.']);
expect(checkPaths(['a:b', 'A:B', 'ok']).problems).toEqual([
'El nombre «a:b» lleva «:», que Windows no admite en un nombre (R4).',
'El nombre «A:B» lleva «:», que Windows no admite en un nombre (R4).',
'El nombre «a:b» lleva «:», que Windows no admite en un nombre.',
'El nombre «A:B» lleva «:», que Windows no admite en un nombre.',
undefined,
]);
expect(checkPaths(['fotos/a.jpg', 'fotos/b.jpg', 'fotos/2025/a.jpg', 'carta.txt'])).toEqual({ problems: [undefined, undefined, undefined, undefined] });
@ -81,7 +81,7 @@ describe('checkPaths', () => {
it('counts the folders of R9', () => {
const paths = Array.from({ length: 65535 }, (_, i) => `d${i}/f`);
expect(checkPaths(paths).overall).toBeUndefined();
expect(checkPaths([...paths, 'e/f']).overall).toBe('Las rutas forman 65.536 carpetas, más de 65.535 (R9).');
expect(checkPaths([...paths, 'e/f']).overall).toBe('Las rutas forman 65.536 carpetas, más de 65.535.');
// A path with a problem does not count.
expect(checkPaths([...paths, 'e:/f']).overall).toBeUndefined();
});
@ -126,16 +126,16 @@ describe('the texts', () => {
it.each([
['hola\nqué tal\tadiós', undefined],
['a\r\n'.repeat(5462), undefined],
['\ud800', 'El comentario tiene un carácter mal formado (§29.6).'],
['x'.repeat(16385), 'El comentario ocupa 16.385 bytes en UTF-8, más de 16.384 (§29.4).'],
['a\u0007', 'El comentario lleva el carácter de control U+0007 (§29.6).'],
['a\u202e', 'El comentario lleva el control bidireccional U+202E, que cambia el orden en que se ve el texto (§29.6).'],
['a\u2029', 'El comentario lleva el separador U+2029 (§29.6).'],
['\ufeffa', 'El comentario lleva la marca de orden de bytes U+FEFF (§29.6).'],
['a\ufdd0', 'El comentario lleva U+FDD0, que Unicode reserva como no carácter (§29.6).'],
['a\u200b', 'El comentario lleva el carácter invisible U+200B, con el que se puede esconder texto (§29.6).'],
['hola\n\u200dadiós', 'La línea 2 del comentario empieza por el invisible U+200D (§29.6).'],
['a\ufe0e', 'La línea 1 del comentario lleva el selector de variante U+FE0E sin un emoji delante que lo admita (§29.6).'],
['\ud800', 'El comentario tiene un carácter mal formado.'],
['x'.repeat(16385), 'El comentario ocupa 16.385 bytes en UTF-8, más de 16.384.'],
['a\u0007', 'El comentario lleva el carácter de control U+0007.'],
['a\u202e', 'El comentario lleva el control bidireccional U+202E, que cambia el orden en que se ve el texto.'],
['a\u2029', 'El comentario lleva el separador U+2029.'],
['\ufeffa', 'El comentario lleva la marca de orden de bytes U+FEFF.'],
['a\ufdd0', 'El comentario lleva U+FDD0, que Unicode reserva como no carácter.'],
['a\u200b', 'El comentario lleva el carácter invisible U+200B, con el que se puede esconder texto.'],
['hola\n\u200dadiós', 'La línea 2 del comentario empieza por el invisible U+200D.'],
['a\ufe0e', 'La línea 1 del comentario lleva el selector de variante U+FE0E sin un emoji delante que lo admita.'],
])('comment %j', (s, want) => {
expect(commentProblem(s)).toBe(want);
});
@ -148,13 +148,13 @@ describe('the texts', () => {
it.each([
['Ana García', undefined],
['', undefined],
['Ana\tB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
['Ana\nB', 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
[' Ana', 'El autor no puede empezar ni acabar por un espacio (§29.6).'],
['x'.repeat(257), 'El autor ocupa 257 bytes en UTF-8, más de 256 (§29.4).'],
['Ana\u200d', 'El autor acaba en el invisible U+200D (§29.6).'],
['A\u200fB', 'El autor lleva el control bidireccional U+200F, que cambia el orden en que se ve el texto (§29.6).'],
['\ud800', 'El autor tiene un carácter mal formado (§29.6).'],
['Ana\tB', 'El autor no puede llevar tabuladores ni saltos de línea.'],
['Ana\nB', 'El autor no puede llevar tabuladores ni saltos de línea.'],
[' Ana', 'El autor no puede empezar ni acabar por un espacio.'],
['x'.repeat(257), 'El autor ocupa 257 bytes en UTF-8, más de 256.'],
['Ana\u200d', 'El autor acaba en el invisible U+200D.'],
['A\u200fB', 'El autor lleva el control bidireccional U+200F, que cambia el orden en que se ve el texto.'],
['\ud800', 'El autor tiene un carácter mal formado.'],
])('author %j', (s, want) => {
expect(authorProblem(s)).toBe(want);
});

@ -53,11 +53,11 @@ export function checkPaths(paths: readonly string[]): PathsCheck {
const old = nodes.get(k);
if (old === undefined) nodes.set(k, { name: s, dir, path: n });
else if (old.name !== s) {
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre (R7).`, paths);
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: se distinguen solo por mayúsculas, por cómo se escribe un acento o por un invisible, y en Windows o en macOS serían el mismo nombre.`, paths);
return;
} else if (old.dir !== dir) {
const prefix = segs.slice(0, i + 1).join('/');
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: «${shown(prefix)}» sería a la vez un fichero y una carpeta (R7).`, paths);
collide(problems, n, old.path, (other) => `Choca con «${shown(other)}»: «${shown(prefix)}» sería a la vez un fichero y una carpeta.`, paths);
return;
} else if (!dir) {
collide(problems, n, old.path, () => 'Repite la ruta de otro fichero de la lista.', paths);
@ -73,7 +73,7 @@ export function checkPaths(paths: readonly string[]): PathsCheck {
});
return {
problems,
...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)} (R9).` } : {}),
...(dirs.size > MAX_IMPLICIT_DIRS ? { overall: `Las rutas forman ${formatInteger(dirs.size)} carpetas, más de ${formatInteger(MAX_IMPLICIT_DIRS)}.` } : {}),
};
}
@ -113,10 +113,10 @@ const KEYS = new Memo<string>(1 << 18);
/** The problem of one path alone, R1 to R6c and R10, or undefined. */
export function pathProblem(path: string): string | undefined {
if (path === '') return 'Escribe la ruta del fichero (R1).';
if (!path.isWellFormed()) return 'La ruta tiene un carácter mal formado (R1).';
if (path === '') return 'Escribe la ruta del fichero.';
if (!path.isWellFormed()) return 'La ruta tiene un carácter mal formado.';
const n = utf8Length(path);
if (n > MAX_PATH_LEN) return `La ruta ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(MAX_PATH_LEN)} (R1).`;
if (n > MAX_PATH_LEN) return `La ruta ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(MAX_PATH_LEN)}.`;
try {
checkPath(path);
return undefined;
@ -150,48 +150,47 @@ type Rule = readonly [RegExp, (who: string, m: RegExpExecArray) => string];
// The details of each rule on a segment, from pathrule.ts, and their words.
const SEGMENT_RULES: readonly (readonly [string, ...Rule])[] = [
['R3', /^(\d+) bytes, more than \d+$/, (who, m) => `${who} ocupa ${formatInteger(Number(m[1]))} bytes en UTF-8, más de ${MAX_SEGMENT_LEN} (R3).`],
['R3', /^the segment is (a dot|two dots)$/, () => '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba (R3).'],
['R3', /^the segment is .* without ZWNJ, ZWJ, VS15 and VS16$/, (who) => `${who}, sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..» (R3).`],
['R3', /^(\d+) bytes, more than \d+$/, (who, m) => `${who} ocupa ${formatInteger(Number(m[1]))} bytes en UTF-8, más de ${MAX_SEGMENT_LEN}.`],
['R3', /^the segment is (a dot|two dots)$/, () => '«.» y «..» no valen como nombre: son la carpeta actual y la de arriba.'],
['R3', /^the segment is .* without ZWNJ, ZWJ, VS15 and VS16$/, (who) => `${who}, sin los invisibles ZWNJ, ZWJ, VS15 y VS16, queda vacío o es «.» o «..».`],
[
'R3',
/^its NFD is (\d+) UTF-16 code units, more than \d+$/,
(who, m) => `${who}, descompuesto en NFD como lo guarda macOS, mide ${formatInteger(Number(m[1]))} unidades UTF-16, más de ${MAX_SEGMENT_UTF16} (R3).`,
(who, m) => `${who}, descompuesto en NFD como lo guarda macOS, mide ${formatInteger(Number(m[1]))} unidades UTF-16, más de ${MAX_SEGMENT_UTF16}.`,
],
['R4', new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (R4).`],
['R4', new RegExp(`^character ${CODE_POINT}$`), (who, m) => `${who} lleva ${character(m[1]!)}, que Windows no admite en un nombre (R4).`],
['R4', new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (R4).`],
['R4', new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que dos nombres distintos se ven iguales (R4).`],
['R4', new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]}.`],
['R4', new RegExp(`^character ${CODE_POINT}$`), (who, m) => `${who} lleva ${character(m[1]!)}, que Windows no admite en un nombre.`],
['R4', new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]}.`],
['R4', new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que dos nombres distintos se ven iguales.`],
[
'R4',
new RegExp(`^private use ${CODE_POINT}$`),
(who, m) => `${who} lleva U+${m[1]}, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite (R4).`,
(who, m) => `${who} lleva U+${m[1]}, del área de uso privado, con el que Cygwin, WSL y macOS representan un carácter que Windows no admite.`,
],
['R4', new RegExp(`^unassigned ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode 18.0.0 no asigna (R4).`],
...placementRules('R4b').map((r) => ['R4b', ...r] as const),
['R5', /^the segment starts with U\+0020$/, (who) => `${who} empieza por un espacio (R5).`],
['R5', /^the segment ends with U\+0020$/, (who) => `${who} acaba en un espacio, que Windows quita (R5).`],
['R5', /^the segment ends with '\.'$/, (who) => `${who} acaba en punto, que Windows quita (R5).`],
['R6', /^(.+) is a reserved device name$/, (who, m) => `${who} usa ${m[1]}, un nombre que Windows reserva para un dispositivo (R6).`],
['R6b', /^the segment has the form of an 8\.3 alias$/, (who) => `${who} tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero (R6b).`],
['R4', new RegExp(`^unassigned ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode 18.0.0 no asigna.`],
...placementRules().map((r) => ['R4b', ...r] as const),
['R5', /^the segment starts with U\+0020$/, (who) => `${who} empieza por un espacio.`],
['R5', /^the segment ends with U\+0020$/, (who) => `${who} acaba en un espacio, que Windows quita.`],
['R5', /^the segment ends with '\.'$/, (who) => `${who} acaba en punto, que Windows quita.`],
['R6', /^(.+) is a reserved device name$/, (who, m) => `${who} usa ${m[1]}, un nombre que Windows reserva para un dispositivo.`],
['R6b', /^the segment has the form of an 8\.3 alias$/, (who) => `${who} tiene la forma de un nombre corto de Windows, como «PROGRA~1», que puede apuntar a otro fichero.`],
[
'R6c',
new RegExp(`^code page (\\d+) maps the segment to one with ${CODE_POINT}$`),
(who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre con ${character(m[2]!)} (R6c).`,
(who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre con ${character(m[2]!)}.`,
],
['R6c', /^code page (\d+) maps the segment to one that breaks (R\w+): /, (who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre que incumple ${m[2]} (R6c).`],
['R6c', /^code page (\d+) maps the segment to one that breaks (R\w+): /, (who, m) => `En la página de códigos ${m[1]} de Windows, ${lower(who)} se convierte en un nombre que incumple ${m[2]}.`],
];
const lower = (who: string): string => who.charAt(0).toLowerCase() + who.slice(1);
// R4b on a segment, or on a line of a text: its details, with the rule
// named at the end.
function placementRules(rule: string): readonly Rule[] {
// R4b on a segment, or on a line of a text: its details.
function placementRules(): readonly Rule[] {
return [
[new RegExp(`^${CODE_POINT} is not part of an emoji variation sequence$`), (who, m) => `${who} lleva el selector de variante U+${m[1]} sin un emoji delante que lo admita (${rule}).`],
[new RegExp(`^${CODE_POINT} at the start$`), (who, m) => `${who} empieza por el invisible U+${m[1]} (${rule}).`],
[new RegExp(`^${CODE_POINT} at the end$`), (who, m) => `${who} acaba en el invisible U+${m[1]} (${rule}).`],
[new RegExp(`^${CODE_POINT} right after ${CODE_POINT}$`), (who, m) => `${who} lleva dos invisibles seguidos, U+${m[2]} y U+${m[1]} (${rule}).`],
[new RegExp(`^${CODE_POINT} is not part of an emoji variation sequence$`), (who, m) => `${who} lleva el selector de variante U+${m[1]} sin un emoji delante que lo admita.`],
[new RegExp(`^${CODE_POINT} at the start$`), (who, m) => `${who} empieza por el invisible U+${m[1]}.`],
[new RegExp(`^${CODE_POINT} at the end$`), (who, m) => `${who} acaba en el invisible U+${m[1]}.`],
[new RegExp(`^${CODE_POINT} right after ${CODE_POINT}$`), (who, m) => `${who} lleva dos invisibles seguidos, U+${m[2]} y U+${m[1]}.`],
];
}
@ -208,24 +207,24 @@ function explainPath(err: PathRuleError, segs: readonly string[]): string {
if (err.rule === 'R2') {
const count = /^(\d+) segments/.exec(err.detail);
return count === null
? 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//» (R2).'
: `La ruta tiene ${formatInteger(Number(count[1]))} niveles, más de ${MAX_SEGMENTS} (R2).`;
? 'La ruta tiene un nombre vacío: no puede empezar ni acabar por «/», ni llevar «//».'
: `La ruta tiene ${formatInteger(Number(count[1]))} niveles, más de ${MAX_SEGMENTS}.`;
}
/* v8 ignore next -- @preserve: every detail of pathrule.ts has its words, and R10 is the last rule */
if (err.rule !== 'R10') return `La ruta no vale: ${err.message}.`;
return `${subject(segs, 0)} empieza por «.datekeys-», que la CLI usa al abrir una cápsula (R10).`;
return `${subject(segs, 0)} empieza por «.datekeys-», que la CLI usa al abrir una cápsula.`;
}
// The details of the rules of the texts, from pathrule.ts, and their words.
const TEXT_RULES: readonly Rule[] = [
[new RegExp(`^control ${CODE_POINT} in the declared author$`), () => 'El autor no puede llevar tabuladores ni saltos de línea (§29.6).'],
[new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]} (§29.6).`],
[new RegExp(`^bidirectional control ${CODE_POINT}$`), (who, m) => `${who} lleva el control bidireccional U+${m[1]}, que cambia el orden en que se ve el texto (§29.6).`],
[new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]} (§29.6).`],
[new RegExp(`^byte order mark ${CODE_POINT}$`), (who) => `${who} lleva la marca de orden de bytes U+FEFF (§29.6).`],
[new RegExp(`^noncharacter ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode reserva como no carácter (§29.6).`],
[new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que se puede esconder texto (§29.6).`],
[/^the declared author starts or ends with U\+0020$/, () => 'El autor no puede empezar ni acabar por un espacio (§29.6).'],
[new RegExp(`^control ${CODE_POINT} in the declared author$`), () => 'El autor no puede llevar tabuladores ni saltos de línea.'],
[new RegExp(`^control ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter de control U+${m[1]}.`],
[new RegExp(`^bidirectional control ${CODE_POINT}$`), (who, m) => `${who} lleva el control bidireccional U+${m[1]}, que cambia el orden en que se ve el texto.`],
[new RegExp(`^separator ${CODE_POINT}$`), (who, m) => `${who} lleva el separador U+${m[1]}.`],
[new RegExp(`^byte order mark ${CODE_POINT}$`), (who) => `${who} lleva la marca de orden de bytes U+FEFF.`],
[new RegExp(`^noncharacter ${CODE_POINT}$`), (who, m) => `${who} lleva U+${m[1]}, que Unicode reserva como no carácter.`],
[new RegExp(`^invisible ${CODE_POINT}$`), (who, m) => `${who} lleva el carácter invisible U+${m[1]}, con el que se puede esconder texto.`],
[/^the declared author starts or ends with U\+0020$/, () => 'El autor no puede empezar ni acabar por un espacio.'],
];
/** The problem of the comment of the form, or undefined; '' has none. */
@ -240,9 +239,9 @@ export function authorProblem(s: string): string | undefined {
function textProblem(s: string, who: string, max: number, check: (s: string) => void): string | undefined {
if (s === '') return undefined;
if (!s.isWellFormed()) return `${who} tiene un carácter mal formado (§29.6).`;
if (!s.isWellFormed()) return `${who} tiene un carácter mal formado.`;
const n = utf8Length(s);
if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)} (§29.4).`;
if (n > max) return `${who} ocupa ${formatInteger(n)} bytes en UTF-8, más de ${formatInteger(max)}.`;
try {
check(s);
return undefined;
@ -258,7 +257,7 @@ function explainText(err: PathRuleError, who: string): string {
const line = /^line (\d+): (.*)$/s.exec(err.detail);
if (line !== null) {
const where = who === 'El autor' ? who : `La línea ${line[1]} del comentario`;
for (const [re, text] of placementRules('§29.6')) {
for (const [re, text] of placementRules()) {
const m = re.exec(line[2]!);
if (m !== null) return text(where, m);
}

@ -79,7 +79,7 @@ describe('planCapsule', () => {
});
it('refuses no files and no comment, more files than a capsule holds, a head over 16 MiB and an L over its maximum', () => {
expect(problem({ files: chooseFiles([]) })).toEqual(['files', 'Elige al menos un fichero, o escribe un mensaje en el comentario (§62.1).']);
expect(problem({ files: chooseFiles([]) })).toEqual(['files', 'Elige al menos un fichero, o escribe un mensaje en el comentario.']);
const many = chooseFiles(Array.from({ length: 65536 }, (_, i) => ({ path: `f${i}`, size: 0 })));
expect(problem({ files: many })).toEqual(['files', 'Hay 65.536 ficheros marcados, y una cápsula guarda como mucho 65.535.']);
expect(planCapsule(input({ files: chooseFiles(many.list.slice(1)) }), GENESIS_MS).ok).toBe(true);
@ -90,11 +90,17 @@ describe('planCapsule', () => {
expect(planCapsule(input({ files: head(MAX_HEAD_LEN - rest) }), GENESIS_MS).ok).toBe(true);
expect(problem({ files: head(MAX_HEAD_LEN - rest + 1) })).toEqual([
'files',
'Las rutas y los datos de los ficheros ocupan 16.777.217 bytes en la cabecera cifrada, más de 16.777.216 bytes: marca menos ficheros o acorta sus rutas (§29.4).',
'Las rutas y los datos de los ficheros ocupan 16.777.217 bytes en la cabecera cifrada, más de 16.777.216 bytes: marca menos ficheros o acorta sus rutas.',
]);
const huge = chooseFiles([{ path: 'a', size: MAX_PAYLOAD_LENGTH }]);
const over = bodyLength({ files: huge.list }) - MAX_PAYLOAD_LENGTH;
expect(problem({ files: huge })).toEqual(['files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - over)}, el máximo de una cápsula (§29.1).`]);
expect(problem({ files: huge })).toEqual(['files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - over)}, el máximo de una cápsula.`]);
});
it('leaves a comment or an author over its limit to its own field, not to the files', () => {
expect(planCapsule(input({ comment: 'x'.repeat(17 << 20) }), GENESIS_MS).ok).toBe(true);
const long = planCapsule(input({ author: 'y'.repeat(300) }), GENESIS_MS);
expect(long.ok && long.plan.length).toBe(bodyLength({ files: [{ path: 'nota.txt', size: 1000 }] }));
});
it('opens at the first round at or after the requested instant, to the millisecond', () => {

@ -10,6 +10,7 @@ import { ACCESS_SLOTS } from '../dkc/age.ts';
import { compactDateKey, type DateKey, type Instant, isLongHorizon, resolveDateKey, roundTime } from '../dkc/datekey.ts';
import { type Policy, TIME_AND_KEY } from '../dkc/header.ts';
import { MAX_HEAD_LEN } from '../dkc/body.ts';
import { utf8Length } from '../dkc/bytes.ts';
import { bodyLengthOf, capsuleLength, headComment, headLengthOf, type MeasuredFiles, measureFiles } from '../dkc/lengths.ts';
import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
import { quicknet } from '../dkc/profile.ts';
@ -18,6 +19,11 @@ import { MAX_CAPSULE_FILES } from './create-files.ts';
import { formatByteCount, formatInteger, safeFileName } from './format.ts';
import { localToEpochMs } from './localtime.ts';
// The limits of the texts of a head, as pathrule.ts has them: that module
// brings the Unicode tables, which the first load of the page does not.
const MAX_COMMENT_BYTES = 16384;
const MAX_AUTHOR_BYTES = 256;
/** An effective unlock time closer than this gets a notice: the capsule opens almost at once. */
export const SOON_MS = 3600_000;
@ -69,9 +75,9 @@ export interface CapsulePlan {
/** The zone repeats the local time, and the later of its two instants was taken. */
readonly ambiguous: boolean;
readonly dateKey: DateKey;
/** The DateKey as the dk1_ string (§17). */
/** The DateKey as the dk1_ string. */
readonly dk1: string;
/** The effective unlock time: the time of the round, at or after the requested instant (§15). */
/** The effective unlock time: the time of the round, at or after the requested instant. */
readonly effective: Instant;
readonly effectiveMs: number;
readonly policy: Policy;
@ -131,8 +137,13 @@ export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } |
export function planCapsule(input: CreateInput, nowMs: number): Planned {
const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem });
const { list, measured } = input.files;
const texts = { comment: input.comment, author: input.author };
if (measured.count === 0 && input.comment === '') return fail('files', 'Elige al menos un fichero, o escribe un mensaje en el comentario (§62.1).');
// A comment or an author over its limit is the problem of its own field
// (create-check.ts), not of the files: the head is measured without it.
const texts = {
comment: utf8Length(headComment(input.comment)) <= MAX_COMMENT_BYTES ? input.comment : '',
author: utf8Length(input.author) <= MAX_AUTHOR_BYTES ? input.author : '',
};
if (measured.count === 0 && input.comment === '') return fail('files', 'Elige al menos un fichero, o escribe un mensaje en el comentario.');
if (measured.count > MAX_CAPSULE_FILES) {
return fail('files', `Hay ${formatInteger(measured.count)} ficheros marcados, y una cápsula guarda como mucho ${formatInteger(MAX_CAPSULE_FILES)}.`);
}
@ -140,13 +151,13 @@ export function planCapsule(input: CreateInput, nowMs: number): Planned {
if (head > MAX_HEAD_LEN) {
return fail(
'files',
`Las rutas y los datos de los ficheros ocupan ${formatByteCount(head)} en la cabecera cifrada, más de ${formatByteCount(MAX_HEAD_LEN)}: marca menos ficheros o acorta sus rutas (§29.4).`,
`Las rutas y los datos de los ficheros ocupan ${formatByteCount(head)} en la cabecera cifrada, más de ${formatByteCount(MAX_HEAD_LEN)}: marca menos ficheros o acorta sus rutas.`,
);
}
// BODY holds the files with its frame, security area and head (§29.2).
// BODY holds the files with its frame, security area and head.
const length = bodyLengthOf(measured, texts);
if (length > MAX_PAYLOAD_LENGTH) {
return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula (§29.1).`);
return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula.`);
}
if (input.date === '') return fail('date', 'Elige el día de apertura.');
if (input.time === '') return fail('time', 'Elige la hora de apertura.');

@ -185,3 +185,12 @@ describe('cliJSON', () => {
expect(out).not.toMatch(/[<>&\u2028\u2029]/);
});
});
describe('escapeInvisible of a checked text', () => {
it('keeps ZWNJ and ZWJ when asked, and escapes them otherwise', () => {
const zwj = String.fromCharCode(0x200d);
const zwnj = String.fromCharCode(0x200c);
expect(escapeInvisible(`a${zwj}b${zwnj}c`, true)).toBe(`a${zwj}b${zwnj}c`);
expect(escapeInvisible(`a${zwj}b`)).toBe(`a${String.fromCharCode(92)}u200db`);
});
});

@ -225,18 +225,20 @@ export function safeFileName(s: string): string {
/**
* A text for display in which every character that is not printable, except
* line feeds and tabs, is written as a JSON escape (\uXXXX, a surrogate pair
* line feeds and tabs, and with `joiners` ZWNJ and ZWJ, which a text of a
* head already checked may hold where R4b allows them (emoji, Persian,
* Indic scripts), is written as a JSON escape (\uXXXX, a surrogate pair
* above U+FFFF). The library's messages quote most input with Go's %q, but
* some carry it raw (an extension_id in "extension <id> v1"), and a bidi
* override there would reorder the rest of the line on screen. The escapes
* keep JSON valid and equivalent, so the CLI JSON can be shown this way too;
* copies always take the exact text.
*/
export function escapeInvisible(s: string): string {
export function escapeInvisible(s: string, joiners = false): string {
let out = '';
for (const ch of s) {
const r = ch.codePointAt(0)!;
if (r === 0x09 || r === 0x0a || isPrintableRune(r)) out += ch;
if (r === 0x09 || r === 0x0a || isPrintableRune(r) || (joiners && (r === 0x200c || r === 0x200d))) out += ch;
else for (let i = 0; i < ch.length; i++) out += `\\u${ch.charCodeAt(i).toString(16).padStart(4, '0')}`;
}
return out;

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save

Powered by TurnKey Linux.