diff --git a/.gitignore b/.gitignore index fb2762de8..f616fc32d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,12 @@ dist/ # dependencies node_modules/ +# synced agent skills source checkout +.cache/ + +# agent skill files synced at build time from nextflow-io/agent-skills +public/.well-known/agent-skills/*/ + # logs npm-debug.log* yarn-debug.log* diff --git a/Makefile b/Makefile index 57f24c2ca..f2cf82caa 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,8 @@ publish: --acl public-read \ --exclude "$0" + bash scripts/set-s3-agent-metadata.sh + invalidate: aws cloudfront create-invalidation --distribution-id E3RPV5P71OW0UF --paths '/*' diff --git a/infra/cloudfront/README.md b/infra/cloudfront/README.md new file mode 100644 index 000000000..68ba7378c --- /dev/null +++ b/infra/cloudfront/README.md @@ -0,0 +1,20 @@ +# CloudFront agent-readiness configuration + +Production hosting uses S3 + CloudFront. Apply these after deploying static files. + +## Link response headers (RFC 8288) + +1. Create a response headers policy from `response-headers-policy.json`, or attach equivalent `Link` headers to the default cache behavior for `/` and `/index.html`. +2. Alternatively, rely on `scripts/set-s3-agent-metadata.sh` (runs during `make publish`) which sets S3 object metadata on `index.html`. + +## Markdown content negotiation + +1. Publish the CloudFront Function in `markdown-negotiation.js`. +2. Associate it with **viewer-request** on the default behavior. +3. Ensure `index.md` is deployed to S3 (copied from `public/index.md` via the Astro build). + +The function rewrites `/` and `/index.html` to `/index.md` when `Accept: text/markdown` is present. + +## Netlify previews + +PR previews use `public/_headers` for Link headers and `netlify/edge-functions/markdown-negotiate.js` for markdown negotiation. diff --git a/infra/cloudfront/markdown-negotiation.js b/infra/cloudfront/markdown-negotiation.js new file mode 100644 index 000000000..e4ec4c7ed --- /dev/null +++ b/infra/cloudfront/markdown-negotiation.js @@ -0,0 +1,18 @@ +// CloudFront Function: serve markdown when Accept: text/markdown is present. +// Associate with viewer-request on the nextflow.io CloudFront distribution. +function handler(event) { + var request = event.request; + var headers = request.headers; + var accept = headers.accept ? headers.accept.value : ""; + var uri = request.uri; + + if (accept.indexOf("text/markdown") === -1) { + return request; + } + + if (uri === "/" || uri === "/index.html") { + request.uri = "/index.md"; + } + + return request; +} diff --git a/infra/cloudfront/response-headers-policy.json b/infra/cloudfront/response-headers-policy.json new file mode 100644 index 000000000..71d276381 --- /dev/null +++ b/infra/cloudfront/response-headers-policy.json @@ -0,0 +1,23 @@ +{ + "Comment": "Link response headers for nextflow.io agent discovery (RFC 8288)", + "Name": "nextflow-io-agent-discovery-headers", + "HeadersConfig": { + "HeaderBehavior": "whitelist", + "Headers": { + "Quantity": 1, + "Items": ["Link"] + } + }, + "CustomHeadersConfig": { + "Quantity": 1, + "Items": [ + { + "Header": "Link", + "Value": "; rel=\"api-catalog\", ; rel=\"service-doc\", ; rel=\"service-desc\"", + "Override": false + } + ] + }, + "SecurityHeadersConfig": {}, + "CorsConfig": {} +} diff --git a/infra/dns-aid/README.md b/infra/dns-aid/README.md new file mode 100644 index 000000000..6d49b6950 --- /dev/null +++ b/infra/dns-aid/README.md @@ -0,0 +1,18 @@ +# DNS for AI Discovery (DNS-AID) + +Publish the records in `nextflow.io.zone` under the `_agents` namespace for `nextflow.io`. + +## Requirements + +1. Add `_index._agents.nextflow.io` and `_a2a._agents.nextflow.io` SVCB records (see zone file). +2. Enable DNSSEC on the public zone and publish DS records at your domain registrar. +3. Verify with DNS-over-HTTPS: + +```bash +curl -s 'https://cloudflare-dns.com/dns-query?name=_index._agents.nextflow.io&type=SVCB' \ + -H 'accept: application/dns-json' +``` + +## Production note + +DNS-AID records are managed outside this repository (Route 53, Cloudflare DNS, or your registrar). Apply the zone file through your DNS operations workflow after review. diff --git a/infra/dns-aid/nextflow.io.zone b/infra/dns-aid/nextflow.io.zone new file mode 100644 index 000000000..75880db93 --- /dev/null +++ b/infra/dns-aid/nextflow.io.zone @@ -0,0 +1,12 @@ +; DNS for AI Discovery (DNS-AID) records for nextflow.io +; Deploy this zone (or merge these records) with your DNS provider. +; Sign the zone with DNSSEC and publish DS records at the registrar. + +$ORIGIN nextflow.io. +$TTL 3600 + +; Index entrypoint — points agents to the site's API catalog +_index._agents.nextflow.io. IN SVCB 1 nextflow.io. alpn="h3,h2" port=443 mandatory=alpn,port + +; Agent-to-agent discovery entrypoint +_a2a._agents.nextflow.io. IN SVCB 1 nextflow.io. alpn="h3,h2" port=443 mandatory=alpn,port diff --git a/netlify/edge-functions/markdown-negotiate.js b/netlify/edge-functions/markdown-negotiate.js new file mode 100644 index 000000000..7dcee7038 --- /dev/null +++ b/netlify/edge-functions/markdown-negotiate.js @@ -0,0 +1,36 @@ +const MARKDOWN_PATHS = { + "/": "/index.md", + "/index.html": "/index.md", +}; + +export default async function handler(request, context) { + const accept = request.headers.get("Accept") ?? ""; + if (!accept.includes("text/markdown")) { + return context.next(); + } + + const url = new URL(request.url); + const markdownPath = MARKDOWN_PATHS[url.pathname]; + if (!markdownPath) { + return context.next(); + } + + const markdownUrl = new URL(markdownPath, url.origin); + const markdownResponse = await context.rewrite(markdownUrl.toString()); + if (!markdownResponse.ok) { + return context.next(); + } + + const body = await markdownResponse.text(); + const tokenEstimate = Math.ceil(body.length / 4); + + return new Response(body, { + status: 200, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "x-markdown-tokens": String(tokenEstimate), + Vary: "Accept", + "Cache-Control": "public, max-age=300, must-revalidate", + }, + }); +} diff --git a/package.json b/package.json index dd21a83a0..42adf7197 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "astro dev", "start": "astro dev", "docs": "bash build_docs.sh", + "prebuild": "node scripts/generate-agent-skills-index.mjs", "build": "astro check && astro build && bash build_docs.sh", + "generate:agent-skills-index": "node scripts/generate-agent-skills-index.mjs", "preview": "astro preview", "astro": "astro" }, diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json new file mode 100644 index 000000000..49a72b55f --- /dev/null +++ b/public/.well-known/agent-skills/index.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "source": "https://github.com/nextflow-io/agent-skills", + "skills": [ + { + "name": "create-workflow", + "type": "skill-md", + "description": "INVOKE THIS SKILL IMMEDIATELY when user asks to: write/create/build a Nextflow pipeline or workflow,\ncreate any bioinformatics pipeline (RNA-seq, DNA-seq, variant calling, ChIP-seq, etc.),\nor compose/chain Nextflow modules from the Nextflow Registry. This skill handles all Nextflow workflow creation tasks.", + "url": "https://nextflow.io/.well-known/agent-skills/create-workflow/SKILL.md" + }, + { + "name": "install-nextflow", + "type": "skill-md", + "description": "Install or upgrade Nextflow on the user's machine. Use when the user wants to install Nextflow, check their current Nextflow version, upgrade Nextflow, or set up the prerequisites (Java 17+) needed to run Nextflow.", + "url": "https://nextflow.io/.well-known/agent-skills/install-nextflow/SKILL.md" + }, + { + "name": "launch-workflow", + "type": "skill-md", + "description": "Launch Nextflow pipeline executions on cloud and HPC clusters via Seqera Platform. Use when the user wants to run/launch/submit a pipeline on a cloud or cluster compute environment, configure a compute environment, push pipeline changes to GitHub before launching, or sign in to Seqera Platform.", + "url": "https://nextflow.io/.well-known/agent-skills/launch-workflow/SKILL.md" + }, + { + "name": "run-module", + "type": "skill-md", + "description": "Run Nextflow Registry modules natively using `nextflow module` commands. Use when running, listing, or getting info about Nextflow modules.", + "url": "https://nextflow.io/.well-known/agent-skills/run-module/SKILL.md" + } + ] +} diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog new file mode 100644 index 000000000..f77ea347a --- /dev/null +++ b/public/.well-known/api-catalog @@ -0,0 +1,13 @@ +{ + "linkset": [ + { + "anchor": "https://nextflow.io/", + "service-doc": [ + { + "href": "https://docs.seqera.io/nextflow/", + "type": "text/html" + } + ] + } + ] +} diff --git a/public/.well-known/mcp/server-card.json b/public/.well-known/mcp/server-card.json new file mode 100644 index 000000000..95cb2d604 --- /dev/null +++ b/public/.well-known/mcp/server-card.json @@ -0,0 +1,11 @@ +{ + "serverInfo": { + "name": "nextflow.io", + "version": "1.0.0" + }, + "transport": { + "type": "webmcp", + "pageUrl": "https://nextflow.io/" + }, + "capabilities": ["tools"] +} diff --git a/public/_headers b/public/_headers new file mode 100644 index 000000000..ab46a8122 --- /dev/null +++ b/public/_headers @@ -0,0 +1,26 @@ +/ + Link: ; rel="api-catalog", ; rel="service-doc" + +/index.html + Link: ; rel="api-catalog", ; rel="service-doc" + +/.well-known/api-catalog + Content-Type: application/linkset+json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + + +/.well-known/mcp/server-card.json + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/.well-known/agent-skills/index.json + Content-Type: application/json + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * + +/index.md + Content-Type: text/markdown; charset=utf-8 + Cache-Control: public, max-age=300, must-revalidate + Access-Control-Allow-Origin: * diff --git a/public/index.md b/public/index.md new file mode 100644 index 000000000..a4a37f6bd --- /dev/null +++ b/public/index.md @@ -0,0 +1,56 @@ +# Nextflow + +> Reproducible Scientific Workflows at Scale + +Nextflow enables scalable, reproducible, and portable scientific workflows for research and production use cases. + +## Quick links + +- [Documentation](https://docs.seqera.io/nextflow/) +- [Community forum](https://community.seqera.io/tag/nextflow) +- [Training](https://training.nextflow.io/latest/) +- [Examples](/basic-pipeline.html) +- [VS Code extension](https://marketplace.visualstudio.com/items?itemName=nextflow.nextflow) +- [Seqera AI assistant](https://seqera.io/ask-ai/) + +## Features + +### Fast prototyping + +Nextflow allows you to write a computational pipeline by making it simpler to put together many different tasks. You may reuse your existing scripts and tools without learning a new language. + +### Reproducibility + +Nextflow supports [Docker](https://www.docker.com/) and [Singularity](https://apptainer.org/) containers, with Git integration for versioned, self-contained pipelines. + +### Continuous checkpoints + +Intermediate results are automatically tracked so you can resume from the last successful step. + +### Portable + +Pipelines run on GridEngine, SLURM, LSF, PBS, Kubernetes, AWS, Google Cloud, Azure, and more without code changes. + +### Stream oriented + +Nextflow extends the Unix pipes model with a fluent DSL for complex stream interactions and functional composition. + +### Unified parallelism + +Parallelisation is defined by process input and output declarations; applications scale transparently. + +## Community resources + +- [nf-core](https://nf-co.re/) — curated community pipelines +- [Seqera Pipelines](https://seqera.io/pipelines/) — browse open-source pipelines +- [GitHub issues](https://github.com/nextflow-io/nextflow/issues) — report bugs or request features + +## Discovery + +Machine-readable resources for agents: + +- API catalog: `/.well-known/api-catalog` +- MCP server card: `/.well-known/mcp/server-card.json` +- Agent skills: `/.well-known/agent-skills/index.json` + +Supported by [Seqera](https://seqera.io). diff --git a/public/robots.txt b/public/robots.txt index 34b8bde68..40e487932 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -12,5 +12,6 @@ Disallow: /misc/ Disallow: /logs/ Disallow: /tests/ Disallow: /releases/ +Content-Signal: ai-train=no, search=yes, ai-input=yes Sitemap: https://nextflow.io/sitemap-index.xml diff --git a/publish.sh b/publish.sh index deb025dcb..de586f381 100755 --- a/publish.sh +++ b/publish.sh @@ -17,4 +17,6 @@ aws s3 sync --delete output/docs s3://www2.nextflow.io/docs \ --storage-class STANDARD \ --acl public-read \ --exclude "$0" \ - "$@" \ No newline at end of file + "$@" + +bash "$(dirname "$0")/scripts/set-s3-agent-metadata.sh" \ No newline at end of file diff --git a/scripts/generate-agent-skills-index.mjs b/scripts/generate-agent-skills-index.mjs new file mode 100644 index 000000000..6ceec696d --- /dev/null +++ b/scripts/generate-agent-skills-index.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Syncs skills from nextflow-io/agent-skills and generates + * /.well-known/agent-skills/index.json. + */ +import { execSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); +const publicDir = join(repoRoot, "public"); +const siteOrigin = "https://nextflow.io"; +const agentSkillsRepo = "https://github.com/nextflow-io/agent-skills.git"; +const cacheDir = join(repoRoot, ".cache", "agent-skills"); +const skillsSourceDir = join(cacheDir, "skills"); +const skillsDestDir = join(publicDir, ".well-known", "agent-skills"); + +function syncAgentSkillsRepo() { + if (!existsSync(cacheDir)) { + execSync(`git clone --depth 1 ${agentSkillsRepo} ${cacheDir}`, { stdio: "inherit" }); + return; + } + + execSync("git fetch origin master && git reset --hard origin/master", { + cwd: cacheDir, + stdio: "inherit", + }); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + return {}; + } + + const fields = {}; + const lines = match[1].split("\n"); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const fieldMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!fieldMatch) { + i++; + continue; + } + + const key = fieldMatch[1]; + const value = fieldMatch[2]; + + if (value === "|" || value === ">") { + i++; + const block = []; + while (i < lines.length) { + const blockLine = lines[i]; + if (/^[A-Za-z0-9_-]+:\s*/.test(blockLine)) { + break; + } + if (blockLine.startsWith(" ")) { + block.push(blockLine.slice(2)); + } else if (blockLine === "") { + block.push(""); + } else { + break; + } + i++; + } + fields[key] = block.join("\n").trim(); + continue; + } + + fields[key] = value.trim(); + i++; + } + + return fields; +} + +function copySkills() { + rmSync(skillsDestDir, { recursive: true, force: true }); + mkdirSync(skillsDestDir, { recursive: true }); + + const skillDirs = readdirSync(skillsSourceDir).filter((entry) => + statSync(join(skillsSourceDir, entry)).isDirectory(), + ); + + const skills = []; + + for (const dirName of skillDirs.sort()) { + const sourceFile = join(skillsSourceDir, dirName, "SKILL.md"); + if (!existsSync(sourceFile)) { + continue; + } + + const destDir = join(skillsDestDir, dirName); + mkdirSync(destDir, { recursive: true }); + const destFile = join(destDir, "SKILL.md"); + cpSync(sourceFile, destFile); + + const content = readFileSync(destFile); + const frontmatter = parseFrontmatter(content.toString("utf8")); + const relativePath = `.well-known/agent-skills/${dirName}/SKILL.md`; + + skills.push({ + name: frontmatter.name ?? dirName, + type: "skill-md", + description: frontmatter.description ?? `Nextflow agent skill from nextflow-io/agent-skills (${dirName}).`, + url: `${siteOrigin}/${relativePath}`, + }); + } + + return skills; +} + +syncAgentSkillsRepo(); +const skills = copySkills(); + +const index = { + $schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + source: "https://github.com/nextflow-io/agent-skills", + skills, +}; + +const outputPath = join(skillsDestDir, "index.json"); +writeFileSync(outputPath, `${JSON.stringify(index, null, 2)}\n`); +console.log(`Wrote ${outputPath} with ${skills.length} skill(s) from nextflow-io/agent-skills.`); diff --git a/scripts/set-s3-agent-metadata.sh b/scripts/set-s3-agent-metadata.sh new file mode 100755 index 000000000..a2de01489 --- /dev/null +++ b/scripts/set-s3-agent-metadata.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Set Content-Type metadata on agent-discovery objects after S3 sync. +# Invoked by publish.sh after the main sync completes. + +set -euo pipefail + +BUCKET="${NXF_S3_BUCKET:-www2.nextflow.io}" + +declare -A CONTENT_TYPES=( + [".well-known/api-catalog"]="application/linkset+json" + [".well-known/oauth-authorization-server"]="application/json" + [".well-known/oauth-protected-resource"]="application/json" + [".well-known/jwks.json"]="application/json" + [".well-known/mcp/server-card.json"]="application/json" + [".well-known/agent-skills/index.json"]="application/json" + ["auth.md"]="text/markdown; charset=utf-8" + ["index.md"]="text/markdown; charset=utf-8" + ["openapi.json"]="application/json" +) + +for key in "${!CONTENT_TYPES[@]}"; do + content_type="${CONTENT_TYPES[$key]}" + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "$content_type" \ + --acl public-read + echo "Set Content-Type for ${key} -> ${content_type}" + else + echo "Skipping ${key} (not found in bucket)" + fi +done + +while IFS= read -r -d '' skill_file; do + key="${skill_file#output/}" + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "text/markdown; charset=utf-8" \ + --acl public-read + echo "Set Content-Type for ${key} -> text/markdown; charset=utf-8" + fi +done < <(find output/.well-known/agent-skills -name 'SKILL.md' -print0 2>/dev/null) + +# Link header on homepage objects +LINK_HEADER='; rel="api-catalog", ; rel="service-doc", ; rel="service-desc"' + +for key in index.html; do + if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then + aws s3 cp "s3://${BUCKET}/${key}" "s3://${BUCKET}/${key}" \ + --metadata-directive REPLACE \ + --content-type "text/html; charset=utf-8" \ + --metadata "Link=${LINK_HEADER}" \ + --acl public-read + echo "Set Link metadata for ${key}" + fi +done diff --git a/src/components/WebMcpBridge/index.tsx b/src/components/WebMcpBridge/index.tsx new file mode 100644 index 000000000..6c88d1358 --- /dev/null +++ b/src/components/WebMcpBridge/index.tsx @@ -0,0 +1,103 @@ +import { useEffect } from "react"; + +type WebMcpTool = { + name: string; + description: string; + inputSchema: Record; + execute: (input: Record) => Promise; +}; + +const tools: WebMcpTool[] = [ + { + name: "get_documentation_links", + description: "Return canonical Nextflow documentation and training URLs.", + inputSchema: { + type: "object", + properties: {}, + }, + execute: async () => ({ + documentation: "https://docs.seqera.io/nextflow/", + training: "https://training.nextflow.io/latest/", + community: "https://community.seqera.io/tag/nextflow", + examples: "https://nextflow.io/examples.html", + api_catalog: "https://nextflow.io/.well-known/api-catalog", + }), + }, + { + name: "get_examples", + description: "List Nextflow pipeline example pages available on nextflow.io.", + inputSchema: { + type: "object", + properties: {}, + }, + execute: async () => [ + { title: "Basic pipeline", url: "https://nextflow.io/basic-pipeline.html" }, + { title: "Mixing scripting languages", url: "https://nextflow.io/mixing-scripting-languages.html" }, + { title: "BLAST pipeline", url: "https://nextflow.io/blast-pipeline.html" }, + { title: "RNA-seq pipeline", url: "https://nextflow.io/rna-seq-pipeline.html" }, + { title: "Machine learning pipeline", url: "https://nextflow.io/machine-learning-pipeline.html" }, + ], + }, + { + name: "search_site", + description: "Search nextflow.io pages by keyword against known site resources.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search keyword" }, + }, + required: ["query"], + }, + execute: async ({ query }) => { + const q = String(query ?? "").toLowerCase(); + const pages = [ + { title: "Home", url: "https://nextflow.io/", keywords: ["nextflow", "workflow", "pipeline", "reproducible"] }, + { title: "Examples", url: "https://nextflow.io/examples.html", keywords: ["example", "tutorial", "pipeline"] }, + { title: "About", url: "https://nextflow.io/about-us.html", keywords: ["about", "seqera", "team"] }, + { + title: "Basic pipeline", + url: "https://nextflow.io/basic-pipeline.html", + keywords: ["basic", "hello", "world"], + }, + { + title: "Documentation", + url: "https://docs.seqera.io/nextflow/", + keywords: ["docs", "documentation", "reference", "manual"], + }, + { title: "Training", url: "https://training.nextflow.io/latest/", keywords: ["training", "course", "learn"] }, + ]; + return pages.filter( + (page) => + page.title.toLowerCase().includes(q) || + page.keywords.some((keyword) => keyword.includes(q) || q.includes(keyword)), + ); + }, + }, +]; + +export default function WebMcpBridge() { + useEffect(() => { + const modelContext = (navigator as Navigator & { modelContext?: Record }).modelContext; + if (!modelContext) { + return; + } + + const abortController = new AbortController(); + + if (typeof modelContext.provideContext === "function") { + (modelContext.provideContext as (ctx: { tools: WebMcpTool[] }) => void)({ tools }); + } else if (typeof modelContext.registerTool === "function") { + for (const tool of tools) { + (modelContext.registerTool as (tool: WebMcpTool, opts: { signal: AbortSignal }) => void)(tool, { + signal: abortController.signal, + }); + } + } + + return () => { + abortController.abort(); + }; + }, []); + + return null; +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 21314bf33..bedf9e8d1 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -4,6 +4,7 @@ import "../styles/focus-styles.css"; import MenuComponent from "@components/Menu/Menu"; import Footer from "@components/Footer.astro"; import CookieBanner from "@components/CookieBanner/index.tsx"; +import WebMcpBridge from "@components/WebMcpBridge/index.tsx"; import { existsSync } from "fs"; import path from "path"; @@ -47,6 +48,11 @@ if (!existsSync(path.join(path.resolve("./public"), image_path))) { + + + + + @@ -122,6 +128,7 @@ if (!existsSync(path.join(path.resolve("./public"), image_path))) { +