feat: initial commit

This commit is contained in:
l.weber 2025-12-05 12:20:05 +01:00
commit a161b86c9a
705 changed files with 288162 additions and 0 deletions

View file

@ -0,0 +1,13 @@
# Auto detect text files and perform LF normalization
* text=auto
*.go diff=golang
go.sum linguist-generated merge=ours
/vendor/ linguist-vendored
*.md linguist-documentation text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2
/.github export-ignore
/.woodpecker linguist-vendored export-ignore
.gitattributes export-ignore
.gitignore export-ignore

View file

@ -0,0 +1,4 @@
*.test
*.out
vendor/
.vscode/

View file

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View file

@ -0,0 +1,37 @@
# longdistance
A Go library for folks whose relationship status with Linked Data is "It's Complicated".
This library implements parts of the [JSON-LD 1.1][jld] specification. It does not currently implement features from the JSON-LD 1.1 Processing Algorithms and API specification that are not needed for handling [ActivityStreams][as].
[jld]: https://www.w3.org/TR/json-ld/
[as]: https://www.w3.org/TR/activitystreams-core/
For each implemented functionality, it passes the associated [JSON-LD test suite][jldtest] provided by the W3C.
[jldtest]: https://w3c.github.io/json-ld-api/tests/
## Documentation
See the [godoc](https://pkg.go.dev/sourcery.dny.nu/longdistance).
## Supported features
* Context processing.
* Remote context retrieval is supported, but requires a loader to be provided.
* Document expansion.
* Document compaction.
* Except `@preserve`.
## Unsupported features
* Document flattening.
* Framing.
* RDF serialisation/deserialisation.
* Remote document retrieval.
If you're able and willing to contribute one of these features, please start by opening an issue so we can discuss how to appraoch it.
## License
This library is licensed under the Mozilla Public License Version 2.0.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
package longdistance
const (
// BlankNode is the blank node prefix.
BlankNode = "_:"
)
// Values for @direction.
const (
DirectionLTR = "ltr"
DirectionRTL = "rtl"
)
// JSON-LD MIME types and profiles.
const (
ApplicationLDJSON = "application/ld+json"
ApplicationJSON = "application/json"
ProfileExpanded = "http://www.w3.org/ns/json-ld#expanded"
ProfileCompacted = "http://www.w3.org/ns/json-ld#compacted"
ProfileContext = "http://www.w3.org/ns/json-ld#context"
ProfileFlattened = "http://www.w3.org/ns/json-ld#flattened"
ProfileFrame = "http://www.w3.org/ns/json-ld#frame"
ProfileFramed = "http://www.w3.org/ns/json-ld#framed"
)

View file

@ -0,0 +1,677 @@
package longdistance
import (
"context"
"fmt"
"iter"
"maps"
"slices"
"strings"
"sourcery.dny.nu/longdistance/internal/json"
"sourcery.dny.nu/longdistance/internal/url"
)
// RemoteContextLimit is the recursion limit for resolving remote contexts.
const RemoteContextLimit = 10
// Context represents a processed JSON-LD context.
type Context struct {
defs map[string]Term
protected map[string]struct{}
currentBaseIRI string
originalBaseIRI string
vocabMapping string
defaultLang string
defaultDirection string
previousContext *Context
inverse inverseContext
}
// newContext initialises a new context with the specified documentURL set as
// the current and original base IRI.
func newContext(documentURL string) *Context {
return &Context{
defs: make(map[string]Term),
protected: make(map[string]struct{}),
defaultLang: "",
defaultDirection: "",
previousContext: nil,
inverse: nil,
currentBaseIRI: documentURL,
originalBaseIRI: documentURL,
}
}
// Terms returns an iterator over context term definitions.
func (c *Context) Terms() iter.Seq2[string, Term] {
return func(yield func(string, Term) bool) {
for k, v := range c.defs {
if !yield(k, v) {
return
}
}
}
}
func (c *Context) initInverse() {
if c.inverse == nil {
c.inverse = workIt(c)
}
}
func (c *Context) clone() *Context {
return &Context{
defs: maps.Clone(c.defs),
protected: maps.Clone(c.protected),
currentBaseIRI: c.currentBaseIRI,
originalBaseIRI: c.originalBaseIRI,
vocabMapping: c.vocabMapping,
defaultLang: c.defaultLang,
defaultDirection: c.defaultDirection,
previousContext: c.previousContext,
inverse: nil,
}
}
// Context takes in JSON and parses it into a [Context].
func (p *Processor) Context(localContext json.RawMessage, baseURL string) (*Context, error) {
if len(localContext) == 0 {
return nil, nil
}
if json.IsNull(localContext) {
return nil, nil
}
return p.context(nil, localContext, baseURL, newCtxProcessingOpts())
}
type ctxProcessingOpts struct {
remotes []string
override bool
propagate bool
validate bool
}
func newCtxProcessingOpts() ctxProcessingOpts {
return ctxProcessingOpts{
propagate: true,
validate: true,
}
}
func (p *Processor) context(
activeContext *Context,
localContext json.RawMessage,
baseURL string,
opts ctxProcessingOpts,
) (*Context, error) {
if activeContext == nil {
activeContext = newContext(baseURL)
}
if p.baseIRI != "" {
activeContext.currentBaseIRI = p.baseIRI
}
// 1)
result := activeContext.clone()
// 2)
if json.IsMap(localContext) {
var propcheck struct {
Propagate *bool `json:"@propagate,omitempty"`
}
if err := json.Unmarshal(localContext, &propcheck); err != nil {
return nil, ErrInvalidPropagateValue
}
if propcheck.Propagate != nil {
opts.propagate = *propcheck.Propagate
}
}
// 3)
if !opts.propagate {
if result.previousContext == nil {
result.previousContext = activeContext.clone()
}
}
// 4)
localContext = json.MakeArray(localContext)
var contexts []json.RawMessage
if err := json.Unmarshal(localContext, &contexts); err != nil {
return nil, fmt.Errorf("invalid context document")
}
if len(contexts) == 0 {
return nil, nil
}
// 5)
for _, context := range contexts {
// 5.1)
switch context[0] {
case '[':
return nil, ErrInvalidLocalContext
case '{':
// goes on after the switch
default:
// 5.1)
if json.IsNull(context) {
// 5.1.1)
if !opts.override && len(result.protected) != 0 {
return nil, ErrInvalidContextNullificaton
}
// 5.1.2)
previous := result.clone()
result = newContext(activeContext.originalBaseIRI)
if !opts.propagate {
result.previousContext = previous
}
// 5.1.3)
continue
}
var s string
if err := json.Unmarshal(context, &s); err != nil {
return nil, ErrInvalidLocalContext
}
// 5.2)
// 5.2.1)
if !url.IsIRI(baseURL) && !url.IsIRI(s) {
return nil, ErrLoadingDocument
}
iri, err := url.Resolve(baseURL, s)
if err != nil {
return nil, ErrLoadingDocument
}
// 5.2.2)
if !opts.validate && slices.Contains(opts.remotes, iri) {
return nil, nil
}
// 5.2.3)
if len(opts.remotes) > RemoteContextLimit {
if p.modeLD10 {
return nil, ErrRecursiveContextInclusion
}
return nil, ErrContextOverflow
}
opts.remotes = append(opts.remotes, iri)
// 5.2.4) 5.2.5)
doc, err := p.retrieveRemoteContext(iri)
if err != nil {
return nil, err
}
// 5.2.6)
newOpts := newCtxProcessingOpts()
newOpts.remotes = slices.Clone(opts.remotes)
newOpts.validate = opts.validate
res, err := p.context(
result,
doc.Context,
doc.URL,
newOpts,
)
if err != nil {
return nil, err
}
result = res
continue
}
// 5.3)
var ctxObj map[string]json.RawMessage
if err := json.Unmarshal(context, &ctxObj); err != nil {
return nil, fmt.Errorf("failed to unmarshal context: %s %w", err, ErrInvalidLocalContext)
}
// 5.5)
if version, ok := ctxObj[KeywordVersion]; ok {
if err := p.handleVersion(version); err != nil {
return nil, err
}
}
// 5.6)
if imp, ok := ctxObj[KeywordImport]; ok {
res, err := p.handleImport(baseURL, imp, ctxObj)
if err != nil {
return nil, err
}
ctxObj = res
}
// 5.7)
if base, ok := ctxObj[KeywordBase]; ok && len(opts.remotes) == 0 {
if err := p.handleBase(result, base); err != nil {
return nil, err
}
}
// 5.8)
if vocab, ok := ctxObj[KeywordVocab]; ok {
if err := p.handleVocab(result, vocab); err != nil {
return nil, err
}
}
// 5.9)
if lang, ok := ctxObj[KeywordLanguage]; ok {
if err := p.handleLanguage(result, lang); err != nil {
return nil, err
}
}
// 5.10)
if dir, ok := ctxObj[KeywordDirection]; ok {
if err := p.handleDirection(result, dir); err != nil {
return nil, err
}
}
// 5.11)
if prop, ok := ctxObj[KeywordPropagate]; ok {
if err := p.handlePropagate(prop); err != nil {
return nil, err
}
}
protected := false
if prot, ok := ctxObj[KeywordProtected]; ok && !json.IsNull(prot) {
if err := json.Unmarshal(prot, &protected); err != nil {
return nil, ErrInvalidProtectedValue
}
}
// 5.12)
defined := map[string]*bool{}
// 5.13)
for k := range ctxObj {
switch k {
case KeywordBase, KeywordDirection, KeywordImport,
KeywordLanguage, KeywordPropagate, KeywordProtected,
KeywordVersion, KeywordVocab:
default:
newOpts := newCreateTermOptions()
newOpts.baseURL = baseURL
newOpts.protected = protected
newOpts.override = opts.override
newOpts.remotes = slices.Clone(opts.remotes)
if err := p.createTerm(
result,
ctxObj,
k,
defined,
newOpts,
); err != nil {
return nil, err
}
}
}
}
return result, nil
}
func (p *Processor) handlePropagate(prop json.RawMessage) error {
if p.modeLD10 {
return ErrInvalidContextEntry
}
if json.IsNull(prop) {
return ErrInvalidPropagateValue
}
var b bool
if err := json.Unmarshal(prop, &b); err != nil {
return ErrInvalidPropagateValue
}
return nil
}
func (p *Processor) handleDirection(result *Context, dir json.RawMessage) error {
if p.modeLD10 {
return ErrInvalidContextEntry
}
if json.IsNull(dir) {
result.defaultDirection = ""
return nil
}
var d string
if err := json.Unmarshal(dir, &d); err != nil {
return ErrInvalidBaseDirection
}
switch d {
case DirectionLTR, DirectionRTL:
default:
return ErrInvalidBaseDirection
}
result.defaultDirection = d
return nil
}
func (p *Processor) handleLanguage(result *Context, lang json.RawMessage) error {
if json.IsNull(lang) {
result.defaultLang = ""
return nil
}
var l string
if err := json.Unmarshal(lang, &l); err != nil {
return ErrInvalidDefaultLanguage
}
result.defaultLang = strings.ToLower(l)
return nil
}
func (p *Processor) handleVocab(result *Context, vocab json.RawMessage) error {
// 5.8.2)
if json.IsNull(vocab) {
result.vocabMapping = ""
return nil
}
var s string
if err := json.Unmarshal(vocab, &s); err != nil {
return ErrInvalidVocabMapping
}
// 5.8.3)
if !(url.IsIRI(s) || url.IsRelative(s) || s == BlankNode) {
return ErrInvalidVocabMapping
}
u, err := p.expandIRI(result, s, true, true, nil, nil)
if err != nil {
return err
}
result.vocabMapping = u
return nil
}
func (p *Processor) handleBase(result *Context, base json.RawMessage) error {
// 5.7.2)
if json.IsNull(base) {
result.currentBaseIRI = ""
return nil
}
var iri string
if err := json.Unmarshal(base, &iri); err != nil {
return ErrInvalidBaseIRI
}
// 5.7.3)
if url.IsIRI(iri) {
result.currentBaseIRI = iri
return nil
}
// 5.7.4)
if url.IsRelative(iri) {
u, err := url.Resolve(result.currentBaseIRI, iri)
if err != nil {
return ErrInvalidBaseIRI
}
result.currentBaseIRI = u
return nil
}
// 5.7.5)
return ErrInvalidBaseIRI
}
func (p *Processor) handleImport(baseURL string, data json.RawMessage, context map[string]json.RawMessage) (map[string]json.RawMessage, error) {
// 5.6.1)
if p.modeLD10 {
return nil, ErrInvalidContextEntry
}
// 5.6.2)
var val string
if err := json.Unmarshal(data, &val); err != nil {
return nil, ErrInvalidImportValue
}
// 5.6.3)
iri, err := url.Resolve(baseURL, val)
if err != nil {
return nil, ErrInvalidRemoteContext
}
// 5.6.4) 5.6.5)
res, err := p.retrieveRemoteContext(iri)
if err != nil {
return nil, err
}
// 5.6.6)
var ctxObj map[string]json.RawMessage
if err := json.Unmarshal(res.Context, &ctxObj); err != nil {
return nil, ErrInvalidRemoteContext
}
// 5.6.7)
if _, ok := ctxObj[KeywordImport]; ok {
return nil, ErrInvalidContextEntry
}
maps.Copy(ctxObj, context)
return ctxObj, nil
}
func (p *Processor) handleVersion(data json.RawMessage) error {
var ver float64
if err := json.Unmarshal(data, &ver); err != nil {
return ErrInvalidVersionValue
}
if ver != 1.1 {
return ErrInvalidVersionValue
}
if p.modeLD10 {
return ErrProcessingMode
}
return nil
}
func (p *Processor) retrieveRemoteContext(
iri string,
) (Document, error) {
// 5.2.4) 5.2.5) the document loader is expected to do the caching
if p.loader == nil {
return Document{}, fmt.Errorf("no loader %w", ErrLoadingRemoteContext)
}
doc, err := p.loader(context.TODO(), iri)
if err != nil {
return Document{}, err
}
return doc, nil
}
type inverseContext map[string]map[string]mapping
type mapping struct {
Language map[string]string
Type map[string]string
Any map[string]string
}
// workIt flips a context and reverses it
//
// ti esrever dna ti pilf ,nwod gniht ym tuP
func workIt(activeContext *Context) inverseContext {
// 1)
result := inverseContext{}
// 2)
defaultLang := KeywordNone
if activeContext.defaultLang != "" {
defaultLang = strings.ToLower(activeContext.defaultLang)
}
// 3)
terms := slices.Collect(maps.Keys(activeContext.defs))
slices.SortFunc(terms, sortedLeast)
for _, key := range terms {
def := activeContext.defs[key]
// 3.1)
if def.IsZero() {
continue
}
// 3.2)
container := KeywordNone
if def.Container != nil {
dc := slices.Clone(def.Container)
slices.Sort(dc)
container = strings.Join(dc, "")
}
// 3.3)
vvar := def.IRI
// 3.4)
if _, ok := result[vvar]; !ok {
result[vvar] = map[string]mapping{}
}
// 3.5)
containerMap := result[vvar]
// 3.6)
if _, ok := containerMap[container]; !ok {
containerMap[container] = mapping{
Language: map[string]string{},
Type: map[string]string{},
Any: map[string]string{
KeywordNone: key,
},
}
}
// 3.7)
typeLanguage := containerMap[container]
// 3.8)
typeMap := typeLanguage.Type
// 3.9)
langMap := typeLanguage.Language
if def.Reverse {
// 3.10)
if _, ok := typeMap[KeywordReverse]; !ok {
typeMap[KeywordReverse] = key
}
} else if def.Type != "" {
if def.Type == KeywordNone {
// 3.11)
if _, ok := langMap[KeywordAny]; !ok {
// 3.11.1)
langMap[KeywordAny] = key
}
if _, ok := typeMap[KeywordAny]; !ok {
// 3.11.2)
typeMap[KeywordAny] = key
}
} else {
// 3.12)
if _, ok := typeMap[def.Type]; !ok {
// 3.12.1
typeMap[def.Type] = key
}
}
} else if def.Language != "" || def.Direction != "" {
if def.Language != "" && def.Direction != "" {
// 3.13)
// 3.13.1) + 3.13.5)
langDir := KeywordNone
if def.Language != KeywordNull && def.Direction != KeywordNull {
// 3.13.2)
langDir = strings.ToLower(def.Language) + "_" + def.Direction
} else if def.Language != KeywordNull {
// 3.13.3)
langDir = strings.ToLower(def.Language)
} else if def.Direction != KeywordNull {
// 3.13.4)
langDir = "_" + def.Direction
}
// 3.13.6)
if _, ok := langMap[langDir]; !ok {
langMap[langDir] = key
}
} else if def.Language != "" {
// 3.14)
lang := KeywordNull
if def.Language != KeywordNull {
lang = strings.ToLower(def.Language)
}
if _, ok := langMap[lang]; !ok {
langMap[lang] = key
}
} else if def.Direction != "" {
// 3.15)
dir := KeywordNone
if def.Direction != KeywordNull {
dir = "_" + def.Direction
}
if _, ok := langMap[dir]; !ok {
langMap[dir] = key
}
}
} else if activeContext.defaultDirection != "" {
// 3.16)
langDir := strings.ToLower(defaultLang) + "_" + activeContext.defaultDirection
if _, ok := langMap[langDir]; !ok {
langMap[langDir] = key
}
if _, ok := langMap[KeywordNone]; !ok {
langMap[KeywordNone] = key
}
if _, ok := typeMap[KeywordNone]; !ok {
typeMap[KeywordNone] = key
}
} else {
// 3.17)
// 3.17.1)
if _, ok := langMap[defaultLang]; !ok {
langMap[defaultLang] = key
}
// 3.17.2)
if _, ok := langMap[KeywordNone]; !ok {
langMap[KeywordNone] = key
}
// 3.17.3)
if _, ok := typeMap[KeywordNone]; !ok {
typeMap[KeywordNone] = key
}
}
}
return result
}

View file

@ -0,0 +1,44 @@
// Package longdistance can be used to process JSON-LD.
//
// You can turn incoming JSON into fully expanded JSON-LD using
// [Processor.Expand]. This will transform the document into a list of [Node].
// Each node has dedicated fields for each JSON-LD keyword, and the catch-all
// [Node.Properties] for everything else. If you serialise this document to JSON
// you'll get JSON-LD Expanded Document form.
//
// By calling [Processor.Compact] you can compact a list of [Node] to what looks
// like regular JSON, based on the provided compaction context. The result is
// serialised JSON that you can send out.
//
// By default a [Processor] cannot load remote contexts. You can install a
// [RemoteContextLoaderFunc] using [WithRemoteContextLoader] when creating the
// processor. You will need to provide your own. In order to not have
// dependencies on the network when processing documents, it's strongly
// recommended to create your own implementation of [RemoteContextLoaderFunc]
// with the necessary contexts built-in. You can take a look at the FileLoader
// in helpers_test.go.
//
// # JSON typing
//
// In order to provide a type-safe implementation, JSON scalars (numbers,
// strings, booleans) are not decoded and stored as [json.RawMessage] instead.
// You can use the optionally specified type to decide how to decode the value.
// When the type is unspecified, the following rules can be used:
// - Numbers with a zero fraction and smaller than 10^21 are int64.
// - Numbers with a decimal point or a value greater than 10^21 are float64.
// - Booleans are booleans.
// - Anything else is a string.
//
// Certain numbers might be encoded as strings to avoid size or precision issues
// with JSON number representation. They should have an accompanying type
// definition to explain how to interpret them. Certain strings might also hold
// a different value, like a timestamp or a duration. Those too should have a
// type specifying how to interpret them.
//
// # Constraints
//
// For JSON-LD, there are a few extra constraints on top of JSON:
// - Do not use keys that look like a JSON-LD keyword: @+alpha characters.
// - Do not use the empty string for a key.
// - Keys must be unique.
package longdistance

View file

@ -0,0 +1,59 @@
package longdistance
import "errors"
// Error types from the JSON-LD specification.
var (
ErrCollidingKeywords = errors.New("colliding keywords")
ErrContextOverflow = errors.New("context overflow")
ErrCyclicIRIMapping = errors.New("cyclic IRI mapping")
ErrInvalidBaseDirection = errors.New("invalid base direction")
ErrInvalidBaseIRI = errors.New("invalid base IRI")
ErrInvalidContainerMapping = errors.New("invalid container mapping")
ErrInvalidContextEntry = errors.New("invalid context entry")
ErrInvalidContextNullificaton = errors.New("invalid context nullification")
ErrInvalidDefaultLanguage = errors.New("invalid default language")
ErrInvalidIDValue = errors.New("invalid @id value")
ErrInvalidImportValue = errors.New("invalid @import value")
ErrInvalidIncludedValue = errors.New("invalid @included value")
ErrInvalidIndexValue = errors.New("invalid @index value")
ErrInvalidIRIMapping = errors.New("invalid IRI mapping")
ErrInvalidKeywordAlias = errors.New("invalid keyword alias")
ErrInvalidLanguageMapping = errors.New("invalid language mapping")
ErrInvalidLanguageMapValue = errors.New("invalid language map value")
ErrInvalidLanguageTaggedString = errors.New("invalid language-tagged string")
ErrInvalidLanguageTaggedValue = errors.New("invalid language-tagged value")
ErrInvalidLocalContext = errors.New("invalid local context")
ErrInvalidNestValue = errors.New("invalid @nest value")
ErrInvalidPrefixValue = errors.New("invalid @prefix value")
ErrInvalidPropagateValue = errors.New("invalid @propagate value")
ErrInvalidProtectedValue = errors.New("invalid @protected value")
ErrInvalidRemoteContext = errors.New("invalid remote context")
ErrInvalidReverseProperty = errors.New("invalid reverse property")
ErrInvalidReversePropertyMap = errors.New("invalid reverse property map")
ErrInvalidReversePropertyValue = errors.New("invalid reverse property value")
ErrInvalidReverseValue = errors.New("invalid @reverse value")
ErrInvalidScopedContext = errors.New("invalid scoped context")
ErrInvalidSetOrListObject = errors.New("invalid set or list object")
ErrInvalidTermDefinition = errors.New("invalid term definition")
ErrInvalidTypedValue = errors.New("invalid typed value")
ErrInvalidTypeMapping = errors.New("invalid type mapping")
ErrInvalidTypeValue = errors.New("invalid type value")
ErrInvalidValueObject = errors.New("invalid value object")
ErrInvalidValueObjectValue = errors.New("invalid value object value")
ErrInvalidVersionValue = errors.New("invalid @version value")
ErrInvalidVocabMapping = errors.New("invalid vocab mapping")
ErrIRIConfusedWithPrefix = errors.New("IRI confused with prefix")
ErrKeywordRedefinition = errors.New("keyword redefinition")
ErrLoadingDocument = errors.New("loading document failed")
ErrLoadingRemoteContext = errors.New("loading remote context failed")
ErrProcessingMode = errors.New("processing mode conflict")
ErrProtectedTermRedefinition = errors.New("protected term redefinition")
ErrRecursiveContextInclusion = errors.New("recursive context inclusion")
)
// Library-specific errors.
var (
ErrFrameExpansionUnsupported = errors.New("frame expansion is not supported")
ErrPreserveUnsupported = errors.New("@preserve is not supported")
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,68 @@
package json
import (
"bytes"
"encoding/json"
)
type RawMessage = json.RawMessage
type Object map[string]RawMessage
type Array []RawMessage
var Compact = json.Compact
var Marshal = json.Marshal
var MarshalIndent = json.MarshalIndent
var Valid = json.Valid
var Unmarshal = json.Unmarshal
var (
beginArray = byte('[')
beginObject = byte('{')
beginString = byte('"')
null = RawMessage(`null`)
)
func IsNull(in RawMessage) bool {
return bytes.Equal(in, null)
}
func IsArray(in RawMessage) bool {
if len(in) == 0 {
return false
}
return in[0] == beginArray
}
func IsMap(in RawMessage) bool {
if len(in) == 0 {
return false
}
return in[0] == beginObject
}
func IsString(in RawMessage) bool {
if len(in) == 0 {
return false
}
return in[0] == beginString
}
func IsScalar(in RawMessage) bool {
return !IsArray(in) && !IsMap(in) && !IsNull(in)
}
func MakeArray(in RawMessage) RawMessage {
if len(in) == 0 {
return json.RawMessage(`[]`)
}
if IsArray(in) {
return in
}
return bytes.Join([][]byte{
[]byte(`[`),
in,
[]byte(`]`),
}, nil)
}

View file

@ -0,0 +1,116 @@
package url
import (
"fmt"
"net/url"
"path"
"slices"
"strings"
)
var Parse = url.Parse
func Relative(base string, iri string) (string, error) {
baseURL, err := Parse(base)
if err != nil {
return "", fmt.Errorf("failed to parse base URL: %w", err)
}
absURL, err := Parse(iri)
if err != nil {
return "", fmt.Errorf("failed to parse absolute URL: %w", err)
}
if baseURL.Scheme != absURL.Scheme || baseURL.Host != absURL.Host {
return "", fmt.Errorf("cannot create relative URL when host or scheme differ")
}
basePath := baseURL.EscapedPath()
absPath := absURL.EscapedPath()
if basePath == absPath {
if absURL.Fragment != "" || absURL.RawQuery != "" {
return (&url.URL{
RawQuery: absURL.RawQuery,
Fragment: absURL.Fragment,
}).String(), nil
}
}
last := strings.LastIndex(basePath, "/")
basePath = basePath[:last+1]
baseParts := strings.Split(basePath, "/")
absParts := strings.Split(absPath, "/")
prefix := 0
lap := len(absParts)
count := min(len(baseParts), lap)
for i, elem := range baseParts[:count] {
if elem == absParts[i] {
prefix++
} else {
break
}
}
relpaths := make([]string, 0, len(baseParts)-prefix)
for range baseParts[prefix+1:] {
relpaths = append(relpaths, "..")
}
relpaths = append(relpaths, absParts[prefix:]...)
final := path.Join(relpaths...)
// Include query and fragment if present
relURL := &url.URL{
Path: final,
RawQuery: absURL.RawQuery,
Fragment: absURL.Fragment,
}
res := relURL.String()
if strings.HasSuffix(res, "..") {
res = res + "/"
}
return res, nil
}
func EndsInGenDelim(s string) bool {
delims := []string{":", "/", "?", "#", "[", "]", "@"}
last := s[len(s)-1:]
return slices.Contains(delims, last)
}
func IsRelative(s string) bool {
_, err := Parse(s)
return err == nil
}
func IsIRI(s string) bool {
u, err := Parse(s)
if err != nil {
return false
}
ns := u.String()
if strings.HasSuffix(s, "#") {
// preserve the empty fragment
ns = ns + "#"
}
return u.IsAbs() && s == ns
}
func Resolve(base string, val string) (string, error) {
r, err := Parse(val)
if err != nil {
return "", err
}
u, err := Parse(base)
if err != nil {
return "", err
}
return u.ResolveReference(r).String(), nil
}

View file

@ -0,0 +1,131 @@
package longdistance
import (
"log/slog"
"strings"
"sourcery.dny.nu/longdistance/internal/json"
"sourcery.dny.nu/longdistance/internal/url"
)
func (p *Processor) expandIRI(
activeContext *Context,
value string,
relative bool,
vocab bool,
localContext map[string]json.RawMessage,
defined map[string]*bool,
) (string, error) {
// 1)
if isKeyword(value) {
return value, nil
}
// 2)
if looksLikeKeyword(value) {
p.logger.Warn("keyword lookalike value encountered",
slog.String("value", value))
// we can't generate a warning, so return nil
// any empty values will be dropped
return "", nil
}
hasLocal := len(localContext) > 0
// 3)
if hasLocal {
if _, ok := localContext[value]; ok {
if v := defined[value]; v == nil || !*v {
if err := p.createTerm(
activeContext,
localContext,
value,
defined,
newCreateTermOptions(),
); err != nil {
return "", err
}
}
}
}
// 4)
if activeContext != nil {
if t, ok := activeContext.defs[value]; ok {
if isKeyword(t.IRI) {
return t.IRI, nil
}
}
}
// 5)
if vocab {
if activeContext != nil {
if t, ok := activeContext.defs[value]; ok {
return t.IRI, nil
}
}
}
// 6)
if strings.Index(value, ":") >= 1 {
// 6.1)
prefix, suffix, found := strings.Cut(value, ":")
if found {
// 6.2)
if prefix == "_" || strings.HasPrefix(suffix, "//") {
return value, nil
}
// 6.3)
if hasLocal {
if _, ok := localContext[prefix]; ok {
if v := defined[prefix]; v == nil || !*v {
if err := p.createTerm(
activeContext,
localContext,
prefix,
defined,
newCreateTermOptions(),
); err != nil {
return "", err
}
}
}
}
// 6.4)
if activeContext != nil {
if t, ok := activeContext.defs[prefix]; ok && t.IRI != "" && t.Prefix {
return t.IRI + suffix, nil
}
}
// 6.5)
if url.IsIRI(value) {
return value, nil
}
}
}
// 7)
if vocab {
if activeContext.vocabMapping != "" {
return activeContext.vocabMapping + value, nil
}
}
// 8)
if relative {
if activeContext.currentBaseIRI == "" {
return value, nil
}
u, err := url.Resolve(activeContext.currentBaseIRI, value)
if err != nil {
return "", err
}
return u, nil
}
return value, nil
}

View file

@ -0,0 +1,96 @@
package longdistance
// JSON-LD keywords.
const (
KeywordAny = "@any"
KeywordBase = "@base"
KeywordContainer = "@container"
KeywordContext = "@context"
KeywordDefault = "@default"
KeywordDirection = "@direction"
KeywordGraph = "@graph"
KeywordID = "@id"
KeywordImport = "@import"
KeywordIncluded = "@included"
KeywordIndex = "@index"
KeywordJSON = "@json"
KeywordLanguage = "@language"
KeywordList = "@list"
KeywordNest = "@nest"
KeywordNone = "@none"
KeywordNull = "@null"
KeywordPrefix = "@prefix"
KeywordPreserve = "@preserve"
KeywordPropagate = "@propagate"
KeywordProtected = "@protected"
KeywordReverse = "@reverse"
KeywordSet = "@set"
KeywordType = "@type"
KeywordValue = "@value"
KeywordVersion = "@version"
KeywordVocab = "@vocab"
)
// isKeyword returns if the string matches a known JSON-LD keyword.
func isKeyword(s string) bool {
switch s {
case KeywordBase,
KeywordContainer,
KeywordContext,
KeywordDefault,
KeywordDirection,
KeywordGraph,
KeywordID,
KeywordImport,
KeywordIncluded,
KeywordIndex,
KeywordJSON,
KeywordLanguage,
KeywordList,
KeywordNest,
KeywordNone,
KeywordPrefix,
KeywordPreserve,
KeywordPropagate,
KeywordProtected,
KeywordReverse,
KeywordSet,
KeywordType,
KeywordValue,
KeywordVersion,
KeywordVocab:
return true
default:
return false
}
}
// looksLikeKeyword determines if a string has the general shape of a JSON-LD
// keyword.
//
// It returns true for strings of the form: "@[alpha]".
//
// This means that a string like @blabla1 will return false, but it's still
// strongly recommended against using those for keys just to avoid confusion.
func looksLikeKeyword(s string) bool {
if s == "" {
return false
}
if s == "@" {
return false
}
if s[0] != '@' {
return false
}
for _, char := range s[1:] {
if (char < 'a' || char > 'z') &&
(char < 'A' || char > 'Z') {
return false
}
}
return true
}

View file

@ -0,0 +1,34 @@
package longdistance
import (
"context"
"sourcery.dny.nu/longdistance/internal/json"
)
// RemoteContextLoaderFunc is called to retrieve a remote context.
//
// It returns a Document, and an error in case retrieval failed.
//
// When building your own loader, please remember that:
// - [Document.URL] is the URL the context was retrieved from after having
// followed any redirects.
// - [Document.Context] is the value of the [KeywordContext] in the returned
// document, or the empty JSON map if the context was absent.
// - Request a context with [ApplicationLDJSON] and profile [ProfileContext].
// You can use [mime.FormatMediaType] to build the value for the Accept
// header.
// - Have proper timeouts, retry handling and request deduplication.
// - Make sure to cache the resulting [Document] to avoid unnecessary future
// requests. Contexts should not change for the lifetime of the application.
type RemoteContextLoaderFunc func(context.Context, string) (Document, error)
// Document holds a retrieved context.
//
// - URL holds the final URL a context was retrieved from, after following
// redirects.
// - Context holds the value of the @context element, or the empty map.
type Document struct {
URL string
Context json.RawMessage
}

View file

@ -0,0 +1,368 @@
package longdistance
import (
"sourcery.dny.nu/longdistance/internal/json"
)
// Properties is a key-to-array-of-[Node] map.
//
// It's used to hold any property that's not a JSON-LD keyword.
type Properties map[string][]Node
// Node represents a node in a JSON-LD graph.
//
// Every supported JSON-LD keyword has a field of its own. All remaining
// properties are tracked on the Properties field.
type Node struct {
Direction string // @direction / KeywordDirection
Graph []Node // @graph / KeywordGraph
ID string // @id / KeywordID
Included []Node // @included / KeywordIncluded
Index string // @index / KeywordIndex
Language string // @language / KeywordLanguage
List []Node // @list / KeywordList
Reverse Properties // @reverse / KeywordReverse
Set []Node // @set / KeywordSet
Type []string // @type / KeywordType
Value json.RawMessage // @value / KeywordValue
Properties Properties // everything else
}
// Internal is a generic type that matches the internals of [Node].
//
// This can be used to convert to a [Node] from any type outside this package
// that happens to be a [Node] underneath.
type Internal interface {
~struct {
Direction string
Graph []Node
ID string
Included []Node
Index string
Language string
List []Node
Reverse Properties
Set []Node
Type []string
Value json.RawMessage
Properties Properties
}
}
// PropertySet returns a [Set] with an entry for each property that is set on
// the [Node].
func (n *Node) PropertySet() map[string]struct{} {
if n == nil {
return nil
}
res := make(map[string]struct{}, len(n.Properties)+2)
if n.Has(KeywordDirection) {
res[KeywordDirection] = struct{}{}
}
if n.Has(KeywordGraph) {
res[KeywordGraph] = struct{}{}
}
if n.Has(KeywordID) {
res[KeywordID] = struct{}{}
}
if n.Has(KeywordIncluded) {
res[KeywordIncluded] = struct{}{}
}
if n.Has(KeywordIndex) {
res[KeywordIndex] = struct{}{}
}
if n.Has(KeywordLanguage) {
res[KeywordLanguage] = struct{}{}
}
if n.Has(KeywordList) {
res[KeywordList] = struct{}{}
}
if n.Has(KeywordReverse) {
res[KeywordReverse] = struct{}{}
}
if n.Has(KeywordSet) {
res[KeywordSet] = struct{}{}
}
if n.Has(KeywordType) {
res[KeywordType] = struct{}{}
}
if n.Has(KeywordValue) {
res[KeywordValue] = struct{}{}
}
for p := range n.Properties {
res[p] = struct{}{}
}
return res
}
func (n *Node) propsWithout(props ...string) map[string]struct{} {
nprops := n.PropertySet()
for _, prop := range props {
delete(nprops, prop)
}
return nprops
}
func (n *Node) isNode() bool {
if n == nil {
return false
}
return !n.Has(KeywordList) && !n.Has(KeywordValue) && !n.Has(KeywordSet)
}
// Has returns if a node has the requested property.
//
// Properties must either be a JSON-LD keyword, or an expanded IRI.
func (n *Node) Has(prop string) bool {
if n == nil {
return false
}
switch prop {
case KeywordID:
return n.ID != ""
case KeywordValue:
return n.Value != nil
case KeywordLanguage:
return n.Language != ""
case KeywordDirection:
return n.Direction != ""
case KeywordType:
return n.Type != nil
case KeywordList:
return n.List != nil
case KeywordSet:
return n.Set != nil
case KeywordGraph:
return n.Graph != nil
case KeywordIncluded:
return n.Included != nil
case KeywordIndex:
return n.Index != ""
case KeywordReverse:
return n.Reverse != nil
default:
for key := range n.Properties {
if prop == key {
return true
}
}
return false
}
}
// IsZero returns if this is the zero value of a [Node].
func (n *Node) IsZero() bool {
if n == nil {
return true
}
return len(n.PropertySet()) == 0
}
// IsSubject checks if this node is a subject.
//
// This means:
// - It has an @id.
// - It may have an @type.
// - It has at least one other property.
func (n *Node) IsSubject() bool {
if n == nil {
return false
}
if !n.Has(KeywordID) {
return false
}
return len(n.propsWithout(KeywordID, KeywordIndex)) != 0
}
// IsSubjectReference checks if this node is a subject reference.
//
// This means:
// - It has an @id.
// - It may have an @type.
// - It has no other properties.
func (n *Node) IsSubjectReference() bool {
if n == nil {
return false
}
if !n.Has(KeywordID) {
return false
}
return len(n.propsWithout(KeywordID, KeywordType)) == 0
}
// IsList checks if this node is a list.
//
// This means:
// - It has an @list.
// - It has no other properties.
func (n *Node) IsList() bool {
if n == nil {
return false
}
if !n.Has(KeywordList) {
return false
}
return len(n.propsWithout(KeywordList, KeywordIndex)) == 0
}
// IsValue checks if this is a value node.
//
// This means:
// - It has an @value.
// - It may have an @direction, @index, @langauge and @type.
// - It has no other properties.
//
// Additionally, it's invalid to have @type together with @language or
// @direction.
func (n *Node) IsValue() bool {
if n == nil {
return false
}
if !n.Has(KeywordValue) {
return false
}
return len(n.propsWithout(
KeywordValue,
KeywordDirection,
KeywordIndex,
KeywordLanguage,
KeywordType,
)) == 0
}
// IsGraph returns if the object is a graph.
//
// This requires:
// - It must have an @graph.
// - It may have @id and @index.
// - It has no other properties.
func (n *Node) IsGraph() bool {
if n == nil {
return false
}
if !n.Has(KeywordGraph) {
return false
}
return len(n.propsWithout(KeywordID, KeywordIndex, KeywordGraph)) == 0
}
// IsSimpleGraph returns if the object is a simple graph.
//
// This requires:
// - It must have an @graph.
// - It may have @index.
// - It has no other properties.
func (n *Node) IsSimpleGraph() bool {
if n == nil {
return false
}
if !n.Has(KeywordGraph) {
return false
}
return len(n.propsWithout(KeywordIndex, KeywordGraph)) == 0
}
// MarshalJSON encodes to Expanded Document Form.
func (n *Node) MarshalJSON() ([]byte, error) {
result := map[string]any{}
if n.Has(KeywordID) {
result[KeywordID] = n.ID
}
if n.Has(KeywordIndex) {
result[KeywordIndex] = n.Index
}
if n.Has(KeywordType) {
var data any
if n.Value != nil && len(n.Type) == 1 {
data = n.Type[0]
} else {
data = n.Type
}
result[KeywordType] = data
}
if n.Has(KeywordValue) {
result[KeywordValue] = n.Value
}
if n.Has(KeywordLanguage) {
result[KeywordLanguage] = n.Language
}
if n.Has(KeywordDirection) {
result[KeywordDirection] = n.Direction
}
if n.Has(KeywordList) {
result[KeywordList] = n.List
}
if n.Has(KeywordGraph) {
result[KeywordGraph] = n.Graph
}
if n.Has(KeywordIncluded) {
result[KeywordIncluded] = n.Included
}
if n.Has(KeywordReverse) {
result[KeywordReverse] = n.Reverse
}
for k, v := range n.Properties {
result[k] = v
}
return json.Marshal(result)
}
// GetNodes returns the nodes stored in property.
func (n *Node) GetNodes(property string) []Node {
switch property {
case KeywordGraph:
return n.Graph
case KeywordIncluded:
return n.Included
case KeywordList:
return n.List
case KeywordSet:
return n.Set
default:
if !n.Has(property) {
return nil
}
return n.Properties[property]
}
}
// AddNodes appends the nodes stored in property.
func (n *Node) AddNodes(property string, nodes ...Node) {
n.Properties[property] = append(n.Properties[property], nodes...)
}
// SetNodes overrides the nodes stored in property.
func (n *Node) SetNodes(property string, nodes ...Node) {
n.Properties[property] = nodes
}

View file

@ -0,0 +1,142 @@
package longdistance
import (
"log/slog"
"sourcery.dny.nu/longdistance/internal/json"
)
// ProcessorOption can be used to customise the behaviour of a [Processor].
type ProcessorOption func(*Processor)
// Processor represents a JSON-LD processor.
//
// Your application should only ever need one of them. Do not create a new one
// for each request you're handling.
//
// Create one with [NewProcessor] and pass any [ProcessorOption] to configure
// the processor.
type Processor struct {
modeLD10 bool
ordered bool
baseIRI string
compactArrays bool
compactToRelative bool
loader RemoteContextLoaderFunc
logger *slog.Logger
expandContext json.RawMessage
excludeIRIsFromCompaction []string
remapPrefixIRIs map[string]string
}
// NewProcessor creates a new JSON-LD processor.
//
// By default:
// - Processing mode is JSON-LD 1.1. This can handle both JSON-LD 1.0 and
// JSON-LD 1.1 documents. To switch to JSON-LD 1.0 only, configure it with
// [With10Processing].
// - No loader is configured. Without one, remote contexts as well as @import
// contexts cannot be processed. Set it with [WithRemoteContextLoader].
// - Arrays are compacted. Change it with [WithCompactArrays].
// - IRIs can compact to relative IRIs. Change it with
// [WithCompactToRelative].
// - Logger is [slog.DiscardHandler]. Set it with [WithLogger]. The logger is
// only used to emit warnings.
func NewProcessor(options ...ProcessorOption) *Processor {
p := &Processor{
compactArrays: true,
compactToRelative: true,
logger: slog.New(slog.DiscardHandler),
}
for _, opt := range options {
opt(p)
}
return p
}
// With10Processing sets the processing mode to json-ld-1.0.
func With10Processing(b bool) ProcessorOption {
return func(p *Processor) {
p.modeLD10 = b
}
}
// WithRemoteContextLoader sets the context loader function.
func WithRemoteContextLoader(l RemoteContextLoaderFunc) ProcessorOption {
return func(p *Processor) {
p.loader = l
}
}
// WithLogger sets the logger that'll be used to emit warnings during
// processing.
//
// Without a logger no warnings will be emitted when keyword lookalikes are
// encountered that are ignored.
func WithLogger(l *slog.Logger) ProcessorOption {
return func(p *Processor) {
p.logger = l
}
}
// WithOrdered ensures that object elements and language maps are processed in
// lexicographical order.
//
// This is typically not needed, but helps to stabilise the test suite.
func WithOrdered(b bool) ProcessorOption {
return func(p *Processor) {
p.ordered = b
}
}
// WithBaseIRI sets an explicit base IRI to use.
func WithBaseIRI(iri string) ProcessorOption {
return func(p *Processor) {
p.baseIRI = iri
}
}
// WithCompactArrays sets whether single-valued arrays should
// be reduced to their value where possible.
func WithCompactArrays(b bool) ProcessorOption {
return func(p *Processor) {
p.compactArrays = b
}
}
// WithCompactToRelative sets whether IRIs can be transformed into
// relative IRIs during IRI compaction.
func WithCompactToRelative(b bool) ProcessorOption {
return func(p *Processor) {
p.compactToRelative = b
}
}
// WithExpandContext provides an additional out-of-band context
// that's used during expansion.
func WithExpandContext(ctx json.RawMessage) ProcessorOption {
return func(p *Processor) {
p.expandContext = ctx
}
}
// WithExcludeIRIsFromCompaction disables IRI compaction for the specified IRIs.
func WithExcludeIRIsFromCompaction(iri ...string) ProcessorOption {
return func(p *Processor) {
p.excludeIRIsFromCompaction = iri
}
}
// WithRemapPrefixIRIs can remap a prefix IRI during context processing.
//
// Prefixes are only remapped for an exact match.
//
// This is useful to remap the incorrect schema.org# to schema.org/.
func WithRemapPrefixIRIs(old, new string) ProcessorOption {
return func(p *Processor) {
if p.remapPrefixIRIs == nil {
p.remapPrefixIRIs = make(map[string]string, 2)
}
p.remapPrefixIRIs[old] = new
}
}

View file

@ -0,0 +1,771 @@
package longdistance
import (
"bytes"
"log/slog"
"maps"
"slices"
"strings"
"sourcery.dny.nu/longdistance/internal/json"
"sourcery.dny.nu/longdistance/internal/url"
)
// Term represents a term definition in a JSON-LD context.
type Term struct {
IRI string
Prefix bool
Protected bool
Reverse bool
BaseIRI string
Context json.RawMessage
Container []string
Direction string
Index string
Language string
Nest string
Type string
}
func (t *Term) equalWithoutProtected(ot *Term) bool {
if t == nil && ot == nil {
return true
}
if t == nil || ot == nil {
return false
}
if t.IRI != ot.IRI {
return false
}
if t.Prefix != ot.Prefix {
return false
}
if t.Reverse != ot.Reverse {
return false
}
if t.BaseIRI != ot.BaseIRI {
return false
}
if !bytes.Equal(t.Context, ot.Context) {
return false
}
if !slices.Equal(t.Container, ot.Container) {
return false
}
if t.Direction != ot.Direction {
return false
}
if t.Index != ot.Index {
return false
}
if t.Language != ot.Language {
return false
}
if t.Nest != ot.Nest {
return false
}
if t.Type != ot.Type {
return false
}
return true
}
func (t *Term) IsZero() bool {
if t == nil {
return true
}
return t.IRI == "" && !t.Prefix && !t.Protected &&
!t.Reverse && t.BaseIRI == "" && t.Context == nil &&
t.Container == nil && t.Direction == "" &&
t.Index == "" && t.Language == "" && t.Nest == "" &&
t.Type == ""
}
type createTermOptions struct {
baseURL string
protected bool
override bool
remotes []string
validate bool
}
func newCreateTermOptions() createTermOptions {
return createTermOptions{
validate: true,
}
}
func (p *Processor) createTerm(
activeContext *Context,
localContext map[string]json.RawMessage,
term string,
defined map[string]*bool,
opts createTermOptions,
) error {
// 1)
if v := defined[term]; v != nil {
if *v {
return nil
}
return ErrCyclicIRIMapping
}
// 2)
if term == "" {
return ErrInvalidTermDefinition
} else {
b := false
defined[term] = &b
}
// 3)
value := localContext[term]
// 4)
if term == KeywordType {
if p.modeLD10 {
return ErrKeywordRedefinition
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(value, &obj); err != nil {
return ErrKeywordRedefinition
}
if len(obj) == 0 {
return ErrKeywordRedefinition
}
objCopy := maps.Clone(obj)
delete(objCopy, KeywordContainer)
delete(objCopy, KeywordProtected)
if len(objCopy) != 0 {
return ErrKeywordRedefinition
}
if v, ok := obj[KeywordContainer]; ok {
var s string
if err := json.Unmarshal(v, &s); err != nil {
return ErrKeywordRedefinition
}
if s != KeywordSet {
return ErrKeywordRedefinition
}
}
if v, ok := obj[KeywordProtected]; ok {
var b bool
if err := json.Unmarshal(v, &b); err != nil {
return ErrKeywordRedefinition
}
}
} else {
// 5)
if isKeyword(term) {
return ErrKeywordRedefinition
}
if looksLikeKeyword(term) {
p.logger.Warn("keyword lookalike term encountered", slog.String("term", term))
return nil
}
}
// 6)
oldDef, oldDefOK := activeContext.defs[term]
delete(activeContext.defs, term)
if !oldDefOK {
// check for aliasses
for _, def := range activeContext.defs {
if def.IRI != "" && def.IRI == term {
oldDef = def
oldDefOK = true
delete(activeContext.defs, term)
break
}
}
}
simpleTerm := false
var valueObj map[string]json.RawMessage
// 7) 8)
if json.IsNull(value) || json.IsString(value) {
// 8)
if json.IsString(value) {
simpleTerm = true
}
// 7)
value = bytes.Join([][]byte{
[]byte(`{"@id":`),
value,
[]byte(`}`),
}, nil)
}
// 9)
if err := json.Unmarshal(value, &valueObj); err != nil {
return ErrInvalidTermDefinition
}
// 10)
termDef := Term{
Prefix: false,
Protected: opts.protected,
Reverse: false,
}
// 11)
if prot, ok := valueObj[KeywordProtected]; ok {
if p.modeLD10 {
return ErrInvalidTermDefinition
}
var b bool
if err := json.Unmarshal(prot, &b); err != nil {
return ErrInvalidProtectedValue
}
termDef.Protected = b
}
// at this point protected is finalised, so add the
// term to the protected set on activeContext
if termDef.Protected {
activeContext.protected[term] = struct{}{}
}
// 12)
if typ, ok := valueObj[KeywordType]; ok {
if json.IsNull(typ) {
return ErrInvalidTypeMapping
}
var s string
// 12.1)
if err := json.Unmarshal(typ, &s); err != nil {
return ErrInvalidTypeMapping
}
// 12.2)
u, err := p.expandIRI(activeContext, s, false, true, localContext, defined)
if err != nil {
return ErrInvalidTypeMapping
}
// 12.3
if p.modeLD10 {
if u == KeywordNone || u == KeywordJSON {
return ErrInvalidTypeMapping
}
}
// 12.4)
switch u {
case KeywordID, KeywordJSON, KeywordNone, KeywordVocab:
default:
if !url.IsIRI(u) {
return ErrInvalidTypeMapping
}
}
// 12.5)
termDef.Type = u
}
// prep for branch 14)
id, idOK := valueObj[KeywordID]
var idStr string
idErr := json.Unmarshal(id, &idStr)
// 13)
if rev, ok := valueObj[KeywordReverse]; ok {
_, hasID := valueObj[KeywordID]
_, hasNest := valueObj[KeywordNest]
// 13.1)
if hasID || hasNest {
return ErrInvalidReverseProperty
}
// 13.2)
if json.IsNull(rev) {
return ErrInvalidIRIMapping
}
var s string
if err := json.Unmarshal(rev, &s); err != nil {
return ErrInvalidIRIMapping
}
// 13.3)
if looksLikeKeyword(s) {
p.logger.Warn("keyword lookalike value encountered",
slog.String("value", s))
return nil
}
// 13.4)
u, err := p.expandIRI(activeContext, s, false, true, localContext, defined)
if err != nil {
return ErrInvalidIRIMapping
}
if !url.IsIRI(u) && u != BlankNode {
return ErrInvalidIRIMapping
}
termDef.IRI = u
// 13.5)
if v, ok := valueObj[KeywordContainer]; ok {
if json.IsNull(v) {
termDef.Container = nil
} else {
var c string
if err := json.Unmarshal(v, &c); err != nil {
return ErrInvalidReverseProperty
}
if c != KeywordSet && c != KeywordIndex {
return ErrInvalidReverseProperty
}
termDef.Container = []string{c}
}
}
// 13.6)
termDef.Reverse = true
// This whole step is missing in the spec but without
// it t0131 can't pass. So. YOLO.
if slices.Contains(termDef.Container, KeywordIndex) {
idxVal, idxOK := valueObj[KeywordIndex]
if idxOK && !json.IsNull(idxVal) {
var idx string
if err := json.Unmarshal(idxVal, &idx); err != nil {
return err
}
termDef.Index = idx
}
}
// 13.7
activeContext.defs[term] = termDef
b := true
defined[term] = &b
return nil
} else if idOK && term != idStr {
// 14.1) 14.2)
if idErr != nil {
return ErrInvalidIRIMapping
}
// 14.1)
if !json.IsNull(id) {
// 14.2)
if !isKeyword(idStr) && looksLikeKeyword(idStr) {
// 14.2.2)
p.logger.Warn("keyword lookalike value encountered",
slog.String("value", idStr))
return nil
}
// 14.2.3)
u, err := p.expandIRI(activeContext, idStr, false, true, localContext, defined)
if err != nil {
return err
}
if !isKeyword(u) && !url.IsIRI(u) && u != BlankNode {
return ErrInvalidIRIMapping
}
if u == KeywordContext {
return ErrInvalidKeywordAlias
}
termDef.IRI = u
// 14.2.4)
if strings.Contains(term, "/") || (!strings.HasPrefix(term, ":") && !strings.HasSuffix(term, ":") && strings.Contains(term, ":")) {
b := true
// 14.2.4.1)
defined[term] = &b
// 14.2.4.2)
tu, err := p.expandIRI(activeContext, term, false, true, localContext, defined)
if err != nil {
return ErrInvalidIRIMapping
}
if tu != u {
return ErrInvalidIRIMapping
}
} else {
// 14.2.5)
if simpleTerm && url.EndsInGenDelim(u) || u == BlankNode {
if v, ok := p.remapPrefixIRIs[u]; ok {
termDef.IRI = v
}
termDef.Prefix = true
}
}
}
} else if strings.Contains(term[1:], ":") {
// 15)
prefix, suffix, _ := strings.Cut(term, ":")
// 15.1)
if !strings.HasPrefix(suffix, "//") {
if _, ok := localContext[prefix]; ok {
err := p.createTerm(activeContext, localContext, prefix, defined, newCreateTermOptions())
if err != nil {
return err
}
}
}
// 15.2)
if def, ok := activeContext.defs[prefix]; ok {
termDef.IRI = def.IRI + suffix
} else {
// 15.3)
termDef.IRI = term
}
} else if strings.Contains(term, "/") {
// 16)
// 16.2)
u, err := p.expandIRI(activeContext, term, false, true, nil, nil)
if err != nil {
return ErrInvalidIRIMapping
}
if !url.IsIRI(u) {
return ErrInvalidIRIMapping
}
termDef.IRI = u
} else if term == KeywordType {
// 17)
termDef.IRI = KeywordType
} else if activeContext.vocabMapping != "" {
// 18)
termDef.IRI = activeContext.vocabMapping + term
} else {
return ErrInvalidIRIMapping
}
// 19)
if cnt, ok := valueObj[KeywordContainer]; ok {
if json.IsNull(cnt) {
return ErrInvalidContainerMapping
}
// 19.2)
// do this check early since we're going to rewrap
// into an array
if p.modeLD10 && !json.IsString(cnt) {
return ErrInvalidContainerMapping
}
cnt = json.MakeArray(cnt)
// 19.1)
var values []string
if err := json.Unmarshal(cnt, &values); err != nil {
return ErrInvalidContainerMapping
}
for _, vl := range values {
switch vl {
case KeywordGraph, KeywordID, KeywordIndex,
KeywordLanguage, KeywordList, KeywordSet,
KeywordType:
default:
return ErrInvalidContainerMapping
}
}
if slices.Contains(values, KeywordGraph) && (slices.Contains(values, KeywordID) || slices.Contains(values, KeywordIndex)) {
kws := map[string]struct{}{}
for _, vl := range values {
kws[vl] = struct{}{}
}
delete(kws, KeywordGraph)
delete(kws, KeywordIndex)
delete(kws, KeywordID)
if _, ok := kws[KeywordSet]; ok && len(kws) != 1 {
return ErrInvalidIRIMapping
}
} else if slices.Contains(values, KeywordSet) {
for _, vl := range values {
switch vl {
case KeywordGraph, KeywordID, KeywordIndex,
KeywordLanguage, KeywordType,
KeywordSet:
default:
return ErrInvalidContainerMapping
}
}
}
// 19.2)
if p.modeLD10 {
if len(values) > 1 {
return ErrInvalidContainerMapping
}
switch values[0] {
case KeywordID, KeywordGraph, KeywordType:
return ErrInvalidContainerMapping
}
}
// 19.3)
termDef.Container = values
// 19.4)
if slices.Contains(values, KeywordType) {
// 19.4.1)
if termDef.Type == "" {
termDef.Type = KeywordID
}
// 19.4.2)
switch termDef.Type {
case KeywordID, KeywordVocab, "":
default:
return ErrInvalidTypeMapping
}
}
}
// 20)
if idx, ok := valueObj[KeywordIndex]; ok {
// 20.1)
if p.modeLD10 {
return ErrInvalidTermDefinition
}
if !slices.Contains(termDef.Container, KeywordIndex) {
return ErrInvalidTermDefinition
}
// 20.2)
var s string
if err := json.Unmarshal(idx, &s); err != nil {
return ErrInvalidTermDefinition
}
u, err := p.expandIRI(activeContext, s, false, true, localContext, defined)
if err != nil {
return ErrInvalidTermDefinition
}
if !url.IsIRI(u) {
return ErrInvalidTermDefinition
}
// 20.3)
termDef.Index = s
}
// 21)
if ctx, ok := valueObj[KeywordContext]; ok {
// 21.1)
if p.modeLD10 {
return ErrInvalidTermDefinition
}
// 21.3)
resolvOpts := newCtxProcessingOpts()
resolvOpts.override = true
resolvOpts.remotes = slices.Clone(opts.remotes)
resolvOpts.validate = false
_, err := p.context(
activeContext,
ctx,
opts.baseURL,
resolvOpts,
)
if err != nil {
return ErrInvalidScopedContext
}
// 21.4
termDef.Context = ctx
termDef.BaseIRI = opts.baseURL
}
_, hasType := valueObj[KeywordType]
// 22)
if lang, ok := valueObj[KeywordLanguage]; ok && !hasType {
if json.IsNull(lang) {
termDef.Language = KeywordNull
} else {
var lm string
// 22.1)
if err := json.Unmarshal(lang, &lm); err != nil {
return ErrInvalidLanguageMapping
}
// 22.2)
termDef.Language = strings.ToLower(lm)
}
}
// 23)
if dir, ok := valueObj[KeywordDirection]; ok && !hasType {
if json.IsNull(dir) {
termDef.Direction = KeywordNull
} else {
var d string
// 23.1)
if err := json.Unmarshal(dir, &d); err != nil {
return ErrInvalidBaseDirection
}
switch d {
case DirectionLTR, DirectionRTL:
default:
return ErrInvalidBaseDirection
}
// 23.2)
termDef.Direction = d
}
}
// 24)
if nest, ok := valueObj[KeywordNest]; ok {
// 24.1)
if p.modeLD10 {
return ErrInvalidTermDefinition
}
if json.IsNull(nest) {
return ErrInvalidNestValue
}
// 24.2)
var n string
if err := json.Unmarshal(nest, &n); err != nil {
return ErrInvalidNestValue
}
if isKeyword(n) && n != KeywordNest {
return ErrInvalidNestValue
}
termDef.Nest = n
}
// 25)
if prefix, ok := valueObj[KeywordPrefix]; ok {
// 25.1)
if p.modeLD10 {
return ErrInvalidTermDefinition
}
// 25.2)
if json.IsNull(prefix) {
return ErrInvalidPrefixValue
}
if strings.Contains(term, ":") || strings.Contains(term, "/") {
return ErrInvalidTermDefinition
}
var p bool
if err := json.Unmarshal(prefix, &p); err != nil {
return ErrInvalidPrefixValue
}
// 25.3)
if p && isKeyword(termDef.IRI) {
return ErrInvalidTermDefinition
}
termDef.Prefix = p
}
// 26)
valKeys := map[string]struct{}{}
for k := range valueObj {
valKeys[k] = struct{}{}
}
for _, kw := range []string{KeywordID, KeywordReverse, KeywordContainer,
KeywordContext, KeywordDirection, KeywordIndex, KeywordLanguage,
KeywordNest, KeywordPrefix, KeywordProtected, KeywordType} {
delete(valKeys, kw)
}
if len(valKeys) > 0 {
return ErrInvalidTermDefinition
}
// 27)
if oldDefOK && oldDef.Protected && !opts.override {
// 27.1)
if !oldDef.equalWithoutProtected(&termDef) {
return ErrProtectedTermRedefinition
}
// 27.2)
termDef = oldDef
}
// 28)
activeContext.defs[term] = termDef
b := true
defined[term] = &b
return nil
}
func selectTerm(
activeContext *Context,
keyIriVar string,
containers []string,
typeLanguage string,
preferredValues []string,
) string {
// 1)
if activeContext.inverse == nil {
activeContext.inverse = workIt(activeContext)
}
// 2)
inverse := activeContext.inverse
// 3)
containerMap := inverse[keyIriVar]
for _, container := range containers {
// 4.1)
// 4.2)
typeLanguageMap, ok := containerMap[container]
if !ok {
continue
}
// 4.3)
var valMap map[string]string
switch typeLanguage {
case KeywordLanguage:
valMap = typeLanguageMap.Language
case KeywordType:
valMap = typeLanguageMap.Type
case KeywordAny:
valMap = typeLanguageMap.Any
}
// 4.4)
for _, pval := range preferredValues {
if v, ok := valMap[pval]; !ok {
// 4.4.1)
continue
} else {
// 4.4.2)
return v
}
}
}
// 5)
return ""
}