@jjlmoya/utils-games-development 1.53.0 → 1.54.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/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/steamBbcodeTranslator/app-client.ts +125 -0
- package/src/tool/steamBbcodeTranslator/bbcode-parser.ts +110 -0
- package/src/tool/steamBbcodeTranslator/bibliography.astro +6 -0
- package/src/tool/steamBbcodeTranslator/bibliography.ts +14 -0
- package/src/tool/steamBbcodeTranslator/component.astro +83 -0
- package/src/tool/steamBbcodeTranslator/entry.ts +28 -0
- package/src/tool/steamBbcodeTranslator/html-parser.ts +42 -0
- package/src/tool/steamBbcodeTranslator/html-renderer.ts +71 -0
- package/src/tool/steamBbcodeTranslator/i18n/de.ts +207 -0
- package/src/tool/steamBbcodeTranslator/i18n/en.ts +236 -0
- package/src/tool/steamBbcodeTranslator/i18n/es.ts +207 -0
- package/src/tool/steamBbcodeTranslator/i18n/fr.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/id.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/it.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ja.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ko.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/nl.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/pl.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/pt.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ru.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/sv.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/tr.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/zh.ts +195 -0
- package/src/tool/steamBbcodeTranslator/index.ts +12 -0
- package/src/tool/steamBbcodeTranslator/logic.test.ts +41 -0
- package/src/tool/steamBbcodeTranslator/logic.ts +96 -0
- package/src/tool/steamBbcodeTranslator/markdown-parser.ts +115 -0
- package/src/tool/steamBbcodeTranslator/markdown-renderer.ts +85 -0
- package/src/tool/steamBbcodeTranslator/seo.astro +15 -0
- package/src/tool/steamBbcodeTranslator/steam-bbcode-translator.css +392 -0
- package/src/tool/steamBbcodeTranslator/storage.ts +30 -0
- package/src/tool/steamBbcodeTranslator/types.ts +36 -0
- package/src/tool/steamBbcodeTranslator/ui.ts +18 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -3,10 +3,11 @@ import { steamCapsuleGenerator } from '../tool/steamCapsuleGenerator/entry';
|
|
|
3
3
|
import { spriteSheetPacker } from '../tool/spriteSheetPacker/entry';
|
|
4
4
|
import { audioLoopPointFinder } from '../tool/audioLoopPointFinder/entry';
|
|
5
5
|
import { saveFileEditor } from '../tool/saveFileEditor/entry';
|
|
6
|
+
import { steamBbcodeTranslator } from '../tool/steamBbcodeTranslator/entry';
|
|
6
7
|
|
|
7
8
|
export const gamesCategory: GamesCategoryEntry = {
|
|
8
9
|
icon: 'mdi:gamepad-variant',
|
|
9
|
-
tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor],
|
|
10
|
+
tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator],
|
|
10
11
|
i18n: {
|
|
11
12
|
de: () => import('./i18n/de').then((m) => m.content),
|
|
12
13
|
en: () => import('./i18n/en').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { saveFileEditor } from './tool/saveFileEditor/entry';
|
|
|
10
10
|
export type { SaveFileEditorUI, SaveFileEditorLocaleContent } from './tool/saveFileEditor/entry';
|
|
11
11
|
export { localizationSanitizer } from './tool/localizationSanitizer/entry';
|
|
12
12
|
export type { LocalizationSanitizerUI, LocalizationSanitizerLocaleContent } from './tool/localizationSanitizer/entry';
|
|
13
|
+
export { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
|
|
14
|
+
export type { SteamBbcodeTranslatorUI, SteamBbcodeTranslatorLocaleContent } from './tool/steamBbcodeTranslator/entry';
|
|
13
15
|
export { gamesCategory } from './category';
|
|
14
16
|
import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
|
|
15
17
|
import { itchioGameTester } from './tool/itchioGameTester/entry';
|
package/src/index.ts
CHANGED
|
@@ -17,5 +17,6 @@ export type {
|
|
|
17
17
|
|
|
18
18
|
export { ALL_ENTRIES, ALL_TOOLS } from './tools';
|
|
19
19
|
export { STEAM_CAPSULE_GENERATOR_TOOL, steamCapsuleGenerator } from './tool/steamCapsuleGenerator';
|
|
20
|
+
export { STEAM_BBCODE_TRANSLATOR_TOOL, steamBbcodeTranslator } from './tool/steamBbcodeTranslator';
|
|
20
21
|
|
|
21
22
|
export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { ALL_TOOLS } from '../tools';
|
|
3
3
|
|
|
4
4
|
describe('Locale Completeness Validation', () => {
|
|
5
|
-
it('
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
5
|
+
it('seven tools are registered', () => {
|
|
6
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
7
7
|
});
|
|
8
8
|
});
|
|
@@ -4,8 +4,8 @@ import { gamesCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 7 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('gamesCategory should be defined', () => {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AutoTranslationResult, MarkupSyntax, TranslationResult } from './types';
|
|
2
|
+
import { clearSourceFromStorage, loadSourceFromStorage, saveSourceToStorage } from './storage';
|
|
3
|
+
import { detectSyntax, getAutoPreviewHtml, translateToAll } from './logic';
|
|
4
|
+
|
|
5
|
+
interface AppElements {
|
|
6
|
+
root: HTMLElement;
|
|
7
|
+
source: HTMLTextAreaElement;
|
|
8
|
+
detected: HTMLElement;
|
|
9
|
+
outputGrid: HTMLElement;
|
|
10
|
+
preview: HTMLElement;
|
|
11
|
+
characterCount: HTMLElement;
|
|
12
|
+
blockCount: HTMLElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getElements(root: HTMLElement): AppElements | null {
|
|
16
|
+
const source = root.querySelector('#bbcode-source') as HTMLTextAreaElement | null;
|
|
17
|
+
const detected = root.querySelector('#bbcode-detected') as HTMLElement | null;
|
|
18
|
+
const outputGrid = root.querySelector('#bbcode-output-grid') as HTMLElement | null;
|
|
19
|
+
const preview = root.querySelector('#bbcode-preview') as HTMLElement | null;
|
|
20
|
+
const characterCount = root.querySelector('#bbcode-character-count') as HTMLElement | null;
|
|
21
|
+
const blockCount = root.querySelector('#bbcode-block-count') as HTMLElement | null;
|
|
22
|
+
|
|
23
|
+
if (!source || !detected || !outputGrid || !preview || !characterCount || !blockCount) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { root, source, detected, outputGrid, preview, characterCount, blockCount };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function copyText(value: string, button: HTMLButtonElement, copiedText: string): Promise<void> {
|
|
31
|
+
await navigator.clipboard?.writeText(value);
|
|
32
|
+
const original = button.textContent;
|
|
33
|
+
button.textContent = copiedText;
|
|
34
|
+
window.setTimeout(() => {
|
|
35
|
+
button.textContent = original;
|
|
36
|
+
}, 1200);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createCard(target: MarkupSyntax, translation: TranslationResult, names: Record<MarkupSyntax, string>, dataset: DOMStringMap): HTMLElement {
|
|
40
|
+
const card = document.createElement('article');
|
|
41
|
+
card.className = 'bbcode-output-card';
|
|
42
|
+
|
|
43
|
+
const heading = document.createElement('div');
|
|
44
|
+
heading.className = 'bbcode-output-card-head';
|
|
45
|
+
|
|
46
|
+
const label = document.createElement('h3');
|
|
47
|
+
label.textContent = names[target];
|
|
48
|
+
|
|
49
|
+
const actions = document.createElement('div');
|
|
50
|
+
actions.className = 'bbcode-output-actions';
|
|
51
|
+
|
|
52
|
+
const copyButton = document.createElement('button');
|
|
53
|
+
copyButton.type = 'button';
|
|
54
|
+
copyButton.className = 'bbcode-icon-button';
|
|
55
|
+
copyButton.textContent = dataset.copy ?? 'Copy';
|
|
56
|
+
copyButton.addEventListener('click', () => copyText(translation.output, copyButton, dataset.copied ?? 'Copied'));
|
|
57
|
+
|
|
58
|
+
actions.append(copyButton);
|
|
59
|
+
heading.append(label, actions);
|
|
60
|
+
|
|
61
|
+
const pre = document.createElement('pre');
|
|
62
|
+
pre.textContent = translation.output;
|
|
63
|
+
card.append(heading, pre);
|
|
64
|
+
|
|
65
|
+
return card;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updateMeta(elements: AppElements, names: Record<MarkupSyntax, string>, result: AutoTranslationResult): void {
|
|
69
|
+
const { root, source, detected, characterCount, blockCount } = elements;
|
|
70
|
+
const syntax = detectSyntax(source.value);
|
|
71
|
+
detected.textContent = source.value.trim()
|
|
72
|
+
? `${root.dataset.detectedLabel ?? 'Detected'}: ${names[syntax]}`
|
|
73
|
+
: root.dataset.detectedEmpty ?? '';
|
|
74
|
+
|
|
75
|
+
characterCount.textContent = String(source.value.length);
|
|
76
|
+
const firstOutputKey = Object.keys(result.outputs)[0] as MarkupSyntax | undefined;
|
|
77
|
+
const firstBlockCount = firstOutputKey ? result.outputs[firstOutputKey]?.stats.blocks ?? 0 : 0;
|
|
78
|
+
blockCount.textContent = String(firstBlockCount);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderOutputCards(elements: AppElements, names: Record<MarkupSyntax, string>, result: AutoTranslationResult): void {
|
|
82
|
+
elements.outputGrid.innerHTML = '';
|
|
83
|
+
Object.entries(result.outputs).forEach(([target, translation]) => {
|
|
84
|
+
if (!translation) return;
|
|
85
|
+
const card = createCard(target as MarkupSyntax, translation, names, elements.root.dataset);
|
|
86
|
+
elements.outputGrid.append(card);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderApp(elements: AppElements, names: Record<MarkupSyntax, string>): void {
|
|
91
|
+
const result = translateToAll(elements.source.value);
|
|
92
|
+
updateMeta(elements, names, result);
|
|
93
|
+
renderOutputCards(elements, names, result);
|
|
94
|
+
elements.preview.innerHTML = getAutoPreviewHtml(elements.source.value) || `<p>${elements.root.dataset.previewEmpty ?? ''}</p>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function initSteamBbcodeTranslatorApp(): void {
|
|
98
|
+
const root = document.getElementById('steam-bbcode-translator');
|
|
99
|
+
if (!root) return;
|
|
100
|
+
const elements = getElements(root);
|
|
101
|
+
if (!elements) return;
|
|
102
|
+
|
|
103
|
+
const names: Record<MarkupSyntax, string> = {
|
|
104
|
+
bbcode: root.dataset.bbcode ?? 'Steam BBCode',
|
|
105
|
+
markdown: root.dataset.markdown ?? 'Markdown',
|
|
106
|
+
html: root.dataset.html ?? 'HTML'
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const stored = loadSourceFromStorage();
|
|
110
|
+
if (stored) elements.source.value = stored;
|
|
111
|
+
|
|
112
|
+
elements.source.addEventListener('input', () => {
|
|
113
|
+
saveSourceToStorage(elements.source.value);
|
|
114
|
+
renderApp(elements, names);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
root.querySelector('#bbcode-clear')?.addEventListener('click', () => {
|
|
118
|
+
elements.source.value = '';
|
|
119
|
+
clearSourceFromStorage();
|
|
120
|
+
renderApp(elements, names);
|
|
121
|
+
elements.source.focus();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
renderApp(elements, names);
|
|
125
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Node, TagNode, TextNode } from './types';
|
|
2
|
+
|
|
3
|
+
const BB_TAG_PATTERN = /\[(\/)?([a-z][a-z0-9]*|\*)(?:=([^\]]*))?\]/gi;
|
|
4
|
+
const SUPPORTED_TAGS = new Set(['h1', 'h2', 'h3', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'url', 'code', 'quote', 'spoiler', 'list', 'olist', '*']);
|
|
5
|
+
|
|
6
|
+
export function text(value: string): TextNode {
|
|
7
|
+
return { kind: 'text', value };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function tag(name: string, value: string | undefined, children: Node[] = []): TagNode {
|
|
11
|
+
return { kind: 'tag', name, value, children };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ParseStackEntry {
|
|
15
|
+
node: TagNode;
|
|
16
|
+
parent: Node[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ParseContext {
|
|
20
|
+
stack: ParseStackEntry[];
|
|
21
|
+
root: Node[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
append: (n: Node) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleAsteriskTag(ctx: ParseContext, full: string): void {
|
|
27
|
+
const inList = ctx.stack.some((entry) => entry.node.name === 'list' || entry.node.name === 'olist');
|
|
28
|
+
if (!inList) {
|
|
29
|
+
ctx.warnings.push('A list item marker was found outside a list.');
|
|
30
|
+
ctx.append(text(full));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
while (ctx.stack.length) {
|
|
34
|
+
const top = ctx.stack[ctx.stack.length - 1];
|
|
35
|
+
if (top && top.node.name === '*') ctx.stack.pop();
|
|
36
|
+
else break;
|
|
37
|
+
}
|
|
38
|
+
const listParent = ctx.stack[ctx.stack.length - 1];
|
|
39
|
+
if (listParent && (listParent.node.name === 'list' || listParent.node.name === 'olist')) {
|
|
40
|
+
const item = tag('*', undefined);
|
|
41
|
+
listParent.node.children.push(item);
|
|
42
|
+
ctx.stack.push({ node: item, parent: listParent.node.children });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleClosingTag(ctx: ParseContext, name: string, full: string): void {
|
|
47
|
+
const matchingIndex = [...ctx.stack].reverse().findIndex((entry) => entry.node.name === name);
|
|
48
|
+
if (matchingIndex === -1) {
|
|
49
|
+
ctx.warnings.push(`Closing tag [/${name}] has no matching opening tag.`);
|
|
50
|
+
ctx.append(text(full));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (let i = 0; i <= matchingIndex; i += 1) ctx.stack.pop();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleOpeningTag(ctx: ParseContext, name: string, value?: string): void {
|
|
57
|
+
const node = tag(name, value);
|
|
58
|
+
ctx.append(node);
|
|
59
|
+
if (name !== 'br') {
|
|
60
|
+
const top = ctx.stack[ctx.stack.length - 1];
|
|
61
|
+
ctx.stack.push({ node, parent: top ? top.node.children : ctx.root });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function processMatch(ctx: ParseContext, match: RegExpExecArray, full: string, rawName: string): void {
|
|
66
|
+
const closing = Boolean(match[1]);
|
|
67
|
+
const name = rawName.toLowerCase();
|
|
68
|
+
const value = match[3]?.trim();
|
|
69
|
+
|
|
70
|
+
if (!SUPPORTED_TAGS.has(name)) {
|
|
71
|
+
ctx.warnings.push(`Unsupported tag [${name}] was kept as text.`);
|
|
72
|
+
ctx.append(text(full));
|
|
73
|
+
} else if (name === '*') {
|
|
74
|
+
handleAsteriskTag(ctx, full);
|
|
75
|
+
} else if (closing) {
|
|
76
|
+
handleClosingTag(ctx, name, full);
|
|
77
|
+
} else {
|
|
78
|
+
handleOpeningTag(ctx, name, value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseBbcode(source: string): { nodes: Node[]; warnings: string[]; tagCount: number } {
|
|
83
|
+
const root: Node[] = [];
|
|
84
|
+
const stack: ParseStackEntry[] = [];
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
let tagCount = 0;
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
|
|
89
|
+
const append = (nodeToAppend: Node) => {
|
|
90
|
+
const top = stack[stack.length - 1];
|
|
91
|
+
const target = top ? top.node.children : root;
|
|
92
|
+
target.push(nodeToAppend);
|
|
93
|
+
};
|
|
94
|
+
const ctx: ParseContext = { stack, root, warnings, append };
|
|
95
|
+
|
|
96
|
+
for (const match of source.matchAll(BB_TAG_PATTERN)) {
|
|
97
|
+
const full = match[0];
|
|
98
|
+
const index = match.index ?? 0;
|
|
99
|
+
if (index > cursor) append(text(source.slice(cursor, index)));
|
|
100
|
+
cursor = index + full.length;
|
|
101
|
+
if (!match[2]) continue;
|
|
102
|
+
tagCount += 1;
|
|
103
|
+
processMatch(ctx, match, full, match[2]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cursor < source.length) append(text(source.slice(cursor)));
|
|
107
|
+
const top = stack[stack.length - 1];
|
|
108
|
+
if (top) warnings.push(`Unclosed tag [${top.node.name}] was closed automatically.`);
|
|
109
|
+
return { nodes: root, warnings: [...new Set(warnings)], tagCount };
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const bibliographyEntries: { name: string; url: string }[] = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Steamworks Store Page Written Description',
|
|
4
|
+
url: 'https://partner.steamgames.com/doc/store/page/description?language=english'
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'Steamworks Importing HTML',
|
|
8
|
+
url: 'https://partner.steamgames.com/doc/marketing/event_tools/import?language=english'
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: 'itch.io Creator Documentation',
|
|
12
|
+
url: 'https://itch.io/docs/creators/getting-started'
|
|
13
|
+
}
|
|
14
|
+
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SteamBbcodeTranslatorUI } from './ui';
|
|
3
|
+
import { sampleBbcode } from './logic';
|
|
4
|
+
import './steam-bbcode-translator.css';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: SteamBbcodeTranslatorUI;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui: t } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div
|
|
14
|
+
class="bbcode-root"
|
|
15
|
+
id="steam-bbcode-translator"
|
|
16
|
+
data-detected-label={t.detectedLabel}
|
|
17
|
+
data-detected-empty={t.detectedEmpty}
|
|
18
|
+
data-bbcode={t.bbcode}
|
|
19
|
+
data-markdown={t.markdown}
|
|
20
|
+
data-html={t.html}
|
|
21
|
+
data-copy={t.copy}
|
|
22
|
+
data-copied={t.copied}
|
|
23
|
+
data-preview-empty={t.previewEmpty}
|
|
24
|
+
data-characters={t.characters}
|
|
25
|
+
data-blocks={t.blocks}
|
|
26
|
+
>
|
|
27
|
+
<div class="bbcode-header">
|
|
28
|
+
<div class="bbcode-title-row">
|
|
29
|
+
<span class="bbcode-mark" aria-hidden="true">[ / ]</span>
|
|
30
|
+
<div>
|
|
31
|
+
<span class="bbcode-kicker">STEAM MARKUP TRANSLATOR</span>
|
|
32
|
+
<strong>{t.privacyNote}</strong>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<span class="bbcode-draft-note">{t.persistenceNote}</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<section class="bbcode-input-card">
|
|
39
|
+
<div class="bbcode-section-head">
|
|
40
|
+
<div>
|
|
41
|
+
<span class="bbcode-step">01</span>
|
|
42
|
+
<h2>{t.editorLabel}</h2>
|
|
43
|
+
</div>
|
|
44
|
+
<span class="bbcode-detected" id="bbcode-detected">{t.detectedEmpty}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<label class="bbcode-sr-only" for="bbcode-source">{t.editorLabel}</label>
|
|
47
|
+
<textarea id="bbcode-source" spellcheck="false" aria-describedby="bbcode-editor-hint">{sampleBbcode}</textarea>
|
|
48
|
+
<div class="bbcode-input-footer">
|
|
49
|
+
<p id="bbcode-editor-hint">{t.editorHint}</p>
|
|
50
|
+
<div class="bbcode-input-meta">
|
|
51
|
+
<span><strong id="bbcode-character-count">0</strong> {t.characters}</span>
|
|
52
|
+
<span><strong id="bbcode-block-count">0</strong> {t.blocks}</span>
|
|
53
|
+
<button type="button" class="bbcode-clear-button" id="bbcode-clear">{t.clear}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<section class="bbcode-output-section" aria-live="polite">
|
|
59
|
+
<div class="bbcode-output-heading">
|
|
60
|
+
<span class="bbcode-step">02</span>
|
|
61
|
+
<h2>{t.detectedLabel}</h2>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="bbcode-output-grid" id="bbcode-output-grid"></div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section class="bbcode-preview-card">
|
|
67
|
+
<div class="bbcode-output-heading">
|
|
68
|
+
<span class="bbcode-step">03</span>
|
|
69
|
+
<h2>{t.previewLabel}</h2>
|
|
70
|
+
</div>
|
|
71
|
+
<div id="bbcode-preview" class="bbcode-preview-surface"><p>{t.previewEmpty}</p></div>
|
|
72
|
+
</section>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
import { initSteamBbcodeTranslatorApp } from './app-client';
|
|
77
|
+
|
|
78
|
+
if (document.readyState === 'loading') {
|
|
79
|
+
document.addEventListener('DOMContentLoaded', initSteamBbcodeTranslatorApp);
|
|
80
|
+
} else {
|
|
81
|
+
initSteamBbcodeTranslatorApp();
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GamesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { SteamBbcodeTranslatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { SteamBbcodeTranslatorUI };
|
|
5
|
+
|
|
6
|
+
export type SteamBbcodeTranslatorLocaleContent = ToolLocaleContent<SteamBbcodeTranslatorUI>;
|
|
7
|
+
|
|
8
|
+
export const steamBbcodeTranslator: GamesToolEntry<SteamBbcodeTranslatorUI> = {
|
|
9
|
+
id: 'steam-bbcode-translator',
|
|
10
|
+
icons: { bg: 'mdi:gamepad-variant', fg: 'mdi:code-brackets' },
|
|
11
|
+
i18n: {
|
|
12
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
13
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
14
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
15
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
16
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
17
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
18
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
19
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
20
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
21
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
22
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
23
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
24
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
25
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
26
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TranslationResult } from './types';
|
|
2
|
+
|
|
3
|
+
export function decodeHtml(value: string): string {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, "'")
|
|
9
|
+
.replace(/&/g, '&');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function countBlocks(source: string): number {
|
|
13
|
+
return source.trim() ? source.split(/\n{2,}/).filter(Boolean).length : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function htmlToBbcode(source: string): TranslationResult {
|
|
17
|
+
let output = source.replace(/<!--[\s\S]*?-->/g, '');
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
output = output.replace(/<h([1-3])\b[^>]*>/gi, (_, level: string) => `[h${level}]`);
|
|
20
|
+
output = output.replace(/<\/h([1-3])>/gi, (_, level: string) => `[/h${level}]`);
|
|
21
|
+
const replacements: [RegExp, string][] = [
|
|
22
|
+
[/<(?:strong|b)\b[^>]*>/gi, '[b]'], [/<\/(?:strong|b)>/gi, '[/b]'],
|
|
23
|
+
[/<(?:em|i)\b[^>]*>/gi, '[i]'], [/<\/(?:em|i)>/gi, '[/i]'],
|
|
24
|
+
[/<u\b[^>]*>/gi, '[u]'], [/<\/u>/gi, '[/u]'],
|
|
25
|
+
[/<(?:del|s|strike)\b[^>]*>/gi, '[s]'], [/<\/(?:del|s|strike)>/gi, '[/s]'],
|
|
26
|
+
[/<blockquote\b[^>]*>/gi, '[quote]'], [/<\/blockquote>/gi, '[/quote]'],
|
|
27
|
+
[/<code\b[^>]*>/gi, '[code]'], [/<\/code>/gi, '[/code]'],
|
|
28
|
+
[/<pre\b[^>]*>/gi, ''], [/<\/pre>/gi, ''],
|
|
29
|
+
[/<details\b[^>]*>/gi, '[spoiler]'], [/<\/details>/gi, '[/spoiler]'],
|
|
30
|
+
[/<summary\b[^>]*>.*?<\/summary>/gi, ''],
|
|
31
|
+
[/<ul\b[^>]*>/gi, '[list]'], [/<\/ul>/gi, '[/list]'],
|
|
32
|
+
[/<ol\b[^>]*>/gi, '[olist]'], [/<\/ol>/gi, '[/olist]'],
|
|
33
|
+
[/<li\b[^>]*>/gi, '[*]'], [/<\/li>/gi, '\n'],
|
|
34
|
+
[/<br\s*\/?>/gi, '\n'], [/<p\b[^>]*>/gi, ''], [/<\/p>/gi, '\n\n']
|
|
35
|
+
];
|
|
36
|
+
for (const [pattern, replacement] of replacements) output = output.replace(pattern, replacement);
|
|
37
|
+
output = output.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[url=$1]$2[/url]');
|
|
38
|
+
if (/<[a-z][^>]*>/i.test(output)) warnings.push('Unsupported HTML tags were removed from the BBCode result.');
|
|
39
|
+
output = decodeHtml(output.replace(/<[^>]+>/g, '')).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
40
|
+
const tagsCount = (source.match(/<\/?[a-z][^>]*>/gi) ?? []).length;
|
|
41
|
+
return { output, warnings, stats: { characters: output.length, tags: tagsCount, blocks: countBlocks(output) } };
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Node, TagNode } from './types';
|
|
2
|
+
|
|
3
|
+
export function escapeHtml(value: string): string {
|
|
4
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
5
|
+
if (character === '&') return '&';
|
|
6
|
+
if (character === '<') return '<';
|
|
7
|
+
if (character === '>') return '>';
|
|
8
|
+
if (character === '"') return '"';
|
|
9
|
+
return ''';
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function inlineHtml(nodes: Node[]): string {
|
|
14
|
+
return nodes.map((node) => node.kind === 'text' ? escapeHtml(node.value) : renderHtmlNode(node, true)).join('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderHtmlList(node: TagNode, ordered: boolean): string {
|
|
18
|
+
const items = node.children.filter((child): child is TagNode => child.kind === 'tag' && child.name === '*');
|
|
19
|
+
const tagName = ordered ? 'ol' : 'ul';
|
|
20
|
+
const listItems = items.map((item) => {
|
|
21
|
+
const inner = item.children.map((child) => {
|
|
22
|
+
if (child.kind === 'tag' && (child.name === 'list' || child.name === 'olist')) return renderHtmlNode(child);
|
|
23
|
+
if (child.kind === 'text') return escapeHtml(child.value);
|
|
24
|
+
return renderHtmlNode(child, true);
|
|
25
|
+
}).join('');
|
|
26
|
+
return `<li>${inner}</li>`;
|
|
27
|
+
}).join('');
|
|
28
|
+
return `<${tagName}>${listItems}</${tagName}>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderHtmlHeading(name: string, content: string): string | null {
|
|
32
|
+
if (name === 'h1') return `<h1>${content.trim()}</h1>`;
|
|
33
|
+
if (name === 'h2') return `<h2>${content.trim()}</h2>`;
|
|
34
|
+
if (name === 'h3') return `<h3>${content.trim()}</h3>`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderHtmlInlineStyle(name: string, content: string): string | null {
|
|
39
|
+
if (name === 'b' || name === 'strong') return `<strong>${content}</strong>`;
|
|
40
|
+
if (name === 'i' || name === 'em') return `<em>${content}</em>`;
|
|
41
|
+
if (name === 'u') return `<u>${content}</u>`;
|
|
42
|
+
if (name === 's' || name === 'strike') return `<del>${content}</del>`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderHtmlUrlOrCode(name: string, content: string, value?: string, inline = false): string {
|
|
47
|
+
if (name === 'url') return value ? `<a href="${escapeHtml(value)}">${content || escapeHtml(value)}</a>` : content;
|
|
48
|
+
if (name === 'code') return inline ? `<code>${content}</code>` : `<pre><code>${content}</code></pre>`;
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderHtmlFormatNode(name: string, content: string, value?: string, inline = false): string {
|
|
53
|
+
const heading = renderHtmlHeading(name, content);
|
|
54
|
+
if (heading !== null) return heading;
|
|
55
|
+
const inlineStyle = renderHtmlInlineStyle(name, content);
|
|
56
|
+
if (inlineStyle !== null) return inlineStyle;
|
|
57
|
+
return renderHtmlUrlOrCode(name, content, value, inline);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderHtmlNode(node: TagNode, inline = false): string {
|
|
61
|
+
const content = inlineHtml(node.children);
|
|
62
|
+
if (node.name === 'quote') return `<blockquote>${content}</blockquote>`;
|
|
63
|
+
if (node.name === 'spoiler') return `<details><summary>Spoiler</summary>${content}</details>`;
|
|
64
|
+
if (node.name === 'list') return renderHtmlList(node, false);
|
|
65
|
+
if (node.name === 'olist') return renderHtmlList(node, true);
|
|
66
|
+
return renderHtmlFormatNode(node.name, content, node.value, inline);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderHtml(nodes: Node[]): string {
|
|
70
|
+
return nodes.map((node) => node.kind === 'text' ? escapeHtml(node.value) : renderHtmlNode(node)).join('').replace(/\n{2,}/g, '<br>');
|
|
71
|
+
}
|