@miliastry/quasar 1.1.0 → 1.2.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.
- package/README.md +1 -1
- package/dist/Visuals/lyne.css +257 -3
- package/dist/Visuals/osu.css +357 -6
- package/dist/index.js +850 -98
- package/dist/index.mjs +846 -99
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +11 -1
- package/dist/types/Visitors/DOMMorpher.d.ts +6 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +120 -8
- package/dist/types/Visitors/index.d.ts +1 -1
- package/dist/types/Visuals/BoxDrawer.d.ts +10 -5
- package/dist/types/Visuals/LyneAudio.d.ts +8 -0
- package/dist/types/Visuals/index.d.ts +1 -0
- package/dist/types/index.d.ts +4 -3
- package/package.json +1 -1
- package/src/BBCode/BBCodeDocumentModel.ts +36 -1
- package/src/HTML/HTMLToGreenNode.ts +8 -0
- package/src/Semantic/SemanticAnalyzer.ts +43 -0
- package/src/Visitors/BBCodeExporter.ts +51 -5
- package/src/Visitors/BlockPatcher.ts +36 -11
- package/src/Visitors/DOMMorpher.ts +44 -38
- package/src/Visitors/HTMLRenderer.ts +402 -59
- package/src/Visitors/index.ts +1 -1
- package/src/Visuals/BoxDrawer.ts +56 -10
- package/src/Visuals/LyneAudio.ts +346 -0
- package/src/Visuals/index.ts +1 -0
- package/src/Visuals/lyne.css +257 -3
- package/src/Visuals/osu.css +357 -6
- package/src/index.ts +6 -3
|
@@ -28,7 +28,7 @@ import { type InterningMode } from '../Syntax/GreenNodePool';
|
|
|
28
28
|
import { GreenNode } from '../Syntax/GreenNode';
|
|
29
29
|
import { RedNode } from '../Syntax/RedNode';
|
|
30
30
|
import { type BBCodeDialect } from './BBCodeToGreenNode';
|
|
31
|
-
import { HTMLRenderer } from '../Visitors/HTMLRenderer';
|
|
31
|
+
import { HTMLRenderer, type HTMLRendererOptions } from '../Visitors/HTMLRenderer';
|
|
32
32
|
export interface BBCodeDocumentModelOptions extends DocumentModelOptions {
|
|
33
33
|
/** Source BBCode text to parse */
|
|
34
34
|
source?: string;
|
|
@@ -64,6 +64,15 @@ export declare class BBCodeDocumentModel extends DocumentModel {
|
|
|
64
64
|
get dialect(): BBCodeDialect;
|
|
65
65
|
get root(): RedNode | null;
|
|
66
66
|
toHTML(renderer?: HTMLRenderer): string;
|
|
67
|
+
/**
|
|
68
|
+
* Fast-path read-only rendering for forum posts, comments, and static views.
|
|
69
|
+
*
|
|
70
|
+
* Completely bypasses semantic analysis (linter), undo stack allocations, and
|
|
71
|
+
* editor node ids (idMode: 'none') to maximize throughput and minimize memory.
|
|
72
|
+
*/
|
|
73
|
+
static renderForum(source: string, options?: HTMLRendererOptions & {
|
|
74
|
+
dialect?: BBCodeDialect;
|
|
75
|
+
}): string;
|
|
67
76
|
/**
|
|
68
77
|
* Parse BBCode text directly to a GreenNode tree using the
|
|
69
78
|
* DocumentEngine's built-in BBCode Lexer + Parser.
|
|
@@ -90,3 +99,4 @@ export declare class BBCodeDocumentModel extends DocumentModel {
|
|
|
90
99
|
*/
|
|
91
100
|
protected exportSource(root: RedNode): string;
|
|
92
101
|
}
|
|
102
|
+
export declare const renderForumBBCode: typeof BBCodeDocumentModel.renderForum;
|
|
@@ -11,3 +11,9 @@
|
|
|
11
11
|
* - Zero extra wrapper <div> elements
|
|
12
12
|
*/
|
|
13
13
|
export declare function morphHTML(container: HTMLElement, newHTML: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* Reconcile two HTML elements of the same tag in-place.
|
|
16
|
+
* Syncs attributes, preserves interactive states (<details open>, .open classes),
|
|
17
|
+
* and recursively morphs child nodes.
|
|
18
|
+
*/
|
|
19
|
+
export declare function morphElement(oldEl: Element, newEl: Element): void;
|
|
@@ -15,8 +15,19 @@ import { type TokenResolverFn, type TokenSource } from '../Tokens';
|
|
|
15
15
|
export interface HTMLRendererOptions {
|
|
16
16
|
/**
|
|
17
17
|
* Replicate osu! forum BBCode spacing quirks.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* NO LO LEE NADIE desde que el motor implementa las reglas reales de
|
|
20
|
+
* consumo de saltos (ver {@link HTMLRenderer.NEWLINE_RULES}). Sólo gobernaba
|
|
21
|
+
* dos heurísticas sobre `[code]` que resultaron ser falsas — osu no se come
|
|
22
|
+
* ningún salto ANTES de un bloque, y el resto de las reglas nunca estuvo
|
|
23
|
+
* condicionado. El espaciado ya no es opcional: Miliastry es "osu con
|
|
24
|
+
* esteroides" y rompe líneas igual, así que apagarlo no tendría a qué
|
|
25
|
+
* volver.
|
|
26
|
+
*
|
|
27
|
+
* Se conserva porque es API pública del renderer y quitarlo rompería a quien
|
|
28
|
+
* lo pase. Es candidato a borrarse en la próxima ruptura de versión.
|
|
29
|
+
*
|
|
30
|
+
* @deprecated Sin efecto. El espaciado de osu es ahora incondicional.
|
|
20
31
|
*/
|
|
21
32
|
osuBehaviour?: boolean;
|
|
22
33
|
/** Registry for resolving custom tags */
|
|
@@ -153,6 +164,19 @@ export declare class HTMLRenderer extends Visitor<string> {
|
|
|
153
164
|
* LYNE's forum renderer applies.
|
|
154
165
|
*/
|
|
155
166
|
private tableDepth;
|
|
167
|
+
/**
|
|
168
|
+
* ¿Estamos emitiendo el vocabulario de clases de osu!?
|
|
169
|
+
*
|
|
170
|
+
* osu estiliza box, spoilerbox, notice, imagemap, youtube, los alineados y
|
|
171
|
+
* los perfiles POR NOMBRE DE CLASE, no por estilo inline. Sobre una userpage
|
|
172
|
+
* real el HTML de Quasar salía sin estilo porque emitía su propio
|
|
173
|
+
* vocabulario (`<details>`, `.notice`, `.imagemap-container`…). Bajo
|
|
174
|
+
* `dialect: 'osu'` se emiten las clases y la estructura de osu; el resto de
|
|
175
|
+
* dialectos conserva la suya, que es la que sus hojas de estilo esperan.
|
|
176
|
+
*/
|
|
177
|
+
private isOsu;
|
|
178
|
+
/** osu recorta los saltos pegados a la apertura y al cierre de box/notice. */
|
|
179
|
+
private static trimOsuEdges;
|
|
156
180
|
private idAttr;
|
|
157
181
|
/**
|
|
158
182
|
* Kinds that carry `data-node-id`.
|
|
@@ -193,12 +217,50 @@ export declare class HTMLRenderer extends Visitor<string> {
|
|
|
193
217
|
*/
|
|
194
218
|
renderChildren(node: RedNode): string;
|
|
195
219
|
private renderNode;
|
|
196
|
-
/**
|
|
197
|
-
private
|
|
198
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
/** How many newlines a construct swallows around its own tags. */
|
|
221
|
+
private static readonly NEWLINE_RULES;
|
|
222
|
+
/**
|
|
223
|
+
* What a Miliastry-only block does. This is what the old
|
|
224
|
+
* `isPrevBlockBoundary` / `isTrailingBlockBoundary` pair did for every block:
|
|
225
|
+
* eat the first newline after the open, every newline before the close, and
|
|
226
|
+
* the first newline after the close.
|
|
227
|
+
*/
|
|
228
|
+
private static readonly LEGACY_BLOCK_RULE;
|
|
229
|
+
/**
|
|
230
|
+
* Containers whose opening tag occupies no source text, so a backwards scan
|
|
231
|
+
* has to walk straight through them.
|
|
232
|
+
*/
|
|
233
|
+
private static readonly WIDTHLESS_OPEN;
|
|
234
|
+
/**
|
|
235
|
+
* Same for the closing side. `list_item` is here because `[/*]` is never
|
|
236
|
+
* written: an item ends where the next `[*]` or the `[/list]` begins, so
|
|
237
|
+
* `\s*\[/list\]` sees the newline that Quasar stores inside the item.
|
|
238
|
+
*/
|
|
239
|
+
private static readonly WIDTHLESS_CLOSE;
|
|
240
|
+
private newlineRule;
|
|
241
|
+
private static isNewlineNode;
|
|
242
|
+
private static isBlankText;
|
|
243
|
+
/**
|
|
244
|
+
* Whether this `spacing` / `empty_line` leaf is eaten by a neighbouring tag
|
|
245
|
+
* and therefore renders nothing.
|
|
246
|
+
*
|
|
247
|
+
* Each leaf is exactly ONE source newline (the parser splits a run into one
|
|
248
|
+
* node per `\n`), so the four scans below can be read straight off the
|
|
249
|
+
* regexes they mirror. A newline eaten by any of them is eaten: osu's passes
|
|
250
|
+
* run in a fixed order, but since a consumed newline is consumed whichever
|
|
251
|
+
* pass claimed it, the union is enough — the per-pass order only matters for
|
|
252
|
+
* a budget that could be spent elsewhere, and budgets here are counted from
|
|
253
|
+
* the tag outwards, exactly as `\n?\n?` counts.
|
|
254
|
+
*/
|
|
255
|
+
private isNewlineSwallowed;
|
|
256
|
+
/** `\[box\]\n*`, `\[quote\]\s*`, `[centre]\n`. */
|
|
257
|
+
private eatenByOpeningTag;
|
|
258
|
+
/** `\n*\[/box\]`, `\s*\[/quote\]`, `\s*\[/list\]`. */
|
|
259
|
+
private eatenByClosingTag;
|
|
260
|
+
/** `\[/box\]\n?`, `\[/list\]\n?\n?`. */
|
|
261
|
+
private eatenAfterClosingTag;
|
|
262
|
+
/** `\s*\[\*\]` — the only pass that eats whitespace BEFORE an opening tag. */
|
|
263
|
+
private eatenBeforeOpeningTag;
|
|
202
264
|
private renderError;
|
|
203
265
|
private wrapInline;
|
|
204
266
|
private wrapBlock;
|
|
@@ -208,6 +270,30 @@ export declare class HTMLRenderer extends Visitor<string> {
|
|
|
208
270
|
/** Keyword-or-number CSS values (font-weight, font-style, text-decoration). */
|
|
209
271
|
private isCssKeyword;
|
|
210
272
|
/** Read a metadata field, falling back to the raw tag attribute. */
|
|
273
|
+
/**
|
|
274
|
+
* Lo único que osu! acepta en `[color=…]`.
|
|
275
|
+
*
|
|
276
|
+
* Su `BBCodeForDB::parseColour` sella el tag con un uid sólo si el valor
|
|
277
|
+
* matchea `#[[:xdigit:]]{6}` o `[[:alpha:]]+` — nada más. No valida que el
|
|
278
|
+
* nombre sea un color CSS de verdad (`banana` pasa), pero `#fff`, `#ffffffff`,
|
|
279
|
+
* `rgb(...)`, `$token` o un hex sin `#` no pasan. Sin uid, la segunda pasada
|
|
280
|
+
* no ve el tag y el opener *y* el closer quedan como texto en la página.
|
|
281
|
+
*/
|
|
282
|
+
private static readonly OSU_COLOR_RE;
|
|
283
|
+
/**
|
|
284
|
+
* `[color]` con el vocabulario de cada dialecto.
|
|
285
|
+
*
|
|
286
|
+
* Miliastry (y Lyne) aceptan a propósito más que osu: `#RGB`, `#RGBA`, un
|
|
287
|
+
* `$token` de diseño, nombres propios. Bajo `dialect: 'osu'` eso es una
|
|
288
|
+
* mentira: el editor pintaría color donde la página publicada muestra el
|
|
289
|
+
* BBCode crudo. Así que replicamos lo que hace osu — literal el opener,
|
|
290
|
+
* literal el closer, y los hijos renderizados normalmente en el medio.
|
|
291
|
+
*
|
|
292
|
+
* El chequeo mira el texto crudo del atributo, no el valor saneado: osu
|
|
293
|
+
* matchea sobre la fuente, así que `[color="#ffffff"]` (con comillas) también
|
|
294
|
+
* se le escapa.
|
|
295
|
+
*/
|
|
296
|
+
private renderColor;
|
|
211
297
|
private colorStyle;
|
|
212
298
|
private fontSizeStyle;
|
|
213
299
|
private fontStyle;
|
|
@@ -252,12 +338,30 @@ export declare class HTMLRenderer extends Visitor<string> {
|
|
|
252
338
|
private renderRaw;
|
|
253
339
|
private renderPlain;
|
|
254
340
|
private renderAlign;
|
|
341
|
+
/**
|
|
342
|
+
* `[centre]` / `[left]` / `[right]` (y `[align=…]`).
|
|
343
|
+
*
|
|
344
|
+
* osu! no usa `text-align` inline: estiliza el bloque por nombre de clase,
|
|
345
|
+
* con la grafía británica `centre`. Fuera del dialecto osu el estilo inline
|
|
346
|
+
* se mantiene, porque ni Miliastry ni Lyne traen esas reglas.
|
|
347
|
+
*/
|
|
348
|
+
private renderAlignAs;
|
|
255
349
|
private renderEffect;
|
|
256
350
|
private renderAnim;
|
|
257
351
|
private renderContainer;
|
|
258
352
|
private static readonly STYLE_PROP_WHITELIST;
|
|
259
353
|
private renderStyleTag;
|
|
260
354
|
private renderQuote;
|
|
355
|
+
/**
|
|
356
|
+
* La estructura exacta que `bbcode-spoilerbox` de osu-web espera.
|
|
357
|
+
*
|
|
358
|
+
* El toggle de osu es JS: `js-spoilerbox__link` es el gancho del click y
|
|
359
|
+
* `js-spoilerbox__body` el panel que abre. Si falta cualquiera de las dos
|
|
360
|
+
* clases el box queda mudo, así que la estructura no es decorativa.
|
|
361
|
+
*/
|
|
362
|
+
private renderOsuSpoilerbox;
|
|
363
|
+
/** El rótulo de un box bajo osu: el del autor, o `SPOILER` en mayúsculas. */
|
|
364
|
+
private osuBoxTitle;
|
|
261
365
|
private renderSpoilerbox;
|
|
262
366
|
private renderBox;
|
|
263
367
|
/**
|
|
@@ -268,6 +372,14 @@ export declare class HTMLRenderer extends Visitor<string> {
|
|
|
268
372
|
* escrito y devolvía `[box=Box]`.
|
|
269
373
|
*/
|
|
270
374
|
private bareTitleAttr;
|
|
375
|
+
/**
|
|
376
|
+
* ¿El título del box lo escribió el autor, o es el relleno del parser?
|
|
377
|
+
*
|
|
378
|
+
* `BBCodeToGreenNode` ya deja `metadata.title = 'Box'`/`'Spoiler'` para un
|
|
379
|
+
* tag pelado, así que el `fallback` de `renderTitle` nunca llega a usarse:
|
|
380
|
+
* quien quiera otro rótulo por defecto tiene que preguntar por aquí.
|
|
381
|
+
*/
|
|
382
|
+
private hasOwnTitle;
|
|
271
383
|
private renderTitle;
|
|
272
384
|
/**
|
|
273
385
|
* `[box=Title:#hex]`, `[tables=striped:#hex]`, `[columns=2:#hex]` → un
|
|
@@ -2,7 +2,7 @@ export { Visitor } from './Visitor';
|
|
|
2
2
|
export type { VisitorContext } from './Visitor';
|
|
3
3
|
export { BBCodeExporter } from './BBCodeExporter';
|
|
4
4
|
export { HTMLRenderer } from './HTMLRenderer';
|
|
5
|
-
export { morphHTML } from './DOMMorpher';
|
|
5
|
+
export { morphHTML, morphElement } from './DOMMorpher';
|
|
6
6
|
export { MarkdownExporter } from './MarkdownExporter';
|
|
7
7
|
export { JSONExporter } from './JSONExporter';
|
|
8
8
|
export { SVGRenderer } from './SVGRenderer';
|
|
@@ -29,13 +29,18 @@ export interface BoxDrawerOptions {
|
|
|
29
29
|
easing?: string;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Animate one `<details>` to its opposite state
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Exported so hosts that toggle boxes programmatically (a keyboard command, an
|
|
36
|
-
* "expand all" action) go through the same animation as a click.
|
|
32
|
+
* Animate one `<details>` to its opposite state. Exported so hosts that toggle
|
|
33
|
+
* boxes programmatically go through the same animation as a click.
|
|
37
34
|
*/
|
|
38
35
|
export declare function toggleBoxWithDrawer(details: HTMLDetailsElement, options?: BoxDrawerOptions): void;
|
|
36
|
+
/**
|
|
37
|
+
* Lo mismo para la caja del dialecto osu, que no es un `<details>`: es
|
|
38
|
+
* `div.js-spoilerbox > a.js-spoilerbox__link + div.js-spoilerbox__body`, y su
|
|
39
|
+
* estado abierto es la clase `js-spoilerbox--open` en el contenedor. Marcado
|
|
40
|
+
* distinto, misma animación — y la misma razón para vivir aquí: Quasar es
|
|
41
|
+
* quien emite el marcado, así que también define cómo se abre.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toggleSpoilerboxWithDrawer(box: HTMLElement, options?: BoxDrawerOptions): void;
|
|
39
44
|
/**
|
|
40
45
|
* Intercept the native toggle of every box inside `root`.
|
|
41
46
|
*
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Document Engine — Visuals / LyneAudio.ts
|
|
3
|
+
*
|
|
4
|
+
* Interactive runtime for Quasar's Lyne / SYNE custom audio player (.lx-audio).
|
|
5
|
+
* Supports both scoped root binding (bindLyneAudio) and global document delegation.
|
|
6
|
+
*/
|
|
7
|
+
export declare function bindLyneAudio(root?: HTMLElement): () => void;
|
|
8
|
+
export declare const setupLyneAudioRuntime: typeof bindLyneAudio;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
export { bindBoxDrawer, toggleBoxWithDrawer } from './BoxDrawer';
|
|
26
26
|
export type { BoxDrawerOptions } from './BoxDrawer';
|
|
27
|
+
export { setupLyneAudioRuntime, bindLyneAudio } from './LyneAudio';
|
|
27
28
|
export declare const visualThemes: readonly [{
|
|
28
29
|
readonly id: 'osu';
|
|
29
30
|
readonly name: 'osu! Forum Style';
|
package/dist/types/index.d.ts
CHANGED
|
@@ -86,7 +86,7 @@ export { Visitor, type VisitorContext } from './Visitors/Visitor';
|
|
|
86
86
|
export { BBCodeExporter, MILIASTRY_ONLY_TAGS, LYNE_ONLY_TAGS, type ExportTarget, type BBCodeExporterOptions, type BBCodeExportOptions } from './Visitors/BBCodeExporter';
|
|
87
87
|
export { BBBlocksExporter, type UIBBBlock } from './Visitors/BBBlocksExporter';
|
|
88
88
|
export { HTMLRenderer, type HTMLRendererOptions } from './Visitors/HTMLRenderer';
|
|
89
|
-
export { morphHTML } from './Visitors/DOMMorpher';
|
|
89
|
+
export { morphHTML, morphElement } from './Visitors/DOMMorpher';
|
|
90
90
|
export { patchBlocksInto, clearPatchCache, type PatchBlocksOptions, type PatchBlocksStats } from './Visitors/BlockPatcher';
|
|
91
91
|
export { SVGRenderer } from './Visitors/SVGRenderer';
|
|
92
92
|
export { MarkdownExporter } from './Visitors/MarkdownExporter';
|
|
@@ -114,7 +114,7 @@ export { PluginRegistry, type PluginManifest, type PluginContribution } from './
|
|
|
114
114
|
export { repairNesting, type NestingRepair, type OrphanCloser, type UnclosedOpener } from './Repair/NestingRepair';
|
|
115
115
|
export { reconcileVisualDOMToBBCode, computeTextDelta, type SurgicalEdit as QuasarSurgicalEdit, type ReconcileResult } from './Reconciler/SurgicalReconciler';
|
|
116
116
|
export * from './Edits';
|
|
117
|
-
export { BBCodeDocumentModel } from './BBCode/BBCodeDocumentModel';
|
|
117
|
+
export { BBCodeDocumentModel, renderForumBBCode } from './BBCode/BBCodeDocumentModel';
|
|
118
118
|
export type { BBCodeDocumentModelOptions } from './BBCode/BBCodeDocumentModel';
|
|
119
119
|
export { bbBlocksToRedTree, bbBlocksToGreenTree, bbBlockToGreenNode, greenToRedNode, tagToNodeKind, nodeKindToTag, isBlockKind, BBCODE_TAG_NAMES, getBBCodeTagNames, type BBCodeDialect, } from './BBCode/BBCodeToGreenNode';
|
|
120
120
|
export type { BBBlock } from './BBCode/BBCodeToGreenNode';
|
|
@@ -122,7 +122,7 @@ export { DocumentEventBus, type DocumentEvent, type DocumentEventHandler } from
|
|
|
122
122
|
export { transformOffset, transformRange, type TransformBias } from './Collab/positions';
|
|
123
123
|
export { visualThemes } from './Visuals/index';
|
|
124
124
|
export type { VisualThemeId } from './Visuals/index';
|
|
125
|
-
export { bindBoxDrawer, toggleBoxWithDrawer } from './Visuals/BoxDrawer';
|
|
125
|
+
export { bindBoxDrawer, toggleBoxWithDrawer, toggleSpoilerboxWithDrawer } from './Visuals/BoxDrawer';
|
|
126
126
|
export type { BoxDrawerOptions } from './Visuals/BoxDrawer';
|
|
127
127
|
export { applyGradient, applyGrow, applyRainbow, applyCentralGradient, applyMultiGradient, applyEffect, countTextLength } from './Utils/treeTransformers';
|
|
128
128
|
export type { GradientEffect, GrowEffect, RainbowEffect, CentralGradientEffect, MultiGradientEffect, TreeEffect } from './Utils/treeTransformers';
|
|
@@ -152,3 +152,4 @@ export { nodeAttrValue, parseImgAttr, sanitizeColor, sanitizeFontSize, sanitizeF
|
|
|
152
152
|
export type { ImgAttr } from './Syntax/nodeAttr';
|
|
153
153
|
export { toTokenResolver, resolveTokenValue, } from './Tokens/index';
|
|
154
154
|
export type { DocumentTokens, TokenResolverFn, TokenSource, } from './Tokens/index';
|
|
155
|
+
export { setupLyneAudioRuntime, bindLyneAudio } from './Visuals/LyneAudio';
|
package/package.json
CHANGED
|
@@ -32,7 +32,7 @@ import { greenToRedNode, greenToRedNodeReusing, type BBCodeDialect } from './BBC
|
|
|
32
32
|
import { parseTokensToGreen } from './Parser'
|
|
33
33
|
import { scanBBCode } from '../Lexer/BBCodeLexer'
|
|
34
34
|
import { BBCodeExporter } from '../Visitors/BBCodeExporter'
|
|
35
|
-
import { HTMLRenderer } from '../Visitors/HTMLRenderer'
|
|
35
|
+
import { HTMLRenderer, type HTMLRendererOptions } from '../Visitors/HTMLRenderer'
|
|
36
36
|
|
|
37
37
|
export interface BBCodeDocumentModelOptions extends DocumentModelOptions {
|
|
38
38
|
/** Source BBCode text to parse */
|
|
@@ -110,6 +110,39 @@ export class BBCodeDocumentModel extends DocumentModel {
|
|
|
110
110
|
return r.render(this.redRoot)
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Fast-path read-only rendering for forum posts, comments, and static views.
|
|
115
|
+
*
|
|
116
|
+
* Completely bypasses semantic analysis (linter), undo stack allocations, and
|
|
117
|
+
* editor node ids (idMode: 'none') to maximize throughput and minimize memory.
|
|
118
|
+
*/
|
|
119
|
+
static renderForum(
|
|
120
|
+
source: string,
|
|
121
|
+
options: HTMLRendererOptions & { dialect?: BBCodeDialect } = {}
|
|
122
|
+
): string {
|
|
123
|
+
const previousIdMode = HTMLRenderer.idMode
|
|
124
|
+
HTMLRenderer.idMode = 'none'
|
|
125
|
+
try {
|
|
126
|
+
const dialect = options.dialect ?? (options.theme === 'lyne' ? 'lyne' : 'miliastry')
|
|
127
|
+
const doc = new BBCodeDocumentModel({
|
|
128
|
+
source: source || ' ',
|
|
129
|
+
dialect,
|
|
130
|
+
autoAnalyze: false,
|
|
131
|
+
maxUndo: 0,
|
|
132
|
+
incremental: false,
|
|
133
|
+
})
|
|
134
|
+
if (!doc.redRoot) return ''
|
|
135
|
+
const renderer = new HTMLRenderer({
|
|
136
|
+
...options,
|
|
137
|
+
dialect,
|
|
138
|
+
registry: options.registry ?? doc.tagRegistry,
|
|
139
|
+
})
|
|
140
|
+
return renderer.render(doc.redRoot)
|
|
141
|
+
} finally {
|
|
142
|
+
HTMLRenderer.idMode = previousIdMode
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
/**
|
|
114
147
|
* Parse BBCode text directly to a GreenNode tree using the
|
|
115
148
|
* DocumentEngine's built-in BBCode Lexer + Parser.
|
|
@@ -176,3 +209,5 @@ export class BBCodeDocumentModel extends DocumentModel {
|
|
|
176
209
|
return new BBCodeExporter(this.tagRegistry, exportTarget).export(root)
|
|
177
210
|
}
|
|
178
211
|
}
|
|
212
|
+
|
|
213
|
+
export const renderForumBBCode = BBCodeDocumentModel.renderForum
|
|
@@ -189,6 +189,14 @@ function domToGreenTree(root: HTMLElement): GreenNode {
|
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
191
|
}
|
|
192
|
+
else if (el.classList.contains('lx-audio')) {
|
|
193
|
+
kind = 'audio';
|
|
194
|
+
const audioSrc = el.getAttribute('data-src') || el.querySelector('audio')?.getAttribute('src') || '';
|
|
195
|
+
if (audioSrc) {
|
|
196
|
+
children.push(greenLeaf('text', audioSrc));
|
|
197
|
+
currentOffset += audioSrc.length;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
192
200
|
else if (el.style.textAlign === 'center') kind = 'center';
|
|
193
201
|
else if (el.style.textAlign === 'right') kind = 'right';
|
|
194
202
|
else if (el.style.textAlign === 'left') kind = 'left';
|
|
@@ -2213,6 +2213,49 @@ export class SemanticAnalyzer {
|
|
|
2213
2213
|
return diagnostics.length > 0 ? diagnostics : null
|
|
2214
2214
|
},
|
|
2215
2215
|
})
|
|
2216
|
+
|
|
2217
|
+
// ── Box missing '=' validator ────────────────────────────────
|
|
2218
|
+
// In osu!, [box] without '=' is not parsed as a collapsible box.
|
|
2219
|
+
// Miliastry can preview it as a default-titled box, but osu! forum compatibility
|
|
2220
|
+
// requires [box=] (or [box=Title]).
|
|
2221
|
+
this.register({
|
|
2222
|
+
code: 'box-missing-equals',
|
|
2223
|
+
severity: 'warning',
|
|
2224
|
+
kinds: ['box'],
|
|
2225
|
+
validate: (node, ctx) => {
|
|
2226
|
+
if (node.text.startsWith('=')) return null
|
|
2227
|
+
const openEnd = node.innerStart > node.range.start ? node.innerStart : node.range.start + 5
|
|
2228
|
+
if (node.range.start < 0 || openEnd > ctx.source.length) return null
|
|
2229
|
+
const openTag = ctx.source.slice(node.range.start, openEnd)
|
|
2230
|
+
if (openTag.includes('=')) return null
|
|
2231
|
+
|
|
2232
|
+
const fixes: DiagnosticFix[] = [
|
|
2233
|
+
{
|
|
2234
|
+
description: "Add '=' to [box]",
|
|
2235
|
+
isAutomatic: true,
|
|
2236
|
+
operations: [
|
|
2237
|
+
{
|
|
2238
|
+
kind: 'replace_text',
|
|
2239
|
+
range: { start: node.range.start, end: openEnd },
|
|
2240
|
+
newText: '[box=]',
|
|
2241
|
+
},
|
|
2242
|
+
],
|
|
2243
|
+
},
|
|
2244
|
+
]
|
|
2245
|
+
|
|
2246
|
+
return createDiagnostic(
|
|
2247
|
+
'box-missing-equals',
|
|
2248
|
+
"[box] without '=' can be previewed in Miliastry, but osu! requires [box=] to parse it correctly",
|
|
2249
|
+
'warning',
|
|
2250
|
+
{
|
|
2251
|
+
nodeId: node.id,
|
|
2252
|
+
nodeKind: node.kind,
|
|
2253
|
+
range: { start: node.range.start, end: openEnd },
|
|
2254
|
+
fixes,
|
|
2255
|
+
},
|
|
2256
|
+
)
|
|
2257
|
+
},
|
|
2258
|
+
})
|
|
2216
2259
|
}
|
|
2217
2260
|
|
|
2218
2261
|
/**
|
|
@@ -199,12 +199,49 @@ function hasAttrValue(value: unknown): boolean {
|
|
|
199
199
|
return value !== undefined && value !== null && String(value) !== ''
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Expande un cuerpo hexadecimal a los 6 dígitos que exige osu, o `null` si no
|
|
204
|
+
* es un hex de longitud reconocible (3, 4, 6 u 8).
|
|
205
|
+
*
|
|
206
|
+
* El canal alfa se descarta a propósito: osu no soporta transparencia en
|
|
207
|
+
* ninguna forma, así que la alternativa a perderlo es publicar un `[color=…]`
|
|
208
|
+
* que su parser rechaza y deja como texto literal en la página.
|
|
209
|
+
*/
|
|
210
|
+
function expandHexForOsu(body: string): string | null {
|
|
211
|
+
if (!/^[0-9a-fA-F]+$/.test(body)) return null
|
|
212
|
+
switch (body.length) {
|
|
213
|
+
case 3:
|
|
214
|
+
return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
|
|
215
|
+
case 4:
|
|
216
|
+
// RGBA corto: expandimos RGB y tiramos el alfa.
|
|
217
|
+
return body[0] + body[0] + body[1] + body[1] + body[2] + body[2]
|
|
218
|
+
case 6:
|
|
219
|
+
// Ya es legal: se devuelve tal cual, sin tocar mayúsculas del autor.
|
|
220
|
+
return body
|
|
221
|
+
case 8:
|
|
222
|
+
// RGBA largo: los dos últimos dígitos son el alfa.
|
|
223
|
+
return body.slice(0, 6)
|
|
224
|
+
default:
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `target` decide si además de convertir `rgb()` hay que adaptar el hex a la
|
|
231
|
+
* gramática de osu (`BBCodeForDB::parseColour`), que sólo acepta `#` + 6
|
|
232
|
+
* dígitos hex o una secuencia puramente alfabética. Para 'miliastry' y 'lyne'
|
|
233
|
+
* el comportamiento es el de siempre: no se reescribe nada.
|
|
234
|
+
*/
|
|
235
|
+
function normalizeColorToHex(color: string, target: ExportTarget = 'miliastry'): string {
|
|
203
236
|
if (!color) return color
|
|
204
237
|
const trimmed = color.trim()
|
|
205
238
|
// Un hex ya es canónico: bajarlo a minúsculas reescribía `[color=#FF0000]`
|
|
206
239
|
// del autor en cada exportación, y `Analysis/RoundTrip` fija lo contrario.
|
|
207
|
-
if (trimmed.startsWith('#'))
|
|
240
|
+
if (trimmed.startsWith('#')) {
|
|
241
|
+
if (target !== 'osu') return trimmed
|
|
242
|
+
const expanded = expandHexForOsu(trimmed.slice(1))
|
|
243
|
+
return expanded === null ? trimmed : `#${expanded}`
|
|
244
|
+
}
|
|
208
245
|
|
|
209
246
|
const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
|
|
210
247
|
if (rgbMatch) {
|
|
@@ -214,6 +251,15 @@ function normalizeColorToHex(color: string): string {
|
|
|
214
251
|
return `#${r}${g}${b}`.toLowerCase()
|
|
215
252
|
}
|
|
216
253
|
|
|
254
|
+
// Hex desnudo: osu exige el `#`, así que `ff0000` se publicaría roto. Sólo
|
|
255
|
+
// lo tratamos como hex si trae algún dígito; una palabra puramente
|
|
256
|
+
// alfabética (`red`, y también `beef`, que es hex válido) ya matchea la
|
|
257
|
+
// alternativa alfabética de osu y no hay que tocarla.
|
|
258
|
+
if (target === 'osu' && /\d/.test(trimmed)) {
|
|
259
|
+
const expanded = expandHexForOsu(trimmed)
|
|
260
|
+
if (expanded !== null) return `#${expanded}`
|
|
261
|
+
}
|
|
262
|
+
|
|
217
263
|
return trimmed
|
|
218
264
|
}
|
|
219
265
|
|
|
@@ -365,7 +411,7 @@ export class BBCodeExporter extends Visitor<string> {
|
|
|
365
411
|
if (this.shouldResolveTokens() && col.startsWith('$')) {
|
|
366
412
|
col = resolveTokenValue(col, this.tokenResolver)
|
|
367
413
|
}
|
|
368
|
-
out = `[color=${normalizeColorToHex(col)}]${out}[/color]`
|
|
414
|
+
out = `[color=${normalizeColorToHex(col, this.target)}]${out}[/color]`
|
|
369
415
|
}
|
|
370
416
|
if (style.fontSize) {
|
|
371
417
|
let size = style.fontSize
|
|
@@ -476,7 +522,7 @@ export class BBCodeExporter extends Visitor<string> {
|
|
|
476
522
|
if (this.shouldResolveTokens() && color.startsWith('$')) {
|
|
477
523
|
color = resolveTokenValue(color, this.tokenResolver)
|
|
478
524
|
}
|
|
479
|
-
return `=${normalizeColorToHex(color)}`
|
|
525
|
+
return `=${normalizeColorToHex(color, this.target)}`
|
|
480
526
|
} else if (node.kind === 'font' && hasAttrValue(node.metadata.font)) {
|
|
481
527
|
let font = String(node.metadata.font)
|
|
482
528
|
if (this.shouldResolveTokens() && font.startsWith('$')) {
|
|
@@ -589,7 +635,7 @@ export class BBCodeExporter extends Visitor<string> {
|
|
|
589
635
|
if (this.shouldResolveTokens() && col.startsWith('$')) {
|
|
590
636
|
col = resolveTokenValue(col, this.tokenResolver)
|
|
591
637
|
}
|
|
592
|
-
return `=${normalizeColorToHex(col)}`
|
|
638
|
+
return `=${normalizeColorToHex(col, this.target)}`
|
|
593
639
|
}
|
|
594
640
|
if (this.shouldResolveTokens() && text.startsWith('=')) {
|
|
595
641
|
let val = text.slice(1)
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
|
|
42
42
|
import { RedNode } from '../Syntax/RedNode'
|
|
43
43
|
import { HTMLRenderer } from './HTMLRenderer'
|
|
44
|
-
import { morphHTML } from './DOMMorpher'
|
|
44
|
+
import { morphHTML, morphElement } from './DOMMorpher'
|
|
45
45
|
import type { TextChangeRange } from '../Incremental/ChangeTracker'
|
|
46
46
|
|
|
47
47
|
export interface PatchBlocksOptions {
|
|
@@ -215,6 +215,21 @@ function blockKey(node: RedNode, index: number): string {
|
|
|
215
215
|
return node.id ?? `__block_${index}`
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Blocks whose HTML is NOT a function of the node alone.
|
|
220
|
+
*
|
|
221
|
+
* A newline leaf renders `<br>` or nothing depending on which tags surround
|
|
222
|
+
* it — `[/box]\n` loses the newline, `text\n` keeps it (see
|
|
223
|
+
* `HTMLRenderer.isNewlineSwallowed`). The block cache is keyed by node
|
|
224
|
+
* identity, so reordering blocks without touching them keeps every id and
|
|
225
|
+
* every cached fragment while changing what the newlines between them mean.
|
|
226
|
+
* Re-rendering them unconditionally costs a couple of sibling hops and keeps
|
|
227
|
+
* the patched DOM equal to a full render, which is the whole contract.
|
|
228
|
+
*/
|
|
229
|
+
function isContextSensitive(node: RedNode): boolean {
|
|
230
|
+
return node.kind === 'spacing' || node.kind === 'empty_line'
|
|
231
|
+
}
|
|
232
|
+
|
|
218
233
|
/** Build a DOM node from an HTML fragment (first child), decoding entities. */
|
|
219
234
|
function nodeFromHtml(html: string): Node {
|
|
220
235
|
const t = document.createElement('template')
|
|
@@ -279,7 +294,6 @@ function shouldMorphInPlace(
|
|
|
279
294
|
return (
|
|
280
295
|
run.kind === 'element' &&
|
|
281
296
|
!!run.node.id &&
|
|
282
|
-
run.node.children.length > 0 &&
|
|
283
297
|
canMorphInPlace(run.node) &&
|
|
284
298
|
renderedTag(run.html) === element.tagName
|
|
285
299
|
)
|
|
@@ -401,7 +415,7 @@ function reconcileKeyed(
|
|
|
401
415
|
let patched = 0
|
|
402
416
|
try {
|
|
403
417
|
runs = buildRuns(blocks, keys, (node, key) => {
|
|
404
|
-
if (cache.lastNode.get(key) === node) {
|
|
418
|
+
if (cache.lastNode.get(key) === node && !isContextSensitive(node)) {
|
|
405
419
|
return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
|
|
406
420
|
}
|
|
407
421
|
const html = renderer.render(node)
|
|
@@ -469,13 +483,19 @@ function reconcileKeyed(
|
|
|
469
483
|
}
|
|
470
484
|
|
|
471
485
|
if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
|
|
472
|
-
// Element with
|
|
473
|
-
// place, preserving the element
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
486
|
+
// Element with a matching tag: morph its attributes and children in
|
|
487
|
+
// place via morphElement, preserving the element identity and runtime state
|
|
488
|
+
// (such as an open <details>, its <summary> and title, or media playback).
|
|
489
|
+
// Tag mismatch (kind change at a stable slot) or wrapper kinds
|
|
490
|
+
// (code/svg/imagemap) go the replace path below.
|
|
491
|
+
const newEl = nodeFromHtml(run.html)
|
|
492
|
+
if (newEl && newEl.nodeType === 1 && (newEl as Element).tagName === (element as Element).tagName) {
|
|
493
|
+
morphElement(element as Element, newEl as Element)
|
|
494
|
+
} else {
|
|
495
|
+
container.replaceChild(newEl, element)
|
|
496
|
+
}
|
|
477
497
|
} else {
|
|
478
|
-
// Text run, an element leaf (img/video/audio
|
|
498
|
+
// Text run, an element leaf (img/video/audio/<br>), a kind whose tag
|
|
479
499
|
// changed, or a wrapper renderer kind: the node's own structure must
|
|
480
500
|
// change — replace outright.
|
|
481
501
|
container.replaceChild(nodeFromHtml(run.html), element)
|
|
@@ -639,7 +659,7 @@ function reconcileWindowed(
|
|
|
639
659
|
let runs: PatchRun[]
|
|
640
660
|
try {
|
|
641
661
|
runs = buildRuns(blocks, keys, (node, key) => {
|
|
642
|
-
if (cache.lastNode.get(key) === node) {
|
|
662
|
+
if (cache.lastNode.get(key) === node && !isContextSensitive(node)) {
|
|
643
663
|
return { html: cache.lastHtml.get(key) ?? '', kind: cache.lastClass.get(key) ?? 'none' }
|
|
644
664
|
}
|
|
645
665
|
const html = renderer.render(node)
|
|
@@ -909,7 +929,12 @@ function reconcileWindowed(
|
|
|
909
929
|
}
|
|
910
930
|
|
|
911
931
|
if (element.nodeType === 1 && shouldMorphInPlace(element as Element, run)) {
|
|
912
|
-
|
|
932
|
+
const newEl = nodeFromHtml(run.html)
|
|
933
|
+
if (newEl && newEl.nodeType === 1 && (newEl as Element).tagName === (element as Element).tagName) {
|
|
934
|
+
morphElement(element as Element, newEl as Element)
|
|
935
|
+
} else {
|
|
936
|
+
container.replaceChild(newEl, element)
|
|
937
|
+
}
|
|
913
938
|
} else {
|
|
914
939
|
container.replaceChild(nodeFromHtml(run.html), element)
|
|
915
940
|
}
|