Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sort attributes of XML elements before converting them into a string #99

Merged
merged 1 commit into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ In your `angular.json` the target `extract-i18n` that can be configured with the
| `fuzzyMatch` | `true` | Whether translation units without matching IDs are fuzzy matched by source text. |
| `resetTranslationState` | `true` | Reset the translation state to new/initial for new/changed units. |
| `prettyNestedTags` | `true` (will change to `false` with v3.0.0) | If source/target only contains xml nodes (interpolations, nested html), `true` formats these with line breaks and indentation. `false` keeps the original angular single line format. Note: while `true` was the historic implementation, it is _not_ recommended, as it adds whitespace between tags that had no whitespace in between and increases bundle sizes. |
| `sortNestedTagAttributess` | `false` | Attributes of xml nodes (interpolations, nested html) in source/target/meaning/description can be sorted for normalization. |
| `collapseWhitespace` | `true` | Collapsing of multiple whitespaces/line breaks in translation sources and targets. |
| `trim` | `false` | Trim translation sources and targets. |
| `includeContext` | `false` | Whether to include the context information (like notes) in the translation files. This is useful for sending the target translation files to translation agencies/services. When `sourceFileOnly`, the context is retained only in the `sourceFile`. |
Expand Down
4 changes: 3 additions & 1 deletion src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ async function extractI18nMergeBuilder(options: Options, context: BuilderContext
function fromXlf(input: string): TranslationFile;
function fromXlf(input: string | undefined | null): TranslationFile | undefined;
function fromXlf(input: string | undefined | null): TranslationFile | undefined {
return (input !== undefined && input !== null) ? (isXliffV2 ? fromXlf2(input) : fromXlf1(input)) : undefined;
const inputOptions = { sortNestedTagAttributes: options.sortNestedTagAttributes ?? false };
return (input !== undefined && input !== null) ? (isXliffV2 ?
fromXlf2(input, inputOptions) : fromXlf1(input, inputOptions)) : undefined;
}

function toXlf(output: TranslationFile): string {
Expand Down
18 changes: 18 additions & 0 deletions src/model/translationFileSerialization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,24 @@ describe('translationFileSerialization', () => {
}], 'de', undefined, undefined));
});

it('should parse xml with placeholder and sorting', () => {
const xlf1 = `<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="de" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="ID1" datatype="html">
<source>Some text <ph id="0" equiv="INTERPOLATION" disp="{{ myLabel }}"/></source>
</trans-unit>
</body>
</file>
</xliff>`;
const translationFile = fromXlf1(xlf1, { sortNestedTagAttributes: true });
expect(translationFile).toEqual(new TranslationFile([{
id: 'ID1',
source: 'Some text <ph disp="{{ myLabel }}" equiv="INTERPOLATION" id="0"/>',
locations: []
}], 'de', undefined, undefined));
});

it('should parse xlf1', () => {
const xlf1 = `<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
Expand Down
42 changes: 28 additions & 14 deletions src/model/translationFileSerialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {Options} from '../options';

const XML_DECLARATION_MATCHER = /^<\?xml [^>]*>\s*/i;

export function fromXlf2(xlf2: string): TranslationFile {
export function fromXlf2(xlf2: string,
options: Pick<Options, 'sortNestedTagAttributes'> = { sortNestedTagAttributes: false }): TranslationFile {

const xmlDeclaration = xlf2.match(XML_DECLARATION_MATCHER)?.[0];
const doc = new XmlDocument(xlf2);
const file = doc.childNamed('file')!;
Expand All @@ -16,11 +18,12 @@ export function fromXlf2(xlf2: string): TranslationFile {
const notes = unit.childNamed('notes');
return {
id: unit.attr.id,
source: toString(...segment.childNamed('source')!.children),
target: toStringOrUndefined(segment.childNamed('target')?.children),
source: toString(options, ...segment.childNamed('source')!.children),
target: toStringOrUndefined(options, segment.childNamed('target')?.children),
state: segment.attr.state,
meaning: toStringOrUndefined(notes?.childWithAttribute('category', 'meaning')?.children),
description: toStringOrUndefined(notes?.childWithAttribute('category', 'description')?.children),
meaning: toStringOrUndefined(options, notes?.childWithAttribute('category', 'meaning')?.children),
description:
toStringOrUndefined(options, notes?.childWithAttribute('category', 'description')?.children),
locations: notes?.children
.filter((n): n is XmlElement => n.type === 'element' && n.attr.category === 'location')
.map(note => {
Expand All @@ -37,7 +40,9 @@ export function fromXlf2(xlf2: string): TranslationFile {
return new TranslationFile(units, doc.attr.srcLang, doc.attr.trgLang, xmlDeclaration);
}

export function fromXlf1(xlf1: string): TranslationFile {
export function fromXlf1(xlf1: string,
options: Pick<Options, 'sortNestedTagAttributes'> = { sortNestedTagAttributes: false }): TranslationFile {

const xmlDeclaration = xlf1.match(XML_DECLARATION_MATCHER)?.[0];
const doc = new XmlDocument(xlf1);
const file = doc.childNamed('file')!;
Expand All @@ -48,11 +53,12 @@ export function fromXlf1(xlf1: string): TranslationFile {
const target = unit.childNamed('target');
return {
id: unit.attr.id,
source: toString(...unit.childNamed('source')!.children),
target: toStringOrUndefined(target?.children),
source: toString(options, ...unit.childNamed('source')!.children),
target: toStringOrUndefined(options, target?.children),
state: target?.attr.state,
meaning: toStringOrUndefined(notes?.find(note => note.attr.from === 'meaning')?.children),
description: toStringOrUndefined(notes?.find(note => note.attr.from === 'description')?.children),
meaning: toStringOrUndefined(options, notes?.find(note => note.attr.from === 'meaning')?.children),
description:
toStringOrUndefined(options, notes?.find(note => note.attr.from === 'description')?.children),
locations: unit.childrenNamed('context-group')
.map(contextGroup => ({
file: contextGroup.childWithAttribute('context-type', 'sourcefile')!.val,
Expand All @@ -63,12 +69,20 @@ export function fromXlf1(xlf1: string): TranslationFile {
return new TranslationFile(units, file.attr['source-language'], file.attr['target-language'], xmlDeclaration);
}

function toString(...nodes: XmlNode[]): string {
return nodes.map(n => n.toString({preserveWhitespace: true, compressed: true})).join('');
function toString(options: Pick<Options, 'sortNestedTagAttributes'>, ...nodes: XmlNode[]): string {
return nodes.map(n => {
if (options.sortNestedTagAttributes && n instanceof XmlElement) {
const attr = Object.entries(n.attr).sort((a, b) => a[0].localeCompare(b[0]));
n.attr = Object.fromEntries(attr);
}
return n.toString({ preserveWhitespace: true, compressed: true });
}).join('');
}

function toStringOrUndefined(nodes: XmlNode[] | undefined): string | undefined {
return nodes ? toString(...nodes) : undefined;
function toStringOrUndefined(options: Pick<Options, 'sortNestedTagAttributes'>, nodes: XmlNode[] | undefined):
string | undefined {

return nodes ? toString(options, ...nodes) : undefined;
}

export function toXlf2(translationFile: TranslationFile, options: Pick<Options, 'prettyNestedTags'>): string {
Expand Down
1 change: 1 addition & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface Options extends JsonObject {
fuzzyMatch: boolean,
resetTranslationState: boolean,
prettyNestedTags: boolean,
sortNestedTagAttributes: boolean,
collapseWhitespace: boolean,
trim: boolean,
includeContext: boolean | 'sourceFileOnly',
Expand Down
5 changes: 5 additions & 0 deletions src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
"default": true,
"description": "If source/target only contains xml nodes (interpolations, nested html), `true` formats these with line breaks and indentation. `false` keeps the original angular single line format. Note: while `true` was the historic implementation, it is _not_ recommended, as it adds whitespace between tags that had no whitespace in between and increases bundle sizes."
},
"sortNestedTagAttributes": {
"type": "boolean",
"default": false,
"description": "Attributes of xml nodes (interpolations, nested html) in source/target/meaning/description can be sorted for normalization."
},
"collapseWhitespace": {
"type": "boolean",
"default": true,
Expand Down
Loading