Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ dependencies:
linked_text: ^1.0.0
```

### Localization

The `{{text}}` placeholder syntax used by `linked_text` clashes with Flutter's `gen_l10n` ICU message parser. To fix this, `l10n.yaml` needs `relax-syntax: true`.

**This is handled automatically.** The package includes a build hook that detects your `l10n.yaml` and adds the setting if it's missing. If your first build after adding `linked_text` fails with ICU syntax errors, just re-run the build — the hook will have already fixed the configuration.

If you don't use `gen_l10n` (no `l10n.yaml`), no action is needed.

## Usage

### Basic usage with a single link
Expand Down
74 changes: 74 additions & 0 deletions hook/build.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Build hook for linked_text that ensures the consuming app's l10n.yaml
// has relax-syntax enabled, which is required for linked_text's
// placeholder syntax.
//
// If relax-syntax is missing, the hook adds it automatically and
// informs the developer. The current build will still fail (gen-l10n
// already ran), but the next build will succeed.

import 'dart:io';

import 'package:hooks/hooks.dart';

import 'src/l10n_validator.dart';

void main(List<String> args) async {
await build(args, (BuildInput input, BuildOutputBuilder output) async {
// We produce no assets. This hook exists solely to ensure l10n.yaml
// is configured correctly for linked_text's placeholder syntax.

final Uri? projectRoot = findProjectRoot(input.outputDirectory);

if (projectRoot == null) {
// Can't find the project root — unusual, but don't block the build.
return;
}

final File l10nFile = File.fromUri(projectRoot.resolve('l10n.yaml'));

// Register l10n.yaml as a dependency so the hook re-runs if it changes.
if (l10nFile.existsSync()) {
output.dependencies.add(l10nFile.uri);
}

if (!l10nFile.existsSync()) {
// No l10n.yaml means gen-l10n won't run automatically.
// Nothing to do.
return;
}

final String content = l10nFile.readAsStringSync();

Comment thread
pento marked this conversation as resolved.
if (hasRelaxSyntax(content)) {
// Already configured correctly.
return;
}

// Add relax-syntax: true to the file.
final String newContent = addRelaxSyntax(content);
l10nFile.writeAsStringSync(newContent);

// Let the developer know what we did. This goes to stderr so it's
// visible in build output without being mistaken for asset data.
stderr.writeln(
'\n'
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
' linked_text: added "relax-syntax: true" to your l10n.yaml\n'
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
'\n'
' The linked_text package requires the ICU message parser to run\n'
' in relaxed mode. This setting has been added automatically.\n'
'\n'
' This build may fail because gen-l10n ran before this change\n'
' was applied. If so, just re-run the build — it will succeed.\n'
'\n'
' Modified file:\n'
' ${l10nFile.path}\n'
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n',
);

Comment thread
pento marked this conversation as resolved.
Outdated
// Don't throw — let the build continue. The current build will likely
// fail from the gen-l10n errors that already happened, but the next
// build will succeed.
});
}
73 changes: 73 additions & 0 deletions hook/src/l10n_validator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'dart:io';

/// Walk up from [startUri] looking for a directory containing pubspec.yaml.
///
/// Returns the [Uri] of the directory containing pubspec.yaml, or `null` if
/// no such directory is found within 20 levels.
Uri? findProjectRoot(Uri startUri) {
Directory dir = Directory.fromUri(startUri);

for (int i = 0; i < 20; i++) {
final File pubspec = File('${dir.path}/pubspec.yaml');
if (pubspec.existsSync()) {
Comment thread
pento marked this conversation as resolved.
return dir.uri;
}

final Directory parent = dir.parent;
if (parent.path == dir.path) {
break;
}
dir = parent;
}

return null;
}

/// Check whether l10n.yaml content already has `relax-syntax: true`.
///
/// Handles variations in whitespace and optional trailing YAML comments.
/// Lines that are commented out (starting with `#`) are not matched.
bool hasRelaxSyntax(String content) {
final RegExp pattern = RegExp(
r'^\s*relax-syntax\s*:\s*true\s*(#.*)?$',
multiLine: true,
);
return pattern.hasMatch(content);
}

/// Add or fix `relax-syntax: true` in the l10n.yaml content.
///
/// If the file already has a `relax-syntax` line set to something other
/// than `true`, replaces it (preserving indentation). Otherwise appends
/// the setting with an explanatory comment.
String addRelaxSyntax(String content) {
// Check if there's an existing relax-syntax line (set to false or
// something else).
final RegExp existingPattern = RegExp(
r'^(\s*)relax-syntax\s*:.*$',
multiLine: true,
);

if (existingPattern.hasMatch(content)) {
// Replace the existing line, preserving indentation.
return content.replaceFirstMapped(existingPattern, (Match match) {
final String indent = match.group(1) ?? '';
return '${indent}relax-syntax: true';
});
}

// Append to the end of the file.
final StringBuffer buffer = StringBuffer(content);

// Ensure we start on a new line.
if (content.isNotEmpty && !content.endsWith('\n')) {
buffer.writeln();
}

buffer
..writeln()
Comment thread
pento marked this conversation as resolved.
Outdated
..writeln('# Required by linked_text for its placeholder syntax.')
..writeln('relax-syntax: true');

return buffer.toString();
}
5 changes: 3 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ topics:
- widget

environment:
sdk: '>=3.4.0 <4.0.0'
flutter: '>=3.22.0'
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"

dependencies:
flutter:
sdk: flutter
hooks: ^1.0.0
url_launcher: ^6.2.0

dev_dependencies:
Expand Down
166 changes: 166 additions & 0 deletions test/l10n_validator_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';

import '../hook/src/l10n_validator.dart';

void main() {
group('hasRelaxSyntax', () {
test('matches relax-syntax: true', () {
expect(hasRelaxSyntax('relax-syntax: true'), isTrue);
});

test('matches without space after colon', () {
expect(hasRelaxSyntax('relax-syntax:true'), isTrue);
});

test('matches with extra spaces around colon', () {
expect(hasRelaxSyntax('relax-syntax : true'), isTrue);
});

test('matches with trailing comment', () {
expect(hasRelaxSyntax('relax-syntax: true # comment'), isTrue);
});

test('does not match relax-syntax: false', () {
expect(hasRelaxSyntax('relax-syntax: false'), isFalse);
});

test('does not match when missing entirely', () {
expect(hasRelaxSyntax('arb-dir: lib/l10n\n'), isFalse);
});

test('does not match commented-out line', () {
expect(hasRelaxSyntax('# relax-syntax: true'), isFalse);
});

test('matches indented line', () {
expect(hasRelaxSyntax(' relax-syntax: true'), isTrue);
});

test('matches among surrounding YAML keys', () {
const String yaml = '''
arb-dir: lib/l10n
relax-syntax: true
output-localization-file: app_localizations.dart
''';
expect(hasRelaxSyntax(yaml), isTrue);
});
});

group('addRelaxSyntax', () {
test('appends setting when relax-syntax is absent', () {
const String input = 'arb-dir: lib/l10n\n';
final String result = addRelaxSyntax(input);

expect(result, contains('relax-syntax: true'));
expect(
result,
contains('# Required by linked_text for its placeholder syntax.'),
);
// Original content is preserved.
expect(result, startsWith('arb-dir: lib/l10n\n'));
});

test('replaces relax-syntax: false with true', () {
const String input = 'arb-dir: lib/l10n\nrelax-syntax: false\n';
final String result = addRelaxSyntax(input);

expect(result, contains('relax-syntax: true'));
expect(result, isNot(contains('relax-syntax: false')));
// Original surrounding content is preserved.
expect(result, startsWith('arb-dir: lib/l10n\n'));
});

test('preserves indentation when replacing', () {
const String input = ' relax-syntax: false\n';
final String result = addRelaxSyntax(input);

expect(result, contains(' relax-syntax: true'));
});

test('handles file not ending with newline', () {
const String input = 'arb-dir: lib/l10n';
final String result = addRelaxSyntax(input);

expect(result, contains('relax-syntax: true'));
// Should still have the original content.
expect(result, startsWith('arb-dir: lib/l10n'));
});

test('preserves existing content around the change', () {
const String input = '''
arb-dir: lib/l10n
relax-syntax: false
output-localization-file: app_localizations.dart
''';
final String result = addRelaxSyntax(input);

expect(result, contains('arb-dir: lib/l10n'));
expect(result, contains('relax-syntax: true'));
expect(result, contains('output-localization-file:'));
expect(result, isNot(contains('relax-syntax: false')));
});
});

group('findProjectRoot', () {
late Directory tempDir;

setUp(() {
tempDir = Directory.systemTemp.createTempSync('l10n_validator_test_');
});

tearDown(() {
tempDir.deleteSync(recursive: true);
});

test('finds pubspec.yaml walking up from a subdirectory', () {
// Create a pubspec.yaml at the root.
File('${tempDir.path}/pubspec.yaml').createSync();

// Create a nested directory to start from.
final Directory nested = Directory('${tempDir.path}/a/b/c')
..createSync(recursive: true);

final Uri? result = findProjectRoot(nested.uri);
expect(result, isNotNull);
expect(result!.toFilePath(), equals('${tempDir.path}/'));
});
Comment thread
pento marked this conversation as resolved.

test('returns root when already at root', () {
File('${tempDir.path}/pubspec.yaml').createSync();

final Uri? result = findProjectRoot(tempDir.uri);
expect(result, isNotNull);
expect(result!.toFilePath(), equals('${tempDir.path}/'));
});
Comment thread
pento marked this conversation as resolved.

test('returns null when no pubspec.yaml exists', () {
// Create a nested directory with no pubspec.yaml anywhere.
final Directory nested = Directory('${tempDir.path}/a/b')
..createSync(recursive: true);
Comment thread
pento marked this conversation as resolved.

final Uri? result = findProjectRoot(nested.uri);
// Will eventually hit filesystem root and stop — should return null
// or find a real pubspec.yaml from the test environment. Since we're
// in a temp dir that's nested under the system temp, the walk will
// eventually hit the filesystem root and return null (or find an
// unrelated pubspec.yaml). We test the 20-iteration limit indirectly.
//
// For a truly isolated test, we rely on the fact that system temp
// directories don't typically contain pubspec.yaml files in their
// parent chain. If this test becomes flaky, it should be adjusted.
//
// We check that if it does return something, it's not our temp dir
// (which has no pubspec.yaml).
if (result != null) {
// It found a pubspec.yaml somewhere up the tree — that's fine,
// but it shouldn't be inside our temp dir.
expect(
result.toFilePath(),
isNot(startsWith('${tempDir.path}/')),
);
}
Comment thread
pento marked this conversation as resolved.
Outdated
});
});
}
Loading