From c07d63e681c7f44f00851d4409ce4c341ffb3c03 Mon Sep 17 00:00:00 2001 From: rwv Date: Wed, 11 Mar 2026 16:40:29 +0800 Subject: [PATCH 1/2] feat(code): add xml formatter and validator --- pnpm-lock.yaml | 90 ++++ pnpm-workspace.yaml | 4 +- registry/tools/package.json | 5 +- registry/tools/src/index.ts | 2 + registry/tools/src/routes.ts | 2 + tools/code/xml-formatter/package.json | 24 ++ .../src/XmlFormatterView.dom.test.ts | 39 ++ .../xml-formatter/src/XmlFormatterView.vue | 11 + .../components/WhatIsXmlFormatter.dom.test.ts | 34 ++ .../src/components/WhatIsXmlFormatter.vue | 115 ++++++ .../src/components/XmlFormatter.dom.test.ts | 189 +++++++++ .../src/components/XmlFormatter.vue | 164 ++++++++ .../src/components/XmlFormatterActions.vue | 278 +++++++++++++ .../src/components/XmlFormatterOptions.vue | 391 ++++++++++++++++++ .../components/XmlFormatterPanels.dom.test.ts | 77 ++++ .../src/components/XmlFormatterPanels.vue | 313 ++++++++++++++ .../XmlFormatterToolbar.dom.test.ts | 104 +++++ .../src/components/XmlFormatterToolbar.vue | 63 +++ .../xml-formatter/src/exports.dom.test.ts | 24 ++ tools/code/xml-formatter/src/index.ts | 1 + tools/code/xml-formatter/src/info.ts | 53 +++ tools/code/xml-formatter/src/routes.ts | 9 + .../xml-formatter/src/utils/xml.dom.test.ts | 63 +++ tools/code/xml-formatter/src/utils/xml.ts | 102 +++++ 24 files changed, 2154 insertions(+), 3 deletions(-) create mode 100644 tools/code/xml-formatter/package.json create mode 100644 tools/code/xml-formatter/src/XmlFormatterView.dom.test.ts create mode 100644 tools/code/xml-formatter/src/XmlFormatterView.vue create mode 100644 tools/code/xml-formatter/src/components/WhatIsXmlFormatter.dom.test.ts create mode 100644 tools/code/xml-formatter/src/components/WhatIsXmlFormatter.vue create mode 100644 tools/code/xml-formatter/src/components/XmlFormatter.dom.test.ts create mode 100644 tools/code/xml-formatter/src/components/XmlFormatter.vue create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterActions.vue create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterOptions.vue create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterPanels.dom.test.ts create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterPanels.vue create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterToolbar.dom.test.ts create mode 100644 tools/code/xml-formatter/src/components/XmlFormatterToolbar.vue create mode 100644 tools/code/xml-formatter/src/exports.dom.test.ts create mode 100644 tools/code/xml-formatter/src/index.ts create mode 100644 tools/code/xml-formatter/src/info.ts create mode 100644 tools/code/xml-formatter/src/routes.ts create mode 100644 tools/code/xml-formatter/src/utils/xml.dom.test.ts create mode 100644 tools/code/xml-formatter/src/utils/xml.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826394fe..8bfd533f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ catalogs: exifr: specifier: ^7.1.3 version: 7.1.3 + fast-xml-parser: + specifier: ^5.5.1 + version: 5.5.1 figlet: specifier: ^1.10.0 version: 1.10.0 @@ -465,6 +468,9 @@ catalogs: wrangler: specifier: 4.69.0 version: 4.69.0 + xml-formatter: + specifier: ^3.6.7 + version: 3.6.7 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -1275,6 +1281,9 @@ importers: '@tools/whirlpool-hash-text-or-file': specifier: workspace:* version: link:../../tools/hash/whirlpool-hash-text-or-file + '@tools/xml-formatter': + specifier: workspace:* + version: link:../../tools/code/xml-formatter '@tools/xml-to-json-converter': specifier: workspace:* version: link:../../tools/code/xml-to-json-converter @@ -2085,6 +2094,45 @@ importers: specifier: 'catalog:' version: 4.0.9 + tools/code/xml-formatter: + dependencies: + '@shared/tools': + specifier: workspace:* + version: link:../../../shared/tools + '@shared/ui': + specifier: workspace:* + version: link:../../../shared/ui + '@vicons/fluent': + specifier: 'catalog:' + version: 0.13.0 + '@vueuse/core': + specifier: 'catalog:' + version: 14.2.1(vue@3.5.29(typescript@5.9.3)) + browser-fs-access: + specifier: 'catalog:' + version: 0.38.0 + fast-xml-parser: + specifier: 'catalog:' + version: 5.5.1 + highlight.js: + specifier: 'catalog:' + version: 11.11.1 + naive-ui: + specifier: 'catalog:' + version: 2.43.2(vue@3.5.29(typescript@5.9.3)) + vue: + specifier: 'catalog:' + version: 3.5.29(typescript@5.9.3) + vue-i18n: + specifier: 'catalog:' + version: 11.2.8(vue@3.5.29(typescript@5.9.3)) + vue-router: + specifier: 'catalog:' + version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) + xml-formatter: + specifier: 'catalog:' + version: 3.6.7 + tools/code/xml-to-json-converter: dependencies: '@shared/tools': @@ -11044,6 +11092,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.1.0: + resolution: {integrity: sha512-7mtITW/we2/wTUZqMyBOR2F8xP4CRxMiSEcQxPIqdRWdO2L/HZSOlzoNyghmyDwNB8BDxePooV1ZTJpkOUhdRg==} + + fast-xml-parser@5.5.1: + resolution: {integrity: sha512-JTpMz8P5mDoNYzXTmTT/xzWjFiCWi0U+UQTJtrFH9muXsr2RqtXZPbnCW5h2mKsOd4u3XcPWCvDSrnaBPlUcMQ==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -12112,6 +12167,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.1.2: + resolution: {integrity: sha512-LXWqJmcpp2BKOEmgt4CyuESFmBfPuhJlAHKJsFzuJU6CxErWk75BrO+Ni77M9OxHN6dCYKM4vj+21Z6cOL96YQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -12594,6 +12653,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.2.0: + resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + super-regex@0.2.0: resolution: {integrity: sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==} engines: {node: '>=14.16'} @@ -13258,6 +13320,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-formatter@3.6.7: + resolution: {integrity: sha512-IsfFYJQuoDqtUlKhm4EzeoBOb+fQwzQVeyxxAQ0sThn/nFnQmyLPTplqq4yRhaOENH/tAyujD2TBfIYzUKB6hg==} + engines: {node: '>= 16'} + xml-js@1.6.11: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true @@ -13270,6 +13336,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-parser-xo@4.1.5: + resolution: {integrity: sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==} + engines: {node: '>= 16'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -16600,6 +16670,16 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.1.0: + dependencies: + path-expression-matcher: 1.1.2 + + fast-xml-parser@5.5.1: + dependencies: + fast-xml-builder: 1.1.0 + path-expression-matcher: 1.1.2 + strnum: 2.2.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -17647,6 +17727,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.1.2: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -18176,6 +18258,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.2.0: {} + super-regex@0.2.0: dependencies: clone-regexp: 3.0.0 @@ -18935,6 +19019,10 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-formatter@3.6.7: + dependencies: + xml-parser-xo: 4.1.5 + xml-js@1.6.11: dependencies: sax: 1.4.4 @@ -18943,6 +19031,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-parser-xo@4.1.5: {} + xmlchars@2.2.0: {} xxhash-wasm@1.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e0a20d0..160d3ea9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,9 +61,9 @@ catalog: '@vitest/coverage-istanbul': ^4.0.16 '@vitest/coverage-v8': ^4.0.18 '@vitest/eslint-plugin': ^1.6.9 + '@vue/compiler-dom': ^3.5.29 '@vue/eslint-config-prettier': ^10.2.0 '@vue/eslint-config-typescript': ^14.7.0 - '@vue/compiler-dom': ^3.5.29 '@vue/test-utils': ^2.4.6 '@vue/tsconfig': ^0.8.1 '@vueuse/core': 14.2.1 @@ -94,6 +94,7 @@ catalog: eslint-plugin-playwright: ^2.7.1 eslint-plugin-vue: ~10.8.0 exifr: ^7.1.3 + fast-xml-parser: ^5.5.1 figlet: ^1.10.0 filesize: ^11.0.13 gifuct-js: ^2.1.2 @@ -165,6 +166,7 @@ catalog: webpxmux: ^0.0.2 webrtc-ips: ^0.2.0 wrangler: 4.69.0 + xml-formatter: ^3.6.7 xml-js: ^1.6.11 xxhash-wasm: ^1.1.0 diff --git a/registry/tools/package.json b/registry/tools/package.json index bb4e4de7..ac4edcee 100644 --- a/registry/tools/package.json +++ b/registry/tools/package.json @@ -83,7 +83,6 @@ "@tools/html-entity-encoder-decoder": "workspace:*", "@tools/html-to-markdown-converter": "workspace:*", "@tools/http-status-code-lookup": "workspace:*", - "@tools/image-to-pdf-converter": "workspace:*", "@tools/iban-validator": "workspace:*", "@tools/ical-event-generator": "workspace:*", "@tools/image-metadata-cleaner": "workspace:*", @@ -91,6 +90,7 @@ "@tools/image-resizer": "workspace:*", "@tools/image-to-avif-converter": "workspace:*", "@tools/image-to-ico": "workspace:*", + "@tools/image-to-pdf-converter": "workspace:*", "@tools/image-to-webp-converter": "workspace:*", "@tools/image-tools": "workspace:*", "@tools/imei-validator": "workspace:*", @@ -136,8 +136,8 @@ "@tools/pbkdf2-key-derivation": "workspace:*", "@tools/pdf-info-viewer": "workspace:*", "@tools/pdf-merger": "workspace:*", - "@tools/pdf-page-organizer": "workspace:*", "@tools/pdf-page-number-adder": "workspace:*", + "@tools/pdf-page-organizer": "workspace:*", "@tools/pdf-splitter": "workspace:*", "@tools/pdf-text-extractor": "workspace:*", "@tools/pdf-to-image-converter": "workspace:*", @@ -209,6 +209,7 @@ "@tools/vat-validator": "workspace:*", "@tools/vin-validator": "workspace:*", "@tools/whirlpool-hash-text-or-file": "workspace:*", + "@tools/xml-formatter": "workspace:*", "@tools/xml-to-json-converter": "workspace:*", "@tools/xxhash-xxh3-128-hash-text-or-file": "workspace:*", "@tools/xxhash-xxh3-64-hash-text-or-file": "workspace:*", diff --git a/registry/tools/src/index.ts b/registry/tools/src/index.ts index dd61809f..c8657316 100644 --- a/registry/tools/src/index.ts +++ b/registry/tools/src/index.ts @@ -126,6 +126,7 @@ import { toolInfo as yamlToTomlConverterToolInfo } from '@tools/yaml-to-toml-con import { toolInfo as tomlToYamlConverterToolInfo } from '@tools/toml-to-yaml-converter' import { toolInfo as xmlToJsonConverterToolInfo } from '@tools/xml-to-json-converter' import { toolInfo as jsonToXmlConverterToolInfo } from '@tools/json-to-xml-converter' +import { toolInfo as xmlFormatterToolInfo } from '@tools/xml-formatter' import { toolInfo as csvToJsonConverterToolInfo } from '@tools/csv-to-json-converter' import { toolInfo as jsonToCsvConverterToolInfo } from '@tools/json-to-csv-converter' import { toolInfo as jsonFormatterToolInfo } from '@tools/json-formatter' @@ -259,6 +260,7 @@ export const tools: ToolInfo[] = [ tomlToYamlConverterToolInfo, xmlToJsonConverterToolInfo, jsonToXmlConverterToolInfo, + xmlFormatterToolInfo, csvToJsonConverterToolInfo, jsonToCsvConverterToolInfo, jsonFormatterToolInfo, diff --git a/registry/tools/src/routes.ts b/registry/tools/src/routes.ts index 45e87a7b..36ebc828 100644 --- a/registry/tools/src/routes.ts +++ b/registry/tools/src/routes.ts @@ -124,6 +124,7 @@ import { routes as yamlToTomlConverterRoutes } from '@tools/yaml-to-toml-convert import { routes as tomlToYamlConverterRoutes } from '@tools/toml-to-yaml-converter/routes' import { routes as xmlToJsonConverterRoutes } from '@tools/xml-to-json-converter/routes' import { routes as jsonToXmlConverterRoutes } from '@tools/json-to-xml-converter/routes' +import { routes as xmlFormatterRoutes } from '@tools/xml-formatter/routes' import { routes as csvToJsonConverterRoutes } from '@tools/csv-to-json-converter/routes' import { routes as jsonToCsvConverterRoutes } from '@tools/json-to-csv-converter/routes' import { routes as jsonFormatterRoutes } from '@tools/json-formatter/routes' @@ -331,6 +332,7 @@ export const routes: ToolRoute[] = [ ...tomlToYamlConverterRoutes, ...xmlToJsonConverterRoutes, ...jsonToXmlConverterRoutes, + ...xmlFormatterRoutes, ...csvToJsonConverterRoutes, ...jsonToCsvConverterRoutes, ...jsonFormatterRoutes, diff --git a/tools/code/xml-formatter/package.json b/tools/code/xml-formatter/package.json new file mode 100644 index 00000000..bf31cd8d --- /dev/null +++ b/tools/code/xml-formatter/package.json @@ -0,0 +1,24 @@ +{ + "name": "@tools/xml-formatter", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./routes": "./src/routes.ts" + }, + "dependencies": { + "@shared/tools": "workspace:*", + "@shared/ui": "workspace:*", + "@vicons/fluent": "catalog:", + "@vueuse/core": "catalog:", + "browser-fs-access": "catalog:", + "fast-xml-parser": "catalog:", + "highlight.js": "catalog:", + "naive-ui": "catalog:", + "vue": "catalog:", + "vue-i18n": "catalog:", + "vue-router": "catalog:", + "xml-formatter": "catalog:" + } +} diff --git a/tools/code/xml-formatter/src/XmlFormatterView.dom.test.ts b/tools/code/xml-formatter/src/XmlFormatterView.dom.test.ts new file mode 100644 index 00000000..8729fa6c --- /dev/null +++ b/tools/code/xml-formatter/src/XmlFormatterView.dom.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import XmlFormatterView from './XmlFormatterView.vue' +import * as toolInfo from './info' + +const ToolDefaultPageLayoutStub = defineComponent({ + name: 'ToolDefaultPageLayout', + props: { + info: { + type: Object, + required: true, + }, + }, + template: '
', +}) + +const XmlFormatterStub = defineComponent({ + name: 'XmlFormatter', + template: '
', +}) + +describe('XmlFormatterView', () => { + it('renders the layout with tool info', () => { + const wrapper = mount(XmlFormatterView, { + global: { + stubs: { + ToolDefaultPageLayout: ToolDefaultPageLayoutStub, + XmlFormatter: XmlFormatterStub, + }, + }, + }) + + const layout = wrapper.findComponent(ToolDefaultPageLayoutStub) + expect(layout.exists()).toBe(true) + expect(layout.props('info')).toEqual(toolInfo) + expect(wrapper.find('.xml-formatter').exists()).toBe(true) + }) +}) diff --git a/tools/code/xml-formatter/src/XmlFormatterView.vue b/tools/code/xml-formatter/src/XmlFormatterView.vue new file mode 100644 index 00000000..7fb2c14c --- /dev/null +++ b/tools/code/xml-formatter/src/XmlFormatterView.vue @@ -0,0 +1,11 @@ + + + diff --git a/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.dom.test.ts b/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.dom.test.ts new file mode 100644 index 00000000..0432918c --- /dev/null +++ b/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.dom.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import WhatIsXmlFormatter from './WhatIsXmlFormatter.vue' + +const DescriptionMarkdownStub = defineComponent({ + name: 'DescriptionMarkdown', + props: { + title: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + }, + template: '
{{ title }}|{{ description }}
', +}) + +describe('WhatIsXmlFormatter', () => { + it('renders the explanation block', () => { + const wrapper = mount(WhatIsXmlFormatter, { + global: { + stubs: { + DescriptionMarkdown: DescriptionMarkdownStub, + }, + }, + }) + + expect(wrapper.text()).toContain('What is XML formatting and validation?') + expect(wrapper.text()).toContain('Everything runs locally in your browser') + }) +}) diff --git a/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.vue b/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.vue new file mode 100644 index 00000000..f0ab5f17 --- /dev/null +++ b/tools/code/xml-formatter/src/components/WhatIsXmlFormatter.vue @@ -0,0 +1,115 @@ + + + + + +{ + "en": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "zh": { + "title": "什么是 XML 格式化与校验?", + "description": "XML 格式化会把紧凑或缩进混乱的标记整理成可读性更高的结构,并提供稳定的缩进与换行。校验则用于检查标签、属性、声明和嵌套关系是否符合 XML 语法规则。\n\n这个工具适合处理 XML 配置文件、RSS 或 Atom 源、SVG 片段、站点地图,以及 SOAP 等 API 载荷。所有处理都在浏览器本地完成,XML 内容不会离开当前页面。" + }, + "zh-CN": { + "title": "什么是 XML 格式化与校验?", + "description": "XML 格式化会把紧凑或缩进混乱的标记整理成可读性更高的结构,并提供稳定的缩进与换行。校验则用于检查标签、属性、声明和嵌套关系是否符合 XML 语法规则。\n\n这个工具适合处理 XML 配置文件、RSS 或 Atom 源、SVG 片段、站点地图,以及 SOAP 等 API 载荷。所有处理都在浏览器本地完成,XML 内容不会离开当前页面。" + }, + "zh-TW": { + "title": "什麼是 XML 格式化與驗證?", + "description": "XML 格式化會將緊湊或縮排混亂的標記整理成更易讀的版面,並提供穩定的縮排與換行。驗證則用於檢查標籤、屬性、宣告與巢狀結構是否符合 XML 語法規則。\n\n這個工具適合處理 XML 設定檔、RSS 或 Atom 來源、SVG 片段、網站地圖,以及 SOAP 等 API 載荷。所有處理都在瀏覽器本機完成,XML 內容不會離開目前頁面。" + }, + "zh-HK": { + "title": "什麼是 XML 格式化與驗證?", + "description": "XML 格式化會將緊湊或縮排混亂的標記整理成更易讀的版面,並提供穩定的縮排與換行。驗證則用於檢查標籤、屬性、宣告與巢狀結構是否符合 XML 語法規則。\n\n這個工具適合處理 XML 設定檔、RSS 或 Atom 來源、SVG 片段、網站地圖,以及 SOAP 等 API 載荷。所有處理都在瀏覽器本機完成,XML 內容不會離開目前頁面。" + }, + "es": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "fr": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "de": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "it": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "ja": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "ko": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "ru": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "pt": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "ar": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "hi": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "tr": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "nl": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "sv": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "pl": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "vi": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "th": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "id": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "he": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "ms": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + }, + "no": { + "title": "What is XML formatting and validation?", + "description": "XML formatting turns compact or inconsistent markup into a readable layout with stable indentation and line breaks. Validation checks whether tags, attributes, declarations, and nesting follow XML syntax rules.\n\nUse this tool for XML configuration files, RSS or Atom feeds, SVG fragments, sitemap files, and SOAP or other API payloads. Everything runs locally in your browser, so your XML never needs to leave the page." + } +} + diff --git a/tools/code/xml-formatter/src/components/XmlFormatter.dom.test.ts b/tools/code/xml-formatter/src/components/XmlFormatter.dom.test.ts new file mode 100644 index 00000000..b41f308f --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatter.dom.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent } from 'vue' +import XmlFormatter from './XmlFormatter.vue' + +const fileOpenMock = vi.fn() +const objectUrlState = { value: 'available' as 'available' | 'missing' } + +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual('@vueuse/core') + const { computed, isRef } = await import('vue') + + return { + ...actual, + useObjectUrl: (source: unknown) => + computed(() => { + if (objectUrlState.value === 'missing') { + return null + } + + const value = isRef(source) ? source.value : source + return value ? 'blob:download' : null + }), + } +}) + +vi.mock('browser-fs-access', () => ({ + fileOpen: (...args: unknown[]) => fileOpenMock(...args), +})) + +const ToolbarStub = defineComponent({ + name: 'XmlFormatterToolbar', + props: [ + 'collapseContent', + 'downloadFileName', + 'downloadUrl', + 'errorColumn', + 'errorLine', + 'forceSelfClosingEmptyTag', + 'hasInvalidXml', + 'hasValidXml', + 'outputXml', + 'selectedIndentation', + 'selectedLineEnding', + 'selectedMode', + ], + template: '
', +}) + +const PanelsStub = defineComponent({ + name: 'XmlFormatterPanels', + props: [ + 'errorColumn', + 'errorContext', + 'errorLine', + 'errorMessage', + 'isInvalid', + 'outputXml', + 'sourceXml', + ], + template: '
', +}) + +const WhatIsStub = defineComponent({ + name: 'WhatIsXmlFormatter', + template: '
', +}) + +const mountWrapper = () => + mount(XmlFormatter, { + global: { + stubs: { + XmlFormatterToolbar: ToolbarStub, + XmlFormatterPanels: PanelsStub, + WhatIsXmlFormatter: WhatIsStub, + }, + }, + }) + +describe('XmlFormatter', () => { + beforeEach(() => { + fileOpenMock.mockReset() + objectUrlState.value = 'available' + }) + + it('passes formatted xml to the panels by default', () => { + const wrapper = mountWrapper() + const panels = wrapper.findComponent(PanelsStub) + + expect(panels.props('outputXml')).toContain("XML Developer's Guide") + expect(panels.props('outputXml')).toContain('') + expect(wrapper.find('.what-is').exists()).toBe(true) + }) + + it('switches to minified output when the toolbar mode changes', async () => { + const wrapper = mountWrapper() + const toolbar = wrapper.findComponent(ToolbarStub) + + await toolbar.vm.$emit('update:selected-mode', 'minified') + await flushPromises() + + expect(toolbar.props('downloadFileName')).toBe('minified.xml') + expect(wrapper.findComponent(PanelsStub).props('outputXml')).toContain( + '', + ) + }) + + it('applies toolbar option updates to formatting state', async () => { + const wrapper = mountWrapper() + const toolbar = wrapper.findComponent(ToolbarStub) + + await toolbar.vm.$emit('update:selected-indentation', 'tab') + await toolbar.vm.$emit('update:selected-line-ending', 'crlf') + await toolbar.vm.$emit('update:collapse-content', false) + await toolbar.vm.$emit('update:force-self-closing-empty-tag', true) + await flushPromises() + + expect(wrapper.findComponent(ToolbarStub).props('selectedIndentation')).toBe('tab') + expect(wrapper.findComponent(ToolbarStub).props('selectedLineEnding')).toBe('crlf') + expect(wrapper.findComponent(ToolbarStub).props('collapseContent')).toBe(false) + expect(wrapper.findComponent(ToolbarStub).props('forceSelfClosingEmptyTag')).toBe(true) + expect(wrapper.findComponent(PanelsStub).props('outputXml')).toContain('\r\n') + expect(wrapper.findComponent(PanelsStub).props('outputXml')).toContain('') + }) + + it('surfaces invalid xml state and error details', async () => { + const wrapper = mountWrapper() + const panels = wrapper.findComponent(PanelsStub) + + await panels.vm.$emit('update:source-xml', '') + await flushPromises() + + const toolbar = wrapper.findComponent(ToolbarStub) + expect(toolbar.props('hasInvalidXml')).toBe(true) + expect(panels.props('isInvalid')).toBe(true) + expect(panels.props('errorLine')).toBe(1) + expect(String(panels.props('errorMessage'))).toContain("Expected closing tag 'item'") + }) + + it('imports xml from a file', async () => { + fileOpenMock.mockResolvedValue({ + text: async () => '', + }) + + const wrapper = mountWrapper() + const toolbar = wrapper.findComponent(ToolbarStub) + + await toolbar.vm.$emit('import') + await flushPromises() + + expect(wrapper.findComponent(PanelsStub).props('sourceXml')).toBe('') + }) + + it('ignores file picker cancellation errors', async () => { + fileOpenMock.mockRejectedValue(new Error('cancelled')) + + const wrapper = mountWrapper() + const toolbar = wrapper.findComponent(ToolbarStub) + const before = wrapper.findComponent(PanelsStub).props('sourceXml') + + await toolbar.vm.$emit('import') + await flushPromises() + + expect(wrapper.findComponent(PanelsStub).props('sourceXml')).toBe(before) + }) + + it('clears the input and restores the example', async () => { + const wrapper = mountWrapper() + const toolbar = wrapper.findComponent(ToolbarStub) + + await toolbar.vm.$emit('clear') + await flushPromises() + expect(wrapper.findComponent(PanelsStub).props('sourceXml')).toBe('') + expect(wrapper.findComponent(PanelsStub).props('outputXml')).toBe('') + + await toolbar.vm.$emit('use-example') + await flushPromises() + expect(String(wrapper.findComponent(PanelsStub).props('sourceXml'))).toContain( + "XML Developer's Guide", + ) + }) + + it('passes through a missing object url when download urls are unavailable', () => { + objectUrlState.value = 'missing' + + const wrapper = mountWrapper() + expect(wrapper.findComponent(ToolbarStub).props('downloadUrl')).toBeUndefined() + }) +}) diff --git a/tools/code/xml-formatter/src/components/XmlFormatter.vue b/tools/code/xml-formatter/src/components/XmlFormatter.vue new file mode 100644 index 00000000..ad01d567 --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatter.vue @@ -0,0 +1,164 @@ + + + diff --git a/tools/code/xml-formatter/src/components/XmlFormatterActions.vue b/tools/code/xml-formatter/src/components/XmlFormatterActions.vue new file mode 100644 index 00000000..a8c6adfe --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterActions.vue @@ -0,0 +1,278 @@ + + + + + +{ + "en": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "zh": { + "import-from-file": "从文件导入", + "use-example": "使用示例", + "clear": "清空", + "formatted": "格式化", + "minified": "压缩", + "download-xml": "下载 XML" + }, + "zh-CN": { + "import-from-file": "从文件导入", + "use-example": "使用示例", + "clear": "清空", + "formatted": "格式化", + "minified": "压缩", + "download-xml": "下载 XML" + }, + "zh-TW": { + "import-from-file": "從檔案匯入", + "use-example": "使用範例", + "clear": "清空", + "formatted": "格式化", + "minified": "壓縮", + "download-xml": "下載 XML" + }, + "zh-HK": { + "import-from-file": "從檔案匯入", + "use-example": "使用範例", + "clear": "清空", + "formatted": "格式化", + "minified": "壓縮", + "download-xml": "下載 XML" + }, + "es": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "fr": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "de": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "it": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "ja": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "ko": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "ru": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "pt": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "ar": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "hi": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "tr": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "nl": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "sv": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "pl": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "vi": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "th": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "id": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "he": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "ms": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + }, + "no": { + "import-from-file": "Import from file", + "use-example": "Use example", + "clear": "Clear", + "formatted": "Formatted", + "minified": "Minified", + "download-xml": "Download XML" + } +} + diff --git a/tools/code/xml-formatter/src/components/XmlFormatterOptions.vue b/tools/code/xml-formatter/src/components/XmlFormatterOptions.vue new file mode 100644 index 00000000..5a5c4466 --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterOptions.vue @@ -0,0 +1,391 @@ + + + + + +{ + "en": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "zh": { + "indentation": "缩进", + "line-endings": "换行符", + "two-spaces": "2 个空格", + "four-spaces": "4 个空格", + "tab": "Tab", + "inline-text": "将文本节点保持在同一行", + "self-close-empty": "空标签使用自闭合形式", + "valid-xml": "XML 有效", + "invalid-xml": "XML 无效", + "browser-only-note": "所有处理都在浏览器本地完成" + }, + "zh-CN": { + "indentation": "缩进", + "line-endings": "换行符", + "two-spaces": "2 个空格", + "four-spaces": "4 个空格", + "tab": "Tab", + "inline-text": "将文本节点保持在同一行", + "self-close-empty": "空标签使用自闭合形式", + "valid-xml": "XML 有效", + "invalid-xml": "XML 无效", + "browser-only-note": "所有处理都在浏览器本地完成" + }, + "zh-TW": { + "indentation": "縮排", + "line-endings": "換行符號", + "two-spaces": "2 個空格", + "four-spaces": "4 個空格", + "tab": "Tab", + "inline-text": "將文字節點保留在同一行", + "self-close-empty": "空標籤使用自閉合形式", + "valid-xml": "XML 有效", + "invalid-xml": "XML 無效", + "browser-only-note": "所有處理都在瀏覽器本機完成" + }, + "zh-HK": { + "indentation": "縮排", + "line-endings": "換行符號", + "two-spaces": "2 個空格", + "four-spaces": "4 個空格", + "tab": "Tab", + "inline-text": "將文字節點保留在同一行", + "self-close-empty": "空標籤使用自閉合形式", + "valid-xml": "XML 有效", + "invalid-xml": "XML 無效", + "browser-only-note": "所有處理都在瀏覽器本機完成" + }, + "es": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "fr": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "de": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "it": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "ja": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "ko": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "ru": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "pt": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "ar": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "hi": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "tr": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "nl": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "sv": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "pl": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "vi": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "th": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "id": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "he": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "ms": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + }, + "no": { + "indentation": "Indentation", + "line-endings": "Line endings", + "two-spaces": "2 spaces", + "four-spaces": "4 spaces", + "tab": "Tab", + "inline-text": "Keep text nodes inline", + "self-close-empty": "Self-close empty tags", + "valid-xml": "Valid XML", + "invalid-xml": "Invalid XML", + "browser-only-note": "Runs locally in your browser" + } +} + diff --git a/tools/code/xml-formatter/src/components/XmlFormatterPanels.dom.test.ts b/tools/code/xml-formatter/src/components/XmlFormatterPanels.dom.test.ts new file mode 100644 index 00000000..071c3b48 --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterPanels.dom.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { NCode } from 'naive-ui' +import XmlFormatterPanels from './XmlFormatterPanels.vue' + +describe('XmlFormatterPanels', () => { + it('emits source updates and shows formatted output', async () => { + const wrapper = mount(XmlFormatterPanels, { + props: { + errorColumn: undefined, + errorContext: '', + errorLine: undefined, + errorMessage: '', + isInvalid: false, + outputXml: '', + sourceXml: '', + }, + }) + + await wrapper.find('textarea').setValue('') + + expect(wrapper.emitted('update:source-xml')?.[0]).toEqual(['']) + expect((wrapper.findComponent(NCode).props('code') as string) ?? '').toBe('') + }) + + it('shows invalid xml details and localized line information', () => { + const wrapper = mount(XmlFormatterPanels, { + props: { + errorColumn: 7, + errorContext: '1 | \n | ^', + errorLine: 1, + errorMessage: 'Unexpected closing tag.', + isInvalid: true, + outputXml: '', + sourceXml: '', + }, + }) + + expect(wrapper.text()).toContain('Invalid XML') + expect(wrapper.text()).toContain('Line 1, column 7') + expect(wrapper.text()).toContain('Unexpected closing tag.') + expect(wrapper.text()).toContain('Error context') + }) + + it('shows an empty state when there is no output yet', () => { + const wrapper = mount(XmlFormatterPanels, { + props: { + errorColumn: undefined, + errorContext: '', + errorLine: undefined, + errorMessage: '', + isInvalid: false, + outputXml: '', + sourceXml: '', + }, + }) + + expect(wrapper.text()).toContain('Formatted or minified XML will appear here') + }) + + it('handles missing error coordinates without rendering a line label', () => { + const wrapper = mount(XmlFormatterPanels, { + props: { + errorColumn: undefined, + errorContext: '1 | ', + errorLine: undefined, + errorMessage: 'Unexpected closing tag.', + isInvalid: true, + outputXml: '', + sourceXml: '', + }, + }) + + expect(wrapper.text()).toContain('Unexpected closing tag.') + expect(wrapper.text()).not.toContain('Line ') + }) +}) diff --git a/tools/code/xml-formatter/src/components/XmlFormatterPanels.vue b/tools/code/xml-formatter/src/components/XmlFormatterPanels.vue new file mode 100644 index 00000000..5aa9c03d --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterPanels.vue @@ -0,0 +1,313 @@ + + + + + + + +{ + "en": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "zh": { + "source-xml": "源 XML", + "output-xml": "输出 XML", + "source-placeholder": "在此粘贴 XML...", + "output-placeholder": "当输入有效时,这里会显示格式化或压缩后的 XML。", + "invalid-xml": "XML 无效", + "line-and-column": "第 {line} 行,第 {column} 列", + "error-context": "错误上下文" + }, + "zh-CN": { + "source-xml": "源 XML", + "output-xml": "输出 XML", + "source-placeholder": "在此粘贴 XML...", + "output-placeholder": "当输入有效时,这里会显示格式化或压缩后的 XML。", + "invalid-xml": "XML 无效", + "line-and-column": "第 {line} 行,第 {column} 列", + "error-context": "错误上下文" + }, + "zh-TW": { + "source-xml": "來源 XML", + "output-xml": "輸出 XML", + "source-placeholder": "在此貼上 XML...", + "output-placeholder": "當輸入有效時,這裡會顯示格式化或壓縮後的 XML。", + "invalid-xml": "XML 無效", + "line-and-column": "第 {line} 行,第 {column} 欄", + "error-context": "錯誤內容" + }, + "zh-HK": { + "source-xml": "來源 XML", + "output-xml": "輸出 XML", + "source-placeholder": "在此貼上 XML...", + "output-placeholder": "當輸入有效時,這裡會顯示格式化或壓縮後的 XML。", + "invalid-xml": "XML 無效", + "line-and-column": "第 {line} 行,第 {column} 欄", + "error-context": "錯誤內容" + }, + "es": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "fr": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "de": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "it": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "ja": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "ko": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "ru": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "pt": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "ar": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "hi": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "tr": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "nl": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "sv": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "pl": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "vi": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "th": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "id": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "he": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "ms": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + }, + "no": { + "source-xml": "Source XML", + "output-xml": "Output XML", + "source-placeholder": "Paste XML here...", + "output-placeholder": "Formatted or minified XML will appear here when the input is valid.", + "invalid-xml": "Invalid XML", + "line-and-column": "Line {line}, column {column}", + "error-context": "Error context" + } +} + diff --git a/tools/code/xml-formatter/src/components/XmlFormatterToolbar.dom.test.ts b/tools/code/xml-formatter/src/components/XmlFormatterToolbar.dom.test.ts new file mode 100644 index 00000000..e4971989 --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterToolbar.dom.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { NCheckbox, NRadioGroup, NSelect } from 'naive-ui' +import XmlFormatterToolbar from './XmlFormatterToolbar.vue' + +const CopyToClipboardButtonStub = defineComponent({ + name: 'CopyToClipboardButton', + props: { + content: { + type: String, + default: '', + }, + disabled: { + type: Boolean, + default: false, + }, + }, + template: '', +}) + +const mountWrapper = () => + mount(XmlFormatterToolbar, { + props: { + collapseContent: true, + downloadFileName: 'formatted.xml', + downloadUrl: 'blob:download', + errorColumn: undefined, + errorLine: undefined, + forceSelfClosingEmptyTag: false, + hasInvalidXml: false, + hasValidXml: true, + outputXml: '', + selectedIndentation: '2-spaces', + selectedLineEnding: 'lf', + selectedMode: 'formatted', + }, + global: { + stubs: { + CopyToClipboardButton: CopyToClipboardButtonStub, + }, + }, + }) + +describe('XmlFormatterToolbar', () => { + it('emits toolbar actions', async () => { + const wrapper = mountWrapper() + const buttons = wrapper.findAll('button') + + await buttons[0]!.trigger('click') + await buttons[1]!.trigger('click') + await buttons[2]!.trigger('click') + + expect(wrapper.emitted('import')).toHaveLength(1) + expect(wrapper.emitted('use-example')).toHaveLength(1) + expect(wrapper.emitted('clear')).toHaveLength(1) + }) + + it('emits option updates and shows valid status', async () => { + const wrapper = mountWrapper() + + await wrapper.findComponent(NRadioGroup).vm.$emit('update:value', 'minified') + const selects = wrapper.findAllComponents(NSelect) + await selects[0]!.vm.$emit('update:value', 'tab') + await selects[1]!.vm.$emit('update:value', 'crlf') + const checkboxes = wrapper.findAllComponents(NCheckbox) + await checkboxes[0]!.vm.$emit('update:checked', false) + await checkboxes[1]!.vm.$emit('update:checked', true) + + expect(wrapper.emitted('update:selected-mode')?.[0]).toEqual(['minified']) + expect(wrapper.emitted('update:selected-indentation')?.[0]).toEqual(['tab']) + expect(wrapper.emitted('update:selected-line-ending')?.[0]).toEqual(['crlf']) + expect(wrapper.emitted('update:collapse-content')?.[0]).toEqual([false]) + expect(wrapper.emitted('update:force-self-closing-empty-tag')?.[0]).toEqual([true]) + expect(wrapper.text()).toContain('Valid XML') + }) + + it('shows invalid status details', () => { + const wrapper = mount(XmlFormatterToolbar, { + props: { + collapseContent: true, + downloadFileName: 'formatted.xml', + downloadUrl: undefined, + errorColumn: 9, + errorLine: 4, + forceSelfClosingEmptyTag: false, + hasInvalidXml: true, + hasValidXml: false, + outputXml: '', + selectedIndentation: '2-spaces', + selectedLineEnding: 'lf', + selectedMode: 'formatted', + }, + global: { + stubs: { + CopyToClipboardButton: CopyToClipboardButtonStub, + }, + }, + }) + + expect(wrapper.text()).toContain('Invalid XML') + expect(wrapper.text()).toContain('4:9') + }) +}) diff --git a/tools/code/xml-formatter/src/components/XmlFormatterToolbar.vue b/tools/code/xml-formatter/src/components/XmlFormatterToolbar.vue new file mode 100644 index 00000000..0bda7651 --- /dev/null +++ b/tools/code/xml-formatter/src/components/XmlFormatterToolbar.vue @@ -0,0 +1,63 @@ + + + diff --git a/tools/code/xml-formatter/src/exports.dom.test.ts b/tools/code/xml-formatter/src/exports.dom.test.ts new file mode 100644 index 00000000..9dfd00f0 --- /dev/null +++ b/tools/code/xml-formatter/src/exports.dom.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import * as toolInfo from './info' +import { routes } from './routes' +import * as index from './index' + +describe('xml formatter exports', () => { + it('exposes tool info and routes', async () => { + expect(toolInfo.toolID).toBe('xml-formatter') + expect(toolInfo.path).toBe('/tools/xml-formatter') + expect(toolInfo.tags).toContain('xml') + expect(toolInfo.features).toContain('offline') + expect(toolInfo.meta.en.name).toBe('XML Formatter & Validator') + expect(Object.keys(toolInfo.meta)).toHaveLength(25) + expect(index).toHaveProperty('toolInfo') + + const route = routes[0] + expect(route?.path).toBe(toolInfo.path) + expect(route?.name).toBe(toolInfo.toolID) + + const componentLoader = route?.component as () => Promise<{ default: unknown }> + const loadedRoute = await componentLoader() + expect(loadedRoute).toHaveProperty('default') + }) +}) diff --git a/tools/code/xml-formatter/src/index.ts b/tools/code/xml-formatter/src/index.ts new file mode 100644 index 00000000..cc8d4f10 --- /dev/null +++ b/tools/code/xml-formatter/src/index.ts @@ -0,0 +1 @@ +export * as toolInfo from './info' diff --git a/tools/code/xml-formatter/src/info.ts b/tools/code/xml-formatter/src/info.ts new file mode 100644 index 00000000..79716ca2 --- /dev/null +++ b/tools/code/xml-formatter/src/info.ts @@ -0,0 +1,53 @@ +export { default as icon } from '@vicons/fluent/BracesVariable20Regular' + +export const toolID = 'xml-formatter' +export const path = '/tools/xml-formatter' +export const tags = ['code', 'xml', 'formatter', 'validator', 'minifier', 'pretty-print'] +export const features = ['offline'] + +const englishName = 'XML Formatter & Validator' +const englishDescription = + 'Format, minify, and validate XML locally in your browser with line and column error details.' + +export const meta = { + en: { + name: englishName, + description: englishDescription, + }, + zh: { + name: 'XML 格式化与校验器', + description: '在浏览器本地格式化、压缩并校验 XML,提供行号与列号级别的错误详情。', + }, + 'zh-CN': { + name: 'XML 格式化与校验器', + description: '在浏览器本地格式化、压缩并校验 XML,提供行号与列号级别的错误详情。', + }, + 'zh-TW': { + name: 'XML 格式化與驗證器', + description: '在瀏覽器本機格式化、壓縮並驗證 XML,提供行號與欄號層級的錯誤詳情。', + }, + 'zh-HK': { + name: 'XML 格式化與驗證器', + description: '在瀏覽器本機格式化、壓縮並驗證 XML,提供行號與欄號層級的錯誤詳情。', + }, + es: { name: englishName, description: englishDescription }, + fr: { name: englishName, description: englishDescription }, + de: { name: englishName, description: englishDescription }, + it: { name: englishName, description: englishDescription }, + ja: { name: englishName, description: englishDescription }, + ko: { name: englishName, description: englishDescription }, + ru: { name: englishName, description: englishDescription }, + pt: { name: englishName, description: englishDescription }, + ar: { name: englishName, description: englishDescription }, + hi: { name: englishName, description: englishDescription }, + tr: { name: englishName, description: englishDescription }, + nl: { name: englishName, description: englishDescription }, + sv: { name: englishName, description: englishDescription }, + pl: { name: englishName, description: englishDescription }, + vi: { name: englishName, description: englishDescription }, + th: { name: englishName, description: englishDescription }, + id: { name: englishName, description: englishDescription }, + he: { name: englishName, description: englishDescription }, + ms: { name: englishName, description: englishDescription }, + no: { name: englishName, description: englishDescription }, +} diff --git a/tools/code/xml-formatter/src/routes.ts b/tools/code/xml-formatter/src/routes.ts new file mode 100644 index 00000000..eb162921 --- /dev/null +++ b/tools/code/xml-formatter/src/routes.ts @@ -0,0 +1,9 @@ +import type { ToolRoute } from '@shared/tools' + +export const routes: ToolRoute[] = [ + { + name: 'xml-formatter', + path: '/tools/xml-formatter', + component: () => import('./XmlFormatterView.vue'), + }, +] as const diff --git a/tools/code/xml-formatter/src/utils/xml.dom.test.ts b/tools/code/xml-formatter/src/utils/xml.dom.test.ts new file mode 100644 index 00000000..8ef3e339 --- /dev/null +++ b/tools/code/xml-formatter/src/utils/xml.dom.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + formatXmlString, + getXmlErrorContext, + getXmlIndentation, + getXmlLineSeparator, + validateXml, +} from './xml' + +describe('xml utilities', () => { + it('returns indentation tokens and line separators', () => { + expect(getXmlIndentation('2-spaces')).toBe(' ') + expect(getXmlIndentation('4-spaces')).toBe(' ') + expect(getXmlIndentation('tab')).toBe('\t') + expect(getXmlLineSeparator('lf')).toBe('\n') + expect(getXmlLineSeparator('crlf')).toBe('\r\n') + }) + + it('validates well-formed xml', () => { + expect(validateXml('Hello')).toEqual({ valid: true }) + }) + + it('returns detailed validation errors for malformed xml', () => { + expect(validateXml('')).toEqual({ + valid: false, + code: 'InvalidTag', + message: + "Expected closing tag 'item' (opened in line 1, col 7) instead of closing tag 'root'.", + line: 1, + column: 13, + }) + }) + + it('builds an error context snippet with a caret marker', () => { + expect(getXmlErrorContext('\n \n', 2, 5)).toBe( + ['1 | ', '2 | ', ' | ^', '3 | '].join('\n'), + ) + }) + + it('formats xml with requested options', () => { + const formatted = formatXmlString('', { + collapseContent: true, + forceSelfClosingEmptyTag: true, + indentation: '4-spaces', + lineEnding: 'crlf', + mode: 'formatted', + }) + + expect(formatted).toBe('\r\n\r\n \r\n') + }) + + it('minifies xml output', () => { + const minified = formatXmlString('\n Hello\n', { + collapseContent: true, + forceSelfClosingEmptyTag: false, + indentation: '2-spaces', + lineEnding: 'lf', + mode: 'minified', + }) + + expect(minified).toBe('Hello') + }) +}) diff --git a/tools/code/xml-formatter/src/utils/xml.ts b/tools/code/xml-formatter/src/utils/xml.ts new file mode 100644 index 00000000..76befe4b --- /dev/null +++ b/tools/code/xml-formatter/src/utils/xml.ts @@ -0,0 +1,102 @@ +import { XMLValidator, type ValidationError } from 'fast-xml-parser' +import xmlFormat from 'xml-formatter' + +export type XmlOutputMode = 'formatted' | 'minified' +export type XmlIndentation = '2-spaces' | '4-spaces' | 'tab' +export type XmlLineEnding = 'lf' | 'crlf' + +export interface XmlFormattingOptions { + collapseContent: boolean + forceSelfClosingEmptyTag: boolean + indentation: XmlIndentation + lineEnding: XmlLineEnding + mode: XmlOutputMode +} + +export type XmlValidationResult = + | { valid: true } + | { + valid: false + code: string + message: string + line: number + column: number + } + +const indentationMap: Record = { + '2-spaces': ' ', + '4-spaces': ' ', + tab: '\t', +} + +const lineSeparatorMap: Record = { + lf: '\n', + crlf: '\r\n', +} + +export function getXmlIndentation(indentation: XmlIndentation): string { + return indentationMap[indentation] +} + +export function getXmlLineSeparator(lineEnding: XmlLineEnding): string { + return lineSeparatorMap[lineEnding] +} + +export function validateXml(xml: string): XmlValidationResult { + const result = XMLValidator.validate(xml) + + if (result === true) { + return { valid: true } + } + + const error = (result as ValidationError).err + + return { + valid: false, + code: error.code, + message: error.msg, + line: error.line, + column: error.col, + } +} + +export function getXmlErrorContext(xml: string, line: number, column: number, padding = 1): string { + const lines = xml.split(/\r?\n/) + const currentIndex = Math.min(Math.max(line - 1, 0), lines.length - 1) + const start = Math.max(currentIndex - padding, 0) + const end = Math.min(currentIndex + padding, lines.length - 1) + const lineNumberWidth = String(end + 1).length + const safeColumn = Math.max(column, 1) + const context: string[] = [] + + for (let index = start; index <= end; index += 1) { + const lineNumber = String(index + 1).padStart(lineNumberWidth, ' ') + context.push(`${lineNumber} | ${lines[index] ?? ''}`) + + if (index === currentIndex) { + context.push(`${' '.repeat(lineNumberWidth)} | ${' '.repeat(safeColumn - 1)}^`) + } + } + + return context.join('\n') +} + +export function formatXmlString(xml: string, options: XmlFormattingOptions): string { + if (options.mode === 'minified') { + return xmlFormat.minify(xml, { + collapseContent: options.collapseContent, + forceSelfClosingEmptyTag: options.forceSelfClosingEmptyTag, + strictMode: true, + throwOnFailure: true, + }) + } + + return xmlFormat(xml, { + collapseContent: options.collapseContent, + forceSelfClosingEmptyTag: options.forceSelfClosingEmptyTag, + indentation: getXmlIndentation(options.indentation), + lineSeparator: getXmlLineSeparator(options.lineEnding), + strictMode: true, + throwOnFailure: true, + }) +} From 21365ff0386fded8665b68f24ddb996f25b21f20 Mon Sep 17 00:00:00 2001 From: rwv Date: Thu, 12 Mar 2026 10:41:13 +0800 Subject: [PATCH 2/2] fix(ui): add fallback tool icon --- .../tool/grid/ToolThing.dom.test.ts | 25 ++++++++++++++++++- .../ui/src/components/tool/grid/ToolThing.vue | 6 +++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/shared/ui/src/components/tool/grid/ToolThing.dom.test.ts b/shared/ui/src/components/tool/grid/ToolThing.dom.test.ts index e17bcfdd..92979065 100644 --- a/shared/ui/src/components/tool/grid/ToolThing.dom.test.ts +++ b/shared/ui/src/components/tool/grid/ToolThing.dom.test.ts @@ -29,7 +29,16 @@ vi.mock('naive-ui', async () => { return { NThing, NAvatar: base('NAvatar', 'span'), - NIcon: base('NIcon', 'span'), + NIcon: defineComponent({ + name: 'NIcon', + props: { + component: { + type: Object, + default: undefined, + }, + }, + template: '', + }), NTag: base('NTag', 'span'), useThemeVars: () => ({ cubicBezierEaseInOut: 'ease', @@ -125,4 +134,18 @@ describe('ToolThing', () => { expect(wrapper.find('a.tool-link').exists()).toBe(true) expect(wrapper.findComponent({ name: 'NAvatar' }).exists()).toBe(true) }) + + it('falls back to the default icon when a tool does not provide one', () => { + const tool = createTool({ icon: undefined }) + const wrapper = mount(ToolThing, { + props: { tool, showIcon: true }, + global: { + stubs: { + CustomRouterLink: RouterLinkStub, + }, + }, + }) + + expect(wrapper.find('.n-icon').attributes('data-has-component')).toBe('true') + }) }) diff --git a/shared/ui/src/components/tool/grid/ToolThing.vue b/shared/ui/src/components/tool/grid/ToolThing.vue index e421741c..ef56c0e4 100644 --- a/shared/ui/src/components/tool/grid/ToolThing.vue +++ b/shared/ui/src/components/tool/grid/ToolThing.vue @@ -4,7 +4,7 @@