@factoidal/core 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.19.0 (2026-10-27)
6
+
7
+ - Notice (owner decision, 2026-10-27): "EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW." It is at the top of both READMEs and the hub, in the package description, in every `factoidal` help text, and on standard error when `serve`, `lws-serve`, `solid-serve`, `solid-client`, `lws-client`, `authz-serve`, `authz-keys`, `xmpp-serve` or `webdav-serve` starts.
8
+ - Authorization server (https://github.com/danbri/factoidal-core/pull/150): SAML 2.0 subject tokens (lws10-authn-saml, token type `urn:ietf:params:oauth:token-type:saml2`) from the identity providers the configuration trusts: `authz-serve --saml-idp FILE` (repeatable; also `lws-serve --authz`), config member `samlIdps` (`[{ entityId, jwk }]`, an RSA public JWK of 2048 bits or more). The assertion needs one enveloped XML Signature in the SAML Core §5 profile (Exclusive XML Canonicalization 1.0, rsa-sha256, SHA-256 digest, the reference `#` + the assertion's `ID`), verified under the configured key, never a key from `ds:KeyInfo`; a document type declaration, comments, processing instructions, a second signature or a wrapped assertion are refused. `saml:NameID` is the subject, the bearer `Recipient` the client; `Conditions` times and every `AudienceRestriction` must name this server. Encrypted assertions are refused (https://github.com/danbri/factoidal/issues/1073). The token type is advertised only when a provider is configured (https://github.com/danbri/factoidal/issues/926). Lean: `XML/ExcC14n.lean`, `XML/DSig.lean`, `SAML/Assertion.lean`; record `docs/designissues/2026-10-27-saml-xml-signature.md`.
9
+
10
+ Fixes for defects found by the echo test ports (https://github.com/danbri/echochamber: Jena-, RDFLib-, N3.js- and RDF4J-style test suites run against this package).
11
+
12
+ - SPARQL (https://github.com/danbri/factoidal-core/pull/146): blank nodes written by SPARQL Update get valid N-Triples labels, so CONSTRUCT, DESCRIBE and `shaclValidate` on that Dataset no longer fail (https://github.com/danbri/factoidal/issues/1074); IN and NOT IN raise an error when a comparison errs and no other element decides, so `2 IN (3, 1/0)` is an error, not false (§17.4.1.9, https://github.com/danbri/factoidal/issues/1049); ORDER BY an aggregate (`ORDER BY DESC(COUNT(?s))`) orders the groups of a grouped query (https://github.com/danbri/factoidal/issues/1050); SUBSTR takes start and length of types derived from xsd:integer, such as xsd:int (https://github.com/danbri/factoidal/issues/1060); no XSD constructor function casts a language-tagged literal: `xsd:string("foo"@en)` and `xsd:byte("1"@en)` are errors, use `STR` (§17.5, https://github.com/danbri/factoidal/issues/1062); `true.` and `false.` read as the keyword and the triple terminator (https://github.com/danbri/factoidal/issues/1075); a variable used only inside MINUS is not in scope, so `(1 AS ?z)` is allowed (§18.2.1, https://github.com/danbri/factoidal/issues/1069).
13
+ - Readers and writers (https://github.com/danbri/factoidal-core/pull/147): N-Triples 1.2 and N-Quads 1.2 read the VERSION directive (https://github.com/danbri/factoidal/issues/1055); TriG refuses a top-level triple with no final '.' (https://github.com/danbri/factoidal/issues/1056); Turtle and TriG read white space or a comment after `^^`, and a prefix named `true` or `false` (https://github.com/danbri/factoidal/issues/1063); one RFC 3987 IRI check: the readers, `fromQuads`/`Dataset.add`, the SPARQL parser and the writers refuse a string that is not an IRI, such as `<a_b:c>`, `<http://www:example.org/>` or `<http://a/%zz>`, with `term.invalid-iri`, so no writer writes text that no reader reads (https://github.com/danbri/factoidal/issues/1057); RDF/XML writer: CR in a literal is written `&#xD;` (https://github.com/danbri/factoidal/issues/1047), a predicate such as `urn:example:p` is written with namespace `urn:example:` (https://github.com/danbri/factoidal/issues/1048); RDF/JS: `dataFactory.literal` gives the language tag in lowercase, and the `Literal` type has `direction` (https://github.com/danbri/factoidal/issues/1054); JSON-LD: a `"@foo"` key in a value object is ignored with or without a base (https://github.com/danbri/factoidal/issues/1064); `parse()` with no format: JSON-LD only for JSON text, N-Quads when a statement names a graph; the rules are in API.md (https://github.com/danbri/factoidal/issues/1058).
14
+ - Cost: the IRI check makes parsing slower: 100,000 triples, median of 3, 0.18 → 0.19: N-Triples 581 → 646 ms (+11%), Turtle 606 → 718 ms (+19%), N-Quads 1.2 616 → 681 ms (+10%). Work to recover it is in progress.
15
+
5
16
  ## 0.18.0 (2026-10-27)
6
17
 
7
18
  - XSD constructor functions for datatypes outside the SPARQL 1.1 §17.5 table (`xsd:byte`, `xsd:int`, `xsd:unsignedInt`, `xsd:date`, `xsd:time`, `xsd:gYear` and the other `xsd:g*` types, the duration types, `xsd:token`, …) now cast as XPath F&O 3.1 §19 says. Before, they changed only the datatype and gave ill-typed literals such as `"HaHa"^^xsd:byte`. An invalid value or a cast that F&O does not allow is now an error (the variable is unbound) (https://github.com/danbri/factoidal/issues/1041, https://github.com/danbri/factoidal-core/pull/142).
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @factoidal/core
2
2
 
3
+ > ⚠️ **EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.**
4
+ >
5
+ > This covers the whole core library and every command line tool, and in
6
+ > particular all protocol, authentication and security code (the LWS, Solid,
7
+ > SPARQL Protocol, WebDAV and XMPP servers and clients, the authorization
8
+ > server, JOSE, XML Signature and SAML, the crypto bindings). Owner decision,
9
+ > 2026-10-27.
10
+
3
11
  RDF, SPARQL, reasoning, validation and Linked Data servers in one package.
4
12
  Runs in Node 22.12+, Deno 2 and browsers. No dependencies.
5
13
 
@@ -43,7 +51,13 @@ RDF 1.2 input uses `turtle12`, `ntriples12`, `nquads12` or `trig12`.
43
51
  - Canonicalization: RDFC-1.0; Verifiable Credentials Data Integrity (eddsa-rdfc-2022), did:key
44
52
  - XML: XML 1.0, XPath 1.0, XSLT 1.0, XForms
45
53
  - Servers: SPARQL 1.1 Protocol and Graph Store (Fuseki-compatible paths,
46
- Jena TDB2 read only), Linked Web Storage 1.0, Solid
54
+ Jena TDB2 read only), Linked Web Storage 1.0, Solid, an OAuth 2.0
55
+ authorization server (token exchange for OpenID Connect ID tokens,
56
+ self-issued CID credentials and SAML 2.0 assertions)
57
+ - Security: JWS/JWT (ES256, RS256, EdDSA), XML Signature verification in the
58
+ SAML Core profile (Exclusive XML Canonicalization 1.0, rsa-sha256), with
59
+ HACL\* primitives. XML Encryption and JWE are not implemented yet:
60
+ https://github.com/danbri/factoidal/issues/1081
47
61
  - A persisted store with a delta log for updates (early stage)
48
62
  - Terms: a JavaScript extension function can answer with an exact term
49
63
  (`{value: "007", datatype: XSD + "integer"}`, an RDF/JS term, a triple
@@ -189,6 +203,7 @@ $ npx factoidal query store 'SELECT * WHERE { ?s ?p ?o } LIMIT 5'
189
203
  $ npx factoidal query "$(npx factoidal sample-store)" 'SELECT (COUNT(*) AS ?n) WHERE { ?s ?p ?o }'
190
204
  $ npx factoidal lws-serve DIR --issuer IRI # Linked Web Storage
191
205
  $ npx factoidal solid-serve DIR # Solid Protocol
206
+ $ npx factoidal authz-serve DIR --resource IRI --saml-idp idp.json # OAuth 2.0 authorization server
192
207
  ```
193
208
 
194
209
  `npx factoidal --help` lists every command. Replacing Apache Jena Fuseki:
package/authz/server.mjs CHANGED
@@ -169,6 +169,8 @@ async function fetchFrom (url, accept, address) {
169
169
  * @param {string|null} [options.storageKey] the key text; default FACTOIDAL_LWS_KEY; null reads none
170
170
  * @param {boolean|string[]} [options.cors] false for no CORS fields, or the origins to answer;
171
171
  * default: echo any valid request Origin (issue 923)
172
+ * @param {{entityId: string, jwk: object}[]} [options.samlIdps] the SAML 2.0 identity providers
173
+ * whose signed assertions are accepted, each with its RSA public JWK (issue 926)
172
174
  * @param {object} [options.engine] an engine to reuse
173
175
  */
174
176
  export async function openAuthorizationServer (options = {}) {
@@ -192,6 +194,9 @@ export async function openAuthorizationServer (options = {}) {
192
194
  // CORS (issue 923): false or a list of origins, passed unread; absent, the
193
195
  // engine echoes the request Origin (L4Factoidal/Authz/Server.lean stepCors).
194
196
  if (options.cors !== undefined) config.cors = options.cors
197
+ // SAML 2.0 identity providers (issue 926): [{ entityId, jwk }], passed
198
+ // unread; the engine checks each (Wasm/Ops/Authz.lean samlIdpsOf?).
199
+ if (options.samlIdps !== undefined) config.samlIdps = options.samlIdps
195
200
  const key = storageKeyText(options.storageKey)
196
201
  if (key !== undefined) config.storageKey = key
197
202
  if (dir !== null) {
package/bin/factoidal.mjs CHANGED
@@ -90,6 +90,15 @@ function err (line) { console.error(line) }
90
90
 
91
91
  // ---------------------------------------------------------------- usage
92
92
 
93
+ // Owner, 2026-10-27: every protocol, authentication and security component,
94
+ // the CLIs and the core library carry this notice. It is printed with every
95
+ // help text and on standard error when a server, client or key command starts.
96
+ const REVIEW_WARNING = 'EXPERIMENTAL UNREVIEWED CODE - USE WITH CARE - DO NOT CREATE PUBLIC SERVICES USING THIS WITHOUT CAREFUL INDEPENDENT EXPERT REVIEW.'
97
+
98
+ /** The commands that serve, speak a protocol, or handle keys and tokens. */
99
+ const WARNED_COMMANDS = new Set(['serve', 'lws-serve', 'solid-serve', 'solid-client', 'lws-client',
100
+ 'authz-serve', 'authz-keys', 'xmpp-serve', 'webdav-serve'])
101
+
93
102
  const USAGE = `factoidal - the Factoidal persisted store, from Node or Deno
94
103
 
95
104
  usage: factoidal <command> [options]
@@ -355,6 +364,7 @@ usage: factoidal authz-serve DIR --resource IRI[,IRI...] [--issuer IRI]
355
364
  [--token-lifetime SECONDS] [--rotate-after SECONDS]
356
365
  [--allow-http] [--allow-private-fetch]
357
366
  [--cors-origin ORIGIN]... [--no-cors]
367
+ [--saml-idp FILE]...
358
368
 
359
369
  An authorization server as a component (https://github.com/danbri/factoidal/issues/916),
360
370
  for lws-serve and any resource server that reads RFC 8414 metadata:
@@ -363,8 +373,17 @@ for lws-serve and any resource server that reads RFC 8414 metadata:
363
373
  POST /token RFC 8693 token exchange for an RFC 9068
364
374
  access token whose aud is the resource
365
375
  Subject tokens: OpenID Connect ID tokens (lws10-authn-openid) and
366
- self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid). SAML
367
- 2.0 is not accepted (https://github.com/danbri/factoidal/issues/926).
376
+ self-issued JWTs for https and did:key subjects (lws10-authn-ssi-cid), and
377
+ SAML 2.0 assertions (lws10-authn-saml) from the identity providers --saml-idp
378
+ names (https://github.com/danbri/factoidal/issues/926).
379
+
380
+ --saml-idp FILE (repeatable) trusts one SAML 2.0 identity provider. FILE is
381
+ JSON: {"entityId": "<the saml:Issuer value>", "jwk": <its RSA public JWK>}.
382
+ An assertion must carry one enveloped XML Signature (exclusive
383
+ canonicalization, rsa-sha256) that verifies under that key, an audience that
384
+ names the issuer of this server, and bearer subject confirmation; its NameID
385
+ is the subject and its Recipient the client. Encrypted assertions are
386
+ refused. Without --saml-idp the token type is refused and not advertised.
368
387
 
369
388
  --resource names the resource IRIs (for lws-serve: BASE.well-known/lws-storage)
370
389
  tokens may be issued for; any other is refused with invalid_target.
@@ -520,7 +539,8 @@ is required):
520
539
  BASE.well-known/lws-token, the JWK Set at
521
540
  BASE.well-known/lws-jwks, signing keys in DIR (as
522
541
  authz-serve; --alg, --token-lifetime, --rotate-after,
523
- --allow-http and --allow-private-fetch as there). The
542
+ --allow-http, --allow-private-fetch and --saml-idp as
543
+ there). The
524
544
  storage trusts it, and any --issuer too. Each request
525
545
  is the authorization server's when the engine says so
526
546
  (L4Factoidal/Authz/Mount.lean); every other is the
@@ -807,7 +827,7 @@ function parseArguments (args, valueOptions) {
807
827
  class UsageError extends Error {}
808
828
 
809
829
  /** Options that may be given more than once; their value is the list. */
810
- const REPEATABLE_OPTIONS = new Set(['cors-origin'])
830
+ const REPEATABLE_OPTIONS = new Set(['cors-origin', 'saml-idp'])
811
831
 
812
832
  const VALUE_OPTIONS = {
813
833
  version: new Set([]),
@@ -821,7 +841,8 @@ const VALUE_OPTIONS = {
821
841
  serve: new Set(['port', 'host', 'query-path', 'gsp-path', 'file', 'layout', 'tdb2']),
822
842
  'lws-serve': new Set(['port', 'host', 'base', 'issuer', 'auth-config', 'owner', 'root-acl',
823
843
  'audience', 'page-size', 'moves', 'controller-inbox', 'delivery-limit', 'delivery-window',
824
- 'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after']),
844
+ 'redirects-max', 'storage', 'cors-origin', 'authz', 'alg', 'token-lifetime', 'rotate-after',
845
+ 'saml-idp']),
825
846
  'solid-serve': new Set(['port', 'host', 'base', 'owner', 'issuer', 'auth-config']),
826
847
  'solid-client': new Set(['body', 'file', 'content-type', 'interpret']),
827
848
  'lws-client': new Set(['body', 'file', 'content-type', 'slug', 'type', 'link', 'if-match',
@@ -829,7 +850,7 @@ const VALUE_OPTIONS = {
829
850
  'target', 'subject-token-file', 'subject-token-type', 'access-token', 'storage', 'root',
830
851
  'inbox']),
831
852
  'authz-serve': new Set(['port', 'host', 'issuer', 'resource', 'alg', 'token-lifetime', 'rotate-after',
832
- 'cors-origin']),
853
+ 'cors-origin', 'saml-idp']),
833
854
  'authz-keys': new Set([]),
834
855
  'xmpp-serve': new Set(['port', 'host', 'domain', 'accounts', 'cert', 'key']),
835
856
  'webdav-serve': new Set(['port', 'host'])
@@ -1744,6 +1765,16 @@ function corsConfig (options) {
1744
1765
  return {}
1745
1766
  }
1746
1767
 
1768
+ // --saml-idp FILE (repeatable): a SAML 2.0 identity provider whose signed
1769
+ // assertions the authorization server accepts, as the config member
1770
+ // `samlIdps`. FILE is JSON {"entityId": ID, "jwk": RSA public JWK}; passed
1771
+ // unread, the engine checks it (Wasm/Ops/Authz.lean, issue 926).
1772
+ function samlConfig (options) {
1773
+ const files = options['saml-idp']
1774
+ if (!Array.isArray(files)) return {}
1775
+ return { samlIdps: files.map((f) => readJsonFile(f)) }
1776
+ }
1777
+
1747
1778
  function serveHost (options) {
1748
1779
  if (options.host === undefined) return '127.0.0.1'
1749
1780
  if (typeof options.host !== 'string' || options.host.length === 0) {
@@ -1877,7 +1908,8 @@ async function commandLwsServe (positional, options) {
1877
1908
  ...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
1878
1909
  ...(options['allow-http'] === true ? { allowHttp: true } : {}),
1879
1910
  ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
1880
- ...corsConfig(options)
1911
+ ...corsConfig(options),
1912
+ ...samlConfig(options)
1881
1913
  }
1882
1914
  let auth
1883
1915
  if (authFile !== null) {
@@ -1964,7 +1996,8 @@ async function commandAuthzServe (positional, options) {
1964
1996
  ...(options['rotate-after'] !== undefined ? { rotateAfter: natOption('rotate-after', options['rotate-after']) } : {}),
1965
1997
  ...(options['allow-http'] === true ? { allowHttp: true } : {}),
1966
1998
  ...(options['allow-private-fetch'] === true ? { allowPrivateFetch: true } : {}),
1967
- ...corsConfig(options)
1999
+ ...corsConfig(options),
2000
+ ...samlConfig(options)
1968
2001
  })
1969
2002
  } catch (error) {
1970
2003
  if (error !== null && typeof error === 'object' && error.envelope !== undefined) {
@@ -2287,12 +2320,14 @@ const COMMANDS = {
2287
2320
 
2288
2321
  async function main () {
2289
2322
  if (argv.length === 0) {
2323
+ out(REVIEW_WARNING + '\n')
2290
2324
  out(USAGE)
2291
2325
  return EXIT_USAGE
2292
2326
  }
2293
2327
  const command = argv[0]
2294
2328
  if (command === '--help' || command === '-h' || command === 'help') {
2295
2329
  const topic = argv[1]
2330
+ out(REVIEW_WARNING + '\n')
2296
2331
  if (typeof topic === 'string' && Object.prototype.hasOwnProperty.call(COMMAND_USAGE, topic)) {
2297
2332
  out(COMMAND_USAGE[topic])
2298
2333
  } else {
@@ -2317,9 +2352,13 @@ async function main () {
2317
2352
  throw error
2318
2353
  }
2319
2354
  if (parsed.options.help) {
2355
+ out(REVIEW_WARNING + '\n')
2320
2356
  out(COMMAND_USAGE[command])
2321
2357
  return EXIT_OK
2322
2358
  }
2359
+ if (WARNED_COMMANDS.has(command)) {
2360
+ err(`factoidal ${command}: WARNING: ${REVIEW_WARNING}`)
2361
+ }
2323
2362
  try {
2324
2363
  return await COMMANDS[command](parsed.positional, parsed.options)
2325
2364
  } catch (error) {
package/index.d.ts CHANGED
@@ -27,8 +27,11 @@ export interface BlankNode {
27
27
  export interface Literal {
28
28
  termType: 'Literal';
29
29
  value: string;
30
- /** Language tag ('' when none). */
30
+ /** Language tag, lowercase ('' when none). */
31
31
  language: string;
32
+ /** Base direction of a directional language-tagged string (RDF 1.2):
33
+ * 'ltr' or 'rtl'; '' when none. RDF/JS Data Model, Literal.direction. */
34
+ direction: '' | 'ltr' | 'rtl';
32
35
  /** Datatype IRI term (xsd:string for plain literals). */
33
36
  datatype: NamedNode;
34
37
  equals(other: Term | null | undefined): boolean;
@@ -37,7 +37,7 @@
37
37
  // bytes change.
38
38
 
39
39
  // Stamped by formal/lean4/Wasm/build-wasm.sh step 9 -- do not hand-edit.
40
- const WASM_VERSION = "925f357453db";
40
+ const WASM_VERSION = "6dbf38660cb9";
41
41
 
42
42
  import createModule from './l4factoidal.mjs';
43
43
 
Binary file
@@ -2,17 +2,17 @@
2
2
  "engine": "lean4",
3
3
  "note": "The Lean 4 (L4Factoidal) engine build provenance and claims: the only engine of @factoidal/core since 0.9.0 (issue 713). This committed file is a placeholder. tools/fetch-builds.sh --set web (through tools/stamp-l4-version.mjs) writes the provenance members from the danbri/factoidal-builds release that built the wasm; formal/lean4/Wasm/build-wasm.sh step 9 writes them for a local build. Do not commit a stamped copy.",
4
4
  "stamped": true,
5
- "release": "danbri/factoidal-builds build-788ee43aec79-web",
6
- "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40457275423",
7
- "gitSha": "788ee43aec79c4d29592c5e82cc7ab350f0d05dd",
8
- "builtAt": "2026-10-27T06:36:59Z",
5
+ "release": "danbri/factoidal-builds build-b8c6b64b8dc1-web",
6
+ "releaseRun": "https://github.com/danbri/factoidal-builds/actions/runs/40525043015",
7
+ "gitSha": "b8c6b64b8dc18a6daa2151a61e28dbb9a4707ade",
8
+ "builtAt": "2026-10-27T15:28:08Z",
9
9
  "leanToolchain": "leanprover/lean4:v4.33.1",
10
10
  "emscripten": "6.0.8",
11
11
  "abiVersion": "1",
12
12
  "engineVersion": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
13
13
  "version": "l4factoidal-wasm 0.2.0 (dispatch: RDF parse/serialize, SPARQL query+update, RDFS/OWL-RL closures, RDFC-1.0, CL/IKL)",
14
- "wasmSha256": "925f357453dbb71d087bef70fd428b054999756d7c967a806403d3d8ee875a57",
15
- "wasmBytes": 17565361,
14
+ "wasmSha256": "6dbf38660cb9966ae7c65ecdb68a38e83d09c1283415c1486b618f203f3f2f39",
15
+ "wasmBytes": 17694204,
16
16
  "claims": {
17
17
  "source": "formal/lean4 (L4Factoidal): no sorry, no user axioms, no native_decide; W3C behaviour pinned by build-time #guard",
18
18
  "suitesAtBuildSha": "see docs/test-results and formal/lean4/PORT_NOTES.md at gitSha"
package/lib/version.mjs CHANGED
@@ -2,4 +2,4 @@
2
2
  // needs import attributes, which not every runtime this package targets
3
3
  // reads). test/dts-drift.test.js checks that it equals package.json's
4
4
  // `version`.
5
- export const version = '0.18.0';
5
+ export const version = '0.19.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@factoidal/core",
3
- "version": "0.18.0",
4
- "description": "RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
3
+ "version": "0.19.0",
4
+ "description": "EXPERIMENTAL UNREVIEWED CODE: do not run public services on it without careful independent expert review. RDF parsing and writing (Turtle, TriG, N-Triples, N-Quads, RDF/XML, JSON-LD), SPARQL 1.1 and 1.2, RDFS and OWL reasoning, SHACL and ShEx validation, CSVW and RML mapping, RDFC-1.0, and SPARQL, LWS and Solid servers, for Node, Deno and browsers.",
5
5
  "keywords": [
6
6
  "sparql",
7
7
  "rdf",
package/rdfjs.mjs CHANGED
@@ -52,11 +52,20 @@ class BlankNode {
52
52
  }
53
53
  }
54
54
 
55
+ // RDF/JS Data Model, interface Literal: "language the language as lowercase
56
+ // [BCP47] string (examples: "en", "en-gb")". A BCP 47 tag is ASCII (RFC 5646
57
+ // section 2.1), and the engine's readers lowercase its ASCII letters (Lean
58
+ // String.toLower), so a factory term equals the same term read from text
59
+ // (https://github.com/danbri/factoidal/issues/1054).
60
+ function lowerAscii(s) {
61
+ return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
62
+ }
63
+
55
64
  class Literal {
56
65
  constructor(value, language, datatype, direction) {
57
66
  this.termType = 'Literal';
58
67
  this.value = value;
59
- this.language = language || '';
68
+ this.language = language ? lowerAscii(String(language)) : '';
60
69
  // RDF/JS Data Model DataFactory.literal: no datatype argument means
61
70
  // xsd:string, a language means rdf:langString, a language and a
62
71
  // direction mean rdf:dirLangString.