Skip to content

Commit

Permalink
@odk/xpath: support for jr:itext function
Browse files Browse the repository at this point in the history
  • Loading branch information
eyelidlessness committed Nov 29, 2023
1 parent b7ff394 commit 894ff10
Show file tree
Hide file tree
Showing 9 changed files with 503 additions and 15 deletions.
3 changes: 3 additions & 0 deletions packages/common/src/constants/xmlns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const ODK_NAMESPACE_URI = 'http://www.opendatakit.org/xforms';
export const OPENROSA_XFORMS_NAMESPACE_URI = 'http://openrosa.org/xforms';
export const XFORMS_NAMESPACE_URI = 'http://www.w3.org/2002/xforms';

export type JavaRosaNamespaceURI = typeof JAVAROSA_NAMESPACE_URI;
export type XFormsNamespaceURI = typeof XFORMS_NAMESPACE_URI;

// Enketo
export const ENKETO_NAMESPACE_URI = 'http://enketo.org/xforms';

Expand Down
60 changes: 58 additions & 2 deletions packages/odk-xpath/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const xpathParser = await TreeSitterXPathParser.init({

Note that this depends on Vite's [`?url` import suffix](https://vitejs.dev/guide/assets.html#explicit-url-imports). The same general approach should apply for other tooling/bundlers or even without a build step, so long as `webTreeSitter` and `xpathLanguage` successfully resolve to their respective WASM resources.

## Example usage
## Usage

To use `@odk/xpath` at runtime, first create an `XFormsXPathEvaluator` instance, specifying a parser instance and the XForm `rootNode`. Usage from that point is API-compatible with the standard DOM [`evaluate` method](https://developer.mozilla.org/en-US/docs/Web/API/XPathEvaluator/evaluate).

Expand Down Expand Up @@ -74,6 +74,63 @@ const evaluator = new Evaluator(xpathParser);

In either case, the `result` returned by `evaluate` is API-compatible with the standard DOM [`XPathResult`](https://developer.mozilla.org/en-US/docs/Web/API/XPathResult).

### XForms `itext` translations

`XFormsXPathEvaluator` supports the JavaRosa `itext` function (`jr:itext` by convention), as specified in ODK XForms, which says:

> Obtains an itext value for the provided reference in the active language from the `<itext>` block in the model.
This active language state is managed at the `XFormXPathEvaluator` instance level, with the default language (again as specified in ODK XForms) active on construction. You can access a form's available languages, and get or set the active language under the `XFormXPathEvaluator.translations` object.

Example:

```ts
const domParser = new DOMParser();
const xform: XMLDocument = domParser.parseFromString(
`<h:html>
<h:head>
<model>
<itext>
<translation lang="English" default="true()">
<text id="hello">
<value>hello</value>
</text>
</translation>
<translation lang="Español">
<text id="hello">
<value>hola</value>
</text>
</translation>
</itext>
</model>
</h:head>
<!-- ... -->
</h:html>`,
'text/xml'
);
const evaluator = new XFormsXPathEvaluator(xpathParser, { rootNode: xform });

evaluator.translations.getLanguages(); // ['English', 'Español']
evaluator.translations.getActiveLanguage(); // 'English'
evaluator.evaluate('jr:itext("hello")', xform, null, XPathResult.STRING_TYPE).stringValue; // 'hello'

evaluator.translations.setActiveLanguage('Español'); // 'Español'
evaluator.translations.getActiveLanguage(); // 'Español'
evaluator.evaluate('jr:itext("hello")', xform, null, XPathResult.STRING_TYPE).stringValue; // 'hola'
```

There are currently a few caveats to `jr:itext` use:

1. `<itext>` and its translations are evaluated from the **document root** of the `rootNode` specified in `XFormsXPathEvaluator` options. As such:

- translations _will_ be resolved if a descendant `rootNode` (e.g. the XForm's primary `<instance>` element) is specified

- translations _will not_ be resolved for an XForm in an unusual DOM structure (e.g. a `DocumentFragment`, or in an arbitrary subtree of an unrelated document)

2. `<value form="...anything...">` is not yet supported. It's unclear what the interface for this usage might be.

3. The interface for getting and setting language context is currently experimental pending integration experience, and may be changed in the future. The intent of this interface is to be relatively agnostic to outside state management, and to isolate this sort of stateful context from the XForm DOM, but that approach may also change.

### Convenience APIs

Both evaluator classes provide the following convenience methods:
Expand Down Expand Up @@ -110,7 +167,6 @@ We intend to support the full ODK XForms function library, but support is curren
- `instance`
- `pulldata`
- `jr:choice-name`
- `jr:itext`

### Non-browser environments

Expand Down
5 changes: 5 additions & 0 deletions packages/odk-xpath/src/functions/javarosa/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { JAVAROSA_NAMESPACE_URI } from '@odk/common/constants/xmlns';
import { FunctionLibrary } from '../../evaluator/functions/FunctionLibrary';
import * as string from './string.ts';

export const jr = new FunctionLibrary(JAVAROSA_NAMESPACE_URI, [...Object.values(string)]);
18 changes: 18 additions & 0 deletions packages/odk-xpath/src/functions/javarosa/string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { StringFunction } from '../../evaluator/functions/StringFunction.ts';
import { XFormsXPathEvaluator } from '../../index.ts';

export const itext = new StringFunction(
'itext',
[{ arityType: 'required', typeHint: 'string' }],
(context, [idExpression]) => {
const { evaluator } = context;

if (!(evaluator instanceof XFormsXPathEvaluator)) {
throw new Error('itext not available');
}

const id = idExpression!.evaluate(context).toString();

return evaluator.translations.getTranslation(id) ?? '';
}
);
122 changes: 122 additions & 0 deletions packages/odk-xpath/src/xforms/XFormsItextTranslations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { XFormsNamespaceURI } from '@odk/common/constants/xmlns.ts';
import type { XFormsXPathEvaluator } from './XFormsXPathEvaluator.ts';

export interface XFormsItextContext {
get language(): string | null;

setLanguages(defaultLanguage: string, languages: readonly string[]): void;
}

interface XFormsItextRootElement extends Element {
readonly namespaceURI: XFormsNamespaceURI;
readonly localName: 'itext';
}

interface XFormsItextTranslationElement extends Element {
readonly namespaceURI: XFormsNamespaceURI;
readonly localName: 'translation';
readonly parentNode: XFormsItextRootElement;

getAttribute(name: 'lang'): string;
getAttribute(name: string): string | null;
}

interface XFormsItextTextElement extends Element {
readonly namspaceURI: XFormsNamespaceURI;
readonly localName: 'text';

getAttribute(name: 'id'): string;
getAttribute(name: string): string | null;
}

export class XFormsItextTranslations {
protected readonly defaultLanguage: string | null;
protected readonly languages: readonly string[];
protected readonly translationsByLanguage: Map<string, Map<string, string>>;

protected activeLanguage: string | null;

constructor(protected readonly evaluator: XFormsXPathEvaluator) {
const { rootNode } = evaluator;
const xformRoot = rootNode.ownerDocument ?? rootNode;
// TODO: this clearly breaks out of `rootNode`'s hierarhy! It's exactly what
// we want for this use case, but it's definitely something that should be
// called out in any case where there might be an impression that `rootNode`
// provides any kind of isolation guarantees.
const translationElements = evaluator.evaluateNodes<XFormsItextTranslationElement>(
'./h:html/h:head/xf:model/xf:itext/xf:translation[@lang]',
{ contextNode: xformRoot }
);
// TODO: spec says this may be `"true()"` or `""`, what about other cases?
const defaultTranslationElement =
translationElements.find((translationElement) => {
return translationElement.hasAttribute('default');
}) ?? translationElements[0];

const defaultLanguage = defaultTranslationElement?.getAttribute('lang') ?? null;
const languages = translationElements.map((translationElement) => {
return translationElement.getAttribute('lang');
});

this.defaultLanguage = defaultLanguage;
this.activeLanguage = defaultLanguage;
this.languages = languages;
this.translationsByLanguage = new Map(
translationElements.map((translationElement) => {
const language = translationElement.getAttribute('lang');
const textElements = evaluator.evaluateNodes<XFormsItextTextElement>('./xf:text[@id]', {
contextNode: translationElement,
});
const translations = new Map(
textElements.flatMap((textElement) => {
const value = evaluator.evaluateString('./xf:value[not(@form)]', {
contextNode: textElement,
});

if (value == null || value === '') {
return [];
}

const id = textElement.getAttribute('id');

return [[id, value]];
})
);

return [language, translations];
})
);
}

getLanguages(): readonly string[] {
return this.languages;
}

getActiveLanguage(): string | null {
return this.activeLanguage;
}

setActiveLanguage(language: string | null): string | null {
this.activeLanguage = language ?? this.defaultLanguage;

return this.activeLanguage;
}

// TODO: currently only the default <value> (i.e. without a `form` attribute)
// is supported.
getTranslation(itextId: string): string | null {
const language = this.activeLanguage ?? this.defaultLanguage;

if (language == null) {
return null;
}

const translations = this.translationsByLanguage.get(language);

if (translations == null) {
throw new Error(`No translations for language: ${language}`);
}

return translations.get(itextId) ?? null;
}
}
9 changes: 7 additions & 2 deletions packages/odk-xpath/src/xforms/XFormsXPathEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { Evaluator } from '../evaluator/Evaluator.ts';
import { FunctionLibraryCollection } from '../evaluator/functions/FunctionLibraryCollection.ts';
import { enk } from '../functions/enketo/index.ts';
import { fn } from '../functions/fn/index.ts';
import { jr } from '../functions/javarosa/index.ts';
import { xf } from '../functions/xforms/index.ts';
import type { AnyParentNode } from '../lib/dom/types.ts';
import type { BaseParser } from '../static/grammar/ExpressionParser.ts';
import { XFormsItextTranslations } from './XFormsItextTranslations.ts';

// Note: order of `FunctionLibrary` items matters! `xf` overrides `fn`, and
// `enk` overrides `xf`.
//
// TODO: break out yet another Enketo entry?
const functions = new FunctionLibraryCollection([enk, xf, fn], {
const functions = new FunctionLibraryCollection([enk, xf, fn, jr], {
defaultNamespaceURIs: [enk.namespaceURI, xf.namespaceURI, fn.namespaceURI],
});

Expand All @@ -22,12 +24,15 @@ interface XFormsXPathEvaluatorOptions extends EvaluatorOptions {
export class XFormsXPathEvaluator extends Evaluator {
override readonly rootNode: AnyParentNode;

readonly translations: XFormsItextTranslations;

constructor(parser: BaseParser, options: XFormsXPathEvaluatorOptions) {
super(parser, {
...options,
functions,
...options,
});

this.rootNode = options.rootNode;
this.translations = new XFormsItextTranslations(this);
}
}
34 changes: 23 additions & 11 deletions packages/odk-xpath/test/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect } from 'vitest';
import { Evaluator } from '../src/index.ts';
import type { AnyXPathEvaluator, XPathResultType } from '../src/shared/interface.ts';
import type { AnyParentNode } from '../src/lib/dom/types.ts';
import type { XPathResultType } from '../src/shared/interface.ts';
import { XFormsXPathEvaluator } from '../src/xforms/XFormsXPathEvaluator.ts';
import { xpathParser } from './parser.ts';

Expand Down Expand Up @@ -37,19 +38,24 @@ interface EvaluationAssertionOptions {
readonly message?: string;
}

interface TestContextOptions {
interface TestContextOptions<XForms extends boolean = false> {
readonly getRootNode?: (testDocument: XMLDocument) => AnyParentNode;
readonly namespaceResolver?: Nullish<XPathNSResolver>;
readonly xforms?: boolean;
readonly xforms?: XForms;
}

export class TestContext {
type TestContextEvaluator<XForms extends boolean> = XForms extends true
? XFormsXPathEvaluator
: Evaluator;

export class TestContext<XForms extends boolean = false> {
readonly document: XMLDocument;
readonly evaluator: AnyXPathEvaluator;
readonly evaluator: TestContextEvaluator<XForms>;
readonly namespaceResolver: XPathNSResolver;

constructor(
readonly sourceXML?: string,
options: TestContextOptions = {}
options: TestContextOptions<XForms> = {}
) {
const xml = sourceXML ?? '<root/>';
const testDocument: XMLDocument = domParser.parseFromString(xml, 'text/xml');
Expand All @@ -62,12 +68,14 @@ export class TestContext {
} as const;

if (options.xforms) {
const rootNode = options.getRootNode?.(testDocument) ?? testDocument;

this.evaluator = new XFormsXPathEvaluator(xpathParser, {
...evaluatorOptions,
rootNode: testDocument,
});
rootNode,
}) as TestContextEvaluator<XForms>;
} else {
this.evaluator = new Evaluator(xpathParser, evaluatorOptions);
this.evaluator = new Evaluator(xpathParser, evaluatorOptions) as TestContextEvaluator<XForms>;
}

this.document = testDocument;
Expand Down Expand Up @@ -265,11 +273,11 @@ export const createTestContext = (xml?: string, options: TestContextOptions = {}
return new TestContext(xml, options);
};

interface XFormsTestContextOptions extends TestContextOptions {
interface XFormsTestContextOptions extends TestContextOptions<true> {
readonly xforms?: true;
}

export class XFormsTestContext extends TestContext {
export class XFormsTestContext extends TestContext<true> {
readonly xforms = true;

constructor(sourceXML?: string, options: XFormsTestContextOptions = {}) {
Expand All @@ -278,6 +286,10 @@ export class XFormsTestContext extends TestContext {
xforms: true,
});
}

setLanguage(language: string | null): string | null {
return this.evaluator.translations.setActiveLanguage(language);
}
}

export const createXFormsTestContext = (
Expand Down
2 changes: 2 additions & 0 deletions packages/odk-xpath/test/xforms/custom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ describe.skip('custom XPath functions', () => {
};
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
interface CustomFunctionEvaluatorTestContext extends XFormsTestContext {
readonly evaluator: CustomFunctionEvaluator;
}
Expand Down
Loading

0 comments on commit 894ff10

Please sign in to comment.