├── .github └── workflows │ ├── deploy.yml │ ├── lint.yml │ ├── playwright.yml │ └── release.yml ├── .gitignore ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── WHITEPAPER.md ├── api ├── .env ├── .gitignore ├── Dockerfile ├── Makefile ├── bin │ └── console ├── compose.override.yaml ├── compose.yaml ├── composer.json ├── composer.lock ├── config │ ├── bundles.php │ ├── packages │ │ ├── api_platform.yaml │ │ ├── cache.yaml │ │ ├── doctrine.yaml │ │ ├── doctrine_migrations.yaml │ │ ├── framework.yaml │ │ ├── mercure.yaml │ │ ├── nelmio_cors.yaml │ │ ├── routing.yaml │ │ ├── security.yaml │ │ └── validator.yaml │ ├── preload.php │ ├── routes.yaml │ ├── routes │ │ └── framework.yaml │ └── services.yaml ├── frankenphp │ ├── Caddyfile │ ├── conf.d │ │ ├── app.dev.ini │ │ ├── app.ini │ │ └── app.prod.ini │ ├── docker-entrypoint.sh │ └── worker.Caddyfile ├── migrations │ ├── .gitignore │ └── Version20240605141831.php ├── public │ ├── index.html │ ├── index.js │ ├── index.php │ └── output.css ├── src │ ├── ApiResource │ │ └── .gitignore │ ├── Command │ │ └── LoadFixturesCommand.php │ ├── Entity │ │ ├── .gitignore │ │ ├── Author.php │ │ └── Book.php │ └── Kernel.php ├── style │ └── input.css ├── symfony.lock └── templates │ ├── foot.html │ └── head.html ├── compose.yaml ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── ld │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── eslint.config.mjs │ ├── ld.ts │ ├── package.json │ └── tsconfig.json ├── mercure │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── eslint.config.mjs │ ├── mercure.ts │ ├── package.json │ └── tsconfig.json └── use-swrld │ ├── .gitignore │ ├── README.md │ ├── eslint.config.mjs │ ├── package.json │ ├── tsconfig.json │ └── use-swrld.ts ├── playwright.config.ts ├── tailwind.config.js ├── tests-server ├── _headers ├── _redirects ├── axios.html ├── fixtures │ ├── authors │ │ ├── 1.jsonld │ │ ├── 2.jsonld │ │ └── 3.jsonld │ ├── books.jsonld │ └── books │ │ ├── 1.jsonld │ │ ├── 2.jsonld │ │ ├── 3.jsonld │ │ └── 4.jsonld ├── github.html ├── mercure.html ├── react.html ├── swr.html ├── tanstack-query.html └── vanilla.html ├── tests ├── axios.spec.ts ├── mercure.spec.ts ├── react.spec.ts ├── swr.spec.ts ├── tanstack-query.spec.ts └── vanilla.spec.ts └── tsconfig.json /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: ["main"] 5 | workflow_dispatch: 6 | jobs: 7 | deploy: 8 | timeout-minutes: 60 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - run: npm ci 17 | - run: lerna run tsc 18 | - run: cp packages/ld/ld.js api/public/ld.js 19 | - run: cp packages/mercure/mercure.js api/public/mercure.js 20 | - run: npm i -g showdown 21 | - working-directory: api 22 | run: make pages 23 | - name: Deploy to Staging server 24 | uses: easingthemes/ssh-deploy@main 25 | with: 26 | REMOTE_HOST: ${{ secrets.REMOTE_HOST }} 27 | SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} 28 | SOURCE: "api/" 29 | ARGS: "-rlgoDzvc -i" 30 | REMOTE_USER: ${{ secrets.REMOTE_USER }} 31 | TARGET: ${{ secrets.REMOTE_TARGET }} 32 | SCRIPT_AFTER: | 33 | cd ${{secrets.REMOTE_TARGET}} 34 | docker compose --env-file .env.local build 35 | docker compose --env-file .env.local up --wait 36 | docker compose exec php bin/console app:load-fixtures 37 | 38 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - run: npm ci 17 | - run: lerna run lint 18 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - 14 | name: Set up Docker Buildx 15 | uses: docker/setup-buildx-action@v3 16 | - 17 | name: Build Docker images 18 | uses: docker/bake-action@v5 19 | with: 20 | pull: true 21 | load: true 22 | files: | 23 | compose.yaml 24 | set: | 25 | *.cache-from=type=gha,scope=${{github.ref}} 26 | *.cache-from=type=gha,scope=refs/heads/main 27 | *.cache-to=type=gha,scope=${{github.ref}},mode=max 28 | - 29 | name: Start services 30 | run: docker compose up --wait --no-build 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: lts/* 34 | - run: npm ci 35 | - run: lerna run tsc 36 | - run: npx playwright install --with-deps 37 | - run: PLAYWRIGHT_JSON_OUTPUT_NAME=results.json npx playwright test --reporter json 38 | - uses: daun/playwright-report-summary@v3 39 | if: github.event_name == 'pull_request' 40 | with: 41 | report-file: results.json 42 | github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 'current' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm ci 18 | - run: lerna run tsc 19 | - run: lerna publish from-package --yes --no-private 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nx 3 | /test-results/ 4 | /playwright-report/ 5 | /blob-report/ 6 | /playwright/.cache/ 7 | mercure.db 8 | /tests-server/*.js 9 | !tests-server/index.js 10 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | # https://caddyserver.com/docs/caddyfile/directives#sorting-algorithm 3 | order mercure after encode 4 | order vulcain after reverse_proxy 5 | } 6 | 7 | localhost 8 | 9 | root /srv 10 | vulcain 11 | file_server 12 | 13 | handle /books { 14 | header "Link" "; rel=\"self\", ; rel=\"mercure\"" 15 | try_files ./tests-server/fixtures/books.jsonld {path} 16 | } 17 | 18 | handle /books/* { 19 | header "Link" "<{path}>; rel=\"self\", ; rel=\"mercure\"" 20 | try_files ./tests-server/fixtures/{path}.jsonld {path} 21 | } 22 | 23 | handle /authors/* { 24 | header "Link" "<{path}>; rel=\"self\", ; rel=\"mercure\"" 25 | try_files ./tests-server/fixtures/{path}.jsonld {path} 26 | } 27 | 28 | handle /index.js { 29 | try_files ./*/{path} {path} 30 | } 31 | 32 | handle /*.js { 33 | root /srv/packages 34 | try_files ./*/{path} {path} 35 | } 36 | 37 | handle /* { 38 | try_files ./tests-server/{path}.html {path} 39 | } 40 | 41 | handle / { 42 | try_files ./tests-server/index.html {path} 43 | } 44 | 45 | mercure { 46 | publisher_jwt key 47 | subscriber_jwt key 48 | anonymous true 49 | ui true 50 | } 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM caddy:2.8-builder AS builder 2 | 3 | RUN xcaddy build --with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy 4 | 5 | FROM caddy:2.8 AS app_server 6 | 7 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2024-present Kévin Dunglas, Antoine Bluchet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linked Data for Edge Side APIs 2 | 3 | - [@api-platform/ld: Fetch linked data](/packages/ld/README.md) 4 | - [@api-platform/mercure: Subscribe to updates](/packages/mercure/README.md) 5 | 6 | ## Tests 7 | 8 | ``` 9 | docker compose up 10 | npx playwright test 11 | ``` 12 | 13 | 14 | -------------------------------------------------------------------------------- /WHITEPAPER.md: -------------------------------------------------------------------------------- 1 | # Edge Side APIs White Paper (Draft) 2 | 3 | Edge Side APIs (ESA) is an architecture pattern that allows the creation of more reliable, more efficient, and less resource-intensive APIs. 4 | 5 | https://edge-side-api.rocks/ 6 | 7 | ## Abstract 8 | 9 | Edge Side APIs (ESA) is an architecture pattern, that allows the creation of more reliable, more efficient, and less resource-intensive APIs. This architecture revives the main REST/HATEOAS principles while taking full advantage of the new capabilities provided by the web platform. 10 | 11 | ESA promotes a mixed approach, synchronous and asynchronous, which allows a great simplicity of development and use, performances rarely reached, and the possibility for the clients to receive updates of the resource it fetched in real-time. Finally, ESA proposes to build on existing standards to expose documentation allowing the creation of generic clients, capable of discovering the capabilities of the API at runtime. 12 | 13 | ## Introduction 14 | 15 | ESA is an architecture for API development that is performant, scalable and reliable. Resources served by the API can be pre-built, and may be generated and stored at edge (edge computing). These small and atomic JSON documents are ideal to lower the energy comsumption of the bandwith, and maximizes the power of the browser's shared cache. Based on HTTP, ESA embraces REST principles and works on any device or script, older or newer. It also benefits of modern ways to invalidate cached content or to preload data with ease, mitigating the n+1 problem. 16 | ESA can be written in any language and is at the core of [API Platform](https://api-platform.com). 17 | 18 | ## Atomic resource 19 | 20 | Resources served by the API must be small, atomic documents. The API must not embed related resources but instead expose them through an URL. Big resources should be splitted using one-to-one relations exposed by an URL. 21 | This allows a better browser and share cache hit rate. Clients fetch only what they initially need and update only small chunks of data providing less frequent re-generations or invalidations. 22 | Hypermedia is at the heart of Edge Side APIs, prefer formats like JSON-LD, Hydra, JSON:API. 23 | 24 | ## Pre-generate 25 | 26 | Pre-generate as much API responses as possible. The generated static JSON documents are stored in a Content Delivery Network. On write re-generate (or invalidate) API responses impacted by the change. The code can optionally run at edge improving performance, energy consumption, scalability and reliability 27 | 28 | ## HTTP 29 | 30 | The API must be usable by any client on any device (browsers, curl, raw TCP sockets) using HTTP. Capable clients can leverage optional features such as cache, preloading, HTTP/3 or push, for better performance and UX. 31 | The API works everywhere, from modern browsers with top notch performances to on older devices or scripts. To reduce the digital environmental footprint, we must build fewer devices and use them longer! 32 | 33 | ## Preload 34 | 35 | Initially relations needed can be preloaded using [Preload links](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload), optionally using 103 Early Hints or HTTP/2 Server Push. For this the API must be informed of what relations they need ([Vulcain](https://vulcain.rocks/), [Prefer-Push](https://datatracker.ietf.org/doc/html/draft-pot-prefer-push)). This mitigates the n+1 problem and reduces latencies. 36 | 37 | ## Push 38 | 39 | The API offers subscription to real-time updates. On write, new versions of the resources are pushed (when re-generating or invalidating). To do so use [Mercure](https://mercure.rocks), Server-Sent Events, Websockets or WebSub. Clients are always up to date as user interfaces update in real-time. 40 | -------------------------------------------------------------------------------- /api/.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | ###> symfony/framework-bundle ### 18 | APP_ENV=dev 19 | APP_SECRET=a8be00aea956ada676625f1b456b4394 20 | ###< symfony/framework-bundle ### 21 | 22 | ###> doctrine/doctrine-bundle ### 23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 25 | # 26 | DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 28 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 29 | # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" 30 | ###< doctrine/doctrine-bundle ### 31 | 32 | ###> nelmio/cors-bundle ### 33 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' 34 | ###< nelmio/cors-bundle ### 35 | 36 | ###> symfony/mercure-bundle ### 37 | # See https://symfony.com/doc/current/mercure.html#configuration 38 | # The URL of the Mercure hub, used by the app to publish updates (can be a local URL) 39 | MERCURE_URL=https://example.com/.well-known/mercure 40 | # The public URL of the Mercure hub, used by the browser to connect 41 | MERCURE_PUBLIC_URL=https://example.com/.well-known/mercure 42 | # The secret used to sign the JWTs 43 | MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" 44 | ###< symfony/mercure-bundle ### 45 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /.env.local 2 | /.env.local.php 3 | /.env.*.local 4 | /config/secrets/prod/prod.decrypt.private.php 5 | /public/bundles/ 6 | /var/ 7 | /vendor/ 8 | /public/ld.js 9 | /public/mercure.js 10 | /public/white-paper.html 11 | /public/linked-data.html 12 | /public/mercure.html 13 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1.4 2 | 3 | # Adapted from https://github.com/dunglas/symfony-docker 4 | 5 | 6 | # Versions 7 | FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream 8 | 9 | 10 | # The different stages of this Dockerfile are meant to be built into separate images 11 | # https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage 12 | # https://docs.docker.com/compose/compose-file/#target 13 | 14 | 15 | # Base FrankenPHP image 16 | FROM frankenphp_upstream AS frankenphp_base 17 | 18 | WORKDIR /app 19 | 20 | # persistent / runtime deps 21 | # hadolint ignore=DL3008 22 | RUN apt-get update && apt-get install --no-install-recommends -y \ 23 | acl \ 24 | file \ 25 | gettext \ 26 | git \ 27 | sqlite3 \ 28 | && rm -rf /var/lib/apt/lists/* 29 | 30 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 31 | ENV COMPOSER_ALLOW_SUPERUSER=1 32 | 33 | RUN set -eux; \ 34 | install-php-extensions \ 35 | @composer \ 36 | apcu \ 37 | intl \ 38 | opcache \ 39 | zip \ 40 | pdo_sqlite \ 41 | ; 42 | 43 | COPY --link frankenphp/conf.d/app.ini $PHP_INI_DIR/conf.d/ 44 | COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint 45 | COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile 46 | 47 | ENTRYPOINT ["docker-entrypoint"] 48 | 49 | HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 50 | CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] 51 | 52 | # Dev FrankenPHP image 53 | FROM frankenphp_base AS frankenphp_dev 54 | 55 | ENV APP_ENV=dev XDEBUG_MODE=off 56 | VOLUME /app/var/ 57 | 58 | RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" 59 | 60 | RUN set -eux; \ 61 | install-php-extensions \ 62 | xdebug \ 63 | ; 64 | 65 | COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/ 66 | 67 | CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ] 68 | 69 | # Prod FrankenPHP image 70 | FROM frankenphp_base AS frankenphp_prod 71 | 72 | ENV APP_ENV=prod 73 | ENV FRANKENPHP_CONFIG="import worker.Caddyfile" 74 | 75 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" 76 | 77 | COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/ 78 | COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile 79 | 80 | # prevent the reinstallation of vendors at every changes in the source code 81 | COPY --link composer.* symfony.* ./ 82 | RUN set -eux; \ 83 | composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress 84 | 85 | # copy sources 86 | COPY --link . ./ 87 | RUN rm -Rf frankenphp/ 88 | 89 | RUN set -eux; \ 90 | mkdir -p var/cache var/log; \ 91 | composer dump-autoload --classmap-authoritative --no-dev; \ 92 | composer dump-env prod; \ 93 | composer run-script --no-dev post-install-cmd; \ 94 | chmod +x bin/console; sync; 95 | 96 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | pages: 2 | showdown makehtml -i ../WHITEPAPER.md > white-paper.html 3 | cat templates/head.html white-paper.html templates/foot.html > public/white-paper.html 4 | rm white-paper.html 5 | showdown makehtml -i ../packages/ld/README.md > linked-data.html 6 | cat templates/head.html linked-data.html templates/foot.html > public/linked-data.html 7 | rm linked-data.html 8 | showdown makehtml -i ../packages/mercure/README.md > mercure.html 9 | cat templates/head.html mercure.html templates/foot.html > public/mercure.html 10 | rm mercure.html 11 | 12 | build: 13 | docker compose --env-file .env.local build 14 | docker compose --env-file .env.local up -d 15 | docker compose exec php bin/console app:load-fixtures 16 | -------------------------------------------------------------------------------- /api/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | =8.2", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "api-platform/doctrine-orm": "^4.0.0-alpha.6", 11 | "api-platform/symfony": "^4.0.0-alpha.6", 12 | "doctrine/dbal": "^3", 13 | "doctrine/doctrine-bundle": "^2.12", 14 | "doctrine/doctrine-migrations-bundle": "^3.3", 15 | "doctrine/orm": "^3.2", 16 | "nelmio/cors-bundle": "^2.4", 17 | "phpstan/phpdoc-parser": "^1.29", 18 | "runtime/frankenphp-symfony": "^0.2.0", 19 | "symfony/asset": "7.1.*", 20 | "symfony/console": "7.1.*", 21 | "symfony/dotenv": "7.1.*", 22 | "symfony/expression-language": "7.1.*", 23 | "symfony/flex": "^2", 24 | "symfony/framework-bundle": "7.1.*", 25 | "symfony/mercure-bundle": "^0.3.9", 26 | "symfony/property-access": "7.1.*", 27 | "symfony/property-info": "7.1.*", 28 | "symfony/runtime": "7.1.*", 29 | "symfony/security-bundle": "7.1.*", 30 | "symfony/serializer": "7.1.*", 31 | "symfony/twig-bundle": "7.1.*", 32 | "symfony/validator": "7.1.*", 33 | "symfony/yaml": "7.1.*", 34 | "twig/extra-bundle": "^2.12|^3.0", 35 | "twig/twig": "^2.12|^3.0" 36 | }, 37 | "config": { 38 | "allow-plugins": { 39 | "php-http/discovery": true, 40 | "symfony/flex": true, 41 | "symfony/runtime": true 42 | }, 43 | "sort-packages": true 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "App\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "App\\Tests\\": "tests/" 53 | } 54 | }, 55 | "replace": { 56 | "symfony/polyfill-ctype": "*", 57 | "symfony/polyfill-iconv": "*", 58 | "symfony/polyfill-php72": "*", 59 | "symfony/polyfill-php73": "*", 60 | "symfony/polyfill-php74": "*", 61 | "symfony/polyfill-php80": "*", 62 | "symfony/polyfill-php81": "*", 63 | "symfony/polyfill-php82": "*" 64 | }, 65 | "scripts": { 66 | "auto-scripts": { 67 | "cache:clear": "symfony-cmd", 68 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 69 | }, 70 | "post-install-cmd": [ 71 | "@auto-scripts" 72 | ], 73 | "post-update-cmd": [ 74 | "@auto-scripts" 75 | ] 76 | }, 77 | "conflict": { 78 | "symfony/symfony": "*" 79 | }, 80 | "extra": { 81 | "symfony": { 82 | "allow-contrib": false, 83 | "require": "7.1.*" 84 | } 85 | }, 86 | "require-dev": { 87 | "symfony/maker-bundle": "^1.59" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /api/config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 6 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 7 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 8 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 9 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 10 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 11 | Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], 12 | ]; 13 | -------------------------------------------------------------------------------- /api/config/packages/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | title: Hello API Platform 3 | version: 1.0.0 4 | formats: 5 | jsonld: ['application/ld+json'] 6 | docs_formats: 7 | jsonld: ['application/ld+json'] 8 | jsonopenapi: ['application/vnd.openapi+json'] 9 | html: ['text/html'] 10 | defaults: 11 | mercure: true 12 | stateless: true 13 | cache_headers: 14 | vary: ['Content-Type', 'Authorization', 'Origin'] 15 | extra_properties: 16 | standard_put: true 17 | enable_swagger_ui: false 18 | -------------------------------------------------------------------------------- /api/config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | # Unique name of your app: used to compute stable namespaces for cache keys. 4 | #prefix_seed: your_vendor_name/app_name 5 | 6 | # The "app" cache stores to the filesystem by default. 7 | # The data in this cache should persist between deploys. 8 | # Other options include: 9 | 10 | # Redis 11 | #app: cache.adapter.redis 12 | #default_redis_provider: redis://localhost 13 | 14 | # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) 15 | #app: cache.adapter.apcu 16 | 17 | # Namespaced pools use the above "app" backend by default 18 | #pools: 19 | #my.dedicated.cache: null 20 | -------------------------------------------------------------------------------- /api/config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '16' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | use_savepoints: true 11 | orm: 12 | auto_generate_proxy_classes: true 13 | enable_lazy_ghost_objects: true 14 | report_fields_where_declared: true 15 | validate_xml_mapping: true 16 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 17 | auto_mapping: true 18 | mappings: 19 | App: 20 | type: attribute 21 | is_bundle: false 22 | dir: '%kernel.project_dir%/src/Entity' 23 | prefix: 'App\Entity' 24 | alias: App 25 | controller_resolver: 26 | auto_mapping: false 27 | 28 | when@test: 29 | doctrine: 30 | dbal: 31 | # "TEST_TOKEN" is typically set by ParaTest 32 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 33 | 34 | when@prod: 35 | doctrine: 36 | orm: 37 | auto_generate_proxy_classes: false 38 | proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' 39 | query_cache_driver: 40 | type: pool 41 | pool: doctrine.system_cache_pool 42 | result_cache_driver: 43 | type: pool 44 | pool: doctrine.result_cache_pool 45 | 46 | framework: 47 | cache: 48 | pools: 49 | doctrine.result_cache_pool: 50 | adapter: cache.app 51 | doctrine.system_cache_pool: 52 | adapter: cache.system 53 | -------------------------------------------------------------------------------- /api/config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: false 7 | -------------------------------------------------------------------------------- /api/config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | 6 | # Note that the session will be started ONLY if you read or write from it. 7 | session: true 8 | 9 | #esi: true 10 | #fragments: true 11 | 12 | when@test: 13 | framework: 14 | test: true 15 | session: 16 | storage_factory_id: session.storage.factory.mock_file 17 | -------------------------------------------------------------------------------- /api/config/packages/mercure.yaml: -------------------------------------------------------------------------------- 1 | mercure: 2 | hubs: 3 | default: 4 | url: '%env(MERCURE_URL)%' 5 | public_url: '%env(MERCURE_PUBLIC_URL)%' 6 | jwt: 7 | secret: '%env(MERCURE_JWT_SECRET)%' 8 | publish: '*' 9 | -------------------------------------------------------------------------------- /api/config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['Content-Type', 'Authorization'] 7 | expose_headers: ['Link'] 8 | max_age: 3600 9 | paths: 10 | '^/': null 11 | -------------------------------------------------------------------------------- /api/config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 4 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 5 | #default_uri: http://localhost 6 | 7 | when@prod: 8 | framework: 9 | router: 10 | strict_requirements: null 11 | -------------------------------------------------------------------------------- /api/config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords 3 | password_hashers: 4 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' 5 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 6 | providers: 7 | users_in_memory: { memory: null } 8 | firewalls: 9 | dev: 10 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 11 | security: false 12 | main: 13 | lazy: true 14 | provider: users_in_memory 15 | 16 | # activate different ways to authenticate 17 | # https://symfony.com/doc/current/security.html#the-firewall 18 | 19 | # https://symfony.com/doc/current/security/impersonating_user.html 20 | # switch_user: true 21 | 22 | # Easy way to control access for large sections of your site 23 | # Note: Only the *first* access control that matches will be used 24 | access_control: 25 | # - { path: ^/admin, roles: ROLE_ADMIN } 26 | # - { path: ^/profile, roles: ROLE_USER } 27 | 28 | when@test: 29 | security: 30 | password_hashers: 31 | # By default, password hashers are resource intensive and take time. This is 32 | # important to generate secure password hashes. In tests however, secure hashes 33 | # are not important, waste resources and increase test times. The following 34 | # reduces the work factor to the lowest possible values. 35 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 36 | algorithm: auto 37 | cost: 4 # Lowest possible value for bcrypt 38 | time_cost: 3 # Lowest possible value for argon 39 | memory_cost: 10 # Lowest possible value for argon 40 | -------------------------------------------------------------------------------- /api/config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | # Enables validator auto-mapping support. 4 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 5 | #auto_mapping: 6 | # App\Entity\: [] 7 | 8 | when@test: 9 | framework: 10 | validation: 11 | not_compromised_password: false 12 | -------------------------------------------------------------------------------- /api/config/preload.php: -------------------------------------------------------------------------------- 1 | ; rel="http://www.w3.org/ns/hydra/core#apiDocumentation", ; rel="mercure"` 67 | # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics 68 | header ?Permissions-Policy "browsing-topics=()" 69 | 70 | file_server 71 | 72 | handle / { 73 | try_files ./index.html {path} 74 | } 75 | 76 | handle /* { 77 | try_files ./{path}.html {path} 78 | } 79 | 80 | php_server 81 | } 82 | -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.dev.ini: -------------------------------------------------------------------------------- 1 | ; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host 2 | ; See https://github.com/docker/for-linux/issues/264 3 | ; The `client_host` below may optionally be replaced with `discover_client_host=yes` 4 | ; Add `start_with_request=yes` to start debug session on each request 5 | xdebug.client_host = xdebug://gateway 6 | -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.ini: -------------------------------------------------------------------------------- 1 | expose_php = 0 2 | date.timezone = UTC 3 | apc.enable_cli = 1 4 | session.use_strict_mode = 1 5 | zend.detect_unicode = 0 6 | 7 | ; https://symfony.com/doc/current/performance.html 8 | realpath_cache_size = 4096K 9 | realpath_cache_ttl = 600 10 | opcache.interned_strings_buffer = 16 11 | opcache.max_accelerated_files = 20000 12 | opcache.memory_consumption = 256 13 | opcache.enable_file_override = 1 14 | -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.prod.ini: -------------------------------------------------------------------------------- 1 | opcache.preload_user = root 2 | opcache.preload = /app/config/preload.php 3 | -------------------------------------------------------------------------------- /api/frankenphp/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then 5 | if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then 6 | composer install --prefer-dist --no-progress --no-interaction 7 | fi 8 | 9 | if grep -q ^DATABASE_URL= .env; then 10 | if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then 11 | php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing 12 | fi 13 | fi 14 | setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 15 | setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 16 | fi 17 | 18 | exec docker-php-entrypoint "$@" 19 | -------------------------------------------------------------------------------- /api/frankenphp/worker.Caddyfile: -------------------------------------------------------------------------------- 1 | worker { 2 | file ./public/index.php 3 | env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime 4 | } 5 | -------------------------------------------------------------------------------- /api/migrations/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/esa/4ede1276da05865562311b22e495949e3e1a598f/api/migrations/.gitignore -------------------------------------------------------------------------------- /api/migrations/Version20240605141831.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE author (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL)'); 24 | $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, title VARCHAR(255) NOT NULL, condition VARCHAR(255) NOT NULL, CONSTRAINT FK_CBE5A331F675F31B FOREIGN KEY (author_id) REFERENCES author (id) NOT DEFERRABLE INITIALLY IMMEDIATE)'); 25 | $this->addSql('CREATE INDEX IDX_CBE5A331F675F31B ON book (author_id)'); 26 | } 27 | 28 | public function down(Schema $schema): void 29 | { 30 | // this down() migration is auto-generated, please modify it to your needs 31 | $this->addSql('DROP TABLE author'); 32 | $this->addSql('DROP TABLE book'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edge side APIs 7 | 8 | 9 | 19 | 20 | 21 |
22 |
23 |
24 |

Edge Side APIs

25 |
26 | 27 | 28 | 48 | 55 |
56 |
57 |
58 |
59 |
61 |
    62 |
  • 63 |

    64 | Edge Side APIs (ESA) is an architecture pattern, that allows the creation of more reliable, more efficient, and less resource-intensive APIs. This architecture revives the main REST/HATEOAS principles while taking full advantage of the new capabilities provided by the web platform. 65 |

    66 |

    67 | ESA promotes a mixed approach, synchronous and asynchronous, which allows a great simplicity of development and use, performances rarely reached, and the possibility for the clients to receive updates of the resource it fetched in real-time. Finally, ESA proposes to build on existing standards to expose documentation allowing the creation of generic clients, capable of discovering the capabilities of the API at runtime. 68 |

    69 |

    70 | Read the White Paper or browse Kevin Dunglas's presentation: 71 |

    72 | 73 |
  • 74 |
  • 75 |

    Atomic resource

    76 |

    77 | Resources served by the API must be small, atomic documents. The API must not embed related resources 78 | but 79 | instead expose them through an URL. Big resources should be splitted using one-to-one relations 80 | exposed by 81 | an 82 | URL. 83 |
    84 | More informations at the @api-platform/ld documentation 85 |

    86 |
  • 87 |
  • 88 |

    Pre-generate

    89 |

    This website's API is full static and can be hosted at edge.

    90 |
  • 91 |
  • 92 |

    HTTP

    93 |

    Click on the links to show status codes, benefit from private browser's cache 94 | without any configuration. 95 |

    96 |
  • 97 |
  • 98 |

    Preload

    99 |

    We preloaded the /books resource on this page.

    100 |
  • 101 |
  • 102 |

    Push

    103 |

    Live updates to every clients

    104 |
    105 |
    106 | 111 | 115 | 116 |
    117 |
    118 |
  • 119 |
120 |
121 |
124 | 125 |
127 |

128 | Welcome! Open a few of these links, you can then update resources using the form at the bottom of the page. 129 |
130 | To understand what happens under the hood, we recommend to open your developer tools on the Network tab. 131 |
132 |
133 |

134 |
135 |
136 |
137 |
138 |
139 | 140 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /api/public/index.js: -------------------------------------------------------------------------------- 1 | import ld, { resources } from "@api-platform/ld"; 2 | import mercure from "@api-platform/mercure"; 3 | import { URLPattern } from "urlpattern-polyfill"; 4 | let books = undefined 5 | 6 | const pattern = new URLPattern(window.origin+'/(books|authors)/:id') 7 | // Fetch fn for @api-platform/ld that calls mercure and keeps a copy on the initial JSON 8 | const fetchFn = (url) => { 9 | return mercure(url, { 10 | onUpdate: (data) => { 11 | resources.set(data['@id'], data) 12 | updateView(books) 13 | } 14 | }) 15 | .then(d => { 16 | return d.json() 17 | }) 18 | } 19 | 20 | // on data update updates the json, it also keeps a map of resources to update via mercure 21 | function onUpdate(obj, { iri, data }) { 22 | books = obj 23 | updateView(obj) 24 | } 25 | 26 | ld('/books', { fetchFn: fetchFn, urlPattern: pattern, onUpdate }) 27 | .then(d => { 28 | books = d 29 | updateView(d) 30 | }) 31 | 32 | 33 | function updateForm() { 34 | const select = document.getElementById('resources') 35 | select.innerHTML = ""; 36 | resources.forEach((value, key) => { 37 | const option = document.createElement('option') 38 | option.innerText = key 39 | select.appendChild(option) 40 | }); 41 | const d = resources.get(select.value) 42 | if (d) { 43 | document.getElementById('data').value = JSON.stringify(d, null, 2) 44 | } 45 | } 46 | 47 | function clone(d) { 48 | return JSON.parse(JSON.stringify(d)) 49 | } 50 | 51 | // update the view 52 | function updateView(d) { 53 | const copy = addLinks(clone(d)) 54 | document.getElementById('json').innerHTML = JSON.stringify(copy, null, 2) 55 | updateForm() 56 | } 57 | 58 | // shortcut to get the clone's current pointer 59 | function getValueAtKey(keys) { 60 | let current = clone 61 | keys.forEach((e, i) => { 62 | if (i === keys.length - 1) { 63 | return 64 | } 65 | 66 | current = current[e] 67 | }) 68 | 69 | return current 70 | } 71 | 72 | // Main function that calls the property getter on click on a link 73 | window.openLink = (event) => { 74 | event.preventDefault() 75 | const k = event.target.dataset.key 76 | const keys = k.split(',') 77 | // calls the getter, it'll run a fetch if data isn't in the cache 78 | let acc = books 79 | keys.forEach((e) => acc = acc[e]) 80 | } 81 | 82 | // Adds to our JSON 83 | function addLinks(d, k) { 84 | Object.keys(d).forEach((i) => { 85 | const key = !k ? i : i === '@id' ? k : `${k},${i}` 86 | const e = d[i] 87 | if (null !== e && typeof e === 'object') { 88 | d[i] = addLinks(e, key) 89 | return 90 | } 91 | 92 | if (i !== '@id' && resources.has(e)) { 93 | d[i] = addLinks(clone(resources.get(e)), key) 94 | return 95 | } 96 | 97 | if (typeof e !== 'string') { 98 | return 99 | } 100 | 101 | let absoluteValue = `${pattern.protocol}://${pattern.hostname}${pattern.port ? ':'+pattern.port : ''}${e}`; 102 | if (pattern.test(absoluteValue)) { 103 | d[i] = `${e}`; 104 | } else if (e && e.startsWith('https')) { 105 | d[i] = `${e}`; 106 | } 107 | }) 108 | 109 | return d 110 | } 111 | 112 | // mercure submit 113 | window.handleSubmit = function(e) { 114 | e.preventDefault(); 115 | const form = e.target; 116 | const formData = new FormData(form); 117 | fetch(formData.get('topic'), { 118 | method: 'PATCH', 119 | body: formData.get('data'), 120 | headers: { 121 | 'Content-Type': 'application/merge-patch+json' 122 | } 123 | }) 124 | } 125 | 126 | window.selectResource = function() { 127 | const select = document.getElementById('resources') 128 | const d = resources.get(select.value) 129 | document.getElementById('data').value = JSON.stringify(d, null, 2) 130 | } 131 | 132 | -------------------------------------------------------------------------------- /api/public/index.php: -------------------------------------------------------------------------------- 1 | li):not(:where([class~="not-prose"],[class~="not-prose"] *))::marker { 878 | font-weight: 400; 879 | color: var(--tw-prose-counters); 880 | } 881 | 882 | .prose :where(ul > li):not(:where([class~="not-prose"],[class~="not-prose"] *))::marker { 883 | color: var(--tw-prose-bullets); 884 | } 885 | 886 | .prose :where(dt):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 887 | color: var(--tw-prose-headings); 888 | font-weight: 600; 889 | margin-top: 1.25em; 890 | } 891 | 892 | .prose :where(hr):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 893 | border-color: var(--tw-prose-hr); 894 | border-top-width: 1px; 895 | margin-top: 3em; 896 | margin-bottom: 3em; 897 | } 898 | 899 | .prose :where(blockquote):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 900 | font-weight: 500; 901 | font-style: italic; 902 | color: var(--tw-prose-quotes); 903 | border-inline-start-width: 0.25rem; 904 | border-inline-start-color: var(--tw-prose-quote-borders); 905 | quotes: "\201C""\201D""\2018""\2019"; 906 | margin-top: 1.6em; 907 | margin-bottom: 1.6em; 908 | padding-inline-start: 1em; 909 | } 910 | 911 | .prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"],[class~="not-prose"] *))::before { 912 | content: open-quote; 913 | } 914 | 915 | .prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"],[class~="not-prose"] *))::after { 916 | content: close-quote; 917 | } 918 | 919 | .prose :where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 920 | color: var(--tw-prose-headings); 921 | font-weight: 800; 922 | font-size: 2.25em; 923 | margin-top: 0; 924 | margin-bottom: 0.8888889em; 925 | line-height: 1.1111111; 926 | } 927 | 928 | .prose :where(h1 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 929 | font-weight: 900; 930 | color: inherit; 931 | } 932 | 933 | .prose :where(h2):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 934 | color: var(--tw-prose-headings); 935 | font-weight: 700; 936 | font-size: 1.5em; 937 | margin-top: 2em; 938 | margin-bottom: 1em; 939 | line-height: 1.3333333; 940 | } 941 | 942 | .prose :where(h2 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 943 | font-weight: 800; 944 | color: inherit; 945 | } 946 | 947 | .prose :where(h3):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 948 | color: var(--tw-prose-headings); 949 | font-weight: 600; 950 | font-size: 1.25em; 951 | margin-top: 1.6em; 952 | margin-bottom: 0.6em; 953 | line-height: 1.6; 954 | } 955 | 956 | .prose :where(h3 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 957 | font-weight: 700; 958 | color: inherit; 959 | } 960 | 961 | .prose :where(h4):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 962 | color: var(--tw-prose-headings); 963 | font-weight: 600; 964 | margin-top: 1.5em; 965 | margin-bottom: 0.5em; 966 | line-height: 1.5; 967 | } 968 | 969 | .prose :where(h4 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 970 | font-weight: 700; 971 | color: inherit; 972 | } 973 | 974 | .prose :where(img):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 975 | margin-top: 2em; 976 | margin-bottom: 2em; 977 | } 978 | 979 | .prose :where(picture):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 980 | display: block; 981 | margin-top: 2em; 982 | margin-bottom: 2em; 983 | } 984 | 985 | .prose :where(video):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 986 | margin-top: 2em; 987 | margin-bottom: 2em; 988 | } 989 | 990 | .prose :where(kbd):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 991 | font-weight: 500; 992 | font-family: inherit; 993 | color: var(--tw-prose-kbd); 994 | box-shadow: 0 0 0 1px rgb(var(--tw-prose-kbd-shadows) / 10%), 0 3px 0 rgb(var(--tw-prose-kbd-shadows) / 10%); 995 | font-size: 0.875em; 996 | border-radius: 0.3125rem; 997 | padding-top: 0.1875em; 998 | padding-inline-end: 0.375em; 999 | padding-bottom: 0.1875em; 1000 | padding-inline-start: 0.375em; 1001 | } 1002 | 1003 | .prose :where(code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1004 | color: var(--tw-prose-code); 1005 | font-weight: 600; 1006 | font-size: 0.875em; 1007 | } 1008 | 1009 | .prose :where(code):not(:where([class~="not-prose"],[class~="not-prose"] *))::before { 1010 | content: "`"; 1011 | } 1012 | 1013 | .prose :where(code):not(:where([class~="not-prose"],[class~="not-prose"] *))::after { 1014 | content: "`"; 1015 | } 1016 | 1017 | .prose :where(a code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1018 | color: inherit; 1019 | } 1020 | 1021 | .prose :where(h1 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1022 | color: inherit; 1023 | } 1024 | 1025 | .prose :where(h2 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1026 | color: inherit; 1027 | font-size: 0.875em; 1028 | } 1029 | 1030 | .prose :where(h3 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1031 | color: inherit; 1032 | font-size: 0.9em; 1033 | } 1034 | 1035 | .prose :where(h4 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1036 | color: inherit; 1037 | } 1038 | 1039 | .prose :where(blockquote code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1040 | color: inherit; 1041 | } 1042 | 1043 | .prose :where(thead th code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1044 | color: inherit; 1045 | } 1046 | 1047 | .prose :where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1048 | color: var(--tw-prose-pre-code); 1049 | background-color: var(--tw-prose-pre-bg); 1050 | overflow-x: auto; 1051 | font-weight: 400; 1052 | font-size: 0.875em; 1053 | line-height: 1.7142857; 1054 | margin-top: 1.7142857em; 1055 | margin-bottom: 1.7142857em; 1056 | border-radius: 0.375rem; 1057 | padding-top: 0.8571429em; 1058 | padding-inline-end: 1.1428571em; 1059 | padding-bottom: 0.8571429em; 1060 | padding-inline-start: 1.1428571em; 1061 | } 1062 | 1063 | .prose :where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1064 | background-color: transparent; 1065 | border-width: 0; 1066 | border-radius: 0; 1067 | padding: 0; 1068 | font-weight: inherit; 1069 | color: inherit; 1070 | font-size: inherit; 1071 | font-family: inherit; 1072 | line-height: inherit; 1073 | } 1074 | 1075 | .prose :where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *))::before { 1076 | content: none; 1077 | } 1078 | 1079 | .prose :where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *))::after { 1080 | content: none; 1081 | } 1082 | 1083 | .prose :where(table):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1084 | width: 100%; 1085 | table-layout: auto; 1086 | text-align: start; 1087 | margin-top: 2em; 1088 | margin-bottom: 2em; 1089 | font-size: 0.875em; 1090 | line-height: 1.7142857; 1091 | } 1092 | 1093 | .prose :where(thead):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1094 | border-bottom-width: 1px; 1095 | border-bottom-color: var(--tw-prose-th-borders); 1096 | } 1097 | 1098 | .prose :where(thead th):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1099 | color: var(--tw-prose-headings); 1100 | font-weight: 600; 1101 | vertical-align: bottom; 1102 | padding-inline-end: 0.5714286em; 1103 | padding-bottom: 0.5714286em; 1104 | padding-inline-start: 0.5714286em; 1105 | } 1106 | 1107 | .prose :where(tbody tr):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1108 | border-bottom-width: 1px; 1109 | border-bottom-color: var(--tw-prose-td-borders); 1110 | } 1111 | 1112 | .prose :where(tbody tr:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1113 | border-bottom-width: 0; 1114 | } 1115 | 1116 | .prose :where(tbody td):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1117 | vertical-align: baseline; 1118 | } 1119 | 1120 | .prose :where(tfoot):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1121 | border-top-width: 1px; 1122 | border-top-color: var(--tw-prose-th-borders); 1123 | } 1124 | 1125 | .prose :where(tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1126 | vertical-align: top; 1127 | } 1128 | 1129 | .prose :where(figure > *):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1130 | margin-top: 0; 1131 | margin-bottom: 0; 1132 | } 1133 | 1134 | .prose :where(figcaption):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1135 | color: var(--tw-prose-captions); 1136 | font-size: 0.875em; 1137 | line-height: 1.4285714; 1138 | margin-top: 0.8571429em; 1139 | } 1140 | 1141 | .prose { 1142 | --tw-prose-body: #374151; 1143 | --tw-prose-headings: #111827; 1144 | --tw-prose-lead: #4b5563; 1145 | --tw-prose-links: #111827; 1146 | --tw-prose-bold: #111827; 1147 | --tw-prose-counters: #6b7280; 1148 | --tw-prose-bullets: #d1d5db; 1149 | --tw-prose-hr: #e5e7eb; 1150 | --tw-prose-quotes: #111827; 1151 | --tw-prose-quote-borders: #e5e7eb; 1152 | --tw-prose-captions: #6b7280; 1153 | --tw-prose-kbd: #111827; 1154 | --tw-prose-kbd-shadows: 17 24 39; 1155 | --tw-prose-code: #111827; 1156 | --tw-prose-pre-code: #e5e7eb; 1157 | --tw-prose-pre-bg: #1f2937; 1158 | --tw-prose-th-borders: #d1d5db; 1159 | --tw-prose-td-borders: #e5e7eb; 1160 | --tw-prose-invert-body: #d1d5db; 1161 | --tw-prose-invert-headings: #fff; 1162 | --tw-prose-invert-lead: #9ca3af; 1163 | --tw-prose-invert-links: #fff; 1164 | --tw-prose-invert-bold: #fff; 1165 | --tw-prose-invert-counters: #9ca3af; 1166 | --tw-prose-invert-bullets: #4b5563; 1167 | --tw-prose-invert-hr: #374151; 1168 | --tw-prose-invert-quotes: #f3f4f6; 1169 | --tw-prose-invert-quote-borders: #374151; 1170 | --tw-prose-invert-captions: #9ca3af; 1171 | --tw-prose-invert-kbd: #fff; 1172 | --tw-prose-invert-kbd-shadows: 255 255 255; 1173 | --tw-prose-invert-code: #fff; 1174 | --tw-prose-invert-pre-code: #d1d5db; 1175 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 1176 | --tw-prose-invert-th-borders: #4b5563; 1177 | --tw-prose-invert-td-borders: #374151; 1178 | font-size: 1rem; 1179 | line-height: 1.75; 1180 | } 1181 | 1182 | .prose :where(picture > img):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1183 | margin-top: 0; 1184 | margin-bottom: 0; 1185 | } 1186 | 1187 | .prose :where(li):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1188 | margin-top: 0.5em; 1189 | margin-bottom: 0.5em; 1190 | } 1191 | 1192 | .prose :where(ol > li):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1193 | padding-inline-start: 0.375em; 1194 | } 1195 | 1196 | .prose :where(ul > li):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1197 | padding-inline-start: 0.375em; 1198 | } 1199 | 1200 | .prose :where(.prose > ul > li p):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1201 | margin-top: 0.75em; 1202 | margin-bottom: 0.75em; 1203 | } 1204 | 1205 | .prose :where(.prose > ul > li > p:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1206 | margin-top: 1.25em; 1207 | } 1208 | 1209 | .prose :where(.prose > ul > li > p:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1210 | margin-bottom: 1.25em; 1211 | } 1212 | 1213 | .prose :where(.prose > ol > li > p:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1214 | margin-top: 1.25em; 1215 | } 1216 | 1217 | .prose :where(.prose > ol > li > p:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1218 | margin-bottom: 1.25em; 1219 | } 1220 | 1221 | .prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1222 | margin-top: 0.75em; 1223 | margin-bottom: 0.75em; 1224 | } 1225 | 1226 | .prose :where(dl):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1227 | margin-top: 1.25em; 1228 | margin-bottom: 1.25em; 1229 | } 1230 | 1231 | .prose :where(dd):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1232 | margin-top: 0.5em; 1233 | padding-inline-start: 1.625em; 1234 | } 1235 | 1236 | .prose :where(hr + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1237 | margin-top: 0; 1238 | } 1239 | 1240 | .prose :where(h2 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1241 | margin-top: 0; 1242 | } 1243 | 1244 | .prose :where(h3 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1245 | margin-top: 0; 1246 | } 1247 | 1248 | .prose :where(h4 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1249 | margin-top: 0; 1250 | } 1251 | 1252 | .prose :where(thead th:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1253 | padding-inline-start: 0; 1254 | } 1255 | 1256 | .prose :where(thead th:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1257 | padding-inline-end: 0; 1258 | } 1259 | 1260 | .prose :where(tbody td, tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1261 | padding-top: 0.5714286em; 1262 | padding-inline-end: 0.5714286em; 1263 | padding-bottom: 0.5714286em; 1264 | padding-inline-start: 0.5714286em; 1265 | } 1266 | 1267 | .prose :where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1268 | padding-inline-start: 0; 1269 | } 1270 | 1271 | .prose :where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1272 | padding-inline-end: 0; 1273 | } 1274 | 1275 | .prose :where(figure):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1276 | margin-top: 2em; 1277 | margin-bottom: 2em; 1278 | } 1279 | 1280 | .prose :where(.prose > :first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1281 | margin-top: 0; 1282 | } 1283 | 1284 | .prose :where(.prose > :last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) { 1285 | margin-bottom: 0; 1286 | } 1287 | 1288 | .prose-slate { 1289 | --tw-prose-body: #334155; 1290 | --tw-prose-headings: #0f172a; 1291 | --tw-prose-lead: #475569; 1292 | --tw-prose-links: #0f172a; 1293 | --tw-prose-bold: #0f172a; 1294 | --tw-prose-counters: #64748b; 1295 | --tw-prose-bullets: #cbd5e1; 1296 | --tw-prose-hr: #e2e8f0; 1297 | --tw-prose-quotes: #0f172a; 1298 | --tw-prose-quote-borders: #e2e8f0; 1299 | --tw-prose-captions: #64748b; 1300 | --tw-prose-kbd: #0f172a; 1301 | --tw-prose-kbd-shadows: 15 23 42; 1302 | --tw-prose-code: #0f172a; 1303 | --tw-prose-pre-code: #e2e8f0; 1304 | --tw-prose-pre-bg: #1e293b; 1305 | --tw-prose-th-borders: #cbd5e1; 1306 | --tw-prose-td-borders: #e2e8f0; 1307 | --tw-prose-invert-body: #cbd5e1; 1308 | --tw-prose-invert-headings: #fff; 1309 | --tw-prose-invert-lead: #94a3b8; 1310 | --tw-prose-invert-links: #fff; 1311 | --tw-prose-invert-bold: #fff; 1312 | --tw-prose-invert-counters: #94a3b8; 1313 | --tw-prose-invert-bullets: #475569; 1314 | --tw-prose-invert-hr: #334155; 1315 | --tw-prose-invert-quotes: #f1f5f9; 1316 | --tw-prose-invert-quote-borders: #334155; 1317 | --tw-prose-invert-captions: #94a3b8; 1318 | --tw-prose-invert-kbd: #fff; 1319 | --tw-prose-invert-kbd-shadows: 255 255 255; 1320 | --tw-prose-invert-code: #fff; 1321 | --tw-prose-invert-pre-code: #cbd5e1; 1322 | --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); 1323 | --tw-prose-invert-th-borders: #475569; 1324 | --tw-prose-invert-td-borders: #334155; 1325 | } 1326 | 1327 | .static { 1328 | position: static; 1329 | } 1330 | 1331 | .fixed { 1332 | position: fixed; 1333 | } 1334 | 1335 | .absolute { 1336 | position: absolute; 1337 | } 1338 | 1339 | .relative { 1340 | position: relative; 1341 | } 1342 | 1343 | .inset-0 { 1344 | inset: 0px; 1345 | } 1346 | 1347 | .inset-y-0 { 1348 | top: 0px; 1349 | bottom: 0px; 1350 | } 1351 | 1352 | .bottom-0 { 1353 | bottom: 0px; 1354 | } 1355 | 1356 | .left-0 { 1357 | left: 0px; 1358 | } 1359 | 1360 | .right-0 { 1361 | right: 0px; 1362 | } 1363 | 1364 | .top-0 { 1365 | top: 0px; 1366 | } 1367 | 1368 | .left-full { 1369 | left: 100%; 1370 | } 1371 | 1372 | .z-10 { 1373 | z-index: 10; 1374 | } 1375 | 1376 | .mx-auto { 1377 | margin-left: auto; 1378 | margin-right: auto; 1379 | } 1380 | 1381 | .ml-3 { 1382 | margin-left: 0.75rem; 1383 | } 1384 | 1385 | .mt-1 { 1386 | margin-top: 0.25rem; 1387 | } 1388 | 1389 | .mt-4 { 1390 | margin-top: 1rem; 1391 | } 1392 | 1393 | .mt-8 { 1394 | margin-top: 2rem; 1395 | } 1396 | 1397 | .mb-8 { 1398 | margin-bottom: 2rem; 1399 | } 1400 | 1401 | .mt-2 { 1402 | margin-top: 0.5rem; 1403 | } 1404 | 1405 | .block { 1406 | display: block; 1407 | } 1408 | 1409 | .flex { 1410 | display: flex; 1411 | } 1412 | 1413 | .grid { 1414 | display: grid; 1415 | } 1416 | 1417 | .hidden { 1418 | display: none; 1419 | } 1420 | 1421 | .size-6 { 1422 | width: 1.5rem; 1423 | height: 1.5rem; 1424 | } 1425 | 1426 | .size-10 { 1427 | width: 2.5rem; 1428 | height: 2.5rem; 1429 | } 1430 | 1431 | .h-20 { 1432 | height: 5rem; 1433 | } 1434 | 1435 | .h-6 { 1436 | height: 1.5rem; 1437 | } 1438 | 1439 | .h-screen { 1440 | height: 100vh; 1441 | } 1442 | 1443 | .w-6 { 1444 | width: 1.5rem; 1445 | } 1446 | 1447 | .w-96 { 1448 | width: 24rem; 1449 | } 1450 | 1451 | .w-\[40\%\] { 1452 | width: 40%; 1453 | } 1454 | 1455 | .w-\[60\%\] { 1456 | width: 60%; 1457 | } 1458 | 1459 | .w-\[calc\(50vw-25\%\)\] { 1460 | width: calc(50vw - 25%); 1461 | } 1462 | 1463 | .w-full { 1464 | width: 100%; 1465 | } 1466 | 1467 | .max-w-md { 1468 | max-width: 28rem; 1469 | } 1470 | 1471 | .max-w-none { 1472 | max-width: none; 1473 | } 1474 | 1475 | .translate-x-96 { 1476 | --tw-translate-x: 24rem; 1477 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1478 | } 1479 | 1480 | .-translate-x-16 { 1481 | --tw-translate-x: -4rem; 1482 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1483 | } 1484 | 1485 | .-translate-x-12 { 1486 | --tw-translate-x: -3rem; 1487 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1488 | } 1489 | 1490 | .-translate-x-8 { 1491 | --tw-translate-x: -2rem; 1492 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1493 | } 1494 | 1495 | .-translate-x-6 { 1496 | --tw-translate-x: -1.5rem; 1497 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1498 | } 1499 | 1500 | .cursor-pointer { 1501 | cursor: pointer; 1502 | } 1503 | 1504 | .grid-cols-1 { 1505 | grid-template-columns: repeat(1, minmax(0, 1fr)); 1506 | } 1507 | 1508 | .flex-col { 1509 | flex-direction: column; 1510 | } 1511 | 1512 | .items-center { 1513 | align-items: center; 1514 | } 1515 | 1516 | .justify-between { 1517 | justify-content: space-between; 1518 | } 1519 | 1520 | .gap-6 { 1521 | gap: 1.5rem; 1522 | } 1523 | 1524 | .space-x-6 > :not([hidden]) ~ :not([hidden]) { 1525 | --tw-space-x-reverse: 0; 1526 | margin-right: calc(1.5rem * var(--tw-space-x-reverse)); 1527 | margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); 1528 | } 1529 | 1530 | .self-end { 1531 | align-self: flex-end; 1532 | } 1533 | 1534 | .overflow-auto { 1535 | overflow: auto; 1536 | } 1537 | 1538 | .overflow-y-auto { 1539 | overflow-y: auto; 1540 | } 1541 | 1542 | .rounded-lg { 1543 | border-radius: 0.5rem; 1544 | } 1545 | 1546 | .rounded-md { 1547 | border-radius: 0.375rem; 1548 | } 1549 | 1550 | .rounded-full { 1551 | border-radius: 9999px; 1552 | } 1553 | 1554 | .border { 1555 | border-width: 1px; 1556 | } 1557 | 1558 | .border-slate-400 { 1559 | --tw-border-opacity: 1; 1560 | border-color: rgb(148 163 184 / var(--tw-border-opacity)); 1561 | } 1562 | 1563 | .bg-cyan-700 { 1564 | --tw-bg-opacity: 1; 1565 | background-color: rgb(14 116 144 / var(--tw-bg-opacity)); 1566 | } 1567 | 1568 | .bg-slate-100 { 1569 | --tw-bg-opacity: 1; 1570 | background-color: rgb(241 245 249 / var(--tw-bg-opacity)); 1571 | } 1572 | 1573 | .bg-white { 1574 | --tw-bg-opacity: 1; 1575 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1576 | } 1577 | 1578 | .bg-cyan-950\/50 { 1579 | background-color: rgb(8 51 68 / 0.5); 1580 | } 1581 | 1582 | .fill-current { 1583 | fill: currentColor; 1584 | } 1585 | 1586 | .p-2 { 1587 | padding: 0.5rem; 1588 | } 1589 | 1590 | .p-8 { 1591 | padding: 2rem; 1592 | } 1593 | 1594 | .p-4 { 1595 | padding: 1rem; 1596 | } 1597 | 1598 | .py-12 { 1599 | padding-top: 3rem; 1600 | padding-bottom: 3rem; 1601 | } 1602 | 1603 | .py-4 { 1604 | padding-top: 1rem; 1605 | padding-bottom: 1rem; 1606 | } 1607 | 1608 | .py-16 { 1609 | padding-top: 4rem; 1610 | padding-bottom: 4rem; 1611 | } 1612 | 1613 | .px-4 { 1614 | padding-left: 1rem; 1615 | padding-right: 1rem; 1616 | } 1617 | 1618 | .pb-4 { 1619 | padding-bottom: 1rem; 1620 | } 1621 | 1622 | .pl-0 { 1623 | padding-left: 0px; 1624 | } 1625 | 1626 | .pr-8 { 1627 | padding-right: 2rem; 1628 | } 1629 | 1630 | .pt-20 { 1631 | padding-top: 5rem; 1632 | } 1633 | 1634 | .text-center { 1635 | text-align: center; 1636 | } 1637 | 1638 | .font-mono { 1639 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 1640 | } 1641 | 1642 | .text-2xl { 1643 | font-size: 1.5rem; 1644 | line-height: 2rem; 1645 | } 1646 | 1647 | .text-sm { 1648 | font-size: 0.875rem; 1649 | line-height: 1.25rem; 1650 | } 1651 | 1652 | .text-xl { 1653 | font-size: 1.25rem; 1654 | line-height: 1.75rem; 1655 | } 1656 | 1657 | .text-4xl { 1658 | font-size: 2.25rem; 1659 | line-height: 2.5rem; 1660 | } 1661 | 1662 | .text-3xl { 1663 | font-size: 1.875rem; 1664 | line-height: 2.25rem; 1665 | } 1666 | 1667 | .font-bold { 1668 | font-weight: 700; 1669 | } 1670 | 1671 | .font-extrabold { 1672 | font-weight: 800; 1673 | } 1674 | 1675 | .font-normal { 1676 | font-weight: 400; 1677 | } 1678 | 1679 | .leading-relaxed { 1680 | line-height: 1.625; 1681 | } 1682 | 1683 | .text-blue-600 { 1684 | --tw-text-opacity: 1; 1685 | color: rgb(37 99 235 / var(--tw-text-opacity)); 1686 | } 1687 | 1688 | .text-cyan-700 { 1689 | --tw-text-opacity: 1; 1690 | color: rgb(14 116 144 / var(--tw-text-opacity)); 1691 | } 1692 | 1693 | .text-green-600 { 1694 | --tw-text-opacity: 1; 1695 | color: rgb(22 163 74 / var(--tw-text-opacity)); 1696 | } 1697 | 1698 | .text-slate-500 { 1699 | --tw-text-opacity: 1; 1700 | color: rgb(100 116 139 / var(--tw-text-opacity)); 1701 | } 1702 | 1703 | .text-slate-600 { 1704 | --tw-text-opacity: 1; 1705 | color: rgb(71 85 105 / var(--tw-text-opacity)); 1706 | } 1707 | 1708 | .text-slate-700 { 1709 | --tw-text-opacity: 1; 1710 | color: rgb(51 65 85 / var(--tw-text-opacity)); 1711 | } 1712 | 1713 | .text-white { 1714 | --tw-text-opacity: 1; 1715 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1716 | } 1717 | 1718 | .opacity-0 { 1719 | opacity: 0; 1720 | } 1721 | 1722 | .shadow-\[0px_5px_28px_-4px_rgba\(0\2c 0\2c 0\2c 0\.2\)\] { 1723 | --tw-shadow: 0px 5px 28px -4px rgba(0,0,0,0.2); 1724 | --tw-shadow-colored: 0px 5px 28px -4px var(--tw-shadow-color); 1725 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1726 | } 1727 | 1728 | .filter { 1729 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); 1730 | } 1731 | 1732 | .transition-colors { 1733 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; 1734 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1735 | transition-duration: 150ms; 1736 | } 1737 | 1738 | .transition-all { 1739 | transition-property: all; 1740 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1741 | transition-duration: 150ms; 1742 | } 1743 | 1744 | .transition-opacity { 1745 | transition-property: opacity; 1746 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1747 | transition-duration: 150ms; 1748 | } 1749 | 1750 | .transition { 1751 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 1752 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1753 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 1754 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1755 | transition-duration: 150ms; 1756 | } 1757 | 1758 | .duration-500 { 1759 | transition-duration: 500ms; 1760 | } 1761 | 1762 | .duration-300 { 1763 | transition-duration: 300ms; 1764 | } 1765 | 1766 | /* poppins-latin-400-normal */ 1767 | 1768 | @font-face { 1769 | font-family: 'Inter Variable'; 1770 | 1771 | font-style: normal; 1772 | 1773 | font-display: swap; 1774 | 1775 | font-weight: 100 900; 1776 | 1777 | src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter:vf@latest/latin-wght-normal.woff2) format('woff2-variations'); 1778 | 1779 | unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; 1780 | } 1781 | 1782 | .hover\:bg-cyan-100:hover { 1783 | --tw-bg-opacity: 1; 1784 | background-color: rgb(207 250 254 / var(--tw-bg-opacity)); 1785 | } 1786 | 1787 | .hover\:text-cyan-500:hover { 1788 | --tw-text-opacity: 1; 1789 | color: rgb(6 182 212 / var(--tw-text-opacity)); 1790 | } 1791 | 1792 | .hover\:underline:hover { 1793 | text-decoration-line: underline; 1794 | } 1795 | 1796 | .focus\:outline-none:focus { 1797 | outline: 2px solid transparent; 1798 | outline-offset: 2px; 1799 | } 1800 | 1801 | .peer:checked ~ .peer-checked\:left-0 { 1802 | left: 0px; 1803 | } 1804 | 1805 | .peer:checked ~ .peer-checked\:translate-x-0 { 1806 | --tw-translate-x: 0px; 1807 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1808 | } 1809 | 1810 | .peer:checked ~ .peer-checked\:opacity-100 { 1811 | opacity: 1; 1812 | } 1813 | 1814 | .peer:checked ~ .group .peer-checked\:group-\[\]\:-translate-x-6 { 1815 | --tw-translate-x: -1.5rem; 1816 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1817 | } 1818 | 1819 | .prose-h2\:mb-2 :is(:where(h2):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1820 | margin-bottom: 0.5rem; 1821 | } 1822 | 1823 | .prose-h2\:mt-8 :is(:where(h2):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1824 | margin-top: 2rem; 1825 | } 1826 | 1827 | .prose-a\:font-bold :is(:where(a):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1828 | font-weight: 700; 1829 | } 1830 | 1831 | .prose-a\:text-cyan-700 :is(:where(a):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1832 | --tw-text-opacity: 1; 1833 | color: rgb(14 116 144 / var(--tw-text-opacity)); 1834 | } 1835 | 1836 | .prose-code\:\!bg-transparent :is(:where(code):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1837 | background-color: transparent !important; 1838 | } 1839 | 1840 | .prose-pre\:rounded-xl :is(:where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1841 | border-radius: 0.75rem; 1842 | } 1843 | 1844 | .prose-pre\:bg-slate-100 :is(:where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1845 | --tw-bg-opacity: 1; 1846 | background-color: rgb(241 245 249 / var(--tw-bg-opacity)); 1847 | } 1848 | 1849 | .prose-pre\:p-4 :is(:where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1850 | padding: 1rem; 1851 | } 1852 | 1853 | .prose-pre\:text-slate-800 :is(:where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *))) { 1854 | --tw-text-opacity: 1; 1855 | color: rgb(30 41 59 / var(--tw-text-opacity)); 1856 | } 1857 | 1858 | @media (min-width: 640px) { 1859 | .sm\:text-4xl { 1860 | font-size: 2.25rem; 1861 | line-height: 2.5rem; 1862 | } 1863 | } 1864 | 1865 | @media (min-width: 768px) { 1866 | .md\:static { 1867 | position: static; 1868 | } 1869 | 1870 | .md\:absolute { 1871 | position: absolute; 1872 | } 1873 | 1874 | .md\:bottom-0 { 1875 | bottom: 0px; 1876 | } 1877 | 1878 | .md\:left-0 { 1879 | left: 0px; 1880 | } 1881 | 1882 | .md\:top-0 { 1883 | top: 0px; 1884 | } 1885 | 1886 | .md\:mb-0 { 1887 | margin-bottom: 0px; 1888 | } 1889 | 1890 | .md\:block { 1891 | display: block; 1892 | } 1893 | 1894 | .md\:flex { 1895 | display: flex; 1896 | } 1897 | 1898 | .md\:hidden { 1899 | display: none; 1900 | } 1901 | 1902 | .md\:h-screen { 1903 | height: 100vh; 1904 | } 1905 | 1906 | .md\:w-auto { 1907 | width: auto; 1908 | } 1909 | 1910 | .md\:w-\[60\%\] { 1911 | width: 60%; 1912 | } 1913 | 1914 | .md\:w-\[40\%\] { 1915 | width: 40%; 1916 | } 1917 | 1918 | .md\:w-\[calc\(50vw-25\%\)\] { 1919 | width: calc(50vw - 25%); 1920 | } 1921 | 1922 | .md\:flex-row { 1923 | flex-direction: row; 1924 | } 1925 | 1926 | .md\:overflow-auto { 1927 | overflow: auto; 1928 | } 1929 | 1930 | .md\:overflow-y-auto { 1931 | overflow-y: auto; 1932 | } 1933 | 1934 | .md\:bg-transparent { 1935 | background-color: transparent; 1936 | } 1937 | 1938 | .md\:p-8 { 1939 | padding: 2rem; 1940 | } 1941 | 1942 | .md\:px-0 { 1943 | padding-left: 0px; 1944 | padding-right: 0px; 1945 | } 1946 | } 1947 | 1948 | @media (min-width: 1024px) { 1949 | .lg\:px-0 { 1950 | padding-left: 0px; 1951 | padding-right: 0px; 1952 | } 1953 | } 1954 | 1955 | @media (min-width: 1280px) { 1956 | .xl\:px-0 { 1957 | padding-left: 0px; 1958 | padding-right: 0px; 1959 | } 1960 | } 1961 | 1962 | @media (max-width: 767px) { 1963 | .mobile-only\:fixed { 1964 | position: fixed; 1965 | } 1966 | 1967 | .mobile-only\:inset-y-0 { 1968 | top: 0px; 1969 | bottom: 0px; 1970 | } 1971 | 1972 | .mobile-only\:right-0 { 1973 | right: 0px; 1974 | } 1975 | 1976 | .mobile-only\:mx-4 { 1977 | margin-left: 1rem; 1978 | margin-right: 1rem; 1979 | } 1980 | 1981 | .mobile-only\:w-96 { 1982 | width: 24rem; 1983 | } 1984 | 1985 | .mobile-only\:max-w-\[90\%\] { 1986 | max-width: 90%; 1987 | } 1988 | 1989 | .mobile-only\:max-w-\[80\%\] { 1990 | max-width: 80%; 1991 | } 1992 | 1993 | .mobile-only\:translate-x-96 { 1994 | --tw-translate-x: 24rem; 1995 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1996 | } 1997 | 1998 | .mobile-only\:flex-col { 1999 | flex-direction: column; 2000 | } 2001 | 2002 | .mobile-only\:justify-between { 2003 | justify-content: space-between; 2004 | } 2005 | 2006 | .mobile-only\:bg-white { 2007 | --tw-bg-opacity: 1; 2008 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 2009 | } 2010 | 2011 | .mobile-only\:text-xl { 2012 | font-size: 1.25rem; 2013 | line-height: 1.75rem; 2014 | } 2015 | 2016 | .mobile-only\:font-bold { 2017 | font-weight: 700; 2018 | } 2019 | 2020 | .mobile-only\:shadow-xl { 2021 | --tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); 2022 | --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color); 2023 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 2024 | } 2025 | 2026 | .peer:checked ~ .mobile-only\:peer-checked\:translate-x-0 { 2027 | --tw-translate-x: 0px; 2028 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 2029 | } 2030 | } 2031 | 2032 | @media (prefers-color-scheme: dark) { 2033 | .dark\:text-blue-500 { 2034 | --tw-text-opacity: 1; 2035 | color: rgb(59 130 246 / var(--tw-text-opacity)); 2036 | } 2037 | 2038 | .dark\:text-green-500 { 2039 | --tw-text-opacity: 1; 2040 | color: rgb(34 197 94 / var(--tw-text-opacity)); 2041 | } 2042 | } -------------------------------------------------------------------------------- /api/src/ApiResource/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/esa/4ede1276da05865562311b22e495949e3e1a598f/api/src/ApiResource/.gitignore -------------------------------------------------------------------------------- /api/src/Command/LoadFixturesCommand.php: -------------------------------------------------------------------------------- 1 | managerRegistry->getManagerForClass(Author::class); 35 | $b = $this->managerRegistry->getManagerForClass(Book::class); 36 | 37 | $cmd = $a->getClassMetadata(Author::class); 38 | $connection = $this->managerRegistry->getConnection(); 39 | $dbPlatform = $connection->getDatabasePlatform(); 40 | 41 | foreach ($a->getRepository(Author::class)->findAll() as $author) { 42 | $a->remove($author); 43 | } 44 | $a->flush(); 45 | 46 | foreach ($b->getRepository(Book::class)->findAll() as $book) { 47 | $b->remove($book); 48 | } 49 | $b->flush(); 50 | 51 | $dan = new Author(); 52 | $dan->setName('Dan Simmons'); 53 | $a->persist($dan); 54 | $peter = new Author(); 55 | $peter->setName("O'Donnell, Peter"); 56 | $a->persist($peter); 57 | $emily = new Author(); 58 | $emily->setName("Emily Rodda"); 59 | $a->persist($emily); 60 | $a->flush(); 61 | 62 | $x = new Book(); 63 | $x->setTitle('Hyperion'); 64 | $x->setAuthor($dan); 65 | $x->setCondition('https://schema.org/UsedCondition'); 66 | $b->persist($x); 67 | $x = new Book(); 68 | $x->setTitle('Silver Mistress'); 69 | $x->setAuthor($peter); 70 | $x->setCondition('https://schema.org/RefurbishedCondition'); 71 | $b->persist($x); 72 | $x = new Book(); 73 | $x->setTitle('Silver Mistress'); 74 | $x->setAuthor($peter); 75 | $x->setCondition('https://schema.org/DamagedCondition'); 76 | $b->persist($x); 77 | $x = new Book(); 78 | $x->setTitle('Dragons of Deltora'); 79 | $x->setAuthor($emily); 80 | $x->setCondition('https://schema.org/NewCondition'); 81 | $b->persist($x); 82 | $b->flush(); 83 | 84 | return Command::SUCCESS; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /api/src/Entity/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/api-platform/esa/4ede1276da05865562311b22e495949e3e1a598f/api/src/Entity/.gitignore -------------------------------------------------------------------------------- /api/src/Entity/Author.php: -------------------------------------------------------------------------------- 1 | id; 29 | } 30 | 31 | public function getName(): ?string 32 | { 33 | return $this->name; 34 | } 35 | 36 | public function setName(string $name): static 37 | { 38 | $this->name = $name; 39 | 40 | return $this; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/src/Entity/Book.php: -------------------------------------------------------------------------------- 1 | true], types: ['https://schema.org/Book'])] 11 | class Book 12 | { 13 | #[ORM\Id] 14 | #[ORM\GeneratedValue] 15 | #[ORM\Column] 16 | #[ApiProperty(readable: false)] 17 | private ?int $id = null; 18 | 19 | #[ORM\Column(length: 255)] 20 | private ?string $title = null; 21 | 22 | #[ORM\Column(length: 255)] 23 | private ?string $condition = null; 24 | 25 | #[ORM\ManyToOne(inversedBy: 'books')] 26 | #[ORM\JoinColumn(nullable: false)] 27 | private ?Author $author = null; 28 | 29 | public function getId(): ?int 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function getTitle(): ?string 35 | { 36 | return $this->title; 37 | } 38 | 39 | public function setTitle(string $title): static 40 | { 41 | $this->title = $title; 42 | 43 | return $this; 44 | } 45 | 46 | public function getCondition(): ?string 47 | { 48 | return $this->condition; 49 | } 50 | 51 | public function setCondition(string $condition): static 52 | { 53 | $this->condition = $condition; 54 | 55 | return $this; 56 | } 57 | 58 | public function getAuthor(): ?Author 59 | { 60 | return $this->author; 61 | } 62 | 63 | public function setAuthor(?Author $author): static 64 | { 65 | $this->author = $author; 66 | 67 | return $this; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /api/src/Kernel.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /api/templates/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edge side APIs 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |

Edge Side APIs

18 |
19 | 20 | 21 | 41 | 48 |
49 |
50 |
51 |
-------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: app-server 4 | build: 5 | context: . 6 | target: app_server 7 | restart: unless-stopped 8 | cap_add: 9 | - NET_ADMIN 10 | ports: 11 | - "80:80" 12 | - "443:443" 13 | - "443:443/udp" 14 | volumes: 15 | - ./Caddyfile:/etc/caddy/Caddyfile 16 | - .:/srv 17 | - caddy_data:/data 18 | - caddy_config:/config 19 | 20 | volumes: 21 | caddy_data: 22 | caddy_config: 23 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "1.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/linked-data", 3 | "version": "1.0.0", 4 | "description": "Meta package for linked data tools.", 5 | "author": "soyuka", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@playwright/test": "^1.44.0", 9 | "@tailwindcss/forms": "^0.5.7", 10 | "@tailwindcss/typography": "^0.5.13", 11 | "@types/node": "^20.12.11", 12 | "lerna": "^8.1.2", 13 | "tailwindcss": "^3.4.5", 14 | "typescript": "^5.4.5" 15 | }, 16 | "workspaces": [ 17 | "packages/ld", 18 | "packages/use-swrld", 19 | "packages/use-mercure", 20 | "packages/mercure" 21 | ], 22 | "private": true, 23 | "scripts": { 24 | "build": "lerna run tsc && cp packages/*/*.js tests-server/", 25 | "watch": "npx tailwindcss -i ./api/style/input.css -o ./api/public/output.css --watch=always" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/ld/.gitignore: -------------------------------------------------------------------------------- 1 | ld.js 2 | -------------------------------------------------------------------------------- /packages/ld/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2024-present Antoine Bluchet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/ld/README.md: -------------------------------------------------------------------------------- 1 | # @api-platform/ld 2 | 3 | Rich JSON formats such as JSON-LD use IRIs to reference embeded data. This library fetches the wanted *Linked Data* automatically. 4 | 5 | I have an API referencing books and their authors, `GET /books/1` returns: 6 | 7 | ```json 8 | { 9 | "@id": "/books/1", 10 | "@type": ["https://schema.org/Book"], 11 | "title": "Hyperion", 12 | "author": "https://localhost/authors/1" 13 | } 14 | ``` 15 | 16 | Thanks to `@api-platform/ld` you can load authors automatically when you need them: 17 | 18 | ```javascript 19 | import ld from '@api-platform/ld' 20 | 21 | const pattern = new URLPattern("/authors/:id", "https://localhost"); 22 | const books = await ld('/books', { 23 | urlPattern: pattern, 24 | onUpdate: (newBooks) => { 25 | log() 26 | } 27 | }) 28 | 29 | function log() { 30 | console.log(books.author?.name) 31 | } 32 | 33 | log() 34 | ``` 35 | 36 | Note that [URLPattern is not yet available widely](https://caniuse.com/?search=urlpattern), we recommend to use [kenchris/urlpattern-polyfill](https://github.com/kenchris/urlpattern-polyfill). 37 | 38 | ## Installation 39 | 40 | ```shell 41 | npm install @api-platform/ld 42 | ``` 43 | 44 | ## Usage 45 | 46 | Use `ld` like [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and specify the [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) to match IRIs that are going to be fetched automatically: 47 | 48 | ```javascript 49 | import ld from '@api-platform/ld' 50 | 51 | await ld('/books', {urlPattern: new URLPattern("/authors/:id", "https://localhost")}) 52 | ``` 53 | 54 | Available options: 55 | 56 | - `fetchFn` fetch function, defaults to `fetch().then((res) => res.json())` 57 | - `urlPattern` the url pattern filter 58 | - `relativeURIs` supports relative URIs (defaults to `true`) 59 | - `onUpdate: (root, options: { iri: string, data: any })` callback on data update 60 | - `onError` error callback on fetch errors 61 | 62 | URLPattern is available as a polyfil at https://www.npmjs.com/package/urlpattern-polyfill 63 | 64 | ## Examples 65 | 66 | ### Tanstack Query 67 | 68 | ```javascript 69 | import ld from "@api-platform/ld"; 70 | import {useEffect} from "react" 71 | import {createRoot} from "react-dom/client" 72 | import { 73 | QueryClient, 74 | useQuery, 75 | QueryClientProvider, 76 | } from '@tanstack/react-query' 77 | 78 | const queryClient = new QueryClient(); 79 | const pattern = new URLPattern("/(books|authors)/:id", window.origin); 80 | 81 | function Books() { 82 | const {isPending, error, data: books} = useQuery({ 83 | queryKey: ['/books'], 84 | notifyOnChangeProps: 'all', 85 | queryFn: ({queryKey}) => ld(queryKey, { 86 | urlPattern: pattern, 87 | onUpdate: (root, {iri, data}) => { 88 | queryClient.setQueryData(queryKey, root) 89 | } 90 | }) 91 | }) 92 | 93 | if (isPending) return 'Loading...' 94 | if (error) return 'An error has occurred: ' + error.message 95 | return ( 96 |
    97 | {books.member.map(b => (
  • {b?.title} - {b?.author?.name}
  • ))} 98 |
99 | ); 100 | } 101 | 102 | function App() { 103 | return ( 104 | 105 | 106 | 107 | ) 108 | } 109 | createRoot(root).render() 110 | ``` 111 | 112 | ### React 113 | 114 | ```javascript 115 | import { useState, useEffect } from "react"; 116 | import { createRoot } from "react-dom/client"; 117 | import ld from "@api-platform/ld"; 118 | 119 | function App() { 120 | const pattern = new URLPattern("/(books|authors)/:id", window.origin); 121 | const [books, setBooks] = useState({}); 122 | 123 | useEffect(() => { 124 | let ignore = false; 125 | setBooks({}); 126 | ld('/books', {onUpdate: (books) => setBooks(books), urlPattern: pattern}) 127 | .then(books => { 128 | if (!ignore) { 129 | setBooks(books); 130 | } 131 | }); 132 | return () => { 133 | ignore = true; 134 | }; 135 | }, []); 136 | 137 | return ( 138 |
    139 | {books.member?.map(b => (
  • {b.title} - {b.author?.name}
  • ))} 140 |
141 | ); 142 | } 143 | 144 | const root = createRoot(document.getElementById("root")); 145 | root.render(); 146 | ``` 147 | 148 | ### Axios 149 | 150 | ```javascript 151 | import ld from "@api-platform/ld"; 152 | const pattern = new URLPattern("/(books|authors)/:id", window.origin); 153 | const list = document.getElementById('list') 154 | 155 | function onUpdate(books) { 156 | const l = [] 157 | books.member.forEach((book) => { 158 | const li = document.createElement('li') 159 | li.dataset.testid = 'book' 160 | li.innerText = `${book.title} - ${book.author?.name}` 161 | l.push(li) 162 | }); 163 | list.replaceChildren(...l) 164 | } 165 | 166 | ld('/books', {urlPattern: pattern, onUpdate, fetchfn: (url, options) => axios.get(url)}) 167 | .then((books) => { 168 | books.member.forEach((book) => { 169 | const li = document.createElement('li') 170 | li.dataset.testid = 'book' 171 | li.innerText = `${book.title} - ${book.author?.name}` 172 | list.appendChild(li) 173 | }); 174 | }) 175 | ``` 176 | 177 | ### VanillaJS 178 | 179 | ```javascript 180 | import ld from "@api-platform/ld"; 181 | const pattern = new URLPattern("/(books|authors)/:id", window.origin); 182 | const list = document.getElementById('list') 183 | 184 | function onUpdate(books) { 185 | const l = [] 186 | books.member.forEach((book) => { 187 | const li = document.createElement('li') 188 | li.dataset.testid = 'book' 189 | li.innerText = `${book.title} - ${book.author?.name}` 190 | l.push(li) 191 | }); 192 | list.replaceChildren(...l) 193 | } 194 | 195 | ld('/books', {urlPattern: pattern, onUpdate}) 196 | .then((books) => { 197 | books.member.forEach((book) => { 198 | const li = document.createElement('li') 199 | li.dataset.testid = 'book' 200 | li.innerText = `${book.title} - ${book.author?.name}` 201 | list.appendChild(li) 202 | }); 203 | }) 204 | ``` 205 | 206 | ### SWR 207 | 208 | Example of a SWR hook: 209 | 210 | ```typescript 211 | import ld, { LdOptions } from '@api-platform/ld' 212 | import useSWR from 'swr' 213 | import {useState} from 'react' 214 | import type { SWRConfiguration, KeyedMutator } from 'swr' 215 | 216 | export type fetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; 217 | export type Fetcher = (...args: any[]) => Promise 218 | export type onUpdateCallback = (root: T, options: { iri: string, data: object }) => void; 219 | 220 | export default function useSWRLd(url: string, fetcher: Fetcher, config: Partial> & SWRConfiguration = {}) { 221 | let cb: undefined | KeyedMutator = undefined 222 | 223 | // You may need to force re-rendering as the comparison algorithm of SWR does not work well when object keys are added 224 | // another solution is to improve the compare function 225 | const [, setRender] = useState(false) 226 | const res = useSWR( 227 | url, 228 | (url: RequestInfo | URL, opts: RequestInit) => 229 | ld(url, { 230 | ...opts, 231 | fetchFn: fetcher, 232 | urlPattern: config.urlPattern, 233 | onUpdate: (root) => { 234 | if (cb) { 235 | cb(root, { optimisticData: root, revalidate: false }) 236 | setRender((s: boolean) => !s) 237 | } 238 | }, 239 | relativeURIs: config.relativeURIs, 240 | onError: config.onError, 241 | }), 242 | config 243 | ); 244 | 245 | cb = res.mutate 246 | return res 247 | } 248 | ``` 249 | -------------------------------------------------------------------------------- /packages/ld/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | {languageOptions: { globals: globals.browser }}, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | {rules: {"@typescript-eslint/no-explicit-any": "off"}} 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/ld/ld.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export type LdOptions = { 3 | fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise; 4 | root: T; 5 | urlPattern: URLPattern; 6 | relativeURIs: boolean; 7 | onUpdate: (root: T, options: { iri: string, data: object }) => void; 8 | onError: (err: unknown) => void; 9 | } & RequestInit; 10 | 11 | type TableMap = Map; 12 | 13 | export const resources: TableMap = new Map() 14 | 15 | function proxify(obj: T, options: LdOptions): T { 16 | if (undefined === options.root) { 17 | resources.clear() 18 | options.root = obj 19 | } 20 | 21 | return new Proxy(obj, { 22 | get: (target: T, prop: string, receiver: unknown) => { 23 | if (prop === 'toJSON') { 24 | return () => obj 25 | } 26 | 27 | const value: string extends keyof T ? T[string] : unknown = Reflect.get(target, prop, receiver); 28 | 29 | if (typeof value === 'function') { 30 | return value; 31 | } 32 | 33 | if (typeof value === 'object' && value !== null) { 34 | return proxify(value, options); 35 | } 36 | 37 | if (typeof value !== 'string') { 38 | return value; 39 | } 40 | 41 | const valueStr = value.toString(); 42 | 43 | // TODO: pattern matcher 44 | let absoluteValue = undefined 45 | if (!options.urlPattern.test(valueStr)) { 46 | if (options.relativeURIs === false) { 47 | return valueStr; 48 | } 49 | 50 | if (valueStr[0] !== '/') { 51 | return valueStr; 52 | } 53 | 54 | absoluteValue = `${options.urlPattern.protocol}://${options.urlPattern.hostname}${options.urlPattern.port ? ':'+options.urlPattern.port : ''}${valueStr}`; 55 | if (!options.urlPattern.test(absoluteValue)) { 56 | return valueStr; 57 | } 58 | } 59 | 60 | if (resources.has(valueStr)) { 61 | return resources.get(valueStr); 62 | } 63 | 64 | resources.set(valueStr, {'@id': valueStr}) 65 | 66 | const callFetch = (iri: string, object: T, tableMap: TableMap, uri: RequestInfo | URL) => { 67 | options.fetchFn(uri) 68 | .then(data => { 69 | tableMap.set(iri, proxify(data, options)) 70 | if (options.onUpdate) { 71 | options.onUpdate(object, { iri, data }) 72 | } 73 | }) 74 | .catch((err) => options.onError(err)) 75 | }; 76 | 77 | callFetch(valueStr, proxify(options.root, options), resources as TableMap, absoluteValue === undefined ? valueStr : absoluteValue) 78 | return resources.get(valueStr) 79 | } 80 | }); 81 | } 82 | 83 | export default function ld(input: RequestInfo | URL, options: Partial> = {}): Promise { 84 | if (!options.urlPattern) { 85 | throw new Error('URL Pattern is mandatory.') 86 | } 87 | 88 | if (undefined === options.fetchFn) { 89 | options.fetchFn = (input, init) => fetch(input, init).then(res => res.json()) 90 | } 91 | 92 | if (undefined === options.relativeURIs) { 93 | options.relativeURIs = true; 94 | } 95 | 96 | if (undefined === options.onError) { 97 | options.onError = console.error; 98 | } 99 | 100 | return options.fetchFn(input, options) 101 | .then((d: T) => { 102 | return proxify(d, options as LdOptions) 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /packages/ld/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/ld", 3 | "version": "1.0.0", 4 | "description": "Fetch Edge Side APIs", 5 | "main": "ld.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "eslint ld.ts", 9 | "tsc": "tsc" 10 | }, 11 | "author": "soyuka", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@eslint/js": "^9.3.0", 15 | "eslint": "^8.57.0", 16 | "globals": "^15.3.0", 17 | "typescript": "^5.4.5", 18 | "typescript-eslint": "^7.10.0" 19 | }, 20 | "dependencies": { 21 | "urlpattern-polyfill": "^10.0.0" 22 | }, 23 | "homepage": "https://edge-side-api.rocks/linked-data" 24 | } 25 | -------------------------------------------------------------------------------- /packages/ld/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/mercure/.gitignore: -------------------------------------------------------------------------------- 1 | mercure.js 2 | -------------------------------------------------------------------------------- /packages/mercure/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2024-present Antoine Bluchet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/mercure/README.md: -------------------------------------------------------------------------------- 1 | # @api-platform/mercure 2 | 3 | `@api-platform/mercure` is an EventSource wrapper that [discovers a Mercure Hub](https://mercure.rocks/spec#discovery) according to the Link headers and handles subscriptions for you. 4 | 5 | ```javascript 6 | import mercure, { close } from "@api-platform/mercure"; 7 | 8 | const res = await mercure('https://localhost/authors/1', { 9 | onUpdate: (author) => console.log(author) 10 | }) 11 | 12 | const author = res.then(res => res.json()) 13 | 14 | // Close if you need to 15 | history.onpushstate = function(e) { 16 | close('https://localhost/authors/1') 17 | } 18 | ``` 19 | 20 | Assuming `/authors/1` returned: 21 | 22 | ``` 23 | Link: ; rel="self" 24 | Link: ; rel="mercure" 25 | ``` 26 | 27 | A new `EventSource` is created by subscribing to the topic `https://localhost/authors/1` on the Hub `https://localhost/.well-known/mercure`. 28 | 29 | ## Installation 30 | 31 | ```shell 32 | npm install @api-platform/mercure 33 | ``` 34 | 35 | ## Usage 36 | 37 | Use `mercure` like [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API): 38 | 39 | ```javascript 40 | import mercure, { close } from "@api-platform/mercure"; 41 | 42 | const res = await mercure('https://localhost/authors/1', { 43 | onUpdate: (author) => console.log(author) 44 | }) 45 | 46 | const author = res.then(res => res.json()) 47 | ``` 48 | 49 | Available options: 50 | 51 | - `onError` on EventSource error callback 52 | - `EventSource` to provide your own `EventSource` constructor 53 | - `fetchFn` to provide your own fetch function, it needs to return a response so that we can read headers 54 | 55 | This can be used in conjunction with [@api-platform/ld](/linked-data) as the `fetchFn`. 56 | 57 | ### Examples 58 | 59 | See [our Tanstack query example](https://github.com/api-platform/esa/blob/main/tests-server/mercure.html) or the source code of our [home page](https://github.com/api-platform/esa/blob/main/api/public/index.js). 60 | -------------------------------------------------------------------------------- /packages/mercure/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | {languageOptions: { globals: globals.browser }}, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | {rules: {"@typescript-eslint/no-explicit-any": "off"}} 11 | ]; 12 | -------------------------------------------------------------------------------- /packages/mercure/mercure.ts: -------------------------------------------------------------------------------- 1 | import {EventSource} from 'eventsource' 2 | let lastEventId: string 3 | const eventSources = new Map(); 4 | const topics = new Map(); 5 | 6 | type Options = { 7 | rawEvent?: boolean; 8 | EventSource?: any; 9 | headers?: {[key: string]: string}; 10 | fetchFn?: (input: RequestInfo | URL, init?: RequestInit) => Promise; 11 | onError?: (error: unknown) => void; 12 | onUpdate?: (data: MessageEvent|T) => void; 13 | withCredentials?: boolean; 14 | } & RequestInit; 15 | 16 | function listen(mercureUrl: string, options: Options = {}) { 17 | if (eventSources.has(mercureUrl)) { 18 | const eventSource = eventSources.get(mercureUrl) 19 | eventSource.eventSource.close() 20 | eventSources.delete(mercureUrl) 21 | } 22 | 23 | if (topics.size === 0) { 24 | return; 25 | } 26 | 27 | const url = new URL(mercureUrl) 28 | topics.forEach((_, topic) => { 29 | url.searchParams.append('topic', topic) 30 | }) 31 | 32 | const headers: {[key: string]: string} = options.headers || {} 33 | if (lastEventId) { 34 | headers['Last-Event-Id'] = lastEventId 35 | } 36 | 37 | const eventSource = new (options.EventSource ?? EventSource)(url.toString(), { withCredentials: options.withCredentials !== undefined ? options.withCredentials : true, headers}); 38 | eventSource.onmessage = (event: MessageEvent) => { 39 | lastEventId = event.lastEventId 40 | if (options.onUpdate) { 41 | try { 42 | options.onUpdate(options.rawEvent ? event : JSON.parse(event.data)) 43 | } catch (e) { 44 | options.onError && options.onError(e) 45 | } 46 | } 47 | } 48 | 49 | eventSource.onerror = options.onError 50 | eventSources.set(mercureUrl, { 51 | options: options, 52 | eventSource: eventSource 53 | }) 54 | } 55 | 56 | export function close(topic: string) { 57 | if (!topics.has(topic)) { 58 | return 59 | } 60 | 61 | const mercureUrl = topics.get(topic) 62 | topics.delete(topic) 63 | const ee = eventSources.get(mercureUrl) 64 | listen(mercureUrl, ee.options) 65 | } 66 | 67 | export default async function mercure(url: string, opts: Options) { 68 | return (opts.fetchFn ? opts.fetchFn(url, opts) : fetch(url, opts)) 69 | .then((res) => { 70 | let mercureUrl; 71 | let topic; 72 | res.headers.get('link')?.split(",") 73 | .map((v) => new RegExp('<(.*)>; *rel="(.*)"', 'gi').exec(v.trimStart())) 74 | .forEach((matches) => { 75 | if (!matches) { 76 | return 77 | } 78 | 79 | if (matches[2] === 'mercure') { 80 | mercureUrl = matches[1] 81 | } 82 | if (matches[2] === 'self') { 83 | topic = matches[1] 84 | } 85 | }); 86 | 87 | if (mercureUrl) { 88 | topics.set(topic === undefined ? url : topic, mercureUrl) 89 | listen(mercureUrl, opts) 90 | } 91 | 92 | return res; 93 | }); 94 | } 95 | 96 | -------------------------------------------------------------------------------- /packages/mercure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/mercure", 3 | "version": "1.0.0", 4 | "description": "Mercure handler", 5 | "main": "mercure.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "lint": "eslint mercure.ts", 9 | "tsc": "tsc" 10 | }, 11 | "author": "soyuka", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@eslint/js": "^9.3.0", 15 | "eslint": "^9.3.0", 16 | "globals": "^14.0.0", 17 | "typescript": "^5.4.5", 18 | "typescript-eslint": "^7.10.0" 19 | }, 20 | "dependencies": { 21 | "eventsource": "^3.0.2", 22 | "urlpattern-polyfill": "^10.0.0" 23 | }, 24 | "homepage": "https://edge-side-api.rocks/mercure" 25 | } 26 | -------------------------------------------------------------------------------- /packages/mercure/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/use-swrld/.gitignore: -------------------------------------------------------------------------------- 1 | use-swrld.js 2 | -------------------------------------------------------------------------------- /packages/use-swrld/README.md: -------------------------------------------------------------------------------- 1 | ## SWR Linked data 2 | 3 | I have an API referencing books and their authors, `GET /books/1` returns: 4 | 5 | ```json 6 | { 7 | "@id": "/books/1", 8 | "@type": ["https://schema.org/Book"], 9 | "title": "Hyperion", 10 | "author": "https://localhost/authors/1" 11 | } 12 | ``` 13 | 14 | Thanks to [`ld`](../ld/) you call `fetch` once, and use the object getter to retrieve the author name, this is how you could create a `useSWRLd` hook: 15 | 16 | ```javascript 17 | import ld, { LdOptions } from '@api-platform/ld' 18 | import useSWR from 'swr' 19 | import {useState} from 'react' 20 | import type { SWRConfiguration, KeyedMutator } from 'swr' 21 | 22 | export type fetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; 23 | export type Fetcher = (...args: any[]) => Promise 24 | export type onUpdateCallback = (root: T, options: { iri: string, data: object }) => void; 25 | 26 | export default function useSWRLd(url: string, fetcher: Fetcher, config: Partial> & SWRConfiguration = {}) { 27 | let cb: undefined | KeyedMutator = undefined 28 | 29 | const [, setRender] = useState(false) 30 | const res = useSWR( 31 | url, 32 | (url: RequestInfo | URL, opts: RequestInit) => 33 | ld(url, { 34 | ...opts, 35 | fetchFn: fetcher, 36 | urlPattern: config.urlPattern, 37 | onUpdate: (root) => { 38 | if (cb) { 39 | cb(root, { optimisticData: root, revalidate: false }) 40 | setRender((s: boolean) => !s) 41 | } 42 | }, 43 | relativeURIs: config.relativeURIs, 44 | onError: config.onError, 45 | }), 46 | config 47 | ); 48 | 49 | cb = res.mutate 50 | return res 51 | } 52 | 53 | function App() { 54 | // An URL Pattern is used to filter IRIs we want to reach automatically 55 | const pattern = new URLPattern("/authors/:id", "https://localhost"); 56 | const { data: books, isLoading, error } = useSWRLd('https://localhost/books', pattern) 57 | if (error) return "An error has occurred."; 58 | if (isLoading) return "Loading..."; 59 | 60 | return ( 61 |
    62 | {books?.member.map(b => (
  • {b?.title} - {b?.author?.name}
  • ))} 63 |
64 | ); 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /packages/use-swrld/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | 6 | export default [ 7 | { languageOptions: { globals: globals.browser } }, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | { rules: { "@typescript-eslint/no-explicit-any": "off" } } 11 | 12 | ]; 13 | -------------------------------------------------------------------------------- /packages/use-swrld/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/use-swrld", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Fetch Edge Side APIs with SWR", 6 | "main": "use-swrld.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "lint": "eslint use-swrld.ts", 10 | "tsc": "tsc" 11 | }, 12 | "author": "soyuka", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@eslint/js": "^9.3.0", 16 | "@types/react": "^18.3.3", 17 | "eslint": "^9.3.0", 18 | "globals": "^14.0.0", 19 | "typescript": "^5.4.5", 20 | "typescript-eslint": "^7.10.0" 21 | }, 22 | "dependencies": { 23 | "dequal": "^2.0.3", 24 | "react": "^18.2.0", 25 | "swr": "^2.2.5", 26 | "urlpattern-polyfill": "^10.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/use-swrld/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/use-swrld/use-swrld.ts: -------------------------------------------------------------------------------- 1 | import ld, { LdOptions } from '@api-platform/ld' 2 | import useSWR from 'swr' 3 | import {useState} from 'react' 4 | import type { SWRConfiguration, KeyedMutator } from 'swr' 5 | 6 | export type fetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; 7 | export type Fetcher = (...args: any[]) => Promise 8 | export type onUpdateCallback = (root: T, options: { iri: string, data: object }) => void; 9 | 10 | export default function useSWRLd(url: string, fetcher: Fetcher, config: Partial> & SWRConfiguration = {}) { 11 | let cb: undefined | KeyedMutator = undefined 12 | 13 | const [, setRender] = useState(false) 14 | const res = useSWR( 15 | url, 16 | (url: RequestInfo | URL, opts: RequestInit) => 17 | ld(url, { 18 | ...opts, 19 | fetchFn: fetcher, 20 | urlPattern: config.urlPattern, 21 | onUpdate: (root) => { 22 | if (cb) { 23 | cb(root, { optimisticData: root, revalidate: false }) 24 | setRender((s: boolean) => !s) 25 | } 26 | }, 27 | relativeURIs: config.relativeURIs, 28 | onError: config.onError, 29 | }), 30 | config 31 | ); 32 | 33 | cb = res.mutate 34 | return res 35 | } 36 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | ignoreHTTPSErrors: true, 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | // baseURL: 'http://127.0.0.1:3000', 29 | 30 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 31 | trace: 'on-first-retry', 32 | }, 33 | 34 | /* Configure projects for major browsers */ 35 | projects: [ 36 | { 37 | name: 'chromium', 38 | use: { ...devices['Desktop Chrome'] }, 39 | }, 40 | 41 | { 42 | name: 'firefox', 43 | use: { ...devices['Desktop Firefox'] }, 44 | }, 45 | 46 | { 47 | name: 'webkit', 48 | use: { ...devices['Desktop Safari'] }, 49 | }, 50 | 51 | /* Test against mobile viewports. */ 52 | // { 53 | // name: 'Mobile Chrome', 54 | // use: { ...devices['Pixel 5'] }, 55 | // }, 56 | // { 57 | // name: 'Mobile Safari', 58 | // use: { ...devices['iPhone 12'] }, 59 | // }, 60 | 61 | /* Test against branded browsers. */ 62 | // { 63 | // name: 'Microsoft Edge', 64 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 65 | // }, 66 | // { 67 | // name: 'Google Chrome', 68 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 69 | // }, 70 | ], 71 | 72 | /* Run your local dev server before starting the tests */ 73 | // webServer: { 74 | // command: 'npm run start', 75 | // url: 'http://127.0.0.1:3000', 76 | // reuseExistingServer: !process.env.CI, 77 | // }, 78 | }); 79 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './api/public/*.html', 5 | './api/templates/*.html', 6 | './api/public/*.js', 7 | ], 8 | theme: { 9 | extend: { 10 | screens: { 11 | 'mobile-only': { max: '767px' }, 12 | }, 13 | fontFamily: { 14 | sans: 'Inter Variable', 15 | }, 16 | }, 17 | }, 18 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')], 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /tests-server/_headers: -------------------------------------------------------------------------------- 1 | https://:project.pages.dev/books 2 | Link: ; rel="self" 3 | Link: ; rel="mercure" 4 | 5 | /authors/1 6 | Link: ; rel="self" 7 | Link: ; rel="mercure" 8 | 9 | /authors/2 10 | Link: ; rel="self" 11 | Link: ; rel="mercure" 12 | 13 | /authors/3 14 | Link: ; rel="self" 15 | Link: ; rel="mercure" 16 | -------------------------------------------------------------------------------- /tests-server/_redirects: -------------------------------------------------------------------------------- 1 | /books /fixtures/books.jsonld 200 2 | /authors/:id /fixtures/authors/:id.jsonld 200 3 | -------------------------------------------------------------------------------- /tests-server/axios.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Linked Data - axios 7 | 15 | 16 | 17 | 18 |
    19 |
20 | 48 | 49 | -------------------------------------------------------------------------------- /tests-server/fixtures/authors/1.jsonld: -------------------------------------------------------------------------------- 1 | {"@id": "/authors/1", "name": "Dan Simmons"} 2 | -------------------------------------------------------------------------------- /tests-server/fixtures/authors/2.jsonld: -------------------------------------------------------------------------------- 1 | {"@id": "/authors/2", "name": "O\u0027Donnell, Peter"} 2 | -------------------------------------------------------------------------------- /tests-server/fixtures/authors/3.jsonld: -------------------------------------------------------------------------------- 1 | {"@id": "/authors/2", "name": "Emily Rodda"} 2 | -------------------------------------------------------------------------------- /tests-server/fixtures/books.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "/contexts/Book", 3 | "@id": "/books", 4 | "@type": "hydra:Collection", 5 | "totalItems": 4, 6 | "member": [ 7 | "/books/1", 8 | "/books/2", 9 | "/books/3", 10 | "/books/4" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tests-server/fixtures/books/1.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@id": "/books/1", 3 | "@type": ["https://schema.org/Book"], 4 | "title": "Hyperion", 5 | "author": "/authors/1", 6 | "condition": "https://schema.org/UsedCondition", 7 | "reviews": "/books/1/reviews" 8 | } 9 | -------------------------------------------------------------------------------- /tests-server/fixtures/books/2.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@id": "/books/2", 3 | "@type": ["https://schema.org/Book"], 4 | "title": "The Silver Mistress", 5 | "author": "/authors/2", 6 | "condition": "https://schema.org/RefurbishedCondition", 7 | "reviews": "/books/2/reviews" 8 | } 9 | -------------------------------------------------------------------------------- /tests-server/fixtures/books/3.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@id": "/books/3", 3 | "@type": ["https://schema.org/Book"], 4 | "title": "Silver Mistress", 5 | "author": "/authors/2", 6 | "condition": "https://schema.org/DamagedCondition", 7 | "reviews": "/books/3/reviews" 8 | } 9 | -------------------------------------------------------------------------------- /tests-server/fixtures/books/4.jsonld: -------------------------------------------------------------------------------- 1 | { 2 | "@id": "/books/4", 3 | "@type": ["https://schema.org/Book"], 4 | "title": "Dragons of Deltora", 5 | "author": "/authors/3", 6 | "condition": "https://schema.org/NewCondition", 7 | "reviews": "/books/4/reviews" 8 | } 9 | -------------------------------------------------------------------------------- /tests-server/github.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Github 7 | 8 | 9 | 10 | 11 | 12 | 13 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 |
34 |

 35 |   import ld from "@api-platform/ld";
 36 |   import { useState, useEffect } from "react";
 37 | 
 38 |   const pattern = new URLPattern("/*", "https://api.github.com");
 39 |   const [repository, setRepository] = useState({});
 40 |   const [showContributors, setShowContributors] = useState(false);
 41 | 
 42 |   useEffect(() => {
 43 |     ld('https://api.github.com/repos/api-platform/api-platform', {
 44 |       urlPattern: pattern,
 45 |       onUpdate: (repository) => setRepository(repository)
 46 |     })
 47 |     .then(result => {
 48 |       setRepository(result);
 49 |     });
 50 |   }, []);
 51 | 
 52 |   return (
 53 |       <div>
 54 |       <h1>{repository.name} ✨ {repository.stargazers_count}</h1>
 55 |       <p>{repository.description}</p>
 56 |       {showContributors ? (
 57 |         <ul>
 58 |           {repository.contributors_url?.map((e) => (<li>{e.login}</li>))}
 59 |         </ul>
 60 |       ) : <button onClick={() => setShowContributors(true)}>Load contributors</button>}
 61 |     </div>
 62 |   );
 63 |   
64 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /tests-server/mercure.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mercure 7 | 19 | 20 | 21 | 22 | 23 |
24 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /tests-server/react.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Linked Data - React 7 | 18 | 19 | 20 | 21 | 22 |
23 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /tests-server/swr.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Linked Data - SWR 7 | 20 | 21 | 22 | 23 | 24 |
25 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /tests-server/tanstack-query.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tanstack Query 7 | 19 | 20 | 21 | 22 | 23 |
24 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /tests-server/vanilla.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Linked Data 7 | 15 | 16 | 17 | 18 |
    19 |
20 | 48 | 49 | -------------------------------------------------------------------------------- /tests/axios.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('axios', async ({ page }) => { 4 | let num = 0 5 | let books = 0 6 | page.on('request', request => { 7 | if (request.url().startsWith('https://localhost/authors')) { 8 | num++ 9 | } 10 | if (request.url().startsWith('https://localhost/books')) { 11 | books++ 12 | } 13 | }) 14 | 15 | await page.goto('https://localhost/axios'); 16 | await page.waitForLoadState('networkidle'); 17 | expect(num).toBe(3); 18 | expect(books).toBe(5); 19 | await expect(page.getByTestId('book')).toHaveCount(4); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/mercure.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('mercure', async ({ page }) => { 4 | let num = 0 5 | let requestedMercure = false 6 | let subscribedToBoth = false 7 | let unsubscribedAuthor1 = false 8 | page.on('request', request => { 9 | if (request.url().startsWith('https://localhost/authors')) { 10 | num++ 11 | } 12 | 13 | if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F1')) { 14 | requestedMercure = true 15 | } 16 | 17 | if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F1&topic=%2Fauthors%2F2')) { 18 | subscribedToBoth = true 19 | } 20 | 21 | if (request.url().startsWith('https://localhost/.well-known/mercure?topic=%2Fauthors%2F2')) { 22 | unsubscribedAuthor1 = true 23 | } 24 | }) 25 | 26 | await page.goto('https://localhost/mercure'); 27 | const button = page.getByTestId('mercure'); 28 | await button.waitFor(); 29 | expect(num).toBe(1); 30 | expect(requestedMercure).toBe(true); 31 | button.click({force: true}); 32 | await expect(page.getByTestId('result')).toHaveText('viewing /authors/1: Soyuka'); 33 | page.getByTestId('author-2').click({force: true}); 34 | await expect(page.getByTestId('result')).toHaveText('viewing /authors/2: O\'Donnell, Peter'); 35 | await page.waitForTimeout(600); // we set gcTime to 500, tanstack query will clear author 1 from cache, therefore we check that mercure gets updated 36 | expect(unsubscribedAuthor1).toBe(true); 37 | }); 38 | 39 | -------------------------------------------------------------------------------- /tests/react.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('react js', async ({ page }) => { 4 | let num = 0 5 | let books = 0 6 | page.on('request', request => { 7 | if (request.url().startsWith('https://localhost/authors')) { 8 | num++ 9 | } 10 | if (request.url().startsWith('https://localhost/books')) { 11 | books++ 12 | } 13 | }) 14 | 15 | await page.goto('https://localhost/react'); 16 | await page.waitForLoadState('networkidle'); 17 | expect(num).toBe(3); 18 | expect(books).toBe(5); 19 | await expect(page.getByTestId('book')).toHaveCount(4); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/swr.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('swr', async ({ page }) => { 4 | let num = 0 5 | page.on('request', request => { 6 | if (request.url().startsWith('https://localhost/authors')) { 7 | num++ 8 | } 9 | }) 10 | 11 | await page.goto('https://localhost/swr'); 12 | await page.waitForLoadState('networkidle'); 13 | expect(num).toBe(3); 14 | await expect(page.getByTestId('book')).toHaveCount(4); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/tanstack-query.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('tanstack query', async ({ page }) => { 4 | let num = 0 5 | page.on('request', request => { 6 | if (request.url().startsWith('https://localhost/authors')) { 7 | num++ 8 | } 9 | }) 10 | 11 | await page.goto('https://localhost/tanstack-query'); 12 | await page.waitForLoadState('networkidle'); 13 | expect(num).toBe(3); 14 | await expect(page.getByTestId('book')).toHaveCount(4); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/vanilla.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('vanilla js', async ({ page }) => { 4 | let num = 0 5 | let books = 0 6 | page.on('request', request => { 7 | if (request.url().startsWith('https://localhost/authors')) { 8 | num++ 9 | } 10 | if (request.url().startsWith('https://localhost/books')) { 11 | books++ 12 | } 13 | }) 14 | 15 | await page.goto('https://localhost/vanilla'); 16 | await page.waitForLoadState('networkidle'); 17 | expect(num).toBe(3); 18 | expect(books).toBe(5); 19 | await expect(page.getByTestId('book')).toHaveCount(4); 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "es2022", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | --------------------------------------------------------------------------------