├── .github └── workflows │ ├── docker-image.yml │ └── docs.yml ├── .gitignore ├── CHANGELOG.md ├── CHANGES.md ├── Dockerfile ├── LICENSE ├── README.md ├── changelog.d └── scriv.ini ├── pyproject.toml ├── requirements.txt └── src ├── docs ├── _.md ├── index.md └── overrides │ ├── assets │ ├── javascripts │ │ └── extra.js │ └── vendor │ │ └── plotly-3.0.1.min.js │ └── partials │ ├── copyright.html │ └── logo.html ├── mkdocs.yml ├── shmarql ├── __init__.py ├── __main__.py ├── biki.py ├── charts.py ├── config.py ├── ext.py ├── fragments.py ├── main.py ├── markdownplugin.py ├── px_util.py └── qry.py ├── static ├── codemirror.css ├── editor.js ├── favicon.ico ├── htmx-2.0.3.min.js ├── matchbrackets.js ├── shmarql.css ├── sparql.js ├── sparqlui.js ├── sqrl.png ├── surreal-1.3.0.js └── tablesort-5.3.0.min.js └── style ├── input.css ├── o.css └── tailwind.config.js /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | - latest 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Login to GitHub Container registry 16 | uses: docker/login-action@v2 17 | with: 18 | registry: ghcr.io 19 | username: ${{ github.actor }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - name: downcase REPO 22 | run: | 23 | echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} 24 | echo ${REPO} 25 | echo ${GITHUB_REPOSITORY} 26 | - name: Build and Push 27 | env: 28 | REGISTRY: ghcr.io 29 | run: | 30 | TAG=${GITHUB_REF#refs/tags/} 31 | docker build -t $REGISTRY/${REPO}:${TAG} -t $REGISTRY/${REPO}:$(date +%s) . 32 | docker push $REGISTRY/${REPO} --all-tags 33 | - name: Notify When Done 34 | run: curl -d "Github Workflow ${REPO} date ✅" ntfy.sh/shmarql 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs to GitHub Pages 2 | 3 | on: workflow_dispatch 4 | 5 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | 11 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 12 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-22.04 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | - name: Setup Python 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: "3.10" 27 | - name: Upgrade pip 28 | run: | 29 | # install pip=>20.1 to use "pip cache dir" 30 | python3 -m pip install --upgrade pip 31 | 32 | - name: Get pip cache dir 33 | id: pip-cache 34 | run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT 35 | 36 | - name: Cache dependencies 37 | uses: actions/cache@v3 38 | with: 39 | path: ${{ steps.pip-cache.outputs.dir }} 40 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pip- 43 | 44 | - name: Install dependencies 45 | run: python3 -m pip install mkdocs-material 46 | 47 | - name: MKDocs 48 | run: cd src && mkdocs build 49 | 50 | - name: Upload artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | # Upload entire repository 54 | path: "/home/runner/work/shmarql/shmarql/src/site" 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | databases 2 | .env 3 | docker-compose.yml 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2025-06-04 2 | 3 | ## Changed 4 | 5 | - Better support for `{MOUNT}sparql` endpoints to handle HTTP OPTIONS methods. 6 | These are done by third-party services that would like to call the SPARQL endpoint without actually executing a query, to check CORS support. 7 | 8 | - Install Fizzysearch and Bikidata libraries from the Github repos while development is in flux, in stead of from PyPI. This will be reverted later to published packages once the interface settles down a bit more. 9 | 10 | # 2025-05-14 11 | 12 | ## Added 13 | 14 | - Map chart type added 15 | 16 | - Support for BIKIDATA_DB, this allows fulltext and semantic search via the BIKIDATA DuckDb. 17 | 18 | ## Deprecated 19 | 20 | - FTS_FILEPATH is deprecated. This is replaced by the BIKIDATA_DB path 21 | 22 | - RDF2VEC_FILEPATH is deprecated. This is now replaced by the RDF2VEC boolean flag, and also requires the BIKIDATA_DB path to also be set. 23 | 24 | # 2025-04-28 25 | 26 | ## Added 27 | 28 | - Add a @papp to shmarfql namespace which comes from the prepend_route decorator in the ./src/ext.py file. This can be used to add new routes to apps which import shmarql as a module buit adds new functionality not present in the core shmarql app. This is useful for creating custom shmarql instances. 29 | 30 | # 2025-04-24 31 | 32 | ## Added 33 | 34 | - Add fabio, swirl and dcat default prefixes 35 | - Add mkdocs-nfdi4culture to the installed packages in requirements.txt 36 | - Show schema:logo, schema:image and schema:description as special cases in Resource view. Also take the http vs https problematics of schema.org into account 37 | - Treat .owl path specified in DATA_LOAD_PATHS loaded as application/rdf+xml 38 | - A new boolean config item WATCH_DOCS. When set to true, it monitors the ./docs directory for changes and rebuilds the documentation when a change is detected. This is useful for local development, but by default should be set to false in production environments. 39 | - Plotly for charts integrated, driven by the comments in the SPARQL queries. 40 | - Startup screen is now simpler, showing some statistics about the attached triplestore data. (and old docs moved to separate repo) 41 | 42 | ## Removed 43 | 44 | - Remove the debug output of rewritten query on each execution 45 | - The old documentation explaining what SHMARQL is and its configuration has been removed from the repository. It is now hosted in a separate repository at https://github.com/epoz/shmarql_site 46 | - Disable the bikidata support for now, until the Fizzysearch port is completely done. 47 | 48 | ## Changed 49 | 50 | - Make field links in Resource view internal links to another Resource view 51 | - For fields with more than 50 data values in resource view, only show the first 50, and add button to show all. 52 | - Increase the timeout to 180s when downloading data using DATA_LOAD_PATHS config, and also allow loading .nt.gz suffixed paths 53 | 54 | # 2025-03-24 55 | 56 | ## Added 57 | 58 | - Start using [scriv](https://scriv.readthedocs.io/) for changelog. 59 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## 2024-05-15 4 | 5 | - bugfix for the XML serializations when queries are "application/sparql-results+xml" 6 | - pin scipy==1.10.1 version due to deprecated methods in newer versions causing problems 7 | - allow loading datafiles (.nt.gz and .ttl.gz) from DATA_LOAD_PATHS that are gzipped 8 | - add locking to the FTS index creation 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.15 AS builder 2 | 3 | ENV PYTHONUNBUFFERED=True 4 | 5 | RUN apt update && apt install -y gcc g++ libffi-dev libc-dev make libsqlite3-dev git perl \ 6 | && git clone --recursive https://github.com/epoz/fts5-snowball.git 7 | 8 | WORKDIR /fts5-snowball 9 | RUN make 10 | 11 | WORKDIR /spellfix 12 | RUN git clone https://gist.github.com/bda9efcdceb1dda0ba7cd80395a86450.git 13 | RUN gcc -g -shared -fPIC bda9efcdceb1dda0ba7cd80395a86450/spellfix.c -o spellfix.so 14 | 15 | WORKDIR /tree_sitter_sparql 16 | RUN git clone https://github.com/epoz/tree-sitter-sparql 17 | RUN pip install tree-sitter==0.20.1 18 | RUN python -c "from tree_sitter import Language; Language.build_library('b/sparql.so', ['tree-sitter-sparql'])" 19 | 20 | 21 | FROM python:3.10.15 22 | 23 | COPY --from=builder /fts5-snowball/fts5stemmer.so /usr/local/lib/ 24 | COPY --from=builder /spellfix/spellfix.so /usr/local/lib/ 25 | COPY --from=builder /tree_sitter_sparql/b/sparql.so /usr/local/lib 26 | 27 | RUN mkdir -p /src 28 | WORKDIR /src 29 | 30 | RUN pip install --upgrade pip 31 | COPY requirements.txt . 32 | RUN pip install -r requirements.txt 33 | 34 | COPY src . 35 | 36 | ENV PYTHONPATH=/src/ 37 | RUN mkdocs build 38 | 39 | CMD ["sh", "-c", "uvicorn --host 0.0.0.0 --port 8000 --log-level debug shmarql:app"] 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SHMARQL 2 | 3 | A Linked Data publishing platform for semantic web professionals in a hurry. Make compelling queries and documentation of your RDF data, using an open-source, simple platform. 4 | 5 | Documentation here: https://shmarql.com/ 6 | 7 | ## TL;DR 8 | 9 | SHMARQL also has a built-in triplestore which you can use to share your RDF data over a SPARQL interface. To use it, you need to specify the path from which to load the datafiles at startup, using an environment variable: `DATA_LOAD_PATHS`. 10 | This also means that the path in which the data is stored is "visible" to the docker container via for example a mounted volume. 11 | 12 | Here is an example, where you have some .ttl files stored in your current directory: 13 | 14 | ```shell 15 | docker run --rm -p 8000:8000 -it -v $(pwd):/data -e DATA_LOAD_PATHS=/data ghcr.io/epoz/shmarql:latest 16 | ``` 17 | 18 | This will load all .ttl files found in the specified directory, and make it available under a /sparql endpoint, eg. http://localhost:8000/sparql 19 | 20 | ## Development instructions 21 | 22 | If you would like to run and modify the code in this repo, there is a [Dockerfile](Dockerfile) which includes the necessary versions of the required libraries. 23 | 24 | First, build the Docker image like so: 25 | 26 | ```shell 27 | docker build -t shmarql . 28 | ``` 29 | 30 | Now you can run a local copy with: 31 | 32 | ```shell 33 | docker run -it --rm -p 8000:8000 shmarql 34 | ``` 35 | -------------------------------------------------------------------------------- /changelog.d/scriv.ini: -------------------------------------------------------------------------------- 1 | [scriv] 2 | format = md 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "shmarql" 3 | version = "0.46" 4 | description = "A Linked Data publishing platform for semantic web professionals in a hurry." 5 | authors = ["Etienne Posthumus "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://shmarql.com/" 9 | repository = "https://github.com/epoz/shmarql" 10 | keywords = ["SPARQL", "search", "RDF", "Linked Data"] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.10" 14 | fizzysearch = "^0.28" 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-fasthtml==0.6.9 2 | httpx==0.27.2 3 | tree-sitter==0.20.1 4 | rdflib==7.1.1 5 | pyoxigraph==0.4.1 6 | mkdocs-material==9.5.42 7 | mkdocs-awesome-nav==3.1.1 8 | sqlite_minutils==4.0.3 9 | pandas==2.2.3 10 | plotly==6.0.1 11 | watchdog==4.0.0 12 | git+https://github.com/ISE-FIZKarlsruhe/bikidata.git@dev#egg=bikidata 13 | git+https://github.com/ISE-FIZKarlsruhe/fizzysearch.git@bikidata#egg=fizzysearch -------------------------------------------------------------------------------- /src/docs/_.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - toc 4 | - navigation 5 | title: "TITLE_PLACE_HOLDER" 6 | --- 7 | 8 | # BODY_PLACE_HOLDER 9 | -------------------------------------------------------------------------------- /src/docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to SHMARQL 2 | 3 | ## Instance Count 4 | 5 | ```shmarql 6 | # shmarql-view: piechart 7 | # shmarql-names: type 8 | # shmarql-values: count 9 | # shmarql-label: Instance Count 10 | 11 | PREFIX xsd: 12 | 13 | SELECT ?type (xsd:integer(COUNT(?subject)) AS ?count) 14 | WHERE { 15 | ?subject a ?type . 16 | } 17 | GROUP BY ?type 18 | ORDER BY ?count 19 | ``` 20 | 21 | Or you can also view the above as a table. 22 | 23 | ```shmarql 24 | PREFIX xsd: 25 | 26 | SELECT ?type (xsd:integer(COUNT(?subject)) AS ?count) 27 | WHERE { 28 | ?subject a ?type . 29 | } 30 | GROUP BY ?type 31 | ORDER BY desc(?count) 32 | ``` 33 | 34 | Or as a barchart. 35 | 36 | ```shmarql 37 | # shmarql-view: barchart 38 | # shmarql-x: type 39 | # shmarql-y: count 40 | # shmarql-label: Instance Count 41 | 42 | PREFIX xsd: 43 | 44 | SELECT ?type (xsd:integer(COUNT(?subject)) AS ?count) 45 | WHERE { 46 | ?subject a ?type . 47 | } 48 | GROUP BY ?type 49 | ORDER BY desc(?count) 50 | ``` 51 | 52 | ## Property Counts 53 | 54 | ```shmarql 55 | select ?p (xsd:integer(COUNT(?s)) AS ?count) where {?s ?p ?o} 56 | group by ?p 57 | order by desc(?count) 58 | ``` 59 | -------------------------------------------------------------------------------- /src/docs/overrides/assets/javascripts/extra.js: -------------------------------------------------------------------------------- 1 | fa_aeroplane = ``; 2 | 3 | document$.subscribe(function () { 4 | document.querySelectorAll(".language-sparql").forEach(function (block) { 5 | thepre = block.querySelector("pre"); 6 | thecode = block.querySelector("code"); 7 | var xLink = document.createElement("a"); 8 | xLink.style.display = "block"; 9 | xLink.title = "Execute Query"; 10 | xLink.href = "../shmarql/?query=" + encodeURIComponent(thecode.innerText); 11 | xLink.innerHTML = fa_aeroplane; 12 | 13 | thepre.appendChild(xLink); 14 | }); 15 | 16 | document.querySelectorAll(".language-shmarql").forEach(function (block) { 17 | thecode = block.querySelector("code"); 18 | fetch( 19 | "../shmarql/fragments/sparql?query=" + 20 | encodeURIComponent(thecode.innerText), 21 | { 22 | method: "POST", 23 | Accept: "text/html", 24 | } 25 | ) 26 | .then((response) => response.text()) 27 | .then((data) => { 28 | block.innerHTML = data; 29 | block.querySelectorAll("script").forEach((script) => { 30 | if (script.textContent) { 31 | eval(script.textContent); 32 | } 33 | if (script.src) { 34 | // For external scripts, you'd need to fetch and eval them 35 | fetch(script.src) 36 | .then((response) => response.text()) 37 | .then((scriptContent) => eval(scriptContent)); 38 | } 39 | }); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/docs/overrides/partials/copyright.html: -------------------------------------------------------------------------------- 1 | {#- 2 | This file was automatically generated - do not edit 3 | -#} 4 | 14 | -------------------------------------------------------------------------------- /src/docs/overrides/partials/logo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SHMARQL 2 | theme: 3 | name: material 4 | custom_dir: docs/overrides 5 | palette: 6 | primary: teal 7 | accent: pink 8 | features: 9 | - content.code.copy 10 | - navigation.instant 11 | - navigation.instant.progress 12 | - toc.follow 13 | - navigation.top 14 | - header.autohide 15 | - content.code.annotate 16 | palette: 17 | # Palette toggle for light mode 18 | - scheme: default 19 | toggle: 20 | icon: material/brightness-7 21 | name: Switch to dark mode 22 | 23 | # Palette toggle for dark mode 24 | - scheme: slate 25 | toggle: 26 | icon: material/brightness-4 27 | name: Switch to light mode 28 | markdown_extensions: 29 | - shmarql 30 | - pymdownx.highlight: 31 | anchor_linenums: true 32 | linenums: true 33 | use_pygments: true 34 | pygments_lang_class: true 35 | - pymdownx.superfences 36 | - admonition 37 | - pymdownx.details 38 | - attr_list 39 | - pymdownx.emoji: 40 | emoji_index: !!python/name:material.extensions.emoji.twemoji 41 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 42 | 43 | extra_javascript: 44 | - overrides/assets/javascripts/extra.js 45 | - overrides/assets/vendor/plotly-3.0.1.min.js 46 | 47 | nav: 48 | - Data Overview: index.md 49 | - Queries: /shmarql/ -------------------------------------------------------------------------------- /src/shmarql/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import app 2 | from .markdownplugin import makeExtension 3 | from .ext import prepend_route as papp 4 | -------------------------------------------------------------------------------- /src/shmarql/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import yaml 3 | from shmarql.config import log 4 | from mkdocs.__main__ import cli as mkdocs_cli 5 | 6 | 7 | @click.option( 8 | "-f", 9 | "--filepath", 10 | type=click.Path(exists=True), 11 | help="Path to the mkdocs.yml file", 12 | ) 13 | @click.command("docs_build") 14 | def docs_build(filepath: str): 15 | log.debug(f"Trying to open filepath: {filepath}") 16 | nav = yaml.safe_load(open(filepath).read()) 17 | SRC_MKDOCS = "mkdocs.yml" 18 | log.debug(f"Assuming the site mkdocs.yml file is in: {SRC_MKDOCS}") 19 | try: 20 | site_mkdocs = yaml.load(open(SRC_MKDOCS).read(), yaml.UnsafeLoader) 21 | except FileNotFoundError: 22 | log.error(f"No {SRC_MKDOCS} file found, exiting.") 23 | return 24 | changed = False 25 | for key in ("site_name", "site_url", "repo_url", "nav"): 26 | if key in site_mkdocs: 27 | del site_mkdocs[key] 28 | for key in ("site_name", "site_url", "repo_url", "nav", "plugins"): 29 | if key in nav: 30 | site_mkdocs[key] = nav[key] 31 | changed = True 32 | 33 | open(SRC_MKDOCS, "w").write(yaml.dump(site_mkdocs)) 34 | click.echo(f"Wrote new {SRC_MKDOCS} file") 35 | 36 | try: 37 | mkdocs_cli(["build", "--site-dir", "site"], standalone_mode=False) 38 | except Exception as e: 39 | log.error(str(e)) 40 | 41 | 42 | @click.group() 43 | def cli(): 44 | pass 45 | 46 | 47 | cli.add_command(docs_build) 48 | 49 | if __name__ == "__main__": 50 | cli() 51 | -------------------------------------------------------------------------------- /src/shmarql/biki.py: -------------------------------------------------------------------------------- 1 | from fasthtml.common import * 2 | import json 3 | import bikidata 4 | from .main import app 5 | from .config import MOUNT 6 | 7 | 8 | def results_to_div(r: dict): 9 | if "error" in r: 10 | return Div( 11 | H3("Error"), 12 | P(r["error"]), 13 | ) 14 | 15 | hits = r.get("results", {}) 16 | 17 | agg_buf = [] 18 | for prop, aggs in r.get("aggregates", {}).items(): 19 | agg_buf.append(H3(f"Aggregates: {prop}")) 20 | agg_buf.append(Table(*[Tr(Td(iri), Td(str(c))) for c, iri in aggs])) 21 | 22 | extra_text = "." 23 | if r["total"] > r["size"]: 24 | extra_text = f", showing the first {r['size']}" 25 | 26 | return Div( 27 | Div( 28 | *agg_buf, 29 | style="margin-bottom: 3ch", 30 | ), 31 | P(f"{r['total']} results in total{extra_text}"), 32 | *[ 33 | Div( 34 | A(hitiri, target="_whatsnew", href=hitiri.strip("<>")), 35 | P( 36 | fields.get( 37 | "", 38 | [""], 39 | )[ 40 | 0 41 | ][:300] 42 | .strip('"') 43 | .replace('"@en', "") 44 | .replace('"@de', "") 45 | .replace(r"\"", '"'), 46 | style="padding: 0.2ch;", 47 | ), 48 | style="border: 1px solid #ccc; padding: 1ch", 49 | ) 50 | for hitiri, fields in hits.items() 51 | ], 52 | ) 53 | 54 | 55 | @app.post(MOUNT + "bikidata") 56 | async def query(request: Request): 57 | body = await request.body() 58 | opts = json.loads(body) 59 | try: 60 | r = bikidata.query(opts) 61 | except Exception as e: 62 | r = {"error": str(e)} 63 | 64 | if opts.get("format") == "json": 65 | return JSONResponse(r) 66 | 67 | return results_to_div(r) 68 | 69 | 70 | @app.get(MOUNT + "bikidata/") 71 | def biki_get(): 72 | return Div( 73 | Textarea( 74 | """{ 75 | "filters": [ 76 | {"p":"fts 2", "o":"culture"} 77 | ], 78 | "aggregates": [ 79 | "" 80 | ], 81 | "size": 20 82 | }""", 83 | id="opts", 84 | style="width: 80%; height: 20ch", 85 | ), 86 | Script( 87 | """me().on("keyup", async event => { 88 | if(event.code === "Enter" && event.ctrlKey) { 89 | fetch('""" 90 | + MOUNT 91 | + """bikidata', { 92 | method: "POST", 93 | body: me("#opts").value, 94 | }).then(r => r.text()).then(r => me("#results").innerHTML = r); 95 | } 96 | 97 | }) 98 | """ 99 | ), 100 | Div(id="results"), 101 | ) 102 | 103 | 104 | @app.get(MOUNT + "bikidata/browse") 105 | def biki_browse(): 106 | clipboard = """ 107 | 108 | 109 | """ 110 | 111 | props = bikidata.query({"aggregates": ["properties"]}) 112 | props = sorted( 113 | [ 114 | (count, piri) 115 | for count, piri in props.get("aggregates", {}).get("properties", []) 116 | ], 117 | reverse=True, 118 | ) 119 | 120 | op = Div( 121 | Select( 122 | Option("must", value="must"), 123 | Option("should", value="should"), 124 | Option("not", value="not"), 125 | cls="op", 126 | style="margin: 1ch; padding: 0.5ch;", 127 | ), 128 | style="display: inline-block; margin: 0.1ch;", 129 | ) 130 | predicate = Div( 131 | Select( 132 | Option("fts", value="fts"), 133 | Option(" - ", value="_", title="No value"), 134 | Option("id", value="id"), 135 | Option("semantic", value="semantic"), 136 | *[Option(f"{c} {p}", value=p) for c, p in props], 137 | cls="pred", 138 | style="margin: 1ch; padding: 0.5ch;", 139 | ), 140 | style="display: inline-block; margin: 0.1ch;", 141 | ) 142 | obj = Div( 143 | Input(type="text", style="width: 30vw", cls="obj"), 144 | style="display: inline-block; margin: 0.1ch;", 145 | ) 146 | 147 | block = Div( 148 | Button( 149 | "-", 150 | style="padding: 0.2ch; border: 1px solid #ccc; border-radius: 8px;", 151 | cls="block_remove", 152 | ), 153 | Button( 154 | "+", 155 | style="padding: 0.2ch; border: 1px solid #ccc; border-radius: 8px;", 156 | cls="block_add", 157 | ), 158 | op, 159 | predicate, 160 | obj, 161 | style="border-bottom: 1px solid #bbb", 162 | ) 163 | 164 | aggregates_chooser = Div( 165 | Button( 166 | "-", 167 | style="padding: 0.2ch; border: 1px solid #ccc; border-radius: 8px;", 168 | cls="agg_remove", 169 | ), 170 | Button( 171 | "+", 172 | style="padding: 0.2ch; border: 1px solid #ccc; border-radius: 8px;", 173 | cls="agg_add", 174 | ), 175 | Select( 176 | Option(" - ", value="-"), 177 | *[Option(p, value=p) for c, p in props], 178 | cls="agg", 179 | style="margin: 1ch; padding: 0.5ch;", 180 | ), 181 | style="margin: 0.1ch;", 182 | ) 183 | 184 | return ( 185 | ( 186 | Script(type="module", src="https://pyscript.net/releases/2025.3.1/core.js"), 187 | Div( 188 | H4("Filters"), 189 | block, 190 | H4("Aggregates"), 191 | aggregates_chooser, 192 | Div( 193 | Button( 194 | "Go", 195 | id="go", 196 | style="background-color: #999; color: white; border-radius: 8px; padding: 4px", 197 | ), 198 | Div(id="results"), 199 | ), 200 | ), 201 | ), 202 | Script( 203 | """from pyscript import display, when, fetch, window 204 | from pyscript.web import page 205 | import json 206 | 207 | @when("click", ".block_add") 208 | def block_add_click(event): 209 | block = event.target.parentElement 210 | new_block = block.cloneNode(True) 211 | new_block.querySelector(".block_remove").onclick = block_remove_click 212 | new_block.querySelector(".block_add").onclick = block_add_click 213 | block.parentElement.insertBefore(new_block, block.nextSibling) 214 | 215 | @when("click", ".block_remove") 216 | def block_remove_click(event): 217 | existing = page.find(".block_remove") 218 | if len(existing) == 1: 219 | return 220 | block = event.target.parentElement 221 | block.parentElement.removeChild(block) 222 | 223 | @when("click", ".agg_add") 224 | def agg_add_click(event): 225 | block = event.target.parentElement 226 | new_block = block.cloneNode(True) 227 | new_block.querySelector(".agg_remove").onclick = agg_remove_click 228 | new_block.querySelector(".agg_add").onclick = agg_add_click 229 | block.parentElement.insertBefore(new_block, block.nextSibling) 230 | 231 | 232 | @when("click", ".agg_remove") 233 | def agg_remove_click(event): 234 | existing = page.find(".agg_remove") 235 | if len(existing) == 1: 236 | return 237 | block = event.target.parentElement 238 | block.parentElement.removeChild(block) 239 | 240 | 241 | @when("click", "#go") 242 | async def go_click(event): 243 | # await window.navigator.clipboard.writeText("een twee drie") 244 | 245 | ops = page.find(".op") 246 | preds = page.find(".pred") 247 | objs = page.find(".obj") 248 | 249 | opts = {} 250 | for i, op in enumerate(ops): 251 | opts[i] = {'op': op.value} 252 | for i, pred in enumerate(preds): 253 | if pred.value != "_": 254 | opts[i]['p'] = pred.value 255 | for i, obj in enumerate(objs): 256 | if obj.value: 257 | opts[i]['o'] = obj.value 258 | opts = {'filters': [x for x in opts.values()]} 259 | 260 | aggs = page.find(".agg") 261 | if len(aggs) > 0: 262 | opts['aggregates'] = [agg.value for agg in aggs if agg.value != "-"] 263 | 264 | opts['format'] = 'html' 265 | 266 | response = await fetch('/bikidata', 267 | method="POST", 268 | headers={ "Content-Type": "application/json"}, 269 | body = json.dumps(opts) 270 | ) 271 | if response.ok: 272 | data = await response.text() 273 | page.find("#results").innerHTML = data 274 | else: 275 | print(response.status) 276 | """, 277 | type="mpy", 278 | ), 279 | ) 280 | -------------------------------------------------------------------------------- /src/shmarql/charts.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import plotly.express as px 3 | from .px_util import do_prefixes 4 | 5 | 6 | def do_barchart(settings: dict, data: pd.DataFrame, label=None): 7 | x_col = settings.get("x", ["label"])[0] 8 | y_col = settings.get("y", ["value"])[0] 9 | data[y_col] = data[y_col].astype(float) 10 | data[x_col] = data[x_col].apply(do_prefixes) 11 | return px.bar(data, x=x_col, y=y_col, title=label) 12 | 13 | 14 | def do_piechart(settings: dict, data: pd.DataFrame, label=None): 15 | label = settings.get("label", [None])[0] 16 | values = settings.get("values", ["value"])[0] 17 | names = settings.get("names", ["label"])[0] 18 | data[values] = data[values].astype(float) 19 | data[names] = data[names].apply(do_prefixes) 20 | return px.pie(data, values=values, names=names, title=label) 21 | 22 | 23 | def do_mapchart(settings: dict, data: pd.DataFrame, label=None): 24 | point = settings.get("point", ["geo"])[0] 25 | 26 | if point in data.columns: 27 | 28 | def safe_extract_lat(point): 29 | try: 30 | return float(point.replace("Point(", "").split(" ")[0]) 31 | except Exception: 32 | return None 33 | 34 | def safe_extract_lon(point): 35 | try: 36 | return float(point.replace("Point(", "").strip(")").split(" ")[1]) 37 | except Exception: 38 | return None 39 | 40 | data["lon"] = data[point].apply(safe_extract_lat) 41 | data["lat"] = data[point].apply(safe_extract_lon) 42 | 43 | # Remove rows where lat or lon is None 44 | data = data.dropna(subset=["lat", "lon"]) 45 | 46 | if "lat" not in data.columns or "lon" not in data.columns: 47 | raise ValueError( 48 | "Data must contain 'lat' and 'lon' columns for map chart, or a 'geo' column containing Point(x y) format." 49 | ) 50 | 51 | lat_col = "lat" 52 | lon_col = "lon" 53 | 54 | data[lat_col] = data[lat_col].astype(float) 55 | data[lon_col] = data[lon_col].astype(float) 56 | 57 | center_lat = float(settings.get("lat", [0])[0]) 58 | center_lon = float(settings.get("lon", [0])[0]) 59 | 60 | zoom = int(settings.get("zoom", [3])[0]) 61 | return px.scatter_map( 62 | data, 63 | lat=lat_col, 64 | lon=lon_col, 65 | zoom=zoom, 66 | title=label, 67 | center={"lat": center_lat, "lon": center_lon}, 68 | ) 69 | -------------------------------------------------------------------------------- /src/shmarql/config.py: -------------------------------------------------------------------------------- 1 | import os, sqlite3, random, json, logging 2 | 3 | log = logging.getLogger("SHMARQL") 4 | handler = logging.StreamHandler() 5 | log.addHandler(handler) 6 | 7 | 8 | DEBUG = os.environ.get("DEBUG", "0") == "1" 9 | if DEBUG: 10 | log.setLevel(logging.DEBUG) 11 | handler.setLevel(logging.DEBUG) 12 | formatter = logging.Formatter( 13 | "%(levelname)-9s %(name)s %(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S" 14 | ) 15 | handler.setFormatter(formatter) 16 | log.debug("Debug logging requested from config env DEBUG") 17 | else: 18 | log.setLevel(logging.INFO) 19 | log.info("SHMARQL Logging at INFO level") 20 | 21 | 22 | ENDPOINT = os.environ.get("ENDPOINT") 23 | 24 | # ENDPOINTS variable with name|url pairs 25 | ens = os.environ.get("ENDPOINTS", "") 26 | 27 | # Split the string into name|url pairs and then further split each pair 28 | ens_pairs = [pair.split("|") for pair in ens.split(" ") if "|" in pair] 29 | 30 | # Convert into a dictionary 31 | ENDPOINTS = {name: url for name, url in ens_pairs} 32 | 33 | SCHEME = os.environ.get("SCHEME", "http://") 34 | DOMAIN = os.environ.get("DOMAIN", "127.0.0.1") 35 | PORT = os.environ.get("PORT", "5001") 36 | # Note, we can't just build a SITE_URI from the above variables because the 37 | # app might be running behind a reverse proxy 38 | SITE_URI = os.environ.get("SITE_URI", "http://127.0.0.1:8000/") 39 | 40 | # This is a mountpoint that will be prefixed to all URIs served by the application 41 | MOUNT = os.environ.get("MOUNT", "/") 42 | 43 | QUERIES_DB = os.environ.get("QUERIES_DB", "queries.db") 44 | thequerydb = sqlite3.connect(QUERIES_DB) 45 | thequerydb.executescript( 46 | """CREATE TABLE IF NOT EXISTS queries (queryhash TEXT, query TEXT, timestamp TEXT, endpoint TEXT, result TEXT, duration FLOAT); 47 | pragma journal_mode=WAL;""" 48 | ) 49 | 50 | if "DATA_LOAD_PATHS" in os.environ: 51 | DATA_LOAD_PATHS = os.environ.get("DATA_LOAD_PATHS").split(" ") 52 | else: 53 | DATA_LOAD_PATHS = [] 54 | STORE_PATH = os.environ.get("STORE_PATH") 55 | 56 | BIKIDATA_DB = os.environ.get("BIKIDATA_DB") 57 | SEMANTIC_INDEX = os.environ.get("SEMANTIC_INDEX", "0") == "1" 58 | RDF2VEC_INDEX = os.environ.get("RDF2VEC_INDEX", "0") == "1" 59 | 60 | FTS_FILEPATH = os.environ.get("FTS_FILEPATH") 61 | RDF2VEC_FILEPATH = os.environ.get("RDF2VEC_FILEPATH") 62 | 63 | if FTS_FILEPATH: 64 | log.exception( 65 | "FTS_FILEPATH is set, but this config has been removed. Please use BIKIDATA_DB instead. See https://shmarql.com/fizzysearch/ for more information." 66 | ) 67 | FTS_FILEPATH = None 68 | if RDF2VEC_FILEPATH: 69 | log.exception( 70 | "RDF2VEC_FILEPATH is set, but this config has been removed. Please use RDF2VEC_INDEX boolean and BIKIDATA_DB instead. See https://shmarql.com/rdf2vec/ for more information." 71 | ) 72 | RDF2VEC_FILEPATH = None 73 | 74 | 75 | SPARQL_QUERY_UI = os.environ.get("SPARQL_QUERY_UI", "1") == "1" 76 | 77 | SITE_ID = os.environ.get( 78 | "SITE_ID", "".join([random.choice("abcdef0123456789") for _ in range(10)]) 79 | ) 80 | 81 | SITEDOCS_PATH = os.environ.get("SITEDOCS_PATH", os.path.join(os.getcwd(), "site")) 82 | SCHPIEL_PATH = os.environ.get("SCHPIEL_PATH") 83 | 84 | PREFIXES_FILEPATH = os.environ.get("PREFIXES_FILEPATH") 85 | DEFAULT_PREFIXES = { 86 | "http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf:", 87 | "http://www.w3.org/2000/01/rdf-schema#": "rdfs:", 88 | "http://www.w3.org/2002/07/owl#": "owl:", 89 | "http://schema.org/": "schema:", 90 | "http://www.wikidata.org/entity/": "wd:", 91 | "http://www.wikidata.org/entity/statement/": "wds:", 92 | "http://wikiba.se/ontology#": "wikibase:", 93 | "http://www.wikidata.org/prop/direct/": "wdt:", 94 | "http://www.w3.org/2004/02/skos/core#": "skos:", 95 | "http://purl.org/dc/terms/": "dct:", 96 | "http://purl.org/dc/elements/1.1/": "dc:", 97 | "http://dbpedia.org/resource/": "dbr:", 98 | "https://www.ica.org/standards/RiC/ontology#": "rico:", 99 | "http://www.w3.org/2003/01/geo/wgs84_pos#": "geo:", 100 | "http://www.w3.org/ns/shacl#": "sh:", 101 | "http://www.w3.org/2001/XMLSchema#": "xsd:", 102 | "http://www.openlinksw.com/virtrdf-data-formats#": "virtrdfdata:", 103 | "http://www.openlinksw.com/schemas/virtrdf#": "virtrdf:", 104 | "http://purl.org/spar/fabio/": "fabio:", 105 | "http://www.w3.org/2003/11/swrl#": "swrl:", 106 | "http://www.w3.org/ns/dcat#": "dcat:", 107 | "https://shmarql.com/": "shmarql:", 108 | "https://nfdi4culture.de/ontology#": "cto:", 109 | "https://nfdi4culture.de/id/": "nfdi4culture:", 110 | "https://nfdi.fiz-karlsruhe.de/ontology/": "nfdicore:", 111 | "https://database.factgrid.de/entity/": "factgrid:", 112 | } 113 | 114 | try: 115 | if PREFIXES_FILEPATH: 116 | # also support reading the prefixed from a .ttl file for convenience 117 | if PREFIXES_FILEPATH.endswith(".ttl"): 118 | PREFIXES = DEFAULT_PREFIXES 119 | for line in open(PREFIXES_FILEPATH).readlines(): 120 | if not line.lower().startswith("@prefix "): 121 | continue 122 | if not line.lower().endswith(" .\n"): 123 | continue 124 | line = line.strip("\n .") 125 | parts = line.split(":") 126 | if len(parts) < 2: 127 | continue 128 | prefix = parts[0][8:] + ":" 129 | prefix_uri = ":".join(parts[1:]).strip("<> ") 130 | if prefix == ":": 131 | prefix = " " 132 | PREFIXES[prefix_uri] = prefix 133 | else: 134 | PREFIXES = DEFAULT_PREFIXES | json.load(open(PREFIXES_FILEPATH)) 135 | else: 136 | PREFIXES = DEFAULT_PREFIXES 137 | except: 138 | log.exception(f"Problem binding PREFIXES from {PREFIXES_FILEPATH}") 139 | 140 | PREFIXES_SNIPPET = "".join( 141 | f"PREFIX {prefix} <{uri}>\n" for uri, prefix in PREFIXES.items() 142 | ) 143 | 144 | WATCH_DOCS = os.environ.get("WATCH_DOCS", "0") == "1" 145 | -------------------------------------------------------------------------------- /src/shmarql/ext.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Callable, List, Optional, Set, Type, Union, Dict, Any 3 | from starlette.applications import Starlette 4 | from starlette.routing import Route, Router 5 | 6 | 7 | def prepend_route( 8 | app: Union[Starlette, Router], 9 | path: str, 10 | methods: Optional[List[str]] = None, 11 | status_code: Optional[int] = None, 12 | name: Optional[str] = None, 13 | include_in_schema: bool = True, 14 | ): 15 | """ 16 | A decorator that prepends a route to the beginning of the Starlette app's routes list. 17 | This ensures the route is checked before other routes during request processing. 18 | 19 | Usage: 20 | @prepend_route(app, "/my-priority-route") 21 | async def my_priority_handler(request): 22 | return JSONResponse({"message": "I get processed first!"}) 23 | """ 24 | if methods is None: 25 | methods = ["GET"] 26 | 27 | def decorator(func: Callable): 28 | # Create the route with the function 29 | route = Route( 30 | path=path, 31 | endpoint=func, 32 | methods=methods, 33 | name=name or func.__name__, 34 | include_in_schema=include_in_schema, 35 | ) 36 | 37 | if hasattr(app, "routes"): 38 | app.routes.insert(0, route) 39 | elif hasattr(app, "router") and hasattr(app.router, "routes"): 40 | app.router.routes.insert(0, route) 41 | else: 42 | raise ValueError("Unable to find routes list in provided app") 43 | 44 | @wraps(func) 45 | def wrapper(*args, **kwargs): 46 | return func(*args, **kwargs) 47 | 48 | return wrapper 49 | 50 | return decorator 51 | -------------------------------------------------------------------------------- /src/shmarql/fragments.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | import random, json 3 | from fasthtml.common import * 4 | from .main import app 5 | from .config import MOUNT, PREFIXES_SNIPPET, PREFIXES 6 | from uuid import uuid4 7 | 8 | from plotly.io import to_json 9 | from .px_util import results_to_df, do_prefixes 10 | import traceback 11 | from .charts import do_barchart, do_piechart, do_mapchart 12 | 13 | 14 | from .qry import do_query 15 | 16 | BTN_STYLE = "bg-slate-300 hover:bg-slate-400 text-black px-2 rounded-lg shadow-xl transition duration-300 font-bold" 17 | 18 | 19 | class HashableResult: 20 | """ 21 | This class is used to make the results of a SPARQL query hashable. 22 | Convenience that you can then add them to a set or dict, or sort them. 23 | """ 24 | 25 | def __init__(self, value): 26 | self.value = value 27 | 28 | def __hash__(self): 29 | return hash(self.value.get("value")) 30 | 31 | def __eq__(self, other): 32 | return isinstance(other, HashableResult) and self.value.get( 33 | "value" 34 | ) == other.value.get("value") 35 | 36 | def __lt__(self, other): 37 | return self.value.get("value") < other.value.get("value") 38 | 39 | def __gt__(self, other): 40 | return self.value.get("value") > other.value.get("value") 41 | 42 | def __getitem__(self, key): 43 | return self.value[key] 44 | 45 | def __setitem__(self, key, value): 46 | self.value[key] = value 47 | 48 | def __delitem__(self, key): 49 | del self.value[key] 50 | 51 | def __iter__(self): 52 | return iter(self.value) 53 | 54 | def __len__(self): 55 | return len(self.value) 56 | 57 | def __contains__(self, key): 58 | return key in self.value 59 | 60 | def __repr__(self): 61 | return str(self.value.get("value")) 62 | 63 | 64 | def make_resource_query(uri: str, encode=True, limit=999): 65 | Q = f""" 66 | # shmarql-view: resource 67 | # shmarql-editor: hide 68 | 69 | SELECT ?resource ?p ?o ?pp ?oo WHERE {{ 70 | values ?resource {{ <{uri}> }} 71 | 72 | ?resource ?p ?o . 73 | OPTIONAL {{ 74 | ?o ?pp ?oo . 75 | }} 76 | }} limit {limit}""" 77 | 78 | if encode: 79 | return quote(Q) 80 | else: 81 | return Q 82 | 83 | 84 | def make_spo(uri: str, spo: str, encode=True, limit=999, extra=""): 85 | uri = f"<{uri}>" 86 | if spo not in ("s", "p", "o"): 87 | return f"select ?s ?p ?o where {{ ?s ?p ?o }} limit {limit}" 88 | 89 | if spo == "s": 90 | tolabel = "o" 91 | else: 92 | tolabel = "s" 93 | 94 | Q = f"""{extra}select ?s ?p ?o ?{tolabel}label where {{ 95 | values ?{spo} {{ {uri} }} 96 | ?s ?p ?o . 97 | optional {{ 98 | ?{tolabel} rdfs:label ?{tolabel}label . 99 | }} 100 | 101 | }} limit {limit}""" 102 | 103 | if encode: 104 | return quote(Q) 105 | else: 106 | return Q 107 | 108 | 109 | @app.post(f"/shmarql/fragments/sparql") 110 | @app.post(f"{MOUNT}shmarql/fragments/sparql") 111 | def fragments_sparql(query: str, results=None): 112 | if query == "": 113 | query = "select * where {?s ?p ?o} limit 10" 114 | if results is None: 115 | results = do_query(query) 116 | 117 | settings = results.get("shmarql_settings", {}) 118 | 119 | if "data" in results: # this was a construct query 120 | return Div(Pre(results["data"])) 121 | if "error" in results: 122 | return ( 123 | Div( 124 | "This query did not work.", 125 | A( 126 | "Click here to see the details of error message", 127 | href="#", 128 | onclick="document.getElementById('error').style.display = 'block';", 129 | ), 130 | Div(results["error"], id="error", style="display:none"), 131 | style="max-height: 30vh; overflow: auto;", 132 | ), 133 | ) 134 | 135 | if "resource" in settings.get("view", []): 136 | return Html(fragments_resource(results, query)) 137 | if settings.get("view", [""])[0].endswith("chart"): 138 | return Html(fragments_chart(query)) 139 | else: 140 | return Html(build_plain_table(query, results)) 141 | 142 | 143 | def build_plain_table(query: str, results: dict): 144 | table_rows = [] 145 | heads = [Th("#", style="color: #aaa; text-align: right;")] 146 | heads.extend( 147 | [ 148 | Th(var, style="font-weight: bold") 149 | for var in results.get("head", {}).get("vars", []) 150 | ] 151 | ) 152 | rownum = 0 153 | for row in results.get("results", {}).get("bindings", []): 154 | rownum += 1 155 | row_columns = [] 156 | row_columns.append( 157 | Td( 158 | rownum, 159 | style="color: #aaa; text-align: right;", 160 | ) 161 | ) 162 | for var in results.get("head", {}).get("vars", []): 163 | value = row.get(var, {"value": ""}) 164 | if value.get("type") == "uri": 165 | S_query = make_spo(value["value"], "s") 166 | P_query = make_spo(value["value"], "p") 167 | O_query = make_spo(value["value"], "o") 168 | 169 | nonce = random.randint(0, pow(2, 32)) 170 | 171 | if var in ("s", "p", "o"): 172 | var_spo = var 173 | else: 174 | var_spo = "s" 175 | 176 | row_columns.append( 177 | Td( 178 | A( 179 | do_prefixes(value["value"]), 180 | href=f"{MOUNT}shmarql/?query=" 181 | + make_resource_query(value["value"]), 182 | ), 183 | Div( 184 | A( 185 | "S", 186 | href=f"{MOUNT}shmarql/?query={S_query}", 187 | style="font-size: 70%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 188 | ), 189 | A( 190 | "P", 191 | href=f"{MOUNT}shmarql/?query={P_query}", 192 | style="font-size: 70%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 193 | ), 194 | A( 195 | "O", 196 | href=f"{MOUNT}shmarql/?query={O_query}", 197 | style="font-size: 70%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 198 | ), 199 | style="font-size: 80%; display: inline-block; margin-left: 0.5em;", 200 | ), 201 | ) 202 | ) 203 | elif value.get("type") == "bnode": 204 | row_columns.append( 205 | Td( 206 | Span( 207 | value["value"], style="font-size: 80%; font-style: italic" 208 | ), 209 | ) 210 | ) 211 | else: 212 | lang = ( 213 | Span( 214 | value.get("xml:lang"), 215 | style="font-size: 80%; vertical-align: super;", 216 | ) 217 | if "xml:lang" in value 218 | else None 219 | ) 220 | 221 | row_columns.append(Td(Span(value["value"]), lang)) 222 | table_rows.append(Tr(*row_columns)) 223 | cached = " (from cache) " if results.get("cached") else "" 224 | 225 | duration_display = ( 226 | f"{int(results.get('duration', 0) * 1000)}ms" 227 | if results.get("duration", 0) < 1 228 | else f"{results.get('duration', 0):.3f}s" 229 | ) 230 | 231 | return Div( 232 | P( 233 | f"{len(results.get('results', {}).get('bindings', []))} results in {duration_display}{cached}", 234 | style="font-size: 50%;", 235 | title="used: " + results.get("endpoint_name", ""), 236 | ), 237 | Table(Thead(Tr(*heads)), Tbody(*table_rows), data_tipe="sparql-results"), 238 | ) 239 | 240 | 241 | def fragments_resource(results: dict, query: str): 242 | """Assuming that in results is a query: 243 | SELECT ?p ?o ?pp ?oo WHERE { 244 | ?p ?o . 245 | optional { 246 | ?o ?pp ?oo . 247 | } 248 | } 249 | Where is the resource we are looking at. 250 | """ 251 | data = {} 252 | seconds = {} 253 | for row in results.get("results", {}).get("bindings", []): 254 | o_object = HashableResult(row.get("o", {})) 255 | data.setdefault(row.get("p", {}).get("value"), set()).add(o_object) 256 | if row.get("pp"): 257 | seconds.setdefault(o_object, {}).setdefault( 258 | row.get("pp", {}).get("value"), set() 259 | ).add(HashableResult(row.get("oo", {}))) 260 | 261 | # now make the values into lists for easier handling 262 | for k, v in data.items(): 263 | data[k] = list(sorted(v)) 264 | for k, v in seconds.items(): 265 | for kk, vv in v.items(): 266 | seconds[k][kk] = list(sorted(vv)) 267 | 268 | buf = [] 269 | ba = buf.append 270 | skip_fields = [] 271 | for title_field in ( 272 | "https://schema.org/name", 273 | "http://www.w3.org/2000/01/rdf-schema#label", 274 | "http://www.w3.org/2004/02/skos/core#prefLabel", 275 | ): 276 | if title_field in data: 277 | title = list(data[title_field])[0] 278 | ba(H1(title, title=title_field)) 279 | skip_fields.append(title_field) 280 | break 281 | 282 | rdf_type = data.get("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") 283 | if rdf_type: 284 | skip_fields.append("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") 285 | rdf_types = [ 286 | A( 287 | do_prefixes(str(x)), 288 | href=f"{MOUNT}shmarql/?query=" + make_spo(x, "o"), 289 | style="margin-right: 0.5em", 290 | ) 291 | for x in rdf_type 292 | ] 293 | 294 | ba(P("a ", *rdf_types, style="font-style: italic;")) 295 | 296 | for description_field in ( 297 | "https://schema.org/description", 298 | "http://schema.org/description", 299 | ): 300 | sdo_description = data.get(description_field) 301 | if sdo_description: 302 | ba(P(sdo_description[0]["value"])) 303 | skip_fields.append(description_field) 304 | 305 | for image_field in ( 306 | "https://schema.org/logo", 307 | "https://schema.org/image", 308 | "http://schema.org/logo", 309 | "http://schema.org/image", 310 | ): 311 | an_image = data.get(image_field) 312 | if an_image: 313 | for i in an_image: 314 | ba(Img(src=i["value"], style="max-width: 100%;")) 315 | 316 | for field, val in data.items(): 317 | if field in skip_fields: 318 | continue 319 | v_label_list = [] 320 | for v in val[:50]: 321 | 322 | if v in seconds: 323 | v_label = seconds[v].get( 324 | "http://www.w3.org/2000/01/rdf-schema#label", [str(v)] 325 | ) 326 | v_type = seconds[v].get( 327 | "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" 328 | ) 329 | if v["type"] == "uri": 330 | v_label_list.append( 331 | A( 332 | v_label[0], 333 | href=f"{MOUNT}shmarql/?query=" 334 | + make_resource_query(v["value"]), 335 | style="margin-right: 1em", 336 | ) 337 | ) 338 | else: 339 | if v_type: 340 | v_label_list.append( 341 | Span(v_label[0], title=v_type[0], style="margin-right: 1em") 342 | ) 343 | else: 344 | v_label_list.append(Span(v_label[0], style="margin-right: 1em")) 345 | else: 346 | v_label_list.append( 347 | A( 348 | v["value"], 349 | href=f"{MOUNT}shmarql/?query=" + make_spo(v["value"], "o"), 350 | ) 351 | ) 352 | P_query = f"{MOUNT}shmarql/?query=" + make_spo(field, "p") 353 | if len(val) > 50: 354 | plain_query = query.replace("# shmarql-view: resource\n", "") 355 | 356 | field_heading = H3( 357 | A(do_prefixes(field), href=P_query), 358 | Span( 359 | f"There are {len(val)} values for this field, showing the first 50,", 360 | style="font-size: 80%; font-style: italic", 361 | ), 362 | A( 363 | "click here to show all", 364 | href=f"{MOUNT}shmarql/?query=" + quote(plain_query), 365 | style="font-size: 80%", 366 | ), 367 | style="margin: 0.5em 0 0 0", 368 | ) 369 | else: 370 | field_heading = H3( 371 | A(do_prefixes(field), href=P_query), style="margin: 0.5em 0 0 0" 372 | ) 373 | ba( 374 | ( 375 | field_heading, 376 | P( 377 | *[vv for vv in v_label_list], 378 | style="font-size: 120%; margin: 0", 379 | ), 380 | ) 381 | ) 382 | return tuple(buf) 383 | 384 | 385 | def build_standalone_table(results, query): 386 | table_rows = [] 387 | heads = [Th(" ")] 388 | heads.extend( 389 | [ 390 | Th(var, style="font-weight: bold") 391 | for var in results.get("head", {}).get("vars", []) 392 | ] 393 | ) 394 | table_rows.append(Tr(*heads)) 395 | 396 | rownum = 0 397 | for row in results.get("results", {}).get("bindings", []): 398 | rownum += 1 399 | row_columns = [] 400 | row_columns.append( 401 | Td( 402 | rownum, 403 | style="padding-right: 0.75ch; color: #aaa; text-align: right;", 404 | ) 405 | ) 406 | for var in results.get("head", {}).get("vars", []): 407 | value = row.get(var, {"value": ""}) 408 | if value.get("type") == "uri": 409 | S_query = make_spo(value["value"], "s") 410 | P_query = make_spo(value["value"], "p") 411 | O_query = make_spo(value["value"], "o") 412 | 413 | # do some fizzy for factgrid 414 | if value["value"].find("database.factgrid.de") > -1: 415 | fizzy_query = quote( 416 | f"""select distinct ?s (STR(?o) AS ?oLabel) where {{ 417 | ?s fizzy:rdf2vec <{value['value']}> . 418 | ?s rdfs:label ?o . 419 | }} 420 | """ 421 | ) 422 | fizzyquery = A( 423 | "✨", 424 | href="?query=" + fizzy_query, 425 | title="Show items similar to this entity using fizzysearch", 426 | ) 427 | else: 428 | fizzyquery = None 429 | 430 | row_columns.append( 431 | Td( 432 | A( 433 | "S", 434 | href=f"{MOUNT}shmarql/?query={S_query}", 435 | style="font-size: 80%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 436 | ), 437 | A( 438 | "P", 439 | href=f"{MOUNT}shmarql/?query={P_query}", 440 | style="font-size: 80%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 441 | ), 442 | A( 443 | "O", 444 | href=f"{MOUNT}shmarql/?query={O_query}", 445 | style="font-size: 80%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 446 | ), 447 | A( 448 | value["value"], 449 | href=value["value"], 450 | style="margin-left: 1ch", 451 | ), 452 | fizzyquery, 453 | cls="border border-gray-300 px-4 py-2 text-sm", 454 | ) 455 | ) 456 | elif value.get("type") == "bnode": 457 | row_columns.append( 458 | Td( 459 | Span( 460 | value["value"], style="font-size: 80%; font-style: italic" 461 | ), 462 | cls="border border-gray-300 px-4 py-2 text-sm", 463 | ) 464 | ) 465 | else: 466 | o_link = A( 467 | "O", 468 | href=f"{MOUNT}shmarql/?query={make_literal_query(value)}", 469 | style="font-size: 80%; background-color: #ddd; color: #000; padding: 3px; text-decoration: none; margin: 0", 470 | ) 471 | lang = ( 472 | Span( 473 | value.get("xml:lang"), cls="text-xs bg-gray-200 text-black px-2" 474 | ) 475 | if "xml:lang" in value 476 | else None 477 | ) 478 | 479 | row_columns.append( 480 | Td( 481 | o_link, 482 | Span(value["value"], style="margin-left: 1ch"), 483 | lang, 484 | cls="border border-gray-300 px-4 py-2 text-sm", 485 | ) 486 | ) 487 | table_rows.append(Tr(*row_columns, cls="hover:bg-gray-50")) 488 | cached = " (from cache) " if results.get("cached") else "" 489 | 490 | duration_display = ( 491 | f"{int(results.get('duration', 0) * 1000)}ms" 492 | if results.get("duration", 0) < 1 493 | else f"{results.get('duration', 0):.3f}s" 494 | ) 495 | 496 | return Div( 497 | Div( 498 | Span( 499 | f"{len(results.get('results', {}).get('bindings', []))} results in {duration_display}{cached}", 500 | title="used: " + results.get("endpoint_name", ""), 501 | ), 502 | A( 503 | "CSV", 504 | title="Download as CSV", 505 | href=f"{MOUNT}shmarql/?query={quote(query)}&format=csv", 506 | cls=BTN_STYLE, 507 | ), 508 | A( 509 | "JSON", 510 | title="Download as JSON", 511 | href=f"{MOUNT}shmarql/?query={quote(query)}&format=json", 512 | cls=BTN_STYLE, 513 | ), 514 | cls="bg-slate-200 text-black p-2 flex flex-row gap-1 text-xs", 515 | ), 516 | Table( 517 | *table_rows, 518 | cls="min-w-full table-auto border-collapse border border-gray-300", 519 | ), 520 | ) 521 | 522 | 523 | @app.get(f"{MOUNT}shmarql/fragments/chart") 524 | def fragments_chart(query: str, results=None): 525 | if results is None: 526 | results = do_query(query) 527 | 528 | settings = results.get("shmarql_settings", {}) 529 | chart_type = settings.get("view")[0] 530 | 531 | df = results_to_df(results) 532 | 533 | chart_id = f"uniq-{uuid4()}" 534 | chart_func = { 535 | "barchart": do_barchart, 536 | "piechart": do_piechart, 537 | "mapchart": do_mapchart, 538 | }.get(chart_type) 539 | try: 540 | chart_json = to_json(chart_func(settings, df, settings.get("label", [None])[0])) 541 | js_options = {} 542 | except Exception as e: 543 | return Div( 544 | f"Chart Error: {traceback.format_exc()}", 545 | style="border: 2px solid red; font-size: 150%; padding: 1em;", 546 | ) 547 | 548 | return Div( 549 | Script( 550 | f""" 551 | var plotly_data = {chart_json}; 552 | Plotly.newPlot('{chart_id}', plotly_data.data, plotly_data.layout, {json.dumps(js_options)}); 553 | """ 554 | ), 555 | id=chart_id, 556 | ) 557 | 558 | 559 | def build_sparql_ui(query, results): 560 | settings = results.get("shmarql_settings", {}) 561 | if "hide" in settings.get("editor", []): 562 | sparql_editor_block_style_button = ( 563 | Button( 564 | "SPARQL", 565 | Script( 566 | """me().on("click", async ev => {me(ev).fadeOut(); me('#sparql_editor_block').style.display = 'block'})""" 567 | ), 568 | style="padding: 4px 8px; font-size: 12px;", 569 | cls="md-button md-button--primary", 570 | ), 571 | Script( 572 | "setTimeout(() => {me('#sparql_editor_block').style.display = 'none'}, 500 )" 573 | ), 574 | ) 575 | else: 576 | sparql_editor_block_style_button = None 577 | 578 | if "resource" in settings.get("view", []): 579 | results_fragment = Div(fragments_resource(results, query), cls="md-typeset") 580 | if settings.get("view", [""])[0].endswith("chart"): 581 | try: 582 | chart_fragment = fragments_chart(query) 583 | except Exception as e: 584 | chart_fragment = Div( 585 | f"Error: {e}", 586 | style="color: red; font-weight: bold; font-size: 150%;", 587 | ) 588 | results_fragment = Div(chart_fragment, cls="md-typeset") 589 | else: 590 | results_fragment = Div(fragments_sparql(query, results), cls="md-typeset") 591 | 592 | return ( 593 | Script(src=f"{MOUNT}static/editor.js"), 594 | Script(src=f"{MOUNT}static/matchbrackets.js"), 595 | Script(src=f"{MOUNT}static/sparql.js"), 596 | Script(src=f"{MOUNT}static/surreal-1.3.0.js"), 597 | Script(src=f"{MOUNT}static/tablesort-5.3.0.min.js"), 598 | sparql_editor_block_style_button, 599 | Div( 600 | Button( 601 | "▶", 602 | Script( 603 | 'me().on("click", async ev => {\r\n if (ev.shiftKey) {\r\n console.log("Shift key was pressed during the click!");\r\n }\r\n executeQuery()\r\n})' 604 | ), 605 | id="execute_sparql", 606 | title="Execute this query, (also use Ctrl+Enter)", 607 | style="padding: 4px 8px; font-size: 12px;", 608 | cls="md-button md-button--primary", 609 | ), 610 | Button( 611 | "Prefixes", 612 | Script( 613 | 'me().on("click", async ev => {\n\n let editorContent = sparqleditor.doc.getValue();\r\n let prefixContent = `' 614 | + PREFIXES_SNIPPET 615 | + "\n\n`;\r\nsparqleditor.doc.setValue(prefixContent + editorContent);\r\n\r\n})" 616 | ), 617 | id="prefixes", 618 | name="prefixes", 619 | style="padding: 4px 8px; font-size: 12px;", 620 | cls="md-button md-button--primary", 621 | ), 622 | Div(Textarea(query, id="code", name="code")), 623 | id="sparql_editor_block", 624 | ), 625 | Div( 626 | results_fragment, 627 | id="results", 628 | style="margin-top: 2vh;", # max-height: 50vh; overflow-y: scroll 629 | ), 630 | Script( 631 | f""" 632 | const link = document.createElement("link"); 633 | link.rel = "stylesheet"; 634 | link.href = "{MOUNT}static/codemirror.css"; 635 | document.head.appendChild(link);""" 636 | ), 637 | Script(f"const MOUNT = '{MOUNT}';"), 638 | Script( 639 | """ 640 | document.addEventListener("DOMContentLoaded", function () { 641 | sparqleditor = CodeMirror.fromTextArea(document.getElementById("code"), { 642 | mode: "application/sparql-query", 643 | matchBrackets: true, 644 | lineNumbers: true, 645 | }); 646 | results = document.getElementById("results"); 647 | }); 648 | 649 | function executeQuery() { 650 | let the_query = sparqleditor.doc.getValue(); 651 | results.innerHTML = '
Loading...
'; 652 | history.pushState({ query: the_query }, "", MOUNT+"shmarql/?query=" + encodeURIComponent(the_query)); 653 | queryStarted = Date.now(); 654 | progress_counter = setTimeout(updateProgress, 1000); 655 | 656 | fetch(`${MOUNT}shmarql/fragments/sparql`, { 657 | method: "POST", 658 | headers: { 659 | "Content-Type": "application/x-www-form-urlencoded" 660 | }, 661 | body: `query=${encodeURIComponent(the_query)}` 662 | }).then(response => response.text()).then(data => { 663 | if(progress_counter) { 664 | clearTimeout(progress_counter); 665 | } 666 | results.innerHTML = data; 667 | const scripts = results.querySelectorAll("script"); 668 | scripts.forEach((script) => { 669 | const newScript = document.createElement("script"); 670 | if (script.src) { 671 | newScript.src = script.src; 672 | newScript.async = false; // To preserve execution order if needed 673 | } else { 674 | newScript.textContent = script.textContent; 675 | } 676 | document.body.appendChild(newScript); 677 | document.body.removeChild(newScript); 678 | }); 679 | 680 | 681 | 682 | 683 | var tables = document.querySelectorAll("table[data-tipe='sparql-results']"); 684 | tables.forEach(function(table) { 685 | new Tablesort(table) 686 | }) 687 | }) 688 | 689 | } 690 | 691 | function updateProgress() { 692 | let progress = Math.round((Date.now() - queryStarted) / 1000); 693 | results.innerHTML = `
Query in progress, took ${progress}s so far...
`; 694 | progress_counter = setTimeout(updateProgress, 1000); 695 | } 696 | 697 | document.body.addEventListener("keypress", function (evt) { 698 | if (evt.ctrlKey && evt.key === "Enter") { 699 | evt.preventDefault(); 700 | executeQuery() 701 | } 702 | }); 703 | """ 704 | ), 705 | ) 706 | -------------------------------------------------------------------------------- /src/shmarql/main.py: -------------------------------------------------------------------------------- 1 | import csv, io, string, json, os 2 | from urllib.parse import quote 3 | from fasthtml.common import * 4 | import pyoxigraph as px 5 | from .px_util import OxigraphSerialization, results_to_xml 6 | from .config import ( 7 | WATCH_DOCS, 8 | PREFIXES_SNIPPET, 9 | MOUNT, 10 | SPARQL_QUERY_UI, 11 | SITEDOCS_PATH, 12 | SCHPIEL_PATH, 13 | SITE_URI, 14 | log, 15 | ) 16 | from plotly.offline._plotlyjs_version import __plotlyjs_version__ as plotlyjs_version 17 | import asyncio 18 | from typing import List, Callable, Dict, Any 19 | from watchdog.observers import Observer 20 | from watchdog.events import FileSystemEventHandler 21 | 22 | from .qry import do_query, hash_query 23 | 24 | app = FastHTML( 25 | pico=False, 26 | hdrs=( 27 | ( 28 | Link( 29 | rel="stylesheet", 30 | type="text/css", 31 | href=f"{MOUNT}shmarql/static/shmarql.css", 32 | ), 33 | Script(src=f"https://cdn.plot.ly/plotly-{plotlyjs_version}.min.js"), 34 | ), 35 | ), 36 | ) 37 | 38 | global_observer = None 39 | main_event_loop = None 40 | 41 | 42 | class FileChangeHandler(FileSystemEventHandler): 43 | def __init__(self, file_types: List[str], callback_function: Callable, loop): 44 | self.file_types = [ 45 | ext.lower() if ext.startswith(".") else f".{ext.lower()}" 46 | for ext in file_types 47 | ] 48 | self.callback_function = callback_function 49 | self.loop = loop 50 | 51 | def on_modified(self, event): 52 | if not event.is_directory: 53 | file_ext = os.path.splitext(event.src_path)[1].lower() 54 | if file_ext in self.file_types: 55 | # Run the callback in the asyncio event loop 56 | asyncio.run_coroutine_threadsafe( 57 | self.callback_function(event.src_path, event_type="modified"), 58 | self.loop, 59 | ) 60 | 61 | def on_created(self, event): 62 | if not event.is_directory: 63 | file_ext = os.path.splitext(event.src_path)[1].lower() 64 | if file_ext in self.file_types: 65 | # Run the callback in the asyncio event loop 66 | asyncio.run_coroutine_threadsafe( 67 | self.callback_function(event.src_path, event_type="created"), 68 | self.loop, 69 | ) 70 | 71 | 72 | def start_directory_watcher( 73 | directory_path: str, 74 | file_types: List[str], 75 | callback_function: Callable, 76 | loop, 77 | recursive: bool = True, 78 | ) -> Observer: 79 | event_handler = FileChangeHandler(file_types, callback_function, loop) 80 | observer = Observer() 81 | observer.schedule(event_handler, directory_path, recursive=recursive) 82 | observer.start() 83 | return observer 84 | 85 | 86 | async def regenerate_docs_site(file_path: str, event_type: str = "modified") -> None: 87 | from mkdocs.__main__ import cli 88 | 89 | log.debug(f"Regenerating docs site due to {event_type} on {file_path}") 90 | try: 91 | cli(["build", "--site-dir", "site"], standalone_mode=False) 92 | except Exception as e: 93 | log.debug(str(e)) 94 | 95 | 96 | @app.on_event("startup") 97 | async def startup_event(): 98 | if not WATCH_DOCS: 99 | return 100 | global global_observer, main_event_loop 101 | main_event_loop = asyncio.get_running_loop() 102 | 103 | directory_to_watch = "./docs" 104 | file_types_to_watch = [".md", ".yml", ".json"] 105 | 106 | global_observer = start_directory_watcher( 107 | directory_to_watch, file_types_to_watch, regenerate_docs_site, main_event_loop 108 | ) 109 | log.debug( 110 | f"Started watching {directory_to_watch} for changes to {file_types_to_watch} files" 111 | ) 112 | 113 | 114 | @app.get("/favicon.ico") 115 | def favicon(): 116 | return FileResponse(f"static/favicon.ico") 117 | 118 | 119 | @app.get("/shmarql/static/{fname:path}") 120 | @app.get(MOUNT + "shmarql/static/{fname:path}") 121 | @app.get(MOUNT + "static/{fname:path}") 122 | def shmarql_get_static(fname: str): 123 | return FileResponse(f"static/{fname}") 124 | 125 | 126 | def make_literal_query(some_literal: dict, encode=True, limit=999): 127 | txt = some_literal["value"].replace("\n", " ") 128 | txt = txt.translate(str.maketrans("", "", string.punctuation)) 129 | txt = [x for x in txt.split(" ") if len(x) > 1][:10] 130 | txt = " ".join(txt).strip(" ") 131 | 132 | Q = f"""select ?s ?p ?o where {{ 133 | ?s ?p ?o . 134 | ?s fizzy:fts "{txt}" . }} limit {limit}""" 135 | if encode: 136 | return quote(Q) 137 | else: 138 | return Q 139 | 140 | 141 | @app.post("/_/oOo") 142 | def oinga(): 143 | from mkdocs.__main__ import cli 144 | 145 | try: 146 | cli(["build", "--site-dir", "site"], standalone_mode=False) 147 | except Exception as e: 148 | return Div(str(e)) 149 | 150 | 151 | from .fragments import * 152 | 153 | 154 | def json_results_to_csv(results: dict): 155 | output = io.StringIO() 156 | writer = csv.writer(output) 157 | 158 | # Write header 159 | writer.writerow([var for var in results.get("head", {}).get("vars", [])]) 160 | for row in results.get("results", {}).get("bindings", []): 161 | writer.writerow( 162 | [ 163 | row.get(var, {"value": ""})["value"] 164 | for var in results.get("head", {}).get("vars", []) 165 | ] 166 | ) 167 | return output.getvalue() 168 | 169 | 170 | def accept_header_to_format(request: Request) -> str: 171 | accept_header = request.headers.get("accept") 172 | accept_headers_incoming = [] 173 | if accept_header: 174 | accept_headers_incoming = [ah.strip() for ah in accept_header.split(",")] 175 | accept_headers = {} 176 | for ah in accept_headers_incoming: 177 | ql = ah.split(";") 178 | ql_val = 1 179 | if len(ql) > 1: 180 | ah_left = ql[0] 181 | ql = ql[1].split("=") 182 | if len(ql) > 1 and ql[0] == "q": 183 | try: 184 | ql_val = float(ql[1]) 185 | except ValueError: 186 | ql_val = 0 187 | else: 188 | ah_left = ql[0] 189 | ql_val = 1 190 | accept_headers[ah_left] = ql_val 191 | 192 | for ah, _ in sorted(accept_headers.items(), reverse=True, key=lambda x: x[1]): 193 | if ah.startswith("application/sparql-results+json"): 194 | return "json" 195 | if ah.startswith("text/turtle"): 196 | return "turtle" 197 | if ah.startswith("application/sparql-results+xml"): 198 | return "xml" 199 | 200 | return "html" 201 | 202 | 203 | @app.options(f"{MOUNT}sparql") 204 | def handle_options(): 205 | headers = { 206 | "Access-Control-Allow-Origin": "*", 207 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 208 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 209 | } 210 | return Response(status_code=204, headers=headers) 211 | 212 | 213 | @app.route(f"{MOUNT}sparql", methods=["GET", "POST"]) 214 | async def sparql(request: Request): 215 | if request.headers.get("content-type") == "application/sparql-query": 216 | query = await request.body() 217 | query = query.decode("utf-8") 218 | else: 219 | query = request.query_params.get("query") 220 | if not query or len(query.strip()) < 2: 221 | return RedirectResponse(f"{MOUNT}shmarql/", status_code=303) 222 | response = await shmarql_get(request, query) 223 | return response 224 | 225 | 226 | @app.get(f"{MOUNT}shmarql") 227 | def shmarql_redir(): 228 | return RedirectResponse(f"{MOUNT}shmarql/", status_code=303) 229 | 230 | 231 | @app.get(f"{MOUNT}shmarql/") 232 | def shmarql_get( 233 | request: Request, 234 | query: str = "select * where {?s ?p ?o} limit 10", 235 | format: str = None, 236 | ): 237 | 238 | if format is None: 239 | format = accept_header_to_format(request) 240 | results = do_query(query) 241 | if format in ("csv", "json", "turtle", "xml"): 242 | if "data" in results: 243 | if format == "turtle": 244 | return Response( 245 | results["data"], 246 | headers={ 247 | "Content-Type": "text/turtle", 248 | "Access-Control-Allow-Origin": "*", 249 | }, 250 | ) 251 | try: 252 | # if format is not turtle, but the results are bytes, try to parse it and return as json 253 | tmp_store = px.Store() 254 | tmp_store.bulk_load(results["data"], "text/turtle") 255 | r = tmp_store.query("select * where {?s ?p ?o}") 256 | results["results"] = {"bindings": OxigraphSerialization(r).json()} 257 | except Exception as e: 258 | results = { 259 | "error": f"{e} Query returned non-parsable data: {repr(results)[:500]}" 260 | } 261 | 262 | if format == "xml": 263 | xml_data = results_to_xml(results) 264 | return Response( 265 | xml_data, 266 | headers={ 267 | "Content-Type": "application/sparql-results+xml", 268 | "Content-Disposition": f"attachment; filename={hash_query(query)}.xml", 269 | "Access-Control-Allow-Origin": "*", 270 | }, 271 | ) 272 | 273 | if format == "csv": 274 | csv_data = json_results_to_csv(results) 275 | 276 | return Response( 277 | csv_data, 278 | headers={ 279 | "Content-Type": "text/csv", 280 | "Content-Disposition": f"attachment; filename={hash_query(query)}.csv", 281 | "Access-Control-Allow-Origin": "*", 282 | }, 283 | ) 284 | if format == "json": 285 | if "endpoint" in results: 286 | del results["endpoint"] 287 | return Response( 288 | json.dumps(results, indent=2), 289 | headers={ 290 | "Content-Type": "application/sparql-results+json", 291 | "Access-Control-Allow-Origin": "*", 292 | }, 293 | ) 294 | 295 | if not SPARQL_QUERY_UI: 296 | return Div( 297 | "There is currently no SPARQL query form to be found here, call it from a command line via a POST request." 298 | ) 299 | 300 | h = open("site/_/index.html").read() 301 | h_txt = build_sparql_ui(query, results) 302 | 303 | return h.replace("BODY_PLACE_HOLDER", to_xml(h_txt)).replace( 304 | "TITLE_PLACE_HOLDER", "" 305 | ) 306 | 307 | 308 | # Why the strange position of this import statement? 309 | # This means that it overrides the static page serving below, but not the main "built-in" functionalities 310 | from .ext import * 311 | 312 | from .biki import * 313 | 314 | 315 | def entity_check(iri: str): 316 | q = f"SELECT * WHERE {{ <{iri}> ?p ?o }}" 317 | res = do_query(q) 318 | return len(res.get("results", {}).get("bindings", [])) > 0 319 | 320 | 321 | @app.get(MOUNT + "{fname:path}") 322 | @app.get("/{fname:path}") 323 | def getter(request: Request, fname: str): 324 | log.debug(f"Getter on {fname}") 325 | new_name = fname 326 | if fname.startswith("/"): 327 | new_name = fname[1:] 328 | if fname == "" or fname.endswith("/"): 329 | new_name += "index.html" 330 | 331 | if SCHPIEL_PATH: 332 | path_to_try = os.path.join(SCHPIEL_PATH, new_name) 333 | if os.path.exists(path_to_try): 334 | return FileResponse(path_to_try) 335 | 336 | path_to_try = os.path.join(SITEDOCS_PATH, new_name) 337 | if MOUNT: 338 | path_to_try = os.path.join(SITEDOCS_PATH, new_name.replace(MOUNT[1:], "", 1)) 339 | else: 340 | path_to_try = os.path.join(SITEDOCS_PATH, new_name) 341 | 342 | log.debug(f"Trying {path_to_try}") 343 | if os.path.exists(path_to_try): 344 | return FileResponse(path_to_try) 345 | 346 | iri = SITE_URI + fname 347 | log.debug(f"Entity Checking {iri}") 348 | if SITE_URI and entity_check(iri): 349 | format = accept_header_to_format(request) 350 | if format == "html": 351 | q = f""" 352 | # shmarql-view: resource 353 | # shmarql-editor: hide 354 | 355 | SELECT ?p ?o ?pp ?oo WHERE {{ 356 | <{iri}> ?p ?o . 357 | OPTIONAL {{ 358 | ?o ?pp ?oo . 359 | }} 360 | }}""" 361 | else: 362 | q = f"CONSTRUCT {{ <{iri}> ?p ?o }} WHERE {{ <{iri}> ?p ?o }}" 363 | 364 | return RedirectResponse(f"{MOUNT}shmarql/?query={quote(q)}") 365 | 366 | # The default 404 from FileResponse leaks the path, make it simpler: 367 | raise HTTPException(404, f"File not found {fname}") 368 | -------------------------------------------------------------------------------- /src/shmarql/markdownplugin.py: -------------------------------------------------------------------------------- 1 | from pygments.lexers.rdf import SparqlLexer 2 | from markdown.extensions.codehilite import CodeHiliteExtension 3 | from markdown import Markdown 4 | from pygments.lexers import find_lexer_class_by_name 5 | from pygments.util import ClassNotFound 6 | 7 | 8 | __all__ = ["ShmarqlLexer"] 9 | 10 | 11 | class ShmarqlLexer(SparqlLexer): 12 | """A custom lexer for SPARQL syntax, mirroring the default SparqlLexer.""" 13 | 14 | name = "shmarql" 15 | aliases = ["shmarql"] 16 | 17 | 18 | class CustomCodeBlockExtension(CodeHiliteExtension): 19 | """Extend the CodeHilite extension to recognize `shmarql` blocks.""" 20 | 21 | def extendMarkdown(self, md: Markdown): 22 | super().extendMarkdown(md) 23 | try: 24 | _ = find_lexer_class_by_name("shmarql") 25 | except ClassNotFound: 26 | from pygments.lexers import LEXERS 27 | 28 | LEXERS["ShmarqlLexer"] = ( 29 | "shmarql.markdownplugin", 30 | ShmarqlLexer.name, 31 | tuple(ShmarqlLexer.aliases), 32 | ShmarqlLexer.filenames, 33 | ShmarqlLexer.mimetypes, 34 | ) 35 | 36 | 37 | def makeExtension(**kwargs): 38 | return CustomCodeBlockExtension(lang="shmarql") 39 | -------------------------------------------------------------------------------- /src/shmarql/px_util.py: -------------------------------------------------------------------------------- 1 | from pyoxigraph import ( 2 | NamedNode, 3 | Literal, 4 | BlankNode, 5 | Quad, 6 | QuerySolutions, 7 | QueryTriples, 8 | Store, 9 | Variable, 10 | RdfFormat, 11 | ) 12 | from io import BytesIO 13 | import logging 14 | from rdflib.plugins.sparql.results.xmlresults import SPARQLXMLWriter 15 | from xml.sax.xmlreader import AttributesNSImpl 16 | from xml.dom import XML_NAMESPACE 17 | import pandas as pd 18 | from typing import Union 19 | from .config import PREFIXES 20 | 21 | 22 | def string_iterator(graph: Store): 23 | for s, p, o, g in graph.quads_for_pattern(None, None, None, None): 24 | yield (str(s), str(p), str(o), str(g)) 25 | 26 | 27 | class SerializationException(Exception): ... 28 | 29 | 30 | def termJSON(term): 31 | if isinstance(term, NamedNode): 32 | return {"type": "uri", "value": term.value} 33 | elif isinstance(term, Literal): 34 | r = {"type": "literal", "value": term.value} 35 | if ( 36 | term.datatype is not None 37 | and term.datatype.value != "http://www.w3.org/2001/XMLSchema#string" 38 | ): 39 | r["datatype"] = term.datatype.value 40 | if term.language is not None: 41 | r["xml:lang"] = term.language 42 | return r 43 | elif isinstance(term, BlankNode): 44 | return {"type": "bnode", "value": term.value} 45 | elif term is None: 46 | return None 47 | else: 48 | raise SerializationException("Unknown term type: %s (%s)" % (term, type(term))) 49 | 50 | 51 | class OxigraphSerialization: 52 | def __init__(self, result): 53 | self.result = result 54 | 55 | def json(self): 56 | if type(self.result) in (QuerySolutions, SynthQuerySolutions): 57 | return self.qr_json() 58 | elif type(self.result) == QueryTriples: 59 | return self.qt_json() 60 | 61 | def to_store(self) -> Store: 62 | tmp_store = Store() 63 | tmp_store.extend([Quad(s, p, o) for s, p, o in self.result]) 64 | return tmp_store 65 | 66 | def qt_json(self): 67 | 68 | result = { 69 | "data": self.result.serialize(format=RdfFormat.N_TRIPLES).decode("utf8") 70 | } 71 | return result 72 | 73 | def qt_turtle(self): 74 | tmp_store = self.to_store() 75 | buf = BytesIO() 76 | tmp_store.dump(buf, "text/turtle") 77 | return buf.getvalue().decode("utf8") 78 | 79 | def qr_json(self): 80 | result = {"head": {"vars": [x.value for x in self.result.variables]}} 81 | rows = [] 82 | for qs in self.result: 83 | row = {} 84 | for var in self.result.variables: 85 | if qs[var] is not None: 86 | row[var.value] = termJSON(qs[var]) 87 | rows.append(row) 88 | result["results"] = {"bindings": rows} 89 | return result 90 | 91 | def xml(self): 92 | SPARQL_XML_NAMESPACE = "http://www.w3.org/2005/sparql-results#" 93 | if type(self.result) in (QuerySolutions, SynthQuerySolutions): 94 | stream = BytesIO() 95 | sxw = SPARQLXMLWriter(stream, encoding="utf-8") 96 | vars = [v.value for v in self.result.variables] 97 | sxw.write_header(vars) 98 | sxw.write_results_header() 99 | for qs in self.result: 100 | sxw.write_start_result() 101 | for name in vars: 102 | if qs[name] is not None: 103 | attr_vals = { 104 | (None, "name"): str(name), 105 | } 106 | attr_qnames = { 107 | (None, "name"): "name", 108 | } 109 | sxw.writer.startElementNS( 110 | (SPARQL_XML_NAMESPACE, "binding"), 111 | "binding", 112 | AttributesNSImpl(attr_vals, attr_qnames), 113 | ) 114 | val = qs[name] 115 | if type(val) == NamedNode: 116 | sxw.writer.startElementNS( 117 | (SPARQL_XML_NAMESPACE, "uri"), 118 | "uri", 119 | AttributesNSImpl({}, {}), 120 | ) 121 | sxw.writer.characters(val.value) 122 | sxw.writer.endElementNS( 123 | (SPARQL_XML_NAMESPACE, "uri"), "uri" 124 | ) 125 | elif type(val) == BlankNode: 126 | sxw.writer.startElementNS( 127 | (SPARQL_XML_NAMESPACE, "bnode"), 128 | "bnode", 129 | AttributesNSImpl({}, {}), 130 | ) 131 | sxw.writer.characters(val.value) 132 | sxw.writer.endElementNS( 133 | (SPARQL_XML_NAMESPACE, "bnode"), "bnode" 134 | ) 135 | elif type(val) == Literal: 136 | attr_vals = {} 137 | attr_qnames = {} 138 | if val.language: 139 | attr_vals[(XML_NAMESPACE, "lang")] = val.language 140 | attr_qnames[(XML_NAMESPACE, "lang")] = "xml:lang" 141 | elif val.datatype: 142 | attr_vals[(None, "datatype")] = val.datatype.value 143 | attr_qnames[(None, "datatype")] = "datatype" 144 | 145 | sxw.writer.startElementNS( 146 | (SPARQL_XML_NAMESPACE, "literal"), 147 | "literal", 148 | AttributesNSImpl(attr_vals, attr_qnames), 149 | ) 150 | sxw.writer.characters(val.value) 151 | sxw.writer.endElementNS( 152 | (SPARQL_XML_NAMESPACE, "literal"), "literal" 153 | ) 154 | 155 | sxw.writer.endElementNS( 156 | (SPARQL_XML_NAMESPACE, "binding"), "binding" 157 | ) 158 | 159 | sxw.write_end_result() 160 | sxw.close() 161 | return stream.getvalue().decode("utf8") 162 | 163 | 164 | class SynthQuerySolutions: 165 | variables = [Variable("s"), Variable("p"), Variable("o")] 166 | 167 | def __init__(self, triples): 168 | self.triples = [ 169 | { 170 | Variable("s"): s, 171 | Variable("p"): p, 172 | Variable("o"): o, 173 | } 174 | for s, p, o in triples 175 | ] 176 | 177 | def __iter__(self): 178 | self.index = 0 179 | return self 180 | 181 | def __next__(self): 182 | if self.index >= len(self.triples): 183 | raise StopIteration 184 | current_variable = self.triples[self.index] 185 | self.index += 1 186 | return current_variable 187 | 188 | 189 | def results_to_triples(results: dict, vars: dict): 190 | buf = [] 191 | if "results" in results and "bindings" in results["results"]: 192 | for row in results["results"]["bindings"]: 193 | line = [] 194 | for tmp in ("s", "p", "o"): 195 | x = row.get(tmp) 196 | if not x is None: 197 | if x.get("type") in ("literal", "typed-literal"): 198 | if tmp == "p": 199 | logging.debug(f"Bogus literal as predicate: {x}") 200 | line.append( 201 | NamedNode( 202 | "http://bogus_literal_as_predicate_from_endpoint/" 203 | ) 204 | ) 205 | continue 206 | 207 | datatype = x.get("datatype") 208 | if datatype: 209 | line.append( 210 | Literal( 211 | x.get("value"), 212 | language=x.get("xml:lang"), 213 | datatype=NamedNode(datatype), 214 | ) 215 | ) 216 | else: 217 | line.append( 218 | Literal(x.get("value"), language=x.get("xml:lang")) 219 | ) 220 | continue 221 | elif x.get("type") == "uri": 222 | line.append(NamedNode(x.get("value"))) 223 | continue 224 | elif x.get("type") == "bnode": 225 | line.append(BlankNode(x.get("value"))) 226 | continue 227 | if vars.get(tmp) != "?" + tmp: 228 | varvalue = vars.get(tmp) 229 | if varvalue.startswith("<"): 230 | line.append(NamedNode(varvalue.strip("<>"))) 231 | continue 232 | elif varvalue.startswith("_"): 233 | line.append(BlankNode(varvalue[1:])) 234 | continue 235 | else: 236 | line.append(Literal(varvalue)) 237 | continue 238 | buf.append(line) 239 | return buf 240 | 241 | 242 | def results_to_xml(results: dict) -> str: 243 | SPARQL_XML_NAMESPACE = "http://www.w3.org/2005/sparql-results#" 244 | stream = BytesIO() 245 | sxw = SPARQLXMLWriter(stream, encoding="utf-8") 246 | vars = results.get("head", {}).get("vars", []) 247 | sxw.write_header(vars) 248 | sxw.write_results_header() 249 | for qs in results.get("results", {}).get("bindings", []): 250 | sxw.write_start_result() 251 | for name in vars: 252 | if qs.get(name) is not None: 253 | attr_vals = { 254 | (None, "name"): name, 255 | } 256 | attr_qnames = { 257 | (None, "name"): "name", 258 | } 259 | sxw.writer.startElementNS( 260 | (SPARQL_XML_NAMESPACE, "binding"), 261 | "binding", 262 | AttributesNSImpl(attr_vals, attr_qnames), 263 | ) 264 | val = qs.get(name) 265 | if val.get("type") == "uri": 266 | sxw.writer.startElementNS( 267 | (SPARQL_XML_NAMESPACE, "uri"), 268 | "uri", 269 | AttributesNSImpl({}, {}), 270 | ) 271 | sxw.writer.characters(val.get("value")) 272 | sxw.writer.endElementNS((SPARQL_XML_NAMESPACE, "uri"), "uri") 273 | elif val.get("type") == "bnode": 274 | sxw.writer.startElementNS( 275 | (SPARQL_XML_NAMESPACE, "bnode"), 276 | "bnode", 277 | AttributesNSImpl({}, {}), 278 | ) 279 | sxw.writer.characters(val.get("value")) 280 | sxw.writer.endElementNS((SPARQL_XML_NAMESPACE, "bnode"), "bnode") 281 | elif type(val) == "literal": 282 | attr_vals = {} 283 | attr_qnames = {} 284 | if val.get("xml:lang"): 285 | attr_vals[(XML_NAMESPACE, "lang")] = val.get("xml:lang") 286 | attr_qnames[(XML_NAMESPACE, "lang")] = "xml:lang" 287 | elif val.get("datatype"): 288 | attr_vals[(None, "datatype")] = val.get("datatype") 289 | attr_qnames[(None, "datatype")] = "datatype" 290 | 291 | sxw.writer.startElementNS( 292 | (SPARQL_XML_NAMESPACE, "literal"), 293 | "literal", 294 | AttributesNSImpl(attr_vals, attr_qnames), 295 | ) 296 | sxw.writer.characters(val.get("value")) 297 | sxw.writer.endElementNS( 298 | (SPARQL_XML_NAMESPACE, "literal"), "literal" 299 | ) 300 | 301 | sxw.writer.endElementNS((SPARQL_XML_NAMESPACE, "binding"), "binding") 302 | 303 | sxw.write_end_result() 304 | sxw.close() 305 | return stream.getvalue().decode("utf8") 306 | 307 | 308 | def results_to_df(results: dict) -> pd.DataFrame: 309 | data = {} 310 | vars = results.get("head", {}).get("vars", []) 311 | for row in results.get("results", {}).get("bindings", []): 312 | for var in vars: 313 | val = row.get(var) 314 | data.setdefault(var, []).append(val.get("value") if val else None) 315 | # this loses the type of the item in the results! 316 | return pd.DataFrame(data) 317 | 318 | 319 | def do_prefixes(iris: Union[str, list]): 320 | """Given a list of IRI values, return a string with the IRIs prefixed""" 321 | if isinstance(iris, str): 322 | iris = [iris] 323 | buf = [] 324 | for iri in iris: 325 | found = False 326 | for uri, prefix in PREFIXES.items(): 327 | if iri.startswith(uri): 328 | buf.append(f"{prefix}{iri[len(uri):]}") 329 | found = True 330 | if not found: 331 | buf.append(iri) 332 | return " ".join(buf) 333 | -------------------------------------------------------------------------------- /src/shmarql/qry.py: -------------------------------------------------------------------------------- 1 | import httpx, logging, random, hashlib, json, time, sqlite3, os, gzip 2 | import fizzysearch 3 | from .config import ( 4 | ENDPOINT, 5 | ENDPOINTS, 6 | QUERIES_DB, 7 | BIKIDATA_DB, 8 | SEMANTIC_INDEX, 9 | PREFIXES_SNIPPET, 10 | DATA_LOAD_PATHS, 11 | STORE_PATH, 12 | log, 13 | ) 14 | import pyoxigraph as px 15 | from .px_util import OxigraphSerialization, string_iterator 16 | 17 | 18 | def hash_query(query: str) -> str: 19 | return hashlib.md5(query.encode("utf8")).hexdigest() 20 | 21 | 22 | def cached_query(query: str, endpoint: str = None): 23 | # Only use the endpoint if specified 24 | if endpoint: 25 | theq = sqlite3.connect(QUERIES_DB).execute( 26 | "SELECT timestamp, result, duration FROM queries WHERE queryhash = ? and endpoint = ? and not result is null ORDER BY timestamp DESC LIMIT 1", 27 | (hash_query(query), endpoint), 28 | ) 29 | else: 30 | theq = sqlite3.connect(QUERIES_DB).execute( 31 | "SELECT timestamp, result, duration FROM queries WHERE queryhash = ? and not result is null ORDER BY timestamp DESC LIMIT 1", 32 | (hash_query(query),), 33 | ) 34 | 35 | for timestamp, result, duration in theq: 36 | result = json.loads(result) 37 | result["timestamp"] = timestamp 38 | result["duration"] = duration 39 | result["cached"] = True 40 | return result 41 | 42 | 43 | def do_query(query: str) -> dict: 44 | to_use = ENDPOINT 45 | 46 | try: 47 | rewritten = fizzysearch.rewrite( 48 | query, 49 | { 50 | "https://fizzysearch.ise.fiz-karlsruhe.de/fts": fizzysearch.use_fts(), 51 | "fizzy:fts": fizzysearch.use_fts(), 52 | "fizzy:ftsStats": fizzysearch.use_fts_stats(), 53 | }, 54 | ) 55 | except Exception as e: 56 | log.exception(f"Problem with fizzysearch: {e}") 57 | return {"error": f"Fizzysearch rewriting error: {e}"} 58 | 59 | shmarql_settings = {} 60 | for comment in rewritten["comments"]: 61 | log.debug(f"fizzysearch SPARQL Comment: {comment}") 62 | if comment.find("shmarql-engine:") > -1: 63 | to_use = ENDPOINTS.get(comment.split(" ")[-1]) 64 | if comment.startswith("shmarql-"): 65 | comment_value = [x.strip(" ") for x in comment[8:].split(":")] 66 | if len(comment_value) > 1: 67 | shmarql_settings.setdefault(comment_value[0], []).append( 68 | ":".join(comment_value[1:]) 69 | ) 70 | 71 | query = rewritten.get("rewritten", query) 72 | 73 | if not to_use: 74 | if len(ENDPOINTS) > 0: 75 | to_use = random.choice(list(ENDPOINTS.values())) 76 | elif len(GRAPH) > 0: 77 | to_use = "__local__" 78 | else: 79 | return {"error": "No endpoint found"} 80 | 81 | if not "nocache" in shmarql_settings: 82 | cached_query_result = cached_query(query) 83 | if cached_query_result: 84 | return cached_query_result 85 | 86 | time_start = time.time() 87 | result = {} 88 | if to_use == "__local__": 89 | try: 90 | qquery = PREFIXES_SNIPPET + "\n" + query 91 | r = GRAPH.query(qquery, use_default_graph_as_union=True) 92 | result = OxigraphSerialization(r).json() 93 | except Exception as e: 94 | return {"error": str(e)} 95 | else: 96 | if rewritten.get("query_type") == "construct": 97 | accept_header = "text/turtle" 98 | else: 99 | accept_header = "application/sparql-results+json" 100 | headers = { 101 | "Accept": accept_header, 102 | "User-Agent": "SHMARQL/2024 (https://shmarql.com/ ep@epoz.org)", 103 | } 104 | 105 | data = { 106 | "query": PREFIXES_SNIPPET + "\n" + query, 107 | } 108 | try: 109 | r = httpx.post(to_use, data=data, headers=headers, timeout=180) 110 | if r.status_code == 200: 111 | try: 112 | result = r.json() 113 | except json.JSONDecodeError: 114 | result = {"data": r.content.decode("utf8")} 115 | elif r.status_code == 500: 116 | return {"error": r.text} 117 | except: 118 | log.exception(f"Problem with {to_use}") 119 | return {"error": "Exception raised querying endpoint"} 120 | 121 | time_end = time.time() 122 | duration = time_end - time_start 123 | 124 | # get the endpoint name from the to_use URL 125 | endpoint_name = "default" 126 | for k, v in ENDPOINTS.items(): 127 | if v == to_use: 128 | endpoint_name = k 129 | 130 | if result: 131 | result["duration"] = duration 132 | result["endpoint_name"] = endpoint_name 133 | result["endpoint"] = to_use 134 | result["shmarql_settings"] = shmarql_settings 135 | 136 | thequerydb = sqlite3.connect(QUERIES_DB) 137 | thequerydb.execute( 138 | "INSERT INTO queries (queryhash, query, timestamp, endpoint, result, duration) VALUES (?, ?, datetime(), ?, ?, ?)", 139 | (hash_query(query), query, to_use, json.dumps(result), duration), 140 | ) 141 | thequerydb.commit() 142 | 143 | return result 144 | else: 145 | return {"error": r.text, "status": r.status_code} 146 | 147 | 148 | def initialize_graph(data_load_paths: list, store_path: str = None) -> px.Store: 149 | log.debug(f"Initialize graph with configs: {data_load_paths} and {store_path}") 150 | store_primary = True 151 | if store_path: 152 | log.debug(f"Opening store from {store_path}") 153 | if data_load_paths: 154 | # If there are multiple workers trying to load at the same time, 155 | # contention for the lock will happen. 156 | # Do a short wait to stagger start times and let one win, the rest will lock and open read_only 157 | time.sleep(random.random() / 2) 158 | try: 159 | GRAPH = px.Store(store_path) 160 | log.debug("This process won the loading contention") 161 | except OSError: 162 | log.debug("Secondary, opening store read-only") 163 | GRAPH = px.Store.secondary(store_path) 164 | store_primary = False 165 | else: 166 | log.debug("Opening store read-only") 167 | GRAPH = px.Store.read_only(store_path) 168 | else: 169 | GRAPH = px.Store() 170 | 171 | if len(GRAPH) < 1 and data_load_paths and store_primary: 172 | for data_load_path in data_load_paths: 173 | if data_load_path.startswith("http://") or data_load_path.startswith( 174 | "https://" 175 | ): 176 | log.debug(f"Downloading {data_load_path}") 177 | # Try downloading this file and parsing it as a string 178 | start_download = time.time() 179 | r = httpx.get(data_load_path, follow_redirects=True, timeout=180) 180 | if r.status_code == 200: 181 | log.debug( 182 | f"Downloading {data_load_path} took {int(time.time() - start_download)} seconds" 183 | ) 184 | d = r.content 185 | # Try and guess content type from extention, default is turtle 186 | # if .rdf or .nt use on of those 187 | if ( 188 | data_load_path.endswith(".rdf") 189 | or data_load_path.endswith(".xml") 190 | or data_load_path.endswith(".owl") 191 | ): 192 | GRAPH.bulk_load(r.content, "application/rdf+xml") 193 | elif data_load_path.endswith(".nt") or data_load_path.endswith( 194 | ".nt.gz" 195 | ): 196 | GRAPH.bulk_load(r.content, "application/n-triples") 197 | else: 198 | GRAPH.bulk_load(r.content, "text/turtle") 199 | else: 200 | if load_file_to_graph(GRAPH, data_load_path): 201 | continue 202 | for dirpath, _, filenames in os.walk(data_load_path): 203 | for filename in filenames: 204 | try: 205 | filepath = os.path.join(dirpath, filename) 206 | load_file_to_graph(GRAPH, filepath) 207 | except SyntaxError: 208 | log.error(f"Failed to parse {filepath}") 209 | 210 | log.debug(f"Graph haz {len(GRAPH)} triples") 211 | 212 | if store_primary and BIKIDATA_DB and not os.path.exists(BIKIDATA_DB): 213 | r = fizzysearch.build_from_iterator(string_iterator(GRAPH)) 214 | log.debug( 215 | f"NEW bikidata built at {BIKIDATA_DB} with {r.get('count', '?')} literals" 216 | ) 217 | 218 | return GRAPH 219 | 220 | 221 | def load_file_to_graph(graph: px.Store, filepath: str) -> bool: 222 | was_file = False 223 | try: 224 | filename = os.path.basename(filepath) 225 | if filename.endswith(".gz"): 226 | filepath = gzip.open(filepath) 227 | filename = filename[:-3] 228 | else: 229 | filepath = open(filepath, "rb") 230 | if filename.lower().endswith(".ttl"): 231 | log.debug(f"Parsing {filepath}") 232 | graph.bulk_load(filepath, "text/turtle") 233 | was_file = True 234 | elif filename.lower().endswith(".nt"): 235 | log.debug(f"Parsing {filepath}") 236 | graph.bulk_load(filepath, "application/n-triples") 237 | was_file = True 238 | except Exception as e: 239 | log.debug(f"{filepath} exception {e}") 240 | return was_file 241 | 242 | 243 | if not (ENDPOINT or len(ENDPOINTS) > 0): 244 | log.debug("No ENDPOINT or ENDPOINTS defined, using local graph") 245 | GRAPH = initialize_graph(DATA_LOAD_PATHS, STORE_PATH) 246 | -------------------------------------------------------------------------------- /src/static/codemirror.css: -------------------------------------------------------------------------------- 1 | /* BASICS */ 2 | 3 | .CodeMirror { 4 | /* Set height, width, borders, and global font properties here */ 5 | font-family: monospace; 6 | height: 400px; 7 | color: black; 8 | direction: ltr; 9 | } 10 | 11 | /* PADDING */ 12 | 13 | .CodeMirror-lines { 14 | padding: 4px 0; /* Vertical padding around content */ 15 | } 16 | .CodeMirror pre.CodeMirror-line, 17 | .CodeMirror pre.CodeMirror-line-like { 18 | padding: 0 4px; /* Horizontal padding of content */ 19 | } 20 | 21 | .CodeMirror-scrollbar-filler, 22 | .CodeMirror-gutter-filler { 23 | background-color: white; /* The little square between H and V scrollbars */ 24 | } 25 | 26 | /* GUTTER */ 27 | 28 | .CodeMirror-gutters { 29 | border-right: 1px solid #ddd; 30 | background-color: #f7f7f7; 31 | white-space: nowrap; 32 | } 33 | .CodeMirror-linenumbers { 34 | } 35 | .CodeMirror-linenumber { 36 | padding: 0 3px 0 5px; 37 | min-width: 20px; 38 | text-align: right; 39 | color: #999; 40 | white-space: nowrap; 41 | } 42 | 43 | .CodeMirror-guttermarker { 44 | color: black; 45 | } 46 | .CodeMirror-guttermarker-subtle { 47 | color: #999; 48 | } 49 | 50 | /* CURSOR */ 51 | 52 | .CodeMirror-cursor { 53 | border-left: 1px solid black; 54 | border-right: none; 55 | width: 0; 56 | } 57 | /* Shown when moving in bi-directional text */ 58 | .CodeMirror div.CodeMirror-secondarycursor { 59 | border-left: 1px solid silver; 60 | } 61 | .cm-fat-cursor .CodeMirror-cursor { 62 | width: auto; 63 | border: 0 !important; 64 | background: #7e7; 65 | } 66 | .cm-fat-cursor div.CodeMirror-cursors { 67 | z-index: 1; 68 | } 69 | .cm-fat-cursor .CodeMirror-line::selection, 70 | .cm-fat-cursor .CodeMirror-line > span::selection, 71 | .cm-fat-cursor .CodeMirror-line > span > span::selection { 72 | background: transparent; 73 | } 74 | .cm-fat-cursor .CodeMirror-line::-moz-selection, 75 | .cm-fat-cursor .CodeMirror-line > span::-moz-selection, 76 | .cm-fat-cursor .CodeMirror-line > span > span::-moz-selection { 77 | background: transparent; 78 | } 79 | .cm-fat-cursor { 80 | caret-color: transparent; 81 | } 82 | @-moz-keyframes blink { 83 | 0% { 84 | } 85 | 50% { 86 | background-color: transparent; 87 | } 88 | 100% { 89 | } 90 | } 91 | @-webkit-keyframes blink { 92 | 0% { 93 | } 94 | 50% { 95 | background-color: transparent; 96 | } 97 | 100% { 98 | } 99 | } 100 | @keyframes blink { 101 | 0% { 102 | } 103 | 50% { 104 | background-color: transparent; 105 | } 106 | 100% { 107 | } 108 | } 109 | 110 | /* Can style cursor different in overwrite (non-insert) mode */ 111 | .CodeMirror-overwrite .CodeMirror-cursor { 112 | } 113 | 114 | .cm-tab { 115 | display: inline-block; 116 | text-decoration: inherit; 117 | } 118 | 119 | .CodeMirror-rulers { 120 | position: absolute; 121 | left: 0; 122 | right: 0; 123 | top: -50px; 124 | bottom: 0; 125 | overflow: hidden; 126 | } 127 | .CodeMirror-ruler { 128 | border-left: 1px solid #ccc; 129 | top: 0; 130 | bottom: 0; 131 | position: absolute; 132 | } 133 | 134 | /* DEFAULT THEME */ 135 | 136 | .cm-s-default .cm-header { 137 | color: blue; 138 | } 139 | .cm-s-default .cm-quote { 140 | color: #090; 141 | } 142 | .cm-negative { 143 | color: #d44; 144 | } 145 | .cm-positive { 146 | color: #292; 147 | } 148 | .cm-header, 149 | .cm-strong { 150 | font-weight: bold; 151 | } 152 | .cm-em { 153 | font-style: italic; 154 | } 155 | .cm-link { 156 | text-decoration: underline; 157 | } 158 | .cm-strikethrough { 159 | text-decoration: line-through; 160 | } 161 | 162 | .cm-s-default .cm-keyword { 163 | color: #708; 164 | } 165 | .cm-s-default .cm-atom { 166 | color: #219; 167 | } 168 | .cm-s-default .cm-number { 169 | color: #164; 170 | } 171 | .cm-s-default .cm-def { 172 | color: #00f; 173 | } 174 | .cm-s-default .cm-variable, 175 | .cm-s-default .cm-punctuation, 176 | .cm-s-default .cm-property, 177 | .cm-s-default .cm-operator { 178 | } 179 | .cm-s-default .cm-variable-2 { 180 | color: #05a; 181 | } 182 | .cm-s-default .cm-variable-3, 183 | .cm-s-default .cm-type { 184 | color: #085; 185 | } 186 | .cm-s-default .cm-comment { 187 | color: #a50; 188 | } 189 | .cm-s-default .cm-string { 190 | color: #a11; 191 | } 192 | .cm-s-default .cm-string-2 { 193 | color: #f50; 194 | } 195 | .cm-s-default .cm-meta { 196 | color: #555; 197 | } 198 | .cm-s-default .cm-qualifier { 199 | color: #555; 200 | } 201 | .cm-s-default .cm-builtin { 202 | color: #30a; 203 | } 204 | .cm-s-default .cm-bracket { 205 | color: #997; 206 | } 207 | .cm-s-default .cm-tag { 208 | color: #170; 209 | } 210 | .cm-s-default .cm-attribute { 211 | color: #00c; 212 | } 213 | .cm-s-default .cm-hr { 214 | color: #999; 215 | } 216 | .cm-s-default .cm-link { 217 | color: #00c; 218 | } 219 | 220 | .cm-s-default .cm-error { 221 | color: #f00; 222 | } 223 | .cm-invalidchar { 224 | color: #f00; 225 | } 226 | 227 | .CodeMirror-composing { 228 | border-bottom: 2px solid; 229 | } 230 | 231 | /* Default styles for common addons */ 232 | 233 | div.CodeMirror span.CodeMirror-matchingbracket { 234 | color: #0b0; 235 | } 236 | div.CodeMirror span.CodeMirror-nonmatchingbracket { 237 | color: #a22; 238 | } 239 | .CodeMirror-matchingtag { 240 | background: rgba(255, 150, 0, 0.3); 241 | } 242 | .CodeMirror-activeline-background { 243 | background: #e8f2ff; 244 | } 245 | 246 | /* STOP */ 247 | 248 | /* The rest of this file contains styles related to the mechanics of 249 | the editor. You probably shouldn't touch them. */ 250 | 251 | .CodeMirror { 252 | position: relative; 253 | overflow: hidden; 254 | background: white; 255 | } 256 | 257 | .CodeMirror-scroll { 258 | overflow: scroll !important; /* Things will break if this is overridden */ 259 | /* 50px is the magic margin used to hide the element's real scrollbars */ 260 | /* See overflow: hidden in .CodeMirror */ 261 | margin-bottom: -50px; 262 | margin-right: -50px; 263 | padding-bottom: 50px; 264 | height: 100%; 265 | outline: none; /* Prevent dragging from highlighting the element */ 266 | position: relative; 267 | z-index: 0; 268 | } 269 | .CodeMirror-sizer { 270 | position: relative; 271 | border-right: 50px solid transparent; 272 | } 273 | 274 | /* The fake, visible scrollbars. Used to force redraw during scrolling 275 | before actual scrolling happens, thus preventing shaking and 276 | flickering artifacts. */ 277 | .CodeMirror-vscrollbar, 278 | .CodeMirror-hscrollbar, 279 | .CodeMirror-scrollbar-filler, 280 | .CodeMirror-gutter-filler { 281 | position: absolute; 282 | z-index: 6; 283 | display: none; 284 | outline: none; 285 | } 286 | .CodeMirror-vscrollbar { 287 | right: 0; 288 | top: 0; 289 | overflow-x: hidden; 290 | overflow-y: scroll; 291 | } 292 | .CodeMirror-hscrollbar { 293 | bottom: 0; 294 | left: 0; 295 | overflow-y: hidden; 296 | overflow-x: scroll; 297 | } 298 | .CodeMirror-scrollbar-filler { 299 | right: 0; 300 | bottom: 0; 301 | } 302 | .CodeMirror-gutter-filler { 303 | left: 0; 304 | bottom: 0; 305 | } 306 | 307 | .CodeMirror-gutters { 308 | position: absolute; 309 | left: 0; 310 | top: 0; 311 | min-height: 100%; 312 | z-index: 3; 313 | } 314 | .CodeMirror-gutter { 315 | white-space: normal; 316 | height: 100%; 317 | display: inline-block; 318 | vertical-align: top; 319 | margin-bottom: -50px; 320 | } 321 | .CodeMirror-gutter-wrapper { 322 | position: absolute; 323 | z-index: 4; 324 | background: none !important; 325 | border: none !important; 326 | } 327 | .CodeMirror-gutter-background { 328 | position: absolute; 329 | top: 0; 330 | bottom: 0; 331 | z-index: 4; 332 | } 333 | .CodeMirror-gutter-elt { 334 | position: absolute; 335 | cursor: default; 336 | z-index: 4; 337 | } 338 | .CodeMirror-gutter-wrapper ::selection { 339 | background-color: transparent; 340 | } 341 | .CodeMirror-gutter-wrapper ::-moz-selection { 342 | background-color: transparent; 343 | } 344 | 345 | .CodeMirror-lines { 346 | cursor: text; 347 | min-height: 1px; /* prevents collapsing before first draw */ 348 | } 349 | .CodeMirror pre.CodeMirror-line, 350 | .CodeMirror pre.CodeMirror-line-like { 351 | /* Reset some styles that the rest of the page might have set */ 352 | -moz-border-radius: 0; 353 | -webkit-border-radius: 0; 354 | border-radius: 0; 355 | border-width: 0; 356 | background: transparent; 357 | font-family: inherit; 358 | font-size: 14pt; 359 | margin: 0; 360 | white-space: pre; 361 | word-wrap: normal; 362 | line-height: inherit; 363 | color: inherit; 364 | z-index: 2; 365 | position: relative; 366 | overflow: visible; 367 | -webkit-tap-highlight-color: transparent; 368 | -webkit-font-variant-ligatures: contextual; 369 | font-variant-ligatures: contextual; 370 | } 371 | .CodeMirror-wrap pre.CodeMirror-line, 372 | .CodeMirror-wrap pre.CodeMirror-line-like { 373 | word-wrap: break-word; 374 | white-space: pre-wrap; 375 | word-break: normal; 376 | } 377 | 378 | .CodeMirror-linebackground { 379 | position: absolute; 380 | left: 0; 381 | right: 0; 382 | top: 0; 383 | bottom: 0; 384 | z-index: 0; 385 | } 386 | 387 | .CodeMirror-linewidget { 388 | position: relative; 389 | z-index: 2; 390 | padding: 0.1px; /* Force widget margins to stay inside of the container */ 391 | } 392 | 393 | .CodeMirror-widget { 394 | } 395 | 396 | .CodeMirror-rtl pre { 397 | direction: rtl; 398 | } 399 | 400 | .CodeMirror-code { 401 | outline: none; 402 | } 403 | 404 | /* Force content-box sizing for the elements where we expect it */ 405 | .CodeMirror-scroll, 406 | .CodeMirror-sizer, 407 | .CodeMirror-gutter, 408 | .CodeMirror-gutters, 409 | .CodeMirror-linenumber { 410 | -moz-box-sizing: content-box; 411 | box-sizing: content-box; 412 | font-size: 14pt; 413 | } 414 | 415 | .CodeMirror-measure { 416 | position: absolute; 417 | width: 100%; 418 | height: 0; 419 | overflow: hidden; 420 | visibility: hidden; 421 | } 422 | 423 | .CodeMirror-cursor { 424 | position: absolute; 425 | pointer-events: none; 426 | } 427 | .CodeMirror-measure pre { 428 | position: static; 429 | } 430 | 431 | div.CodeMirror-cursors { 432 | visibility: hidden; 433 | position: relative; 434 | z-index: 3; 435 | } 436 | div.CodeMirror-dragcursors { 437 | visibility: visible; 438 | } 439 | 440 | .CodeMirror-focused div.CodeMirror-cursors { 441 | visibility: visible; 442 | } 443 | 444 | .CodeMirror-selected { 445 | background: #d9d9d9; 446 | } 447 | .CodeMirror-focused .CodeMirror-selected { 448 | background: #d7d4f0; 449 | } 450 | .CodeMirror-crosshair { 451 | cursor: crosshair; 452 | } 453 | .CodeMirror-line::selection, 454 | .CodeMirror-line > span::selection, 455 | .CodeMirror-line > span > span::selection { 456 | background: #d7d4f0; 457 | } 458 | .CodeMirror-line::-moz-selection, 459 | .CodeMirror-line > span::-moz-selection, 460 | .CodeMirror-line > span > span::-moz-selection { 461 | background: #d7d4f0; 462 | } 463 | 464 | .cm-searching { 465 | background-color: #ffa; 466 | background-color: rgba(255, 255, 0, 0.4); 467 | } 468 | 469 | /* Used to force a border model for a node */ 470 | .cm-force-border { 471 | padding-right: 0.1px; 472 | } 473 | 474 | @media print { 475 | /* Hide the cursor when printing */ 476 | .CodeMirror div.CodeMirror-cursors { 477 | visibility: hidden; 478 | } 479 | } 480 | 481 | /* See issue #2901 */ 482 | .cm-tab-wrap-hack:after { 483 | content: ""; 484 | } 485 | 486 | /* Help users use markselection to safely style text background */ 487 | span.CodeMirror-selectedtext { 488 | background: none; 489 | } 490 | -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epoz/shmarql/43f5c40931d74fbed9eef3415f7978ab0fecf286/src/static/favicon.ico -------------------------------------------------------------------------------- /src/static/matchbrackets.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: https://codemirror.net/LICENSE 3 | 4 | (function(mod) { 5 | if (typeof exports == "object" && typeof module == "object") // CommonJS 6 | mod(require("../../lib/codemirror")); 7 | else if (typeof define == "function" && define.amd) // AMD 8 | define(["../../lib/codemirror"], mod); 9 | else // Plain browser env 10 | mod(CodeMirror); 11 | })(function(CodeMirror) { 12 | var ie_lt8 = /MSIE \d/.test(navigator.userAgent) && 13 | (document.documentMode == null || document.documentMode < 8); 14 | 15 | var Pos = CodeMirror.Pos; 16 | 17 | var matching = {"(": ")>", ")": "(<", "[": "]>", "]": "[<", "{": "}>", "}": "{<", "<": ">>", ">": "<<"}; 18 | 19 | function bracketRegex(config) { 20 | return config && config.bracketRegex || /[(){}[\]]/ 21 | } 22 | 23 | function findMatchingBracket(cm, where, config) { 24 | var line = cm.getLineHandle(where.line), pos = where.ch - 1; 25 | var afterCursor = config && config.afterCursor 26 | if (afterCursor == null) 27 | afterCursor = /(^| )cm-fat-cursor($| )/.test(cm.getWrapperElement().className) 28 | var re = bracketRegex(config) 29 | 30 | // A cursor is defined as between two characters, but in in vim command mode 31 | // (i.e. not insert mode), the cursor is visually represented as a 32 | // highlighted box on top of the 2nd character. Otherwise, we allow matches 33 | // from before or after the cursor. 34 | var match = (!afterCursor && pos >= 0 && re.test(line.text.charAt(pos)) && matching[line.text.charAt(pos)]) || 35 | re.test(line.text.charAt(pos + 1)) && matching[line.text.charAt(++pos)]; 36 | if (!match) return null; 37 | var dir = match.charAt(1) == ">" ? 1 : -1; 38 | if (config && config.strict && (dir > 0) != (pos == where.ch)) return null; 39 | var style = cm.getTokenTypeAt(Pos(where.line, pos + 1)); 40 | 41 | var found = scanForBracket(cm, Pos(where.line, pos + (dir > 0 ? 1 : 0)), dir, style, config); 42 | if (found == null) return null; 43 | return {from: Pos(where.line, pos), to: found && found.pos, 44 | match: found && found.ch == match.charAt(0), forward: dir > 0}; 45 | } 46 | 47 | // bracketRegex is used to specify which type of bracket to scan 48 | // should be a regexp, e.g. /[[\]]/ 49 | // 50 | // Note: If "where" is on an open bracket, then this bracket is ignored. 51 | // 52 | // Returns false when no bracket was found, null when it reached 53 | // maxScanLines and gave up 54 | function scanForBracket(cm, where, dir, style, config) { 55 | var maxScanLen = (config && config.maxScanLineLength) || 10000; 56 | var maxScanLines = (config && config.maxScanLines) || 1000; 57 | 58 | var stack = []; 59 | var re = bracketRegex(config) 60 | var lineEnd = dir > 0 ? Math.min(where.line + maxScanLines, cm.lastLine() + 1) 61 | : Math.max(cm.firstLine() - 1, where.line - maxScanLines); 62 | for (var lineNo = where.line; lineNo != lineEnd; lineNo += dir) { 63 | var line = cm.getLine(lineNo); 64 | if (!line) continue; 65 | var pos = dir > 0 ? 0 : line.length - 1, end = dir > 0 ? line.length : -1; 66 | if (line.length > maxScanLen) continue; 67 | if (lineNo == where.line) pos = where.ch - (dir < 0 ? 1 : 0); 68 | for (; pos != end; pos += dir) { 69 | var ch = line.charAt(pos); 70 | if (re.test(ch) && (style === undefined || 71 | (cm.getTokenTypeAt(Pos(lineNo, pos + 1)) || "") == (style || ""))) { 72 | var match = matching[ch]; 73 | if (match && (match.charAt(1) == ">") == (dir > 0)) stack.push(ch); 74 | else if (!stack.length) return {pos: Pos(lineNo, pos), ch: ch}; 75 | else stack.pop(); 76 | } 77 | } 78 | } 79 | return lineNo - dir == (dir > 0 ? cm.lastLine() : cm.firstLine()) ? false : null; 80 | } 81 | 82 | function matchBrackets(cm, autoclear, config) { 83 | // Disable brace matching in long lines, since it'll cause hugely slow updates 84 | var maxHighlightLen = cm.state.matchBrackets.maxHighlightLineLength || 1000, 85 | highlightNonMatching = config && config.highlightNonMatching; 86 | var marks = [], ranges = cm.listSelections(); 87 | for (var i = 0; i < ranges.length; i++) { 88 | var match = ranges[i].empty() && findMatchingBracket(cm, ranges[i].head, config); 89 | if (match && (match.match || highlightNonMatching !== false) && cm.getLine(match.from.line).length <= maxHighlightLen) { 90 | var style = match.match ? "CodeMirror-matchingbracket" : "CodeMirror-nonmatchingbracket"; 91 | marks.push(cm.markText(match.from, Pos(match.from.line, match.from.ch + 1), {className: style})); 92 | if (match.to && cm.getLine(match.to.line).length <= maxHighlightLen) 93 | marks.push(cm.markText(match.to, Pos(match.to.line, match.to.ch + 1), {className: style})); 94 | } 95 | } 96 | 97 | if (marks.length) { 98 | // Kludge to work around the IE bug from issue #1193, where text 99 | // input stops going to the textarea whenever this fires. 100 | if (ie_lt8 && cm.state.focused) cm.focus(); 101 | 102 | var clear = function() { 103 | cm.operation(function() { 104 | for (var i = 0; i < marks.length; i++) marks[i].clear(); 105 | }); 106 | }; 107 | if (autoclear) setTimeout(clear, 800); 108 | else return clear; 109 | } 110 | } 111 | 112 | function doMatchBrackets(cm) { 113 | cm.operation(function() { 114 | if (cm.state.matchBrackets.currentlyHighlighted) { 115 | cm.state.matchBrackets.currentlyHighlighted(); 116 | cm.state.matchBrackets.currentlyHighlighted = null; 117 | } 118 | cm.state.matchBrackets.currentlyHighlighted = matchBrackets(cm, false, cm.state.matchBrackets); 119 | }); 120 | } 121 | 122 | function clearHighlighted(cm) { 123 | if (cm.state.matchBrackets && cm.state.matchBrackets.currentlyHighlighted) { 124 | cm.state.matchBrackets.currentlyHighlighted(); 125 | cm.state.matchBrackets.currentlyHighlighted = null; 126 | } 127 | } 128 | 129 | CodeMirror.defineOption("matchBrackets", false, function(cm, val, old) { 130 | if (old && old != CodeMirror.Init) { 131 | cm.off("cursorActivity", doMatchBrackets); 132 | cm.off("focus", doMatchBrackets) 133 | cm.off("blur", clearHighlighted) 134 | clearHighlighted(cm); 135 | } 136 | if (val) { 137 | cm.state.matchBrackets = typeof val == "object" ? val : {}; 138 | cm.on("cursorActivity", doMatchBrackets); 139 | cm.on("focus", doMatchBrackets) 140 | cm.on("blur", clearHighlighted) 141 | } 142 | }); 143 | 144 | CodeMirror.defineExtension("matchBrackets", function() {matchBrackets(this, true);}); 145 | CodeMirror.defineExtension("findMatchingBracket", function(pos, config, oldConfig){ 146 | // Backwards-compatibility kludge 147 | if (oldConfig || typeof config == "boolean") { 148 | if (!oldConfig) { 149 | config = config ? {strict: true} : null 150 | } else { 151 | oldConfig.strict = config 152 | config = oldConfig 153 | } 154 | } 155 | return findMatchingBracket(this, pos, config) 156 | }); 157 | CodeMirror.defineExtension("scanForBracket", function(pos, dir, style, config){ 158 | return scanForBracket(this, pos, dir, style, config); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /src/static/shmarql.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | 7. Disable tap highlights on iOS 36 | */ 37 | 38 | html, 39 | :host { 40 | line-height: 1.5; 41 | /* 1 */ 42 | -webkit-text-size-adjust: 100%; 43 | /* 2 */ 44 | -moz-tab-size: 4; 45 | /* 3 */ 46 | -o-tab-size: 4; 47 | tab-size: 4; 48 | /* 3 */ 49 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 50 | /* 4 */ 51 | font-feature-settings: normal; 52 | /* 5 */ 53 | font-variation-settings: normal; 54 | /* 6 */ 55 | -webkit-tap-highlight-color: transparent; 56 | /* 7 */ 57 | } 58 | 59 | /* 60 | 1. Remove the margin in all browsers. 61 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 62 | */ 63 | 64 | body { 65 | margin: 0; 66 | /* 1 */ 67 | line-height: inherit; 68 | /* 2 */ 69 | } 70 | 71 | /* 72 | 1. Add the correct height in Firefox. 73 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 74 | 3. Ensure horizontal rules are visible by default. 75 | */ 76 | 77 | hr { 78 | height: 0; 79 | /* 1 */ 80 | color: inherit; 81 | /* 2 */ 82 | border-top-width: 1px; 83 | /* 3 */ 84 | } 85 | 86 | /* 87 | Add the correct text decoration in Chrome, Edge, and Safari. 88 | */ 89 | 90 | abbr:where([title]) { 91 | -webkit-text-decoration: underline dotted; 92 | text-decoration: underline dotted; 93 | } 94 | 95 | /* 96 | Remove the default font size and weight for headings. 97 | */ 98 | 99 | h1, 100 | h2, 101 | h3, 102 | h4, 103 | h5, 104 | h6 { 105 | font-size: inherit; 106 | font-weight: inherit; 107 | } 108 | 109 | /* 110 | Reset links to optimize for opt-in styling instead of opt-out. 111 | */ 112 | 113 | a { 114 | color: inherit; 115 | text-decoration: inherit; 116 | } 117 | 118 | /* 119 | Add the correct font weight in Edge and Safari. 120 | */ 121 | 122 | b, 123 | strong { 124 | font-weight: bolder; 125 | } 126 | 127 | /* 128 | 1. Use the user's configured `mono` font-family by default. 129 | 2. Use the user's configured `mono` font-feature-settings by default. 130 | 3. Use the user's configured `mono` font-variation-settings by default. 131 | 4. Correct the odd `em` font sizing in all browsers. 132 | */ 133 | 134 | code, 135 | kbd, 136 | samp, 137 | pre { 138 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 139 | /* 1 */ 140 | font-feature-settings: normal; 141 | /* 2 */ 142 | font-variation-settings: normal; 143 | /* 3 */ 144 | font-size: 1em; 145 | /* 4 */ 146 | } 147 | 148 | /* 149 | Add the correct font size in all browsers. 150 | */ 151 | 152 | small { 153 | font-size: 80%; 154 | } 155 | 156 | /* 157 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 158 | */ 159 | 160 | sub, 161 | sup { 162 | font-size: 75%; 163 | line-height: 0; 164 | position: relative; 165 | vertical-align: baseline; 166 | } 167 | 168 | sub { 169 | bottom: -0.25em; 170 | } 171 | 172 | sup { 173 | top: -0.5em; 174 | } 175 | 176 | /* 177 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 178 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 179 | 3. Remove gaps between table borders by default. 180 | */ 181 | 182 | table { 183 | text-indent: 0; 184 | /* 1 */ 185 | border-color: inherit; 186 | /* 2 */ 187 | border-collapse: collapse; 188 | /* 3 */ 189 | } 190 | 191 | /* 192 | 1. Change the font styles in all browsers. 193 | 2. Remove the margin in Firefox and Safari. 194 | 3. Remove default padding in all browsers. 195 | */ 196 | 197 | button, 198 | input, 199 | optgroup, 200 | select, 201 | textarea { 202 | font-family: inherit; 203 | /* 1 */ 204 | font-feature-settings: inherit; 205 | /* 1 */ 206 | font-variation-settings: inherit; 207 | /* 1 */ 208 | font-size: 100%; 209 | /* 1 */ 210 | font-weight: inherit; 211 | /* 1 */ 212 | line-height: inherit; 213 | /* 1 */ 214 | letter-spacing: inherit; 215 | /* 1 */ 216 | color: inherit; 217 | /* 1 */ 218 | margin: 0; 219 | /* 2 */ 220 | padding: 0; 221 | /* 3 */ 222 | } 223 | 224 | /* 225 | Remove the inheritance of text transform in Edge and Firefox. 226 | */ 227 | 228 | button, 229 | select { 230 | text-transform: none; 231 | } 232 | 233 | /* 234 | 1. Correct the inability to style clickable types in iOS and Safari. 235 | 2. Remove default button styles. 236 | */ 237 | 238 | button, 239 | input:where([type='button']), 240 | input:where([type='reset']), 241 | input:where([type='submit']) { 242 | -webkit-appearance: button; 243 | /* 1 */ 244 | background-color: transparent; 245 | /* 2 */ 246 | background-image: none; 247 | /* 2 */ 248 | } 249 | 250 | /* 251 | Use the modern Firefox focus style for all focusable elements. 252 | */ 253 | 254 | :-moz-focusring { 255 | outline: auto; 256 | } 257 | 258 | /* 259 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 260 | */ 261 | 262 | :-moz-ui-invalid { 263 | box-shadow: none; 264 | } 265 | 266 | /* 267 | Add the correct vertical alignment in Chrome and Firefox. 268 | */ 269 | 270 | progress { 271 | vertical-align: baseline; 272 | } 273 | 274 | /* 275 | Correct the cursor style of increment and decrement buttons in Safari. 276 | */ 277 | 278 | ::-webkit-inner-spin-button, 279 | ::-webkit-outer-spin-button { 280 | height: auto; 281 | } 282 | 283 | /* 284 | 1. Correct the odd appearance in Chrome and Safari. 285 | 2. Correct the outline style in Safari. 286 | */ 287 | 288 | [type='search'] { 289 | -webkit-appearance: textfield; 290 | /* 1 */ 291 | outline-offset: -2px; 292 | /* 2 */ 293 | } 294 | 295 | /* 296 | Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | ::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /* 304 | 1. Correct the inability to style clickable types in iOS and Safari. 305 | 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; 310 | /* 1 */ 311 | font: inherit; 312 | /* 2 */ 313 | } 314 | 315 | /* 316 | Add the correct display in Chrome and Safari. 317 | */ 318 | 319 | summary { 320 | display: list-item; 321 | } 322 | 323 | /* 324 | Removes the default spacing and border for appropriate elements. 325 | */ 326 | 327 | blockquote, 328 | dl, 329 | dd, 330 | h1, 331 | h2, 332 | h3, 333 | h4, 334 | h5, 335 | h6, 336 | hr, 337 | figure, 338 | p, 339 | pre { 340 | margin: 0; 341 | } 342 | 343 | fieldset { 344 | margin: 0; 345 | padding: 0; 346 | } 347 | 348 | legend { 349 | padding: 0; 350 | } 351 | 352 | ol, 353 | ul, 354 | menu { 355 | list-style: none; 356 | margin: 0; 357 | padding: 0; 358 | } 359 | 360 | /* 361 | Reset default styling for dialogs. 362 | */ 363 | 364 | dialog { 365 | padding: 0; 366 | } 367 | 368 | /* 369 | Prevent resizing textareas horizontally by default. 370 | */ 371 | 372 | textarea { 373 | resize: vertical; 374 | } 375 | 376 | /* 377 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 378 | 2. Set the default placeholder color to the user's configured gray 400 color. 379 | */ 380 | 381 | input::-moz-placeholder, textarea::-moz-placeholder { 382 | opacity: 1; 383 | /* 1 */ 384 | color: #9ca3af; 385 | /* 2 */ 386 | } 387 | 388 | input::placeholder, 389 | textarea::placeholder { 390 | opacity: 1; 391 | /* 1 */ 392 | color: #9ca3af; 393 | /* 2 */ 394 | } 395 | 396 | /* 397 | Set the default cursor for buttons. 398 | */ 399 | 400 | button, 401 | [role="button"] { 402 | cursor: pointer; 403 | } 404 | 405 | /* 406 | Make sure disabled buttons don't get the pointer cursor. 407 | */ 408 | 409 | :disabled { 410 | cursor: default; 411 | } 412 | 413 | /* 414 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 415 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 416 | This can trigger a poorly considered lint error in some tools but is included by design. 417 | */ 418 | 419 | img, 420 | svg, 421 | video, 422 | canvas, 423 | audio, 424 | iframe, 425 | embed, 426 | object { 427 | display: block; 428 | /* 1 */ 429 | vertical-align: middle; 430 | /* 2 */ 431 | } 432 | 433 | /* 434 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 435 | */ 436 | 437 | img, 438 | video { 439 | max-width: 100%; 440 | height: auto; 441 | } 442 | 443 | /* Make elements with the HTML hidden attribute stay hidden by default */ 444 | 445 | [hidden] { 446 | display: none; 447 | } 448 | 449 | *, ::before, ::after { 450 | --tw-border-spacing-x: 0; 451 | --tw-border-spacing-y: 0; 452 | --tw-translate-x: 0; 453 | --tw-translate-y: 0; 454 | --tw-rotate: 0; 455 | --tw-skew-x: 0; 456 | --tw-skew-y: 0; 457 | --tw-scale-x: 1; 458 | --tw-scale-y: 1; 459 | --tw-pan-x: ; 460 | --tw-pan-y: ; 461 | --tw-pinch-zoom: ; 462 | --tw-scroll-snap-strictness: proximity; 463 | --tw-gradient-from-position: ; 464 | --tw-gradient-via-position: ; 465 | --tw-gradient-to-position: ; 466 | --tw-ordinal: ; 467 | --tw-slashed-zero: ; 468 | --tw-numeric-figure: ; 469 | --tw-numeric-spacing: ; 470 | --tw-numeric-fraction: ; 471 | --tw-ring-inset: ; 472 | --tw-ring-offset-width: 0px; 473 | --tw-ring-offset-color: #fff; 474 | --tw-ring-color: rgb(59 130 246 / 0.5); 475 | --tw-ring-offset-shadow: 0 0 #0000; 476 | --tw-ring-shadow: 0 0 #0000; 477 | --tw-shadow: 0 0 #0000; 478 | --tw-shadow-colored: 0 0 #0000; 479 | --tw-blur: ; 480 | --tw-brightness: ; 481 | --tw-contrast: ; 482 | --tw-grayscale: ; 483 | --tw-hue-rotate: ; 484 | --tw-invert: ; 485 | --tw-saturate: ; 486 | --tw-sepia: ; 487 | --tw-drop-shadow: ; 488 | --tw-backdrop-blur: ; 489 | --tw-backdrop-brightness: ; 490 | --tw-backdrop-contrast: ; 491 | --tw-backdrop-grayscale: ; 492 | --tw-backdrop-hue-rotate: ; 493 | --tw-backdrop-invert: ; 494 | --tw-backdrop-opacity: ; 495 | --tw-backdrop-saturate: ; 496 | --tw-backdrop-sepia: ; 497 | --tw-contain-size: ; 498 | --tw-contain-layout: ; 499 | --tw-contain-paint: ; 500 | --tw-contain-style: ; 501 | } 502 | 503 | ::backdrop { 504 | --tw-border-spacing-x: 0; 505 | --tw-border-spacing-y: 0; 506 | --tw-translate-x: 0; 507 | --tw-translate-y: 0; 508 | --tw-rotate: 0; 509 | --tw-skew-x: 0; 510 | --tw-skew-y: 0; 511 | --tw-scale-x: 1; 512 | --tw-scale-y: 1; 513 | --tw-pan-x: ; 514 | --tw-pan-y: ; 515 | --tw-pinch-zoom: ; 516 | --tw-scroll-snap-strictness: proximity; 517 | --tw-gradient-from-position: ; 518 | --tw-gradient-via-position: ; 519 | --tw-gradient-to-position: ; 520 | --tw-ordinal: ; 521 | --tw-slashed-zero: ; 522 | --tw-numeric-figure: ; 523 | --tw-numeric-spacing: ; 524 | --tw-numeric-fraction: ; 525 | --tw-ring-inset: ; 526 | --tw-ring-offset-width: 0px; 527 | --tw-ring-offset-color: #fff; 528 | --tw-ring-color: rgb(59 130 246 / 0.5); 529 | --tw-ring-offset-shadow: 0 0 #0000; 530 | --tw-ring-shadow: 0 0 #0000; 531 | --tw-shadow: 0 0 #0000; 532 | --tw-shadow-colored: 0 0 #0000; 533 | --tw-blur: ; 534 | --tw-brightness: ; 535 | --tw-contrast: ; 536 | --tw-grayscale: ; 537 | --tw-hue-rotate: ; 538 | --tw-invert: ; 539 | --tw-saturate: ; 540 | --tw-sepia: ; 541 | --tw-drop-shadow: ; 542 | --tw-backdrop-blur: ; 543 | --tw-backdrop-brightness: ; 544 | --tw-backdrop-contrast: ; 545 | --tw-backdrop-grayscale: ; 546 | --tw-backdrop-hue-rotate: ; 547 | --tw-backdrop-invert: ; 548 | --tw-backdrop-opacity: ; 549 | --tw-backdrop-saturate: ; 550 | --tw-backdrop-sepia: ; 551 | --tw-contain-size: ; 552 | --tw-contain-layout: ; 553 | --tw-contain-paint: ; 554 | --tw-contain-style: ; 555 | } 556 | 557 | .prose { 558 | color: var(--tw-prose-body); 559 | max-width: 65ch; 560 | } 561 | 562 | .prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) { 563 | color: var(--tw-prose-lead); 564 | font-size: 1.25em; 565 | line-height: 1.6; 566 | margin-top: 1.2em; 567 | margin-bottom: 1.2em; 568 | } 569 | 570 | .prose :where(a):not(:where([class~="not-prose"] *)) { 571 | color: var(--tw-prose-links); 572 | text-decoration: underline; 573 | font-weight: 500; 574 | } 575 | 576 | .prose :where(strong):not(:where([class~="not-prose"] *)) { 577 | color: var(--tw-prose-bold); 578 | font-weight: 600; 579 | } 580 | 581 | .prose :where(a strong):not(:where([class~="not-prose"] *)) { 582 | color: inherit; 583 | } 584 | 585 | .prose :where(blockquote strong):not(:where([class~="not-prose"] *)) { 586 | color: inherit; 587 | } 588 | 589 | .prose :where(thead th strong):not(:where([class~="not-prose"] *)) { 590 | color: inherit; 591 | } 592 | 593 | .prose :where(ol):not(:where([class~="not-prose"] *)) { 594 | list-style-type: decimal; 595 | margin-top: 1.25em; 596 | margin-bottom: 1.25em; 597 | padding-left: 1.625em; 598 | } 599 | 600 | .prose :where(ol[type="A"]):not(:where([class~="not-prose"] *)) { 601 | list-style-type: upper-alpha; 602 | } 603 | 604 | .prose :where(ol[type="a"]):not(:where([class~="not-prose"] *)) { 605 | list-style-type: lower-alpha; 606 | } 607 | 608 | .prose :where(ol[type="A" s]):not(:where([class~="not-prose"] *)) { 609 | list-style-type: upper-alpha; 610 | } 611 | 612 | .prose :where(ol[type="a" s]):not(:where([class~="not-prose"] *)) { 613 | list-style-type: lower-alpha; 614 | } 615 | 616 | .prose :where(ol[type="I"]):not(:where([class~="not-prose"] *)) { 617 | list-style-type: upper-roman; 618 | } 619 | 620 | .prose :where(ol[type="i"]):not(:where([class~="not-prose"] *)) { 621 | list-style-type: lower-roman; 622 | } 623 | 624 | .prose :where(ol[type="I" s]):not(:where([class~="not-prose"] *)) { 625 | list-style-type: upper-roman; 626 | } 627 | 628 | .prose :where(ol[type="i" s]):not(:where([class~="not-prose"] *)) { 629 | list-style-type: lower-roman; 630 | } 631 | 632 | .prose :where(ol[type="1"]):not(:where([class~="not-prose"] *)) { 633 | list-style-type: decimal; 634 | } 635 | 636 | .prose :where(ul):not(:where([class~="not-prose"] *)) { 637 | list-style-type: disc; 638 | margin-top: 1.25em; 639 | margin-bottom: 1.25em; 640 | padding-left: 1.625em; 641 | } 642 | 643 | .prose :where(ol > li):not(:where([class~="not-prose"] *))::marker { 644 | font-weight: 400; 645 | color: var(--tw-prose-counters); 646 | } 647 | 648 | .prose :where(ul > li):not(:where([class~="not-prose"] *))::marker { 649 | color: var(--tw-prose-bullets); 650 | } 651 | 652 | .prose :where(hr):not(:where([class~="not-prose"] *)) { 653 | border-color: var(--tw-prose-hr); 654 | border-top-width: 1px; 655 | margin-top: 3em; 656 | margin-bottom: 3em; 657 | } 658 | 659 | .prose :where(blockquote):not(:where([class~="not-prose"] *)) { 660 | font-weight: 500; 661 | font-style: italic; 662 | color: var(--tw-prose-quotes); 663 | border-left-width: 0.25rem; 664 | border-left-color: var(--tw-prose-quote-borders); 665 | quotes: "\201C""\201D""\2018""\2019"; 666 | margin-top: 1.6em; 667 | margin-bottom: 1.6em; 668 | padding-left: 1em; 669 | } 670 | 671 | .prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *))::before { 672 | content: open-quote; 673 | } 674 | 675 | .prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *))::after { 676 | content: close-quote; 677 | } 678 | 679 | .prose :where(h1):not(:where([class~="not-prose"] *)) { 680 | color: var(--tw-prose-headings); 681 | font-weight: 800; 682 | font-size: 2.25em; 683 | margin-top: 0; 684 | margin-bottom: 0.8888889em; 685 | line-height: 1.1111111; 686 | } 687 | 688 | .prose :where(h1 strong):not(:where([class~="not-prose"] *)) { 689 | font-weight: 900; 690 | color: inherit; 691 | } 692 | 693 | .prose :where(h2):not(:where([class~="not-prose"] *)) { 694 | color: var(--tw-prose-headings); 695 | font-weight: 700; 696 | font-size: 1.5em; 697 | margin-top: 2em; 698 | margin-bottom: 1em; 699 | line-height: 1.3333333; 700 | } 701 | 702 | .prose :where(h2 strong):not(:where([class~="not-prose"] *)) { 703 | font-weight: 800; 704 | color: inherit; 705 | } 706 | 707 | .prose :where(h3):not(:where([class~="not-prose"] *)) { 708 | color: var(--tw-prose-headings); 709 | font-weight: 600; 710 | font-size: 1.25em; 711 | margin-top: 1.6em; 712 | margin-bottom: 0.6em; 713 | line-height: 1.6; 714 | } 715 | 716 | .prose :where(h3 strong):not(:where([class~="not-prose"] *)) { 717 | font-weight: 700; 718 | color: inherit; 719 | } 720 | 721 | .prose :where(h4):not(:where([class~="not-prose"] *)) { 722 | color: var(--tw-prose-headings); 723 | font-weight: 600; 724 | margin-top: 1.5em; 725 | margin-bottom: 0.5em; 726 | line-height: 1.5; 727 | } 728 | 729 | .prose :where(h4 strong):not(:where([class~="not-prose"] *)) { 730 | font-weight: 700; 731 | color: inherit; 732 | } 733 | 734 | .prose :where(img):not(:where([class~="not-prose"] *)) { 735 | margin-top: 2em; 736 | margin-bottom: 2em; 737 | } 738 | 739 | .prose :where(figure > *):not(:where([class~="not-prose"] *)) { 740 | margin-top: 0; 741 | margin-bottom: 0; 742 | } 743 | 744 | .prose :where(figcaption):not(:where([class~="not-prose"] *)) { 745 | color: var(--tw-prose-captions); 746 | font-size: 0.875em; 747 | line-height: 1.4285714; 748 | margin-top: 0.8571429em; 749 | } 750 | 751 | .prose :where(code):not(:where([class~="not-prose"] *)) { 752 | color: var(--tw-prose-code); 753 | font-weight: 600; 754 | font-size: 0.875em; 755 | } 756 | 757 | .prose :where(code):not(:where([class~="not-prose"] *))::before { 758 | content: "`"; 759 | } 760 | 761 | .prose :where(code):not(:where([class~="not-prose"] *))::after { 762 | content: "`"; 763 | } 764 | 765 | .prose :where(a code):not(:where([class~="not-prose"] *)) { 766 | color: inherit; 767 | } 768 | 769 | .prose :where(h1 code):not(:where([class~="not-prose"] *)) { 770 | color: inherit; 771 | } 772 | 773 | .prose :where(h2 code):not(:where([class~="not-prose"] *)) { 774 | color: inherit; 775 | font-size: 0.875em; 776 | } 777 | 778 | .prose :where(h3 code):not(:where([class~="not-prose"] *)) { 779 | color: inherit; 780 | font-size: 0.9em; 781 | } 782 | 783 | .prose :where(h4 code):not(:where([class~="not-prose"] *)) { 784 | color: inherit; 785 | } 786 | 787 | .prose :where(blockquote code):not(:where([class~="not-prose"] *)) { 788 | color: inherit; 789 | } 790 | 791 | .prose :where(thead th code):not(:where([class~="not-prose"] *)) { 792 | color: inherit; 793 | } 794 | 795 | .prose :where(pre):not(:where([class~="not-prose"] *)) { 796 | color: var(--tw-prose-pre-code); 797 | background-color: var(--tw-prose-pre-bg); 798 | overflow-x: auto; 799 | font-weight: 400; 800 | font-size: 0.875em; 801 | line-height: 1.7142857; 802 | margin-top: 1.7142857em; 803 | margin-bottom: 1.7142857em; 804 | border-radius: 0.375rem; 805 | padding-top: 0.8571429em; 806 | padding-right: 1.1428571em; 807 | padding-bottom: 0.8571429em; 808 | padding-left: 1.1428571em; 809 | } 810 | 811 | .prose :where(pre code):not(:where([class~="not-prose"] *)) { 812 | background-color: transparent; 813 | border-width: 0; 814 | border-radius: 0; 815 | padding: 0; 816 | font-weight: inherit; 817 | color: inherit; 818 | font-size: inherit; 819 | font-family: inherit; 820 | line-height: inherit; 821 | } 822 | 823 | .prose :where(pre code):not(:where([class~="not-prose"] *))::before { 824 | content: none; 825 | } 826 | 827 | .prose :where(pre code):not(:where([class~="not-prose"] *))::after { 828 | content: none; 829 | } 830 | 831 | .prose :where(table):not(:where([class~="not-prose"] *)) { 832 | width: 100%; 833 | table-layout: auto; 834 | text-align: left; 835 | margin-top: 2em; 836 | margin-bottom: 2em; 837 | font-size: 0.875em; 838 | line-height: 1.7142857; 839 | } 840 | 841 | .prose :where(thead):not(:where([class~="not-prose"] *)) { 842 | border-bottom-width: 1px; 843 | border-bottom-color: var(--tw-prose-th-borders); 844 | } 845 | 846 | .prose :where(thead th):not(:where([class~="not-prose"] *)) { 847 | color: var(--tw-prose-headings); 848 | font-weight: 600; 849 | vertical-align: bottom; 850 | padding-right: 0.5714286em; 851 | padding-bottom: 0.5714286em; 852 | padding-left: 0.5714286em; 853 | } 854 | 855 | .prose :where(tbody tr):not(:where([class~="not-prose"] *)) { 856 | border-bottom-width: 1px; 857 | border-bottom-color: var(--tw-prose-td-borders); 858 | } 859 | 860 | .prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)) { 861 | border-bottom-width: 0; 862 | } 863 | 864 | .prose :where(tbody td):not(:where([class~="not-prose"] *)) { 865 | vertical-align: baseline; 866 | } 867 | 868 | .prose :where(tfoot):not(:where([class~="not-prose"] *)) { 869 | border-top-width: 1px; 870 | border-top-color: var(--tw-prose-th-borders); 871 | } 872 | 873 | .prose :where(tfoot td):not(:where([class~="not-prose"] *)) { 874 | vertical-align: top; 875 | } 876 | 877 | .prose { 878 | --tw-prose-body: #374151; 879 | --tw-prose-headings: #111827; 880 | --tw-prose-lead: #4b5563; 881 | --tw-prose-links: #111827; 882 | --tw-prose-bold: #111827; 883 | --tw-prose-counters: #6b7280; 884 | --tw-prose-bullets: #d1d5db; 885 | --tw-prose-hr: #e5e7eb; 886 | --tw-prose-quotes: #111827; 887 | --tw-prose-quote-borders: #e5e7eb; 888 | --tw-prose-captions: #6b7280; 889 | --tw-prose-code: #111827; 890 | --tw-prose-pre-code: #e5e7eb; 891 | --tw-prose-pre-bg: #1f2937; 892 | --tw-prose-th-borders: #d1d5db; 893 | --tw-prose-td-borders: #e5e7eb; 894 | --tw-prose-invert-body: #d1d5db; 895 | --tw-prose-invert-headings: #fff; 896 | --tw-prose-invert-lead: #9ca3af; 897 | --tw-prose-invert-links: #fff; 898 | --tw-prose-invert-bold: #fff; 899 | --tw-prose-invert-counters: #9ca3af; 900 | --tw-prose-invert-bullets: #4b5563; 901 | --tw-prose-invert-hr: #374151; 902 | --tw-prose-invert-quotes: #f3f4f6; 903 | --tw-prose-invert-quote-borders: #374151; 904 | --tw-prose-invert-captions: #9ca3af; 905 | --tw-prose-invert-code: #fff; 906 | --tw-prose-invert-pre-code: #d1d5db; 907 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 908 | --tw-prose-invert-th-borders: #4b5563; 909 | --tw-prose-invert-td-borders: #374151; 910 | font-size: 1rem; 911 | line-height: 1.75; 912 | } 913 | 914 | .prose :where(p):not(:where([class~="not-prose"] *)) { 915 | margin-top: 1.25em; 916 | margin-bottom: 1.25em; 917 | } 918 | 919 | .prose :where(video):not(:where([class~="not-prose"] *)) { 920 | margin-top: 2em; 921 | margin-bottom: 2em; 922 | } 923 | 924 | .prose :where(figure):not(:where([class~="not-prose"] *)) { 925 | margin-top: 2em; 926 | margin-bottom: 2em; 927 | } 928 | 929 | .prose :where(li):not(:where([class~="not-prose"] *)) { 930 | margin-top: 0.5em; 931 | margin-bottom: 0.5em; 932 | } 933 | 934 | .prose :where(ol > li):not(:where([class~="not-prose"] *)) { 935 | padding-left: 0.375em; 936 | } 937 | 938 | .prose :where(ul > li):not(:where([class~="not-prose"] *)) { 939 | padding-left: 0.375em; 940 | } 941 | 942 | .prose :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 943 | margin-top: 0.75em; 944 | margin-bottom: 0.75em; 945 | } 946 | 947 | .prose :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 948 | margin-top: 1.25em; 949 | } 950 | 951 | .prose :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 952 | margin-bottom: 1.25em; 953 | } 954 | 955 | .prose :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 956 | margin-top: 1.25em; 957 | } 958 | 959 | .prose :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 960 | margin-bottom: 1.25em; 961 | } 962 | 963 | .prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) { 964 | margin-top: 0.75em; 965 | margin-bottom: 0.75em; 966 | } 967 | 968 | .prose :where(hr + *):not(:where([class~="not-prose"] *)) { 969 | margin-top: 0; 970 | } 971 | 972 | .prose :where(h2 + *):not(:where([class~="not-prose"] *)) { 973 | margin-top: 0; 974 | } 975 | 976 | .prose :where(h3 + *):not(:where([class~="not-prose"] *)) { 977 | margin-top: 0; 978 | } 979 | 980 | .prose :where(h4 + *):not(:where([class~="not-prose"] *)) { 981 | margin-top: 0; 982 | } 983 | 984 | .prose :where(thead th:first-child):not(:where([class~="not-prose"] *)) { 985 | padding-left: 0; 986 | } 987 | 988 | .prose :where(thead th:last-child):not(:where([class~="not-prose"] *)) { 989 | padding-right: 0; 990 | } 991 | 992 | .prose :where(tbody td, tfoot td):not(:where([class~="not-prose"] *)) { 993 | padding-top: 0.5714286em; 994 | padding-right: 0.5714286em; 995 | padding-bottom: 0.5714286em; 996 | padding-left: 0.5714286em; 997 | } 998 | 999 | .prose :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"] *)) { 1000 | padding-left: 0; 1001 | } 1002 | 1003 | .prose :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"] *)) { 1004 | padding-right: 0; 1005 | } 1006 | 1007 | .prose :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1008 | margin-top: 0; 1009 | } 1010 | 1011 | .prose :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1012 | margin-bottom: 0; 1013 | } 1014 | 1015 | .prose-sm :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1016 | margin-top: 0.5714286em; 1017 | margin-bottom: 0.5714286em; 1018 | } 1019 | 1020 | .prose-sm :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1021 | margin-top: 1.1428571em; 1022 | } 1023 | 1024 | .prose-sm :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1025 | margin-bottom: 1.1428571em; 1026 | } 1027 | 1028 | .prose-sm :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1029 | margin-top: 1.1428571em; 1030 | } 1031 | 1032 | .prose-sm :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1033 | margin-bottom: 1.1428571em; 1034 | } 1035 | 1036 | .prose-sm :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1037 | margin-top: 0; 1038 | } 1039 | 1040 | .prose-sm :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1041 | margin-bottom: 0; 1042 | } 1043 | 1044 | .prose-base :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1045 | margin-top: 0.75em; 1046 | margin-bottom: 0.75em; 1047 | } 1048 | 1049 | .prose-base :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1050 | margin-top: 1.25em; 1051 | } 1052 | 1053 | .prose-base :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1054 | margin-bottom: 1.25em; 1055 | } 1056 | 1057 | .prose-base :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1058 | margin-top: 1.25em; 1059 | } 1060 | 1061 | .prose-base :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1062 | margin-bottom: 1.25em; 1063 | } 1064 | 1065 | .prose-base :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1066 | margin-top: 0; 1067 | } 1068 | 1069 | .prose-base :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1070 | margin-bottom: 0; 1071 | } 1072 | 1073 | .prose-lg :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1074 | margin-top: 0.8888889em; 1075 | margin-bottom: 0.8888889em; 1076 | } 1077 | 1078 | .prose-lg :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1079 | margin-top: 1.3333333em; 1080 | } 1081 | 1082 | .prose-lg :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1083 | margin-bottom: 1.3333333em; 1084 | } 1085 | 1086 | .prose-lg :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1087 | margin-top: 1.3333333em; 1088 | } 1089 | 1090 | .prose-lg :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1091 | margin-bottom: 1.3333333em; 1092 | } 1093 | 1094 | .prose-lg :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1095 | margin-top: 0; 1096 | } 1097 | 1098 | .prose-lg :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1099 | margin-bottom: 0; 1100 | } 1101 | 1102 | .prose-xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1103 | margin-top: 0.8em; 1104 | margin-bottom: 0.8em; 1105 | } 1106 | 1107 | .prose-xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1108 | margin-top: 1.2em; 1109 | } 1110 | 1111 | .prose-xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1112 | margin-bottom: 1.2em; 1113 | } 1114 | 1115 | .prose-xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1116 | margin-top: 1.2em; 1117 | } 1118 | 1119 | .prose-xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1120 | margin-bottom: 1.2em; 1121 | } 1122 | 1123 | .prose-xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1124 | margin-top: 0; 1125 | } 1126 | 1127 | .prose-xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1128 | margin-bottom: 0; 1129 | } 1130 | 1131 | .prose-2xl :where(.prose > ul > li p):not(:where([class~="not-prose"] *)) { 1132 | margin-top: 0.8333333em; 1133 | margin-bottom: 0.8333333em; 1134 | } 1135 | 1136 | .prose-2xl :where(.prose > ul > li > *:first-child):not(:where([class~="not-prose"] *)) { 1137 | margin-top: 1.3333333em; 1138 | } 1139 | 1140 | .prose-2xl :where(.prose > ul > li > *:last-child):not(:where([class~="not-prose"] *)) { 1141 | margin-bottom: 1.3333333em; 1142 | } 1143 | 1144 | .prose-2xl :where(.prose > ol > li > *:first-child):not(:where([class~="not-prose"] *)) { 1145 | margin-top: 1.3333333em; 1146 | } 1147 | 1148 | .prose-2xl :where(.prose > ol > li > *:last-child):not(:where([class~="not-prose"] *)) { 1149 | margin-bottom: 1.3333333em; 1150 | } 1151 | 1152 | .prose-2xl :where(.prose > :first-child):not(:where([class~="not-prose"] *)) { 1153 | margin-top: 0; 1154 | } 1155 | 1156 | .prose-2xl :where(.prose > :last-child):not(:where([class~="not-prose"] *)) { 1157 | margin-bottom: 0; 1158 | } 1159 | 1160 | .m-2 { 1161 | margin: 0.5rem; 1162 | } 1163 | 1164 | .mb-3 { 1165 | margin-bottom: 0.75rem; 1166 | } 1167 | 1168 | .mt-3 { 1169 | margin-top: 0.75rem; 1170 | } 1171 | 1172 | .mt-5 { 1173 | margin-top: 1.25rem; 1174 | } 1175 | 1176 | .block { 1177 | display: block; 1178 | } 1179 | 1180 | .flex { 1181 | display: flex; 1182 | } 1183 | 1184 | .h-20 { 1185 | height: 5rem; 1186 | } 1187 | 1188 | .min-w-full { 1189 | min-width: 100%; 1190 | } 1191 | 1192 | .table-auto { 1193 | table-layout: auto; 1194 | } 1195 | 1196 | .border-collapse { 1197 | border-collapse: collapse; 1198 | } 1199 | 1200 | .flex-row { 1201 | flex-direction: row; 1202 | } 1203 | 1204 | .items-center { 1205 | align-items: center; 1206 | } 1207 | 1208 | .gap-1 { 1209 | gap: 0.25rem; 1210 | } 1211 | 1212 | .rounded-lg { 1213 | border-radius: 0.5rem; 1214 | } 1215 | 1216 | .border { 1217 | border-width: 1px; 1218 | } 1219 | 1220 | .border-t-2 { 1221 | border-top-width: 2px; 1222 | } 1223 | 1224 | .border-gray-300 { 1225 | --tw-border-opacity: 1; 1226 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 1227 | } 1228 | 1229 | .border-t-black { 1230 | --tw-border-opacity: 1; 1231 | border-top-color: rgb(0 0 0 / var(--tw-border-opacity)); 1232 | } 1233 | 1234 | .bg-slate-200 { 1235 | --tw-bg-opacity: 1; 1236 | background-color: rgb(226 232 240 / var(--tw-bg-opacity)); 1237 | } 1238 | 1239 | .bg-slate-300 { 1240 | --tw-bg-opacity: 1; 1241 | background-color: rgb(203 213 225 / var(--tw-bg-opacity)); 1242 | } 1243 | 1244 | .bg-gray-200 { 1245 | --tw-bg-opacity: 1; 1246 | background-color: rgb(229 231 235 / var(--tw-bg-opacity)); 1247 | } 1248 | 1249 | .p-2 { 1250 | padding: 0.5rem; 1251 | } 1252 | 1253 | .px-2 { 1254 | padding-left: 0.5rem; 1255 | padding-right: 0.5rem; 1256 | } 1257 | 1258 | .px-4 { 1259 | padding-left: 1rem; 1260 | padding-right: 1rem; 1261 | } 1262 | 1263 | .py-2 { 1264 | padding-top: 0.5rem; 1265 | padding-bottom: 0.5rem; 1266 | } 1267 | 1268 | .px-1 { 1269 | padding-left: 0.25rem; 1270 | padding-right: 0.25rem; 1271 | } 1272 | 1273 | .text-sm { 1274 | font-size: 0.875rem; 1275 | line-height: 1.25rem; 1276 | } 1277 | 1278 | .text-xs { 1279 | font-size: 0.75rem; 1280 | line-height: 1rem; 1281 | } 1282 | 1283 | .font-bold { 1284 | font-weight: 700; 1285 | } 1286 | 1287 | .text-black { 1288 | --tw-text-opacity: 1; 1289 | color: rgb(0 0 0 / var(--tw-text-opacity)); 1290 | } 1291 | 1292 | .text-white { 1293 | --tw-text-opacity: 1; 1294 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1295 | } 1296 | 1297 | .shadow-xl { 1298 | --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 1299 | --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); 1300 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1301 | } 1302 | 1303 | .transition { 1304 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 1305 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1306 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 1307 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1308 | transition-duration: 150ms; 1309 | } 1310 | 1311 | .duration-300 { 1312 | transition-duration: 300ms; 1313 | } 1314 | 1315 | body { 1316 | padding-left: 2vw; 1317 | } 1318 | 1319 | .hover\:bg-gray-50:hover { 1320 | --tw-bg-opacity: 1; 1321 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 1322 | } 1323 | 1324 | .hover\:bg-slate-400:hover { 1325 | --tw-bg-opacity: 1; 1326 | background-color: rgb(148 163 184 / var(--tw-bg-opacity)); 1327 | } -------------------------------------------------------------------------------- /src/static/sparql.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: https://codemirror.net/LICENSE 3 | 4 | (function(mod) { 5 | if (typeof exports == "object" && typeof module == "object") // CommonJS 6 | mod(require("../../lib/codemirror")); 7 | else if (typeof define == "function" && define.amd) // AMD 8 | define(["../../lib/codemirror"], mod); 9 | else // Plain browser env 10 | mod(CodeMirror); 11 | })(function(CodeMirror) { 12 | "use strict"; 13 | 14 | CodeMirror.defineMode("sparql", function(config) { 15 | var indentUnit = config.indentUnit; 16 | var curPunc; 17 | 18 | function wordRegexp(words) { 19 | return new RegExp("^(?:" + words.join("|") + ")$", "i"); 20 | } 21 | var ops = wordRegexp(["str", "lang", "langmatches", "datatype", "bound", "sameterm", "isiri", "isuri", 22 | "iri", "uri", "bnode", "count", "sum", "min", "max", "avg", "sample", 23 | "group_concat", "rand", "abs", "ceil", "floor", "round", "concat", "substr", "strlen", 24 | "replace", "ucase", "lcase", "encode_for_uri", "contains", "strstarts", "strends", 25 | "strbefore", "strafter", "year", "month", "day", "hours", "minutes", "seconds", 26 | "timezone", "tz", "now", "uuid", "struuid", "md5", "sha1", "sha256", "sha384", 27 | "sha512", "coalesce", "if", "strlang", "strdt", "isnumeric", "regex", "exists", 28 | "isblank", "isliteral", "a", "bind"]); 29 | var keywords = wordRegexp(["base", "prefix", "select", "distinct", "reduced", "construct", "describe", 30 | "ask", "from", "named", "where", "order", "limit", "offset", "filter", "optional", 31 | "graph", "by", "asc", "desc", "as", "having", "undef", "values", "group", 32 | "minus", "in", "not", "service", "silent", "using", "insert", "delete", "union", 33 | "true", "false", "with", 34 | "data", "copy", "to", "move", "add", "create", "drop", "clear", "load"]); 35 | var operatorChars = /[*+\-<>=&|\^\/!\?]/; 36 | 37 | function tokenBase(stream, state) { 38 | var ch = stream.next(); 39 | curPunc = null; 40 | if (ch == "$" || ch == "?") { 41 | if(ch == "?" && stream.match(/\s/, false)){ 42 | return "operator"; 43 | } 44 | stream.match(/^[A-Za-z0-9_\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][A-Za-z0-9_\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]*/); 45 | return "variable-2"; 46 | } 47 | else if (ch == "<" && !stream.match(/^[\s\u00a0=]/, false)) { 48 | stream.match(/^[^\s\u00a0>]*>?/); 49 | return "atom"; 50 | } 51 | else if (ch == "\"" || ch == "'") { 52 | state.tokenize = tokenLiteral(ch); 53 | return state.tokenize(stream, state); 54 | } 55 | else if (/[{}\(\),\.;\[\]]/.test(ch)) { 56 | curPunc = ch; 57 | return "bracket"; 58 | } 59 | else if (ch == "#") { 60 | stream.skipToEnd(); 61 | return "comment"; 62 | } 63 | else if (ch === "^") { 64 | ch = stream.peek(); 65 | if (ch === "^") stream.eat("^"); 66 | else stream.eatWhile(operatorChars); 67 | return "operator"; 68 | } 69 | else if (operatorChars.test(ch)) { 70 | stream.eatWhile(operatorChars); 71 | return "operator"; 72 | } 73 | else if (ch == ":") { 74 | eatPnLocal(stream); 75 | return "atom"; 76 | } 77 | else if (ch == "@") { 78 | stream.eatWhile(/[a-z\d\-]/i); 79 | return "meta"; 80 | } 81 | else { 82 | stream.eatWhile(/[_\w\d]/); 83 | if (stream.eat(":")) { 84 | eatPnLocal(stream); 85 | return "atom"; 86 | } 87 | var word = stream.current(); 88 | if (ops.test(word)) 89 | return "builtin"; 90 | else if (keywords.test(word)) 91 | return "keyword"; 92 | else 93 | return "variable"; 94 | } 95 | } 96 | 97 | function eatPnLocal(stream) { 98 | stream.match(/(\.(?=[\w_\-\\%])|[:\w_-]|\\[-\\_~.!$&'()*+,;=/?#@%]|%[a-f\d][a-f\d])+/i); 99 | } 100 | 101 | function tokenLiteral(quote) { 102 | return function(stream, state) { 103 | var escaped = false, ch; 104 | while ((ch = stream.next()) != null) { 105 | if (ch == quote && !escaped) { 106 | state.tokenize = tokenBase; 107 | break; 108 | } 109 | escaped = !escaped && ch == "\\"; 110 | } 111 | return "string"; 112 | }; 113 | } 114 | 115 | function pushContext(state, type, col) { 116 | state.context = {prev: state.context, indent: state.indent, col: col, type: type}; 117 | } 118 | function popContext(state) { 119 | state.indent = state.context.indent; 120 | state.context = state.context.prev; 121 | } 122 | 123 | return { 124 | startState: function() { 125 | return {tokenize: tokenBase, 126 | context: null, 127 | indent: 0, 128 | col: 0}; 129 | }, 130 | 131 | token: function(stream, state) { 132 | if (stream.sol()) { 133 | if (state.context && state.context.align == null) state.context.align = false; 134 | state.indent = stream.indentation(); 135 | } 136 | if (stream.eatSpace()) return null; 137 | var style = state.tokenize(stream, state); 138 | 139 | if (style != "comment" && state.context && state.context.align == null && state.context.type != "pattern") { 140 | state.context.align = true; 141 | } 142 | 143 | if (curPunc == "(") pushContext(state, ")", stream.column()); 144 | else if (curPunc == "[") pushContext(state, "]", stream.column()); 145 | else if (curPunc == "{") pushContext(state, "}", stream.column()); 146 | else if (/[\]\}\)]/.test(curPunc)) { 147 | while (state.context && state.context.type == "pattern") popContext(state); 148 | if (state.context && curPunc == state.context.type) { 149 | popContext(state); 150 | if (curPunc == "}" && state.context && state.context.type == "pattern") 151 | popContext(state); 152 | } 153 | } 154 | else if (curPunc == "." && state.context && state.context.type == "pattern") popContext(state); 155 | else if (/atom|string|variable/.test(style) && state.context) { 156 | if (/[\}\]]/.test(state.context.type)) 157 | pushContext(state, "pattern", stream.column()); 158 | else if (state.context.type == "pattern" && !state.context.align) { 159 | state.context.align = true; 160 | state.context.col = stream.column(); 161 | } 162 | } 163 | 164 | return style; 165 | }, 166 | 167 | indent: function(state, textAfter) { 168 | var firstChar = textAfter && textAfter.charAt(0); 169 | var context = state.context; 170 | if (/[\]\}]/.test(firstChar)) 171 | while (context && context.type == "pattern") context = context.prev; 172 | 173 | var closing = context && firstChar == context.type; 174 | if (!context) 175 | return 0; 176 | else if (context.type == "pattern") 177 | return context.col; 178 | else if (context.align) 179 | return context.col + (closing ? 0 : 1); 180 | else 181 | return context.indent + (closing ? 0 : indentUnit); 182 | }, 183 | 184 | lineComment: "#" 185 | }; 186 | }); 187 | 188 | CodeMirror.defineMIME("application/sparql-query", "sparql"); 189 | 190 | }); 191 | -------------------------------------------------------------------------------- /src/static/sparqlui.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | sparqleditor = CodeMirror.fromTextArea(document.getElementById("code"), { 3 | mode: "application/sparql-query", 4 | matchBrackets: true, 5 | lineNumbers: true, 6 | }); 7 | }); 8 | 9 | document.body.addEventListener("htmx:configRequest", function (evt) { 10 | if (evt.detail.elt.id === "execute_sparql") { 11 | evt.detail.parameters["query"] = sparqleditor.doc.getValue(); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/static/sqrl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epoz/shmarql/43f5c40931d74fbed9eef3415f7978ab0fecf286/src/static/sqrl.png -------------------------------------------------------------------------------- /src/static/surreal-1.3.0.js: -------------------------------------------------------------------------------- 1 | // Welcome to Surreal 1.3.0 2 | // Documentation: https://github.com/gnat/surreal 3 | // Locality of Behavior (LoB): https://htmx.org/essays/locality-of-behaviour/ 4 | let surreal = (function () { 5 | let $ = { // Convenience for internals. 6 | $: this, // Convenience for internals. 7 | plugins: [], 8 | 9 | // Table of contents and convenient call chaining sugar. For a familiar "jQuery like" syntax. 🙂 10 | // Check before adding new: https://youmightnotneedjquery.com/ 11 | sugar(e) { 12 | if ($.isNodeList(e)) { e.forEach(_ => { $.sugar(_) }) } // Apply Surreal to nodes in array as well as the array. 13 | if (!$.isNode(e) && !$.isNodeList(e)) { console.warn(`Surreal: Not a supported element / node / array of nodes "${e}"`); return e } 14 | if (e.hasOwnProperty('hasSurreal')) return e // Surreal already applied 15 | 16 | // General 17 | e.run = (f) => { return $.run(e, f) } 18 | e.remove = () => { return $.remove(e) } 19 | 20 | // Classes and CSS. 21 | e.classAdd = (name) => { return $.classAdd(e, name) } 22 | e.class_add = e.add_class = e.addClass = e.classAdd // Alias 23 | e.classRemove = (name) => { return $.classRemove(e, name) } 24 | e.class_remove = e.remove_class = e.removeClass = e.classRemove // Alias 25 | e.classToggle = (name, force) => { return $.classToggle(e, name, force) } 26 | e.class_toggle = e.toggle_class = e.toggleClass = e.classToggle // Alias 27 | e.styles = (value) => { return $.styles(e, value) } 28 | 29 | // Events. 30 | e.on = (name, f) => { return $.on(e, name, f) } 31 | e.off = (name, f) => { return $.off(e, name, f) } 32 | e.offAll = (name) => { return $.offAll(e, name) } 33 | e.off_all = e.offAll // Alias 34 | e.disable = () => { return $.disable(e) } 35 | e.enable = () => { return $.enable(e) } 36 | e.send = (name, detail) => { return $.send(e, name, detail) } 37 | e.trigger = e.send // Alias 38 | e.halt = (ev, keepBubbling, keepDefault) => { return $.halt(ev, keepBubbling, keepDefault) } 39 | 40 | // Attributes. 41 | e.attribute = (name, value) => { return $.attribute(e, name, value) } 42 | e.attributes = e.attr = e.attribute // Alias 43 | 44 | // Add all plugins. 45 | $.plugins.forEach(function(func) { func(e) }) 46 | 47 | e.hasSurreal = 1 48 | return e 49 | }, 50 | // Return single element. Selector not needed if used with inline 56 | // 57 | me(selector=null, start=document, warning=true) { 58 | if (selector == null) return $.sugar(start.currentScript.parentElement) // Just local me() in 304 | // Example: 305 | const onloadAdd = addOnload = onload_add = add_onload = (f) => { 306 | if (typeof window.onload === 'function') { // window.onload already is set, queue functions together (creates a call chain). 307 | let onload_old = window.onload 308 | window.onload = () => { 309 | onload_old() 310 | f() 311 | } 312 | return 313 | } 314 | window.onload = f // window.onload was not set yet. 315 | } 316 | console.log("Surreal: Added shortcuts.") 317 | -------------------------------------------------------------------------------- /src/static/tablesort-5.3.0.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * tablesort v5.2.1 (2021-10-30) 3 | * http://tristen.ca/tablesort/demo/ 4 | * Copyright (c) 2021 ; Licensed MIT 5 | */ 6 | !function(){function a(b,c){if(!(this instanceof a))return new a(b,c);if(!b||"TABLE"!==b.tagName)throw new Error("Element must be a table");this.init(b,c||{})}var b=[],c=function(a){var b;return window.CustomEvent&&"function"==typeof window.CustomEvent?b=new CustomEvent(a):(b=document.createEvent("CustomEvent"),b.initCustomEvent(a,!1,!1,void 0)),b},d=function(a,b){return a.getAttribute(b.sortAttribute||"data-sort")||a.textContent||a.innerText||""},e=function(a,b){return a=a.trim().toLowerCase(),b=b.trim().toLowerCase(),a===b?0:a0)if(a.tHead&&a.tHead.rows.length>0){for(e=0;e0&&n.push(m),o++;if(!n)return}for(o=0;oli):not(:where([class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(hr):not(:where([class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-bottom:3em;margin-top:3em}.prose :where(blockquote):not(:where([class~=not-prose] *)){border-left-color:var(--tw-prose-quote-borders);border-left-width:.25rem;color:var(--tw-prose-quotes);font-style:italic;font-weight:500;margin-bottom:1.6em;margin-top:1.6em;padding-left:1em;quotes:"\201C""\201D""\2018""\2019"}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose] *)){color:var(--tw-prose-headings);font-size:2.25em;font-weight:800;line-height:1.1111111;margin-bottom:.8888889em;margin-top:0}.prose :where(h1 strong):not(:where([class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.5em;font-weight:700;line-height:1.3333333;margin-bottom:1em;margin-top:2em}.prose :where(h2 strong):not(:where([class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose] *)){color:var(--tw-prose-headings);font-size:1.25em;font-weight:600;line-height:1.6;margin-bottom:.6em;margin-top:1.6em}.prose :where(h3 strong):not(:where([class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;line-height:1.5;margin-bottom:.5em;margin-top:1.5em}.prose :where(h4 strong):not(:where([class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(figure>*):not(:where([class~=not-prose] *)){margin-bottom:0;margin-top:0}.prose :where(figcaption):not(:where([class~=not-prose] *)){color:var(--tw-prose-captions);font-size:.875em;line-height:1.4285714;margin-top:.8571429em}.prose :where(code):not(:where([class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose] *)):before{content:"`"}.prose :where(code):not(:where([class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose] *)){color:inherit}.prose :where(h1 code):not(:where([class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose] *)){color:inherit}.prose :where(blockquote code):not(:where([class~=not-prose] *)){color:inherit}.prose :where(thead th code):not(:where([class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose] *)){background-color:var(--tw-prose-pre-bg);border-radius:.375rem;color:var(--tw-prose-pre-code);font-size:.875em;font-weight:400;line-height:1.7142857;margin-bottom:1.7142857em;margin-top:1.7142857em;overflow-x:auto;padding:.8571429em 1.1428571em}.prose :where(pre code):not(:where([class~=not-prose] *)){background-color:initial;border-radius:0;border-width:0;color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;padding:0}.prose :where(pre code):not(:where([class~=not-prose] *)):before{content:none}.prose :where(pre code):not(:where([class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose] *)){font-size:.875em;line-height:1.7142857;margin-bottom:2em;margin-top:2em;table-layout:auto;text-align:left;width:100%}.prose :where(thead):not(:where([class~=not-prose] *)){border-bottom-color:var(--tw-prose-th-borders);border-bottom-width:1px}.prose :where(thead th):not(:where([class~=not-prose] *)){color:var(--tw-prose-headings);font-weight:600;padding-bottom:.5714286em;padding-left:.5714286em;padding-right:.5714286em;vertical-align:bottom}.prose :where(tbody tr):not(:where([class~=not-prose] *)){border-bottom-color:var(--tw-prose-td-borders);border-bottom-width:1px}.prose :where(tbody tr:last-child):not(:where([class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose] *)){vertical-align:initial}.prose :where(tfoot):not(:where([class~=not-prose] *)){border-top-color:var(--tw-prose-th-borders);border-top-width:1px}.prose :where(tfoot td):not(:where([class~=not-prose] *)){vertical-align:top}.prose{--tw-prose-body:#374151;--tw-prose-headings:#111827;--tw-prose-lead:#4b5563;--tw-prose-links:#111827;--tw-prose-bold:#111827;--tw-prose-counters:#6b7280;--tw-prose-bullets:#d1d5db;--tw-prose-hr:#e5e7eb;--tw-prose-quotes:#111827;--tw-prose-quote-borders:#e5e7eb;--tw-prose-captions:#6b7280;--tw-prose-code:#111827;--tw-prose-pre-code:#e5e7eb;--tw-prose-pre-bg:#1f2937;--tw-prose-th-borders:#d1d5db;--tw-prose-td-borders:#e5e7eb;--tw-prose-invert-body:#d1d5db;--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:#9ca3af;--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:#9ca3af;--tw-prose-invert-bullets:#4b5563;--tw-prose-invert-hr:#374151;--tw-prose-invert-quotes:#f3f4f6;--tw-prose-invert-quote-borders:#374151;--tw-prose-invert-captions:#9ca3af;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:#d1d5db;--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:#4b5563;--tw-prose-invert-td-borders:#374151;font-size:1rem;line-height:1.75}.prose :where(p):not(:where([class~=not-prose] *)){margin-bottom:1.25em;margin-top:1.25em}.prose :where(video):not(:where([class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(figure):not(:where([class~=not-prose] *)){margin-bottom:2em;margin-top:2em}.prose :where(li):not(:where([class~=not-prose] *)){margin-bottom:.5em;margin-top:.5em}.prose :where(ol>li):not(:where([class~=not-prose] *)){padding-left:.375em}.prose :where(ul>li):not(:where([class~=not-prose] *)){padding-left:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose :where(hr+*):not(:where([class~=not-prose] *)){margin-top:0}.prose :where(h2+*):not(:where([class~=not-prose] *)){margin-top:0}.prose :where(h3+*):not(:where([class~=not-prose] *)){margin-top:0}.prose :where(h4+*):not(:where([class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose] *)){padding-left:0}.prose :where(thead th:last-child):not(:where([class~=not-prose] *)){padding-right:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose] *)){padding:.5714286em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose] *)){padding-left:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose] *)){padding-right:0}.prose :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.prose-sm :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.5714286em;margin-top:.5714286em}.prose-sm :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.1428571em}.prose-sm :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.1428571em}.prose-sm :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.1428571em}.prose-sm :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.1428571em}.prose-sm :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose-sm :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.prose-base :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.75em;margin-top:.75em}.prose-base :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.25em}.prose-base :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.25em}.prose-base :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.25em}.prose-base :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.25em}.prose-base :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose-base :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.prose-lg :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.8888889em;margin-top:.8888889em}.prose-lg :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.3333333em}.prose-lg :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.3333333em}.prose-lg :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose-lg :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.prose-xl :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.8em;margin-top:.8em}.prose-xl :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.2em}.prose-xl :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.2em}.prose-xl :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.2em}.prose-xl :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.2em}.prose-xl :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose-xl :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.prose-2xl :where(.prose>ul>li p):not(:where([class~=not-prose] *)){margin-bottom:.8333333em;margin-top:.8333333em}.prose-2xl :where(.prose>ul>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.3333333em}.prose-2xl :where(.prose>ul>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.3333333em}.prose-2xl :where(.prose>ol>li>:first-child):not(:where([class~=not-prose] *)){margin-top:1.3333333em}.prose-2xl :where(.prose>ol>li>:last-child):not(:where([class~=not-prose] *)){margin-bottom:1.3333333em}.prose-2xl :where(.prose>:first-child):not(:where([class~=not-prose] *)){margin-top:0}.prose-2xl :where(.prose>:last-child):not(:where([class~=not-prose] *)){margin-bottom:0}.block{display:block}.flex{display:flex}.border{border-width:1px} -------------------------------------------------------------------------------- /src/style/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["../shmarql/**/*.py"], 4 | theme: { 5 | extend: { 6 | screens: { 7 | widescreen: { raw: "(min-aspect-ratio: 3/2)" }, 8 | tallscreen: { raw: "(min-aspect-ratio: 1/2)" }, 9 | }, 10 | }, 11 | }, 12 | plugins: [require("@tailwindcss/typography")], 13 | }; 14 | --------------------------------------------------------------------------------