diff --git a/astro.config.mjs b/astro.config.mjs
new file mode 100644
index 0000000..59d116c
--- /dev/null
+++ b/astro.config.mjs
@@ -0,0 +1,22 @@
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+import {config} from './config.js'
+import yaml from '@rollup/plugin-yaml';
+
+export default defineConfig({
+ adapter: node({
+ mode: 'middleware',
+ }),
+ output: "server",
+ outDir: config.outDir,
+ trailingSlash: 'ignore',
+ vite: {
+ plugins: [yaml()],
+ ssr: {
+ external: ['better-sqlite3']
+ },
+ optimizeDeps: {
+ exclude: ['better-sqlite3']
+ }
+ }
+});
diff --git a/config.js b/config.js
index a6b10b4..ae3dba1 100644
--- a/config.js
+++ b/config.js
@@ -1,16 +1,11 @@
import * as dotenv from 'dotenv'
import {join} from 'path'
-import { fileURLToPath } from "node:url";
import path from "node:path";
import fsp from "node:fs/promises";
import yaml from "js-yaml";
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-const ROOT_DIR = path.resolve(__dirname);
-
async function loadManifest() {
- const manifestPath = path.join(ROOT_DIR, "manifest.yaml");
+ const manifestPath = path.join(process.cwd(), "manifest.yaml");
const raw = await fsp.readFile(manifestPath, "utf8");
return yaml.load(raw);
}
@@ -34,12 +29,8 @@ const config = {
code_path: `${rootdir}/${outdir}/codes`,
kroki_server: kroki_server,
client_menu:true,
- highlighter:{
- theme:"dark-plus",
- langs:['javascript','js','python','yaml']
- },
+ highlighter:manifest.render.highlighter,
copy_assets:false,
- copy_assets_dir: "_astro",
assets_hash_dir:true, //N.A. if(copy_assets == false)
fetch: manifest.fetch
}
diff --git a/manifest.yaml b/manifest.yaml
index 3b6ee0c..63f5f25 100644
--- a/manifest.yaml
+++ b/manifest.yaml
@@ -6,7 +6,11 @@ fetch:
dest: content
collect:
folder_single_doc: false
- file_link_ext: ["svg","webp","png","jpeg","jpg","xlsx","glb"]
+ file_link_ext: ["svg","webp","png","jpeg","jpg","xlsx","glb","puml"]
file_compress_ext: ['txt','md','json','csv','tsv','yaml','yml']
external_storage_kb: 512
inline_compression_kb: 32
+render:
+ highlighter:
+ theme: dark-plus
+ langs: ['javascript','js','python','yaml']
diff --git a/package.json b/package.json
index 4df0411..9975c34 100644
--- a/package.json
+++ b/package.json
@@ -11,15 +11,18 @@
"astro": "astro",
"fetch": "node scripts/fetch.js",
"server": "node server/server.js",
- "collect": "node scripts/collect.js"
+ "collect": "node scripts/collect.js",
+ "diagrams": "node scripts/diagrams.js"
},
"dependencies": {
+ "@astrojs/node": "^9.5.1",
"@google/model-viewer": "^4.1.0",
"@octokit/rest": "^21.0.2",
"@svgdotjs/svg.js": "^3.2.4",
"adm-zip": "^0.5.16",
- "astro": "^5.0.3",
- "content-structure": "2.0.1",
+ "better-sqlite3": "^12.4.1",
+ "astro": "^5.16.0",
+ "content-structure": "^2.1.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"datatables.net-dt": "^1.13.7",
@@ -39,7 +42,6 @@
"remark": "^15.0.1",
"sharp": "^0.33.5",
"shiki": "^3.15.0",
- "swiper": "^11.1.15",
"three": "^0.172.0",
"unist-util-visit": "^5.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 968dbed..9f1aea4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@astrojs/node':
+ specifier: ^9.5.1
+ version: 9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))
'@google/model-viewer':
specifier: ^4.1.0
version: 4.1.0(three@0.172.0)
@@ -21,11 +24,14 @@ importers:
specifier: ^0.5.16
version: 0.5.16
astro:
- specifier: ^5.0.3
+ specifier: ^5.16.0
version: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
+ better-sqlite3:
+ specifier: ^12.4.1
+ version: 12.4.6
content-structure:
- specifier: 2.0.1
- version: 2.0.1
+ specifier: ^2.1.0
+ version: 2.1.0
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
@@ -123,6 +129,11 @@ packages:
'@astrojs/markdown-remark@6.3.9':
resolution: {integrity: sha512-hX2cLC/KW74Io1zIbn92kI482j9J7LleBLGCVU9EP3BeH5MVrnFawOnqD0t/q6D1Z+ZNeQG2gNKMslCcO36wng==}
+ '@astrojs/node@9.5.1':
+ resolution: {integrity: sha512-7k+SU877OUQylPr0mFcWrGvNuC78Lp9w+GInY8Rwc+LkHyDP9xls+nZAioK0WDWd+fyeQnlHbpDGURO3ZHuDVg==}
+ peerDependencies:
+ astro: ^5.14.3
+
'@astrojs/prism@3.3.0':
resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
@@ -176,8 +187,8 @@ packages:
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
- '@csstools/css-syntax-patches-for-csstree@1.0.17':
- resolution: {integrity: sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==}
+ '@csstools/css-syntax-patches-for-csstree@1.0.19':
+ resolution: {integrity: sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==}
engines: {node: '>=18'}
'@csstools/css-tokenizer@3.0.4':
@@ -1072,8 +1083,8 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
- content-structure@2.0.1:
- resolution: {integrity: sha512-OxR+eHwafV9VfFf2pATPGGZSg7kwc70SQg31BJYDj8kM8ck0+liLfemdm5BTSb26ynsLgahDgAuMy7aK3yxoEg==}
+ content-structure@2.1.0:
+ resolution: {integrity: sha512-Rizv3nXGZCwt5RiybZbfOScQaaWOLxqan9INjzRboV78X9UfRdRYiUbR1QrWS6iNLaL8Wuzc/AF/TQGN+t17aA==}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
@@ -1393,6 +1404,10 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -1497,6 +1512,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
+ http-errors@2.0.1:
+ resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+ engines: {node: '>= 0.8'}
+
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1807,10 +1826,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
@@ -2166,10 +2193,17 @@ packages:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
+ send@1.2.0:
+ resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
+ engines: {node: '>= 18'}
+
serve-static@1.16.2:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
+ server-destroy@1.0.1:
+ resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
+
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -2246,6 +2280,10 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -2733,6 +2771,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@astrojs/node@9.5.1(astro@5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3))':
+ dependencies:
+ '@astrojs/internal-helpers': 0.7.5
+ astro: 5.16.0(@types/node@24.10.1)(rollup@4.53.3)(typescript@5.9.3)
+ send: 1.2.0
+ server-destroy: 1.0.1
+ transitivePeerDependencies:
+ - supports-color
+
'@astrojs/prism@3.3.0':
dependencies:
prismjs: 1.30.0
@@ -2784,7 +2831,7 @@ snapshots:
dependencies:
'@csstools/css-tokenizer': 3.0.4
- '@csstools/css-syntax-patches-for-csstree@1.0.17': {}
+ '@csstools/css-syntax-patches-for-csstree@1.0.19': {}
'@csstools/css-tokenizer@3.0.4': {}
@@ -3590,7 +3637,7 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
- content-structure@2.0.1:
+ content-structure@2.1.0:
dependencies:
better-sqlite3: 12.4.6
glob: 13.0.0
@@ -3600,6 +3647,7 @@ snapshots:
remark: 15.0.1
remark-directive: 4.0.0
remark-gfm: 4.0.1
+ sharp: 0.33.5
slugify: 1.6.6
unified: 11.0.5
unist-util-visit: 5.0.0
@@ -3672,7 +3720,7 @@ snapshots:
cssstyle@5.3.3:
dependencies:
'@asamuzakjp/css-color': 4.1.0
- '@csstools/css-syntax-patches-for-csstree': 1.0.17
+ '@csstools/css-syntax-patches-for-csstree': 1.0.19
css-tree: 3.1.0
data-urls@6.0.0:
@@ -3949,6 +3997,8 @@ snapshots:
fresh@0.5.2: {}
+ fresh@2.0.0: {}
+
fs-constants@1.0.0: {}
fsevents@2.3.3:
@@ -4127,6 +4177,14 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
+ http-errors@2.0.1:
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.2
+ toidentifier: 1.0.1
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -4633,10 +4691,16 @@ snapshots:
mime-db@1.52.0: {}
+ mime-db@1.54.0: {}
+
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
mime@1.6.0: {}
mimic-response@3.1.0: {}
@@ -5077,6 +5141,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ send@1.2.0:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.2.1
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
serve-static@1.16.2:
dependencies:
encodeurl: 2.0.0
@@ -5086,6 +5166,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ server-destroy@1.0.1: {}
+
setprototypeof@1.2.0: {}
sharp@0.33.5:
@@ -5219,6 +5301,8 @@ snapshots:
statuses@2.0.1: {}
+ statuses@2.0.2: {}
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..0307b12
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/readme.md b/readme.md
index 8083762..63630ed 100644
--- a/readme.md
+++ b/readme.md
@@ -43,3 +43,24 @@ True content based ISR (Incremental Static Regenration) with cache warmup.
- `folders` pulls those subfolders and flattens their contents into `dest`; omit `folders` to copy the whole repo. `dest` defaults to the repo name and is cleared before copying.
- Set `GITHUB_TOKEN` to avoid GitHub rate limits.
- Run `pnpm fetch` (or `node scripts/fetch.js`) after installing dependencies.
+
+## collection
+- All configs are optional and have defaults
+- Configure `collect` in `manifest.yaml`. Example:
+ ```yaml
+ collect:
+ folder_single_doc: false
+ file_link_ext: ["svg","webp","png","jpeg","jpg","xlsx","glb"]
+ file_compress_ext: ['txt','md','json','csv','tsv','yaml','yml']
+ external_storage_kb: 512
+ inline_compression_kb: 32
+ ```
+ - `folder_single_doc` default is false for one document per file, when true, generates one document per folder merging its markdown files.
+ - `file_link_ext` : only these extensions will be considered as assets to manage
+ - `file_compress_ext` : files subject to compressions in blobs storage
+ - `external_storage_kb` : threshold to manage blobs in folders and not in db
+ - `inline_compression_kb` : threshold above which db blobs get compressed
+- Run `pnpm collect` to parse the `.content` directory Markdown and referenced assets and store them in `.structure/structure.db`
+
+# Notes
+* XLSX files support dropped but could potentially generate two assets, original file for download and asset table for direct asset vieweing
diff --git a/scripts/diagrams.js b/scripts/diagrams.js
new file mode 100644
index 0000000..7fc2d9f
--- /dev/null
+++ b/scripts/diagrams.js
@@ -0,0 +1,223 @@
+#!/usr/bin/env node
+import {join} from 'path';
+import {readFileSync} from 'fs';
+import {gunzipSync} from 'zlib';
+import {createHash} from 'crypto';
+import {config} from '../config.js';
+import {openDatabase} from 'content-structure/src/sqlite_utils/index.js';
+
+const diagramExts = new Set(['plantuml', 'blockdiag', 'mermaid']);
+const diagramTypeMap = {codeblock: 'code_diagram', linked_file: 'file_diagram'};
+const languageAliases = {puml: 'plantuml'};
+const dbPath = join(config.collect_content.outdir, 'structure.db');
+const db = openDatabase(dbPath);
+
+function sha512(buffer) {
+ return createHash('sha512').update(buffer).digest('hex');
+}
+
+function normalizeLanguage(value) {
+ const normalized = String(value ?? '').trim().toLowerCase();
+ if (!normalized) {
+ return '';
+ }
+ const trimmed = normalized.startsWith('.') ? normalized.slice(1) : normalized;
+ return languageAliases[trimmed] ?? trimmed;
+}
+
+function loadBlob(blobUid) {
+ if (!blobUid) {
+ return null;
+ }
+ const row = db
+ .prepare('SELECT blob_uid, hash, path, payload, compression FROM blob_store WHERE blob_uid = ?')
+ .get(blobUid);
+ if (!row) {
+ return null;
+ }
+ let buffer = null;
+ if (row.payload) {
+ buffer = Buffer.from(row.payload);
+ } else if (row.path && row.hash) {
+ const absPath = join(config.collect_content.outdir, 'blobs', row.path, row.hash);
+ try {
+ buffer = readFileSync(absPath);
+ } catch {
+ buffer = null;
+ }
+ }
+ if (!buffer) {
+ return null;
+ }
+ if (row.compression) {
+ try {
+ buffer = gunzipSync(buffer);
+ } catch {
+ /* ignore decompression errors */
+ }
+ }
+ return buffer;
+}
+
+function getDocSid(parentDocUid) {
+ if (!parentDocUid) {
+ return null;
+ }
+ const row = db.prepare('SELECT sid FROM documents WHERE uid = ?').get(parentDocUid);
+ return row?.sid ?? null;
+}
+
+function getCurrentMaxBlobId() {
+ const rows = db.prepare('SELECT blob_uid FROM blob_store').all();
+ let max = 0;
+ for (const {blob_uid: blobUid} of rows) {
+ const value = parseInt(blobUid, 16);
+ if (!Number.isNaN(value) && value > max) {
+ max = value;
+ }
+ }
+ return max;
+}
+
+function resolveBlobUidForHash(hash) {
+ const row = db.prepare('SELECT blob_uid FROM blob_store WHERE hash = ?').get(hash);
+ return row?.blob_uid ?? null;
+}
+
+async function renderDiagram(language, code) {
+ const response = await fetch(`${config.kroki_server}/${language}/svg/`, {
+ method: 'POST',
+ body: code,
+ headers: {'Content-Type': 'text/plain'}
+ });
+ if (!response.ok) {
+ throw new Error(`Kroki render failed (${response.status})`);
+ }
+ return response.text();
+}
+
+async function main() {
+ if (typeof fetch !== 'function') {
+ throw new Error('Global fetch is not available. Run with Node 18+ or provide a fetch polyfill.');
+ }
+ const diagramSources = db
+ .prepare("SELECT uid, blob_uid, parent_doc_uid, ext, type FROM asset_info WHERE type IN ('codeblock', 'linked_file')")
+ .all();
+ if (!diagramSources.length) {
+ console.log('No diagram-capable assets found; nothing to render.');
+ return;
+ }
+
+ const versionRow = db.prepare('SELECT version_id FROM assets LIMIT 1').get();
+ const versionId = versionRow?.version_id ?? 'manual';
+ let nextBlobId = getCurrentMaxBlobId();
+
+ const insertBlob = db.prepare(
+ 'INSERT INTO blob_store (blob_uid, hash, path, first_seen, last_seen, size, compression, payload) VALUES (?, ?, NULL, ?, ?, ?, ?, ?)'
+ );
+ const insertAssetInfo = db.prepare(
+ 'INSERT INTO asset_info (uid, type, blob_uid, parent_doc_uid, path, ext, first_seen, last_seen) VALUES (?, ?, ?, ?, NULL, ?, ?, ?)'
+ );
+ const insertAssetLink = db.prepare(
+ 'INSERT INTO assets (asset_uid, version_id, doc_sid, blob_uid, type) VALUES (?, ?, ?, ?, ?)'
+ );
+ const writeDiagram = db.transaction((payload) => {
+ if (payload.insertBlob) {
+ insertBlob.run(
+ payload.blobUid,
+ payload.hash,
+ payload.now,
+ payload.now,
+ payload.size,
+ 0,
+ payload.buffer
+ );
+ }
+ insertAssetInfo.run(
+ payload.diagramUid,
+ payload.diagramType,
+ payload.blobUid,
+ payload.parentDocUid,
+ 'svg',
+ payload.now,
+ payload.now
+ );
+ if (payload.docSid) {
+ insertAssetLink.run(
+ payload.diagramUid,
+ versionId,
+ payload.docSid,
+ payload.blobUid,
+ payload.diagramType
+ );
+ }
+ });
+
+ for (const asset of diagramSources) {
+ const diagramType = diagramTypeMap[asset.type];
+ if (!diagramType) {
+ continue;
+ }
+
+ const ext = normalizeLanguage(asset.ext);
+ if (!diagramExts.has(ext)) {
+ continue;
+ }
+
+ const diagramUid = `${asset.uid}.svg`;
+ const existing = db.prepare('SELECT uid FROM asset_info WHERE uid = ?').get(diagramUid);
+ if (existing) {
+ console.log(`Skipping existing diagram ${diagramUid}`);
+ continue;
+ }
+
+ const codeBuffer = loadBlob(asset.blob_uid);
+ if (!codeBuffer) {
+ console.warn(`Skipping ${asset.uid}: missing code payload`);
+ continue;
+ }
+
+ let svgText;
+ try {
+ svgText = await renderDiagram(ext, codeBuffer.toString('utf-8'));
+ } catch (error) {
+ console.error(`Render failed for ${asset.uid}: ${error.message}`);
+ continue;
+ }
+
+ const svgBuffer = Buffer.from(svgText, 'utf-8');
+ const hash = sha512(svgBuffer);
+ const now = new Date().toISOString();
+
+ let blobUid = resolveBlobUidForHash(hash);
+ let insertedBlob = false;
+ if (!blobUid) {
+ nextBlobId += 1;
+ blobUid = nextBlobId.toString(16);
+ insertedBlob = true;
+ }
+
+ const docSid = getDocSid(asset.parent_doc_uid);
+ writeDiagram({
+ insertBlob: insertedBlob,
+ blobUid,
+ hash,
+ now,
+ size: svgBuffer.length,
+ buffer: svgBuffer,
+ diagramUid,
+ parentDocUid: asset.parent_doc_uid,
+ docSid,
+ diagramType
+ });
+
+ console.log(
+ `${diagramUid} [${diagramType}]: ${insertedBlob ? 'generated' : 'reused'} blob ${blobUid} (hash ${hash.slice(0, 8)})`
+ );
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 0000000..3751748
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1,3 @@
+*.debug
+cert
+auth/session.json
diff --git a/server/auth/auth_router.js b/server/auth/auth_router.js
new file mode 100644
index 0000000..e17bd44
--- /dev/null
+++ b/server/auth/auth_router.js
@@ -0,0 +1,82 @@
+import passport from 'passport'
+import {Strategy} from 'passport-github'
+import express from 'express'
+import session from 'express-session'
+import { verifyUser } from './auth_utils.js'
+import { env } from 'node:process';
+
+import * as dotenv from 'dotenv'
+dotenv.config()
+
+const GitHubStrategy = Strategy;
+
+const callbackURL = process.env.PROTOCOL+"://"+process.env.HOST+":"+process.env.PORT+"/auth/github/callback"
+const strategyConfig = {
+ clientID: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ callbackURL: callbackURL
+}
+let first_url = '/'
+
+
+passport.use(new GitHubStrategy(strategyConfig, verifyUser));
+passport.serializeUser((user,done)=>{done(null,user)});
+passport.deserializeUser((user,done)=>{done(null,user)});
+
+
+const sessionStore = new session.MemoryStore()
+const sessionHandler = session({
+ secret:process.env.SESSION_SECRET,
+ resave:false,
+ saveUninitialized:false,
+ // using MemoryStore for limited users pool only (accepting reset on server restart)
+ store: sessionStore
+})
+
+const storeUser = (req,res,next)=>{
+ const user = req.session?.passport?.user
+ if(user){
+ env[req.sessionID] = user.id
+ //TODO on session expires delte the sessionID to prevent a memory leak
+ }
+ next()
+}
+
+const onSuccess = function(req, res) {
+ console.log(`req.sessionID : ${req.sessionID} ; authenticated : ${req.isAuthenticated()}`)
+ // Auth Success, redirect to first requested url
+ res.redirect(first_url);
+}
+
+const checkAuthenticated = (req,res,next)=>{
+ if(req.isAuthenticated()){
+ next()
+ }else{
+ console.log(`req.sessionID : ${req.sessionID} ; authenticated : ${req.isAuthenticated()}`)
+ first_url = req.url
+ res.redirect('/auth/github')
+ }
+}
+
+const authRouter = express.Router()
+//'sessionStore', 'sessionID', 'session' (session.cookie, session.passport.user)
+//sessionStore => [ '_events', '_eventsCount', '_maxListeners', 'sessions', 'generate' ]
+authRouter.use(sessionHandler)
+//'logIn', 'login', 'logOut', 'logout', 'isAuthenticated', 'isUnauthenticated'
+authRouter.use(passport.initialize())
+authRouter.use(passport.session())
+authRouter.use(storeUser)
+authRouter.get('/auth/github',
+ passport.authenticate('github'));
+
+authRouter.get('/auth/github/callback',
+ passport.authenticate('github', { failureRedirect: '/access' }),
+ onSuccess);
+
+authRouter.use(checkAuthenticated)
+
+//TODO checkAuthorised
+
+export{
+ authRouter
+}
diff --git a/server/auth/auth_utils.js b/server/auth/auth_utils.js
new file mode 100644
index 0000000..4c32c80
--- /dev/null
+++ b/server/auth/auth_utils.js
@@ -0,0 +1,50 @@
+import { env } from 'node:process';
+
+function showKeys(info,obj){
+ console.log(info)
+ let list_keys = Object.keys(obj)
+ list_keys = list_keys.filter((item)=>(!item.startsWith('_')))
+ console.log(list_keys)
+}
+
+function verifyUser(accessToken, refreshToken, profile, cb){
+ const user = profile
+ console.log(` * verifyUser(id:${user.id})`)
+ console.log(" checking user id, this is a demo, all users accepted ")
+ user.role = 'admin',
+ user.groups = ['Markdown','Astro','Blog','About']
+
+ //in case of real verification, check user in DB, otherwise return non null err
+ cb(null,user)
+}
+
+function get_session_id(cookie){
+ const prefix = "connect.sid=s%3A"
+ if(cookie){
+ if(cookie.startsWith(prefix)){
+ return (cookie.split(prefix)[1].split(".")[0])
+ }
+ }
+ return 0
+}
+
+function session_user(request){
+ if(request.user){
+ return request.user
+ }
+ else if(import.meta.env.PROD){
+ const cookie = request.headers.get('cookie');
+ const session_id = get_session_id(cookie)
+ const user = env[session_id] //give back the user saved by storeUser
+ request.user = user
+ return user
+ }
+ else return ""
+}
+
+export {
+ showKeys,
+ verifyUser,
+ get_session_id,
+ session_user
+}
diff --git a/server/bigdoc.service b/server/bigdoc.service
new file mode 100644
index 0000000..f7e8985
--- /dev/null
+++ b/server/bigdoc.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Astro big doc site template signin with github
+After=multi-user.target
+
+[Service]
+Type=simple
+WorkingDirectory=/home/user/astro-big-doc
+ExecStart=node /home/user/astro-big-doc/server/server.js
+Restart=on-abort
+
+[Install]
+WantedBy=multi-user.target
diff --git a/server/create.sh b/server/create.sh
new file mode 100644
index 0000000..d74a754
--- /dev/null
+++ b/server/create.sh
@@ -0,0 +1,2 @@
+openssl req -x509 -newkey rsa:2048 -keyout cert/keytmp.pem -out cert/cert.pem -days 365
+openssl rsa -in cert/keytmp.pem -out cert/key.pem
diff --git a/server/install.sh b/server/install.sh
new file mode 100644
index 0000000..3a4a991
--- /dev/null
+++ b/server/install.sh
@@ -0,0 +1,7 @@
+sudo cp bigdoc.service /lib/systemd/system/
+sudo chmod 644 /lib/systemd/system/bigdoc.service
+sudo chmod +x server/server.js
+sudo systemctl daemon-reload
+sudo systemctl enable bigdoc.service
+sudo systemctl start bigdoc.service
+sudo systemctl status bigdoc.service
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..9a3b4a7
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,52 @@
+import express from 'express';
+import https from 'https'
+import { fileURLToPath } from 'url';
+import { join, dirname } from 'path';
+import { readFileSync, } from 'fs';
+import { handler as ssrHandler } from '../dist/server/entry.mjs';
+import cors from 'cors';
+
+import * as dotenv from 'dotenv'
+dotenv.config()
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const outdir = (process.env.OUT_DIR==null)?"dist/client/":process.env.OUT_DIR
+const protocol = (process.env.PROTOCOL==null)?"http":process.env.PROTOCOL
+const host = (process.env.HOST==null)?"0.0.0.0":process.env.HOST
+const port = (process.env.PORT==null)?"3001":process.env.PORT
+
+const app = express();
+if(process.env.ENABLE_CORS == "true"){
+ app.use(cors());
+ console.log("\n -- !!! CORS enabled !!! -- APIs can be used from other sites --\n")
+}
+
+if(process.env.ENABLE_AUTH === "true"){
+ const { authRouter } = await import('./auth/auth_router.js');
+ app.use(authRouter)
+ console.log(" with auth")
+}else{
+ console.log("\n -- !!! no auth !!! -- Authentication is disabled -- \n")
+}
+
+app.use(ssrHandler);
+app.use(express.static(outdir))
+
+app.use((req, res, next) => {
+ res.status(404).send("Sorry can't find that!")
+ })
+
+
+if(protocol == "https"){
+ const key = readFileSync(join(__dirname, process.env.KEY_FILE),'utf8')
+ const cert = readFileSync(join(__dirname, process.env.CERT_FILE),'utf8')
+ const httpsServer = https.createServer({key,cert},app)
+ httpsServer.listen(port,host,()=>{
+ console.log(`listening on ${protocol}://${host}:${port}`)
+ });
+}else{
+ app.listen(port,host,()=>{
+ console.log(`listening on ${protocol}://${host}:${port}`)
+ });
+}
diff --git a/src/assets/copy.svg b/src/assets/copy.svg
new file mode 100644
index 0000000..3ca7889
--- /dev/null
+++ b/src/assets/copy.svg
@@ -0,0 +1,15 @@
+
+
+
+
diff --git a/src/assets/discord.svg b/src/assets/discord.svg
new file mode 100644
index 0000000..1b0d6f6
--- /dev/null
+++ b/src/assets/discord.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/assets/download.svg b/src/assets/download.svg
new file mode 100644
index 0000000..e0109c2
--- /dev/null
+++ b/src/assets/download.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/full-screen.svg b/src/assets/full-screen.svg
new file mode 100644
index 0000000..9d1e73e
--- /dev/null
+++ b/src/assets/full-screen.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/src/assets/fullscreen.drawio b/src/assets/fullscreen.drawio
new file mode 100644
index 0000000..d13dbb8
--- /dev/null
+++ b/src/assets/fullscreen.drawio
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/fullscreen.svg b/src/assets/fullscreen.svg
new file mode 100644
index 0000000..f3d810c
--- /dev/null
+++ b/src/assets/fullscreen.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/github-dark.png b/src/assets/github-dark.png
new file mode 100644
index 0000000..23a5c56
Binary files /dev/null and b/src/assets/github-dark.png differ
diff --git a/src/assets/github.svg b/src/assets/github.svg
new file mode 100644
index 0000000..bda3e73
--- /dev/null
+++ b/src/assets/github.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/assets/hamburger.svg b/src/assets/hamburger.svg
new file mode 100644
index 0000000..2baf2d0
--- /dev/null
+++ b/src/assets/hamburger.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/assets/link.svg b/src/assets/link.svg
new file mode 100644
index 0000000..f7084cc
--- /dev/null
+++ b/src/assets/link.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/assets/new.svg b/src/assets/new.svg
new file mode 100644
index 0000000..bbef588
--- /dev/null
+++ b/src/assets/new.svg
@@ -0,0 +1,18 @@
+
diff --git a/src/assets/notes-caution.svg b/src/assets/notes-caution.svg
new file mode 100644
index 0000000..8dfd1ab
--- /dev/null
+++ b/src/assets/notes-caution.svg
@@ -0,0 +1,18 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/notes-danger.svg b/src/assets/notes-danger.svg
new file mode 100644
index 0000000..97c67db
--- /dev/null
+++ b/src/assets/notes-danger.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/assets/notes-note.svg b/src/assets/notes-note.svg
new file mode 100644
index 0000000..0a9719c
--- /dev/null
+++ b/src/assets/notes-note.svg
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/src/assets/notes-tip.svg b/src/assets/notes-tip.svg
new file mode 100644
index 0000000..38d8a1b
--- /dev/null
+++ b/src/assets/notes-tip.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/src/assets/pdf.svg b/src/assets/pdf.svg
new file mode 100644
index 0000000..19fea32
--- /dev/null
+++ b/src/assets/pdf.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/assets/rightarrow.svg b/src/assets/rightarrow.svg
new file mode 100644
index 0000000..d3320a7
--- /dev/null
+++ b/src/assets/rightarrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/search.png b/src/assets/search.png
new file mode 100644
index 0000000..fa2ff6b
Binary files /dev/null and b/src/assets/search.png differ
diff --git a/src/components/gallery/gallery.astro b/src/components/gallery/gallery.astro
new file mode 100644
index 0000000..b2d298c
--- /dev/null
+++ b/src/components/gallery/gallery.astro
@@ -0,0 +1,69 @@
+---
+import {calculate_span,select_masonry} from './grid_utils.js'
+import {getImageInfo} from '../../libs/structure-db.js';
+
+interface GalleryItem {
+ uid: string;
+}
+
+export interface Props {
+ model: GalleryItem[];
+ masonry: boolean;
+}
+
+const { model, masonry=null } = Astro.props as Props;
+console.log(model)
+const images_uids = model.map((item)=>(item.uid));
+const imagesInfo = images_uids.map((uid)=>{
+ const info = getImageInfo(uid);
+ return {
+ url: `/assets/${uid}`,
+ ...info,
+ ...calculate_span(info.ratio)
+ }
+});
+
+let mas = masonry
+if(mas == null){
+ mas = select_masonry(imagesInfo)
+}
+
+---
+
+
+ {imagesInfo.map((image)=>(
+
+
+
+ ))}
+
+
+
+
+
diff --git a/src/components/gallery/gallery.js b/src/components/gallery/gallery.js
new file mode 100644
index 0000000..5f0f583
--- /dev/null
+++ b/src/components/gallery/gallery.js
@@ -0,0 +1,22 @@
+async function initPhotoSwipe() {
+ // Dynamically import the PhotoSwipeLightbox module and CSS
+ const { default: PhotoSwipeLightbox } = await import('photoswipe/lightbox');
+ await import('photoswipe/style.css');
+
+ // Initialize PhotoSwipeLightbox
+ const lightbox = new PhotoSwipeLightbox({
+ gallery: '#my-gallery',
+ children: 'a',
+ pswpModule: () => import('photoswipe') // This remains the same, dynamically importing the main module
+ });
+
+ // Initiate the lightbox
+ lightbox.init();
+}
+
+// Call initPhotoSwipe on page load
+if (document.readyState === 'loading') { // Loading hasn't finished yet
+ document.addEventListener('DOMContentLoaded', initPhotoSwipe);
+} else { // `DOMContentLoaded` has already fired
+ initPhotoSwipe();
+}
diff --git a/src/components/gallery/grid_utils.js b/src/components/gallery/grid_utils.js
new file mode 100644
index 0000000..18166e1
--- /dev/null
+++ b/src/components/gallery/grid_utils.js
@@ -0,0 +1,21 @@
+
+
+function calculate_span(aspectRatio){
+ let spanWidth = 1, spanHeight = 1
+ if(aspectRatio > 1) { // Wider image
+ spanWidth = Math.round(aspectRatio) // Adjust this logic as per your grid layout needs
+ } else if(aspectRatio < 1) { // Taller image
+ spanHeight = Math.round(1 / aspectRatio) // Adjust this logic as per your grid layout needs
+ }
+ return {spanWidth,spanHeight}
+}
+
+function select_masonry(imageUrls){
+ const countGreaterOrEqualOne = imageUrls.filter(item => item.ratio >= 1).length;
+ return countGreaterOrEqualOne > imageUrls.length / 2;
+}
+
+export {
+ calculate_span,
+ select_masonry
+}
diff --git a/src/components/icon.astro b/src/components/icon.astro
new file mode 100644
index 0000000..bbd7077
--- /dev/null
+++ b/src/components/icon.astro
@@ -0,0 +1,13 @@
+---
+
+import githubImg from '@/assets/github-dark.png'
+import search from '@/assets/search.png'
+export interface Props {
+ filename: string;
+}
+
+const { filename } = Astro.props as Props;
+
+---
+{(filename == "github") &&
}
+{(filename == "search") &&
}
diff --git a/src/components/markdown/AstroMarkdown.astro b/src/components/markdown/AstroMarkdown.astro
new file mode 100644
index 0000000..cac4f1e
--- /dev/null
+++ b/src/components/markdown/AstroMarkdown.astro
@@ -0,0 +1,96 @@
+---
+import Heading from './Heading.astro';
+import DataTable from './table/DataTable.astro'
+import MarkdownImage from './image/MarkdownImage.astro'
+import Code from './code/Code.astro'
+import Tag from './Tag.astro'
+import Link from './Link.astro'
+import Directive from './directive/Directive.astro'
+import ContainerDirective from './directive/ContainerDirective.astro';
+import {toHast} from 'mdast-util-to-hast'
+import {toHtml} from 'hast-util-to-html'
+import {dirname} from 'path'
+import {text_only_children,has_image} from './node_check.js'
+export interface Props {
+ items: {
+ ast?: string;
+ type?: string;
+ [key: string]: unknown;
+ }[];
+ data: object;
+ headings?: object[];
+}
+
+const {items = [], data, headings = []} = Astro.props as Props;
+
+function renderAstToHtml(astNode){
+ try{
+ return toHtml(toHast(astNode));
+ }catch{
+ return '';
+ }
+}
+
+function paragraphStyle(node){
+ if(node.type !== 'paragraph'){
+ return 'paragraph';
+ }
+ if(text_only_children(node)){
+ return 'paragraph text';
+ }
+ if(has_image(node)){
+ return 'paragraph image';
+ }
+ return 'paragraph';
+}
+
+---
+{items.map((item) => {
+ if (item.type === 'heading') {
+ return ;
+ }
+ if (item.type === 'image') {
+ return ;
+ }
+ if (item.type === 'table') {
+ return ;
+ }
+ if (item.type === 'code') {
+ return ;
+ }
+ if (item.type === 'link') {
+ return ;
+ }
+ if (item.type === 'containerDirective') {
+ return (
+
+ {item.ast.children?.map((child) => (
+
+ ))}
+
+ );
+ }
+ if (item.ast) {
+ const astHtml = item.ast ? renderAstToHtml(item.ast) : null;
+ if (astHtml) {
+ return ;
+ }
+ }
+ if (item.type === 'paragraph' && item.ast) {
+ const style = paragraphStyle(item);
+ const astHtml = item.ast ? renderAstToHtml(item.ast) : null;
+ if (astHtml) {
+ return (
+
+ {item.children?.map((child) => (
+
+ )) ?? item.value}
+
+ )
+ }
+ }
+ if (item.type === 'paragraph' && !item.ast) {
+ return {item.body_text};
+ }
+ return md.nan;
+})}
diff --git a/src/components/markdown/Heading.astro b/src/components/markdown/Heading.astro
new file mode 100644
index 0000000..1cce7fc
--- /dev/null
+++ b/src/components/markdown/Heading.astro
@@ -0,0 +1,70 @@
+---
+import {toHast} from 'mdast-util-to-hast'
+import {toHtml} from 'hast-util-to-html'
+import Svgicons from '../svgicons.astro';
+export interface Props {
+ item: {
+ sid: string;
+ level: number;
+ slug: string;
+ body_text?: string;
+ };
+ data: {
+ url: string;
+ };
+}
+
+const { item, data } = Astro.props as Props;
+const HeadingLevel = `h${item.level}`
+
+---
+
+
+ {item.body_text}
+
+
+
+
+
+
+
diff --git a/src/components/markdown/Link.astro b/src/components/markdown/Link.astro
new file mode 100644
index 0000000..59febc0
--- /dev/null
+++ b/src/components/markdown/Link.astro
@@ -0,0 +1,71 @@
+---
+import {toHast} from 'mdast-util-to-hast'
+import {toHtml} from 'hast-util-to-html'
+import ModelViewer from './model/ModelViewer.astro';
+import LinkCode from './code/LinkCode.astro';
+import kroki from './code/kroki.yaml'
+import { getAssetInfo } from '@/libs/structure-db';
+import Code from './code/Code.astro';
+
+export interface Props {
+ item: {
+ asset_uid?: string;
+ body_text: string;
+ ast: {
+ title: string;
+ url: string;
+ };
+ };
+}
+
+const { item } = Astro.props as Props;
+
+const hasAsset = (item.asset_uid != null)
+const external = !hasAsset && item.ast.url.startsWith('http')
+
+const url = hasAsset ? `/assets/${item.asset_uid}` : item.ast.url
+
+let is_model3d = false
+let is_table = false
+let is_diagram = false
+let is_link = true
+
+if (hasAsset){
+ const asset_info = getAssetInfo(item);
+ is_model3d = (asset_info.ext === "glb")
+ is_diagram = Object.keys(kroki.formats).includes(asset_info.ext)
+ is_link = !is_model3d && !is_table && !is_diagram
+}
+
+
+---
+{is_model3d &&
+
+}
+{is_link &&
+
+ {item.body_text}
+
+}
+{is_diagram &&
+
+}
+
diff --git a/src/components/markdown/cards/Cards.astro b/src/components/markdown/cards/Cards.astro
new file mode 100644
index 0000000..72e163a
--- /dev/null
+++ b/src/components/markdown/cards/Cards.astro
@@ -0,0 +1,52 @@
+---
+import {getDocument, getEntry} from '@/libs/structure-db.js'
+import CardMeta from './CardsMeta.astro'
+import yaml from 'js-yaml'
+
+export interface Props {
+ code: string;
+}
+
+const { code } = Astro.props as Props;
+
+const cards = yaml.load(code)
+
+const entries = cards.map(entry => getDocument({ uid: entry.uid }));
+const resolvedCards = entries
+ .map((entry, index) => ({entry, config: cards[index]}))
+ .filter(({entry}) => entry);
+
+---
+
+ {
+ resolvedCards.map(({entry,config})=>(
+
+
+
+ ))
+ }
+
+
+
diff --git a/src/components/markdown/cards/CardsMeta.astro b/src/components/markdown/cards/CardsMeta.astro
new file mode 100644
index 0000000..91a550c
--- /dev/null
+++ b/src/components/markdown/cards/CardsMeta.astro
@@ -0,0 +1,124 @@
+---
+import Button from '@/components/markdown/directive/ButtonDirective.astro'
+export interface Props {
+ data: {
+ title: string;
+ url: string;
+ image?: string;
+ features?: string[];
+ tags?: string[];};
+}
+
+const { data } = Astro.props as Props;
+
+let isSvg = false
+let isImg = false
+let asseturl = ""
+if(data.image){
+ asseturl = `/assets/${data.image}`
+ isSvg = data.image.endsWith(".svg")
+ isImg = !isSvg
+}
+const url = Object.hasOwn(data,"link")?data.link:`/${data.url}`
+let hasFeatures = Object.hasOwn(data,"features") && (data.features.length > 0)
+let hasTags = Object.hasOwn(data,"tags") && (data.tags.length > 0)
+
+---
+
+
+
diff --git a/src/components/markdown/code/Code.astro b/src/components/markdown/code/Code.astro
new file mode 100644
index 0000000..687f488
--- /dev/null
+++ b/src/components/markdown/code/Code.astro
@@ -0,0 +1,57 @@
+---
+import Highlighter from './Highlighter.astro'
+import ModelViewerCode from '../model/ModelViewerCode.astro';
+import Cards from '../cards/Cards.astro'
+import Gallery from '../../gallery/gallery.astro';
+import kroki from './kroki.yaml'
+import { getAssetWithBlob } from '@/libs/structure-db';
+import DiagramCode from './DiagramCode.astro';
+
+export interface Props {
+ item: {
+ asset_uid: string;
+ ast:{
+ model?: object;
+ gallery?: object;
+ }
+ }
+}
+
+const { item } = Astro.props as Props;
+const {asset, buffer} = getAssetWithBlob(item.asset_uid)
+const code = new TextDecoder().decode(buffer)
+let language = asset.ext ?? 'plaintext'
+const krokiLanguage = asset.ext ? kroki.formats[asset.ext] : undefined
+if(asset.type == "linked_file" && krokiLanguage){
+ console.log("Kroki format detected for extension:", asset.ext)
+ language = krokiLanguage
+}
+console.log("Language:", language)
+const params = asset.params?asset.params.split(' '):[]
+const asset_uid = item.asset_uid
+
+const yaml_glb = ((language == "yaml") && (asset.params?.startsWith("glb")))
+const yaml_cards = ((language == "yaml") && (asset.params?.startsWith("cards")))
+const yaml_gallery = ((language == "yaml") && (asset.params?.startsWith("gallery")))
+
+const is_diagram = kroki.languages.includes(language)
+const custom_yaml = yaml_glb || yaml_cards || yaml_gallery
+const other_language = (!is_diagram && !custom_yaml)
+
+---
+{(is_diagram)&&
+
+}
+{yaml_glb &&
+
+}
+{yaml_cards &&
+
+}
+{yaml_gallery &&
+
+}
+{other_language&&
+
+}
+
diff --git a/src/components/markdown/code/DiagramCode.astro b/src/components/markdown/code/DiagramCode.astro
new file mode 100644
index 0000000..0e6c4ea
--- /dev/null
+++ b/src/components/markdown/code/DiagramCode.astro
@@ -0,0 +1,63 @@
+---
+import Highlighter from './Highlighter.astro'
+import Panzoom from '@/components/panzoom/panzoom.astro'
+import { config } from '@/config.js';
+
+export interface Props {
+ uid:string;
+ code: string;
+ language: string;
+ params: object;
+ meta: object;
+}
+
+const {uid, code, language, params, meta} = Astro.props as Props;
+const diagram_url = `${config.base}/assets/${encodeURIComponent(uid)}.svg`
+let alt = ""
+let title = ""
+if(params && (params.length > 0)){
+ alt = params[0]
+ title = params[0]
+}
+
+---
+
+
+
+
+
+
+
+
+
diff --git a/src/components/markdown/code/Highlighter.astro b/src/components/markdown/code/Highlighter.astro
new file mode 100644
index 0000000..836c96b
--- /dev/null
+++ b/src/components/markdown/code/Highlighter.astro
@@ -0,0 +1,105 @@
+---
+import Svgicons from '@/components/svgicons.astro';
+import {codeToHtml} from './highlighter.js'
+
+export interface Props {
+ code: string;
+ language: string;
+ params: object;
+}
+
+const { code, language, params } = Astro.props as Props;
+
+const {hash,html} = await codeToHtml(code, { lang: language, theme:'dark-plus' })
+
+---
+
+
+
+ Copied!
+
+
+
+
+
+
+
diff --git a/src/components/markdown/code/highlighter.js b/src/components/markdown/code/highlighter.js
new file mode 100644
index 0000000..1211014
--- /dev/null
+++ b/src/components/markdown/code/highlighter.js
@@ -0,0 +1,36 @@
+import {config} from '@/config.js'
+import {join} from 'path'
+import {bundledLanguages, createHighlighter} from 'shiki';
+import { exists,save_file, shortMD5 } from '@/libs/utils.js';
+
+const highlighter = await createHighlighter({
+ themes:[config.highlighter.theme],
+ langs:config.highlighter.langs,
+})
+await highlighter.loadTheme(config.highlighter.theme)
+
+async function codeToHtml(code, highlighter_config){
+ const requested_language = highlighter_config.lang
+ let lang = requested_language
+ if(!Object.keys(bundledLanguages).includes(requested_language)){
+ console.warn(` (X) ${requested_language} is not available, fall back on js`)
+ lang = 'js'
+ }
+ if (!highlighter.getLoadedLanguages().includes(lang)) {
+ await highlighter.loadLanguage(lang)
+ }
+
+
+ const html = highlighter.codeToHtml(code, { lang: lang, theme:config.highlighter.theme })
+ const hash = shortMD5(code)
+ const file_path = join(config.code_path,hash,"code.txt")
+ //persist for highlighter copy, for code not saved by a diag gen
+ if(!await exists(file_path)){
+ await save_file(file_path,code)
+ }
+ return {hash,html}
+}
+
+export{
+ codeToHtml
+}
diff --git a/src/components/markdown/code/kroki.yaml b/src/components/markdown/code/kroki.yaml
new file mode 100644
index 0000000..3fc9c63
--- /dev/null
+++ b/src/components/markdown/code/kroki.yaml
@@ -0,0 +1,20 @@
+languages:
+ - graphviz
+ - nwdiag
+ - packetdiag
+ - bytefield
+ - wavedrom
+ - rackdiag
+ - wireviz
+ - vegalite
+ - mermaid
+ - blockdiag
+ - seqdiag
+ - actdiag
+ - pikchr
+ - pikchr
+ - erd
+ - nomnoml
+ - plantuml
+formats:
+ puml: plantuml
diff --git a/src/components/markdown/directive/ButtonDirective.astro b/src/components/markdown/directive/ButtonDirective.astro
new file mode 100644
index 0000000..dbda684
--- /dev/null
+++ b/src/components/markdown/directive/ButtonDirective.astro
@@ -0,0 +1,65 @@
+---
+import Svgicons from '@/components/svgicons.astro';
+import {extname} from 'path'
+
+export interface Props {
+ label: string;
+ link: string;
+ icon: string;
+}
+
+const { label, link, icon=null } = Astro.props as Props;
+const external = link.startsWith('http')
+let used_icon = icon
+
+if(icon == null){
+ if(external){
+ used_icon = "new"
+ }else if([".zip",".hex"].includes(extname(link))){
+ used_icon = "download"
+ }
+}
+---
+
+
+
diff --git a/src/components/markdown/directive/ContainerDirective.astro b/src/components/markdown/directive/ContainerDirective.astro
new file mode 100644
index 0000000..4da9beb
--- /dev/null
+++ b/src/components/markdown/directive/ContainerDirective.astro
@@ -0,0 +1,31 @@
+---
+import NotesDirective from "./NotesDirective.astro"
+import DetailsDirective from "./DetailsDirective.astro"
+export interface Props {
+ name: string; //note, tip, caution, danger
+ attributes: object;
+}
+
+const {name, attributes} = Astro.props as Props;
+const className = name.toLowerCase()
+let typeName = "other"
+if(["note","tip","caution","danger"].includes(className)){
+ typeName = "notes"
+}else if(className == "details"){
+ typeName = "details"
+}
+---
+{(typeName == "notes")&&
+
+
+
+}
+{(typeName == "details")&&
+
+
+
+}
+{(typeName == "other")&&
+ {className}
+
+}
diff --git a/src/components/markdown/directive/DetailsDirective.astro b/src/components/markdown/directive/DetailsDirective.astro
new file mode 100644
index 0000000..9f5774f
--- /dev/null
+++ b/src/components/markdown/directive/DetailsDirective.astro
@@ -0,0 +1,56 @@
+---
+import Svgicons from '@/components/svgicons.astro';
+export interface Props {
+ attributes: object;
+}
+
+const {attributes} = Astro.props as Props;
+const summary = Object.hasOwn(attributes,"summary")?attributes.summary:"Details..."
+
+---
+
+
+ {summary}
+
+
+
+
+
+
+
+
diff --git a/src/components/markdown/directive/Directive.astro b/src/components/markdown/directive/Directive.astro
new file mode 100644
index 0000000..c6bb1b7
--- /dev/null
+++ b/src/components/markdown/directive/Directive.astro
@@ -0,0 +1,22 @@
+---
+import ButtonDirective from './ButtonDirective.astro'
+
+export interface Props {
+ name: string;
+ attributes: object[];
+}
+
+const { name, attributes } = Astro.props as Props;
+const is_image = (name == "image")
+const is_button = (name == "button")
+const is_other = !(is_image || is_button)
+
+---
+{is_button &&
+
+}
+{is_other &&
+ {name}
+ {Object.keys(attributes).map((key)=>({key} = {attributes[key]}))}
+
+}
diff --git a/src/components/markdown/directive/NotesDirective.astro b/src/components/markdown/directive/NotesDirective.astro
new file mode 100644
index 0000000..d221887
--- /dev/null
+++ b/src/components/markdown/directive/NotesDirective.astro
@@ -0,0 +1,83 @@
+---
+import Svgicons from '@/components/svgicons.astro';
+export interface Props {
+ name: string; //note, tip, caution, danger
+ attributes: object;
+}
+
+const {name, attributes} = Astro.props as Props;
+const title = Object.hasOwn(attributes,"title")?attributes.title:name
+const className = name.toLowerCase()
+---
+
+
+
diff --git a/src/components/markdown/heading.js b/src/components/markdown/heading.js
new file mode 100644
index 0000000..a135c89
--- /dev/null
+++ b/src/components/markdown/heading.js
@@ -0,0 +1,40 @@
+
+function init(){
+ const copyIcons = document.querySelectorAll('.icon.copy');
+ copyIcons.forEach(icon => {
+ icon.addEventListener('click', function() {
+ // Copy data-sid to clipboard
+ const url = this.getAttribute('data-url');
+ if (getComputedStyle(this).position === 'static') {
+ this.style.position = 'relative';
+ }
+ const full_url = `${window.location.origin}/${url}`;
+ navigator.clipboard.writeText(full_url).then(() => {
+ // Show success message
+ const message = document.createElement('span');
+ message.textContent = 'Copied!';
+ message.style.position = 'absolute';
+ message.style.left = '100%';
+ message.style.top = '0';
+ message.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
+ message.style.color = '#fff';
+ message.style.borderRadius = '4px';
+ message.style.padding = '2px 8px';
+ message.style.fontSize = '0.75rem';
+ message.style.marginLeft = '10px';
+ message.style.pointerEvents = 'none';
+ this.appendChild(message);
+
+ // Remove the message after 1 second
+ setTimeout(() => {
+ this.removeChild(message);
+ }, 1000);
+ }).catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
+ });
+ });
+}
+
+document.addEventListener('DOMContentLoaded', init);
+
diff --git a/src/components/markdown/image/MarkdownImage.astro b/src/components/markdown/image/MarkdownImage.astro
new file mode 100644
index 0000000..169c1d4
--- /dev/null
+++ b/src/components/markdown/image/MarkdownImage.astro
@@ -0,0 +1,18 @@
+---
+import Panzoom from '@/components/panzoom/panzoom.astro'
+
+export interface Props {
+ item: {
+ slug: string;
+ asset_uid: string;
+ };
+}
+
+const {item} = Astro.props as Props;
+
+const alt = item.slug;
+const asseturl = `/assets/${item.asset_uid}`;
+const meta = {};
+console.log(`MarkdownImage> asseturl: ${asseturl}`);
+---
+
diff --git a/src/components/markdown/model/ModelViewer.astro b/src/components/markdown/model/ModelViewer.astro
new file mode 100644
index 0000000..4f5432e
--- /dev/null
+++ b/src/components/markdown/model/ModelViewer.astro
@@ -0,0 +1,49 @@
+---
+import SvgIcons from '@/components/svgicons.astro'
+
+export interface Props {
+ src: string;
+ title: string;
+}
+
+const { src, title} = Astro.props as Props;
+
+
+---
+
+
+
+
+
+
+
diff --git a/src/components/markdown/model/ModelViewerCode.astro b/src/components/markdown/model/ModelViewerCode.astro
new file mode 100644
index 0000000..c66f91c
--- /dev/null
+++ b/src/components/markdown/model/ModelViewerCode.astro
@@ -0,0 +1,96 @@
+---
+import SvgIcons from '@/components/svgicons.astro'
+import yaml from 'js-yaml'
+
+export interface Props {
+ uid: string;
+ code: string;
+ model: {
+ [key: string]: any;
+ };
+ };
+
+const { uid, code, model } = Astro.props as Props;
+
+const code_data = yaml.load(code) as object;
+let should_render = true
+let src = ""
+let title = ""
+let poster = ""
+let environment_image = ""
+let error_text = ""
+//mandatory params
+if(Object.hasOwn(model,"src")){
+ src = `/assets/${model.src}`
+ console.log("ModelViewerCode src param:",src)
+}else{
+ should_render = false
+ const err = "ModelViewerCode missing 'src' param ; "
+ console.warn(err)
+ error_text += err
+}
+if(Object.hasOwn(code_data,"title")){
+ title = code_data.title
+}else{
+ should_render = false
+ const err = "ModelViewerCode missing 'title' param"
+ console.warn(err)
+ error_text += err
+}
+//optional params
+if(Object.hasOwn(model,"poster")){
+ poster = `/assets/${model.poster}`
+}
+if(Object.hasOwn(model,"environment-image")){
+ environment_image = `/assets/${model["environment-image"]}`
+}
+
+//potential additional params
+//camera-orbit="-10.92deg 80.06deg 58.4m"
+//field-of-view="30deg"
+
+---
+{should_render &&
+
+
+
+}
+{!should_render &&
+ {error_text}
+}
+
+
+
+
diff --git a/src/components/markdown/node_check.js b/src/components/markdown/node_check.js
new file mode 100644
index 0000000..8e88162
--- /dev/null
+++ b/src/components/markdown/node_check.js
@@ -0,0 +1,29 @@
+
+function text_only_children(node){
+ if(!node.children || node.children.length === 0){
+ return false
+ }
+ for(const child of node.children){
+ if(!["text","link"].includes(child.type)){
+ return false
+ }
+ }
+ return true
+}
+
+function has_image(node){
+ if(!node.children || node.children.length === 0){
+ return false
+ }
+ for(const child of node.children){
+ if(child.type == "image"){
+ return true
+ }
+ }
+ return false
+}
+
+export{
+ text_only_children,
+ has_image
+}
diff --git a/src/components/markdown/table/DataTable.astro b/src/components/markdown/table/DataTable.astro
new file mode 100644
index 0000000..83e479c
--- /dev/null
+++ b/src/components/markdown/table/DataTable.astro
@@ -0,0 +1,25 @@
+---
+
+export interface Props {
+ item: object;
+}
+
+const { item } = Astro.props as Props;
+
+---
+
+
+
+
+
diff --git a/src/components/markdown/table/Table.astro b/src/components/markdown/table/Table.astro
new file mode 100644
index 0000000..8a8ee77
--- /dev/null
+++ b/src/components/markdown/table/Table.astro
@@ -0,0 +1,27 @@
+---
+import {toHast} from 'mdast-util-to-hast'
+import {toHtml} from 'hast-util-to-html'
+
+export interface Props {
+ node: object;
+}
+
+const { node } = Astro.props as Props;
+
+---
+
+
+
+
+
diff --git a/src/components/markdown/table/data-tables.js b/src/components/markdown/table/data-tables.js
new file mode 100644
index 0000000..3a1f479
--- /dev/null
+++ b/src/components/markdown/table/data-tables.js
@@ -0,0 +1,49 @@
+import 'datatables.net-dt/css/jquery.dataTables.css'
+
+function checkEntries(table, tableElement) {
+ const info = table.page.info();
+ if (info.recordsTotal < 10) {
+ tableElement.parentElement.querySelector('.dataTables_length').style.display = 'none';
+ tableElement.parentElement.querySelector('.dataTables_info').style.display = 'none';
+ tableElement.parentElement.querySelector('.dataTables_paginate').style.display = 'none';
+ }
+ if (info.recordsTotal < 5) {
+ tableElement.parentElement.querySelector('.dataTables_filter').style.display = 'none';
+ }
+}
+
+async function init(){
+ const containers_els = document.querySelectorAll(".data-table")
+ if(containers_els.length === 0){//prevent irrelvant page execution
+ return
+ }
+
+ const DataTable = (await import('datatables.net-dt')).default;
+
+ containers_els.forEach(async table_element => {
+ const data_table_uid = table_element.getAttribute("data-table-uid")
+ const response = await fetch(`/assets/${data_table_uid}`);
+ if (!response.ok) {
+ throw new Error(`Failed to load /assets/${data_table_uid}`);
+ }
+ const data_table = await response.json();
+ if (!Array.isArray(data_table) || data_table.length === 0) {
+ console.warn(`No data available for table ${data_table_uid}`);
+ return;
+ }
+
+ const columns = Object.keys(data_table[0]).map(key => ({
+ title: key,
+ data: key
+ }));
+
+ const table = new DataTable(table_element,{
+ order:[],
+ data: data_table,
+ columns
+ });
+ checkEntries(table, table_element);
+ })
+ }
+
+document.addEventListener('DOMContentLoaded', init, false);
diff --git a/src/components/markdown/table/table.js b/src/components/markdown/table/table.js
new file mode 100644
index 0000000..90ee5d2
--- /dev/null
+++ b/src/components/markdown/table/table.js
@@ -0,0 +1,32 @@
+//also in //also in (content-structure)src\md_utils.js
+function astToDataTable(tableNode) {
+ const data = [];
+ for (const row of tableNode.children) {
+ if (row.type === 'tableRow') {
+ const rowData = [];
+ for (const cell of row.children) {
+ if (cell.type === 'tableCell') {
+ const textNode = cell.children.find(child => child.type === 'text');
+ if (textNode) {
+ rowData.push(textNode.value);
+ }
+ }
+ }
+
+ data.push(rowData);
+ }
+ }
+
+ return data;
+}
+
+function xlsxJson_to_DataTable(xlsxJson){
+ const headers = Object.keys(xlsxJson[0]);
+ const rows = xlsxJson.map(rowObject => headers.map(header => rowObject[header]));
+ return [headers, ...rows];
+}
+
+export{
+ astToDataTable,
+ xlsxJson_to_DataTable
+}
diff --git a/src/components/panzoom/client_utils.js b/src/components/panzoom/client_utils.js
new file mode 100644
index 0000000..c4e872e
--- /dev/null
+++ b/src/components/panzoom/client_utils.js
@@ -0,0 +1,15 @@
+
+function event(element,event_name,data=null){
+ var event = new CustomEvent(event_name, {detail:data});
+ element.dispatchEvent(event);
+}
+
+function window_event(event_name,data){
+ var event = new CustomEvent(event_name, {detail:data});
+ window.dispatchEvent(event);
+}
+
+export{
+ event,
+ window_event
+}
diff --git a/src/components/panzoom/lib_panzoommodal.js b/src/components/panzoom/lib_panzoommodal.js
new file mode 100644
index 0000000..732e0d4
--- /dev/null
+++ b/src/components/panzoom/lib_panzoommodal.js
@@ -0,0 +1,214 @@
+import { svg_text_focus } from './lib_svg_utils';
+
+let pzref = null
+const zoomOptions = {
+ minZoom: 0.1,
+ maxZoom:4,
+ //autocenter:true
+}
+
+function addFocusStyles(shadowRoot) {
+ if (!shadowRoot.getElementById('glowStyles')) {
+ const style = document.createElement('style');
+ style.id = 'focusStyles';
+ style.textContent = `
+ .focus-effect {
+ font-weight: bold;
+ }
+ `;
+ shadowRoot.appendChild(style);
+ }
+}
+
+async function appendShadowSVG(center,svg){
+ //cannot detatch a shadow root, so check existing before creation
+ let shadowRoot = center.shadowRoot
+ if(!shadowRoot){
+ shadowRoot = center.attachShadow({mode: 'open'});
+ }
+ const div = document.createElement("div")//needed for the panzoom as it takes the parent
+ shadowRoot.appendChild(div)
+ addFocusStyles(shadowRoot)
+ let new_svg
+ const clone_fails_with_SVGjs = true
+ if(clone_fails_with_SVGjs){
+ new_svg = serializeAndDeserializeSVG(svg);
+ }else{
+ new_svg = svg.cloneNode(true)
+ }
+ div.appendChild(new_svg)
+ const oldstyle = new_svg.getAttribute("style")
+ new_svg.setAttribute("style",`${oldstyle};user-select: none; cursor:grab;`)
+ //new_svg.querySelectorAll('tspan,text').forEach((el)=>{
+ // el.style.cursor = "pointer";
+ //});
+ return new_svg
+}
+
+function serializeAndDeserializeSVG(svg) {
+ const serializer = new XMLSerializer();
+ const svgStr = serializer.serializeToString(svg);
+ const parser = new DOMParser();
+ const new_svg = parser.parseFromString(svgStr, "image/svg+xml").documentElement;
+ return new_svg;
+}
+
+async function cloneAsset(center){
+ const container = center.parentElement.parentElement.parentElement.parentElement
+ const obj = container.querySelector("object")
+ let is_svg = false
+ let svg
+ let svg_img
+ if(obj){
+ is_svg = true
+ svg = obj.contentDocument.querySelector("svg")
+ svg_img = await appendShadowSVG(center,svg)
+ }else{
+ const img = container.querySelector("img")
+ svg_img = img.cloneNode(true)
+ center.appendChild(svg_img)
+ }
+ return {is_svg,svg_img}
+}
+
+function window_url_add_pan(x,y){
+ // Convert to integers to remove fractions and ensure the format "&pan=x33_y48"
+ const intX = Math.floor(x);
+ const intY = Math.floor(y);
+ console.log(`Pan finished at (${intX},${intY})`);
+
+ const currentUrl = new URL(window.location.href);
+ currentUrl.searchParams.set('pan', `x${intX}_y${intY}`);
+ window.history.pushState({}, "", currentUrl.toString());
+}
+
+function window_url_add_zoom(zoom){
+ // Round to two decimal places to ensure the format "&zoom=1.27"
+ const roundedZoom = Math.round(zoom * 100) / 100;
+ console.log(`Zoom done at (${roundedZoom})`);
+
+ const currentUrl = new URL(window.location.href);
+ currentUrl.searchParams.set('zoom', roundedZoom.toString());
+ window.history.pushState({}, "", currentUrl.toString());
+}
+function window_url_add_modal(center){
+ const container = center.parentElement.parentElement.parentElement.parentElement
+ let modal_name
+ const data_name = container.getAttribute("data-name")
+ if(data_name != "diagram.svg"){
+ modal_name = data_name
+ }else{
+ modal_name = container.getAttribute("data-sid")
+ }
+ const new_href = window.location.origin+window.location.pathname+`?modal=${modal_name}`
+ window.history.pushState({},"",new_href)
+}
+function window_url_remove_modal(){
+ const new_href = window.location.origin+window.location.pathname
+ window.history.pushState({},"",new_href)
+}
+function is_url_modal(center){
+ const params = new URL(location.href).searchParams;
+ const modal_name = params.get('modal');
+ if(modal_name){
+ const container = center.parentElement.parentElement.parentElement.parentElement
+ const pz_name = container.getAttribute("data-name")
+ return (modal_name == pz_name)
+ }
+ return false
+}
+
+async function handle_url_modal(modal,is_svg,svg,pzref){
+ const params = new URL(location.href).searchParams;
+
+ // Handling text focus if applicable
+ const text = params.get('text')
+ if(text){
+ if(is_svg){
+ await svg_text_focus(modal,svg,text,pzref)
+ }
+ }
+
+ // Handling pan parameter
+ const pan = params.get('pan');
+ if (pan) {
+ const matches = pan.match(/x(-?\d+)_y(-?\d+)/i); // Adjusted regex to include negative numbers
+ console.log(matches)
+ if (matches) {
+ const x = parseInt(matches[1], 10);
+ const y = parseInt(matches[2], 10);
+ setTimeout(()=>{pzref.smoothMoveTo(x, y)}, 400)
+ console.log(`Moving to x: ${x}, y: ${y}`);
+ }
+ }
+
+ // Handling zoom parameter
+ const zoom = params.get('zoom');
+ if (zoom) {
+ const scale = parseFloat(zoom);
+ let delay = 400
+ if(pan){
+ delay = 0
+ }
+ const svg_cx = svg.getAttribute("width").replace(/px$/, '')/2
+ const svg_cy = svg.getAttribute("height").replace(/px$/, '')/2
+ setTimeout(()=>{pzref.smoothZoom(svg_cx, svg_cy, zoom)}, delay)
+ console.log(`Zooming to scale: ${scale}`);
+ }
+}
+
+async function openModal(event){
+
+ const modal = event.target
+ const close = modal.querySelector(".close")
+ const center = modal.querySelector(".modal-center")
+
+ const {is_svg,svg_img} = await cloneAsset(center)
+ if(pzref){
+ pzref.dispose()
+ }
+ const { default: panzoom } = await import('panzoom');
+ pzref = panzoom(svg_img,zoomOptions)
+ pzref.on('panend', () => {
+ const t = pzref.getTransform()
+ window_url_add_pan(t.x,t.y)
+ });
+ pzref.on('zoom', function() {
+ window_url_add_zoom(pzref.getTransform().scale)
+ });
+
+ close.onclick = ()=>{
+ //console.log("closed click")
+ modal.classList.remove("visible")
+ pzref.dispose()
+ const img = center.querySelector("img")
+ if(img){
+ img.remove()
+ }else{// SVG - remove the parent div and leave the shadowRoot for reuse
+ const shadowRoot = center.shadowRoot
+ const svg = shadowRoot.querySelector("svg")
+ svg.parentElement.remove()
+ }
+ window_url_remove_modal()
+ }
+ modal.classList.add("visible")
+ if(is_url_modal(center)){
+ handle_url_modal(modal.querySelector(".modal-content"),is_svg,svg_img,pzref)
+ }else{
+ window_url_add_modal(center)
+ }
+}
+
+function initModalEvents(){
+ const modalsbkgs = document.querySelectorAll(`.modal-background`)
+ modalsbkgs.forEach(modal=>{
+ if(modal.getAttribute("data-state") == "init"){
+ modal.addEventListener("open",openModal ,false)
+ modal.setAttribute("data-state","run")
+ }
+ })
+}
+
+export{
+ initModalEvents
+}
diff --git a/src/components/panzoom/lib_svg_filters.js b/src/components/panzoom/lib_svg_filters.js
new file mode 100644
index 0000000..717a4ba
--- /dev/null
+++ b/src/components/panzoom/lib_svg_filters.js
@@ -0,0 +1,133 @@
+//taken over from https://github.com/WebSVG/deep-svg/blob/master/src/svg_filters.js
+
+//https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate
+//calcMode [discrete | linear | paced | spline]
+
+function anim_radius(id,max){
+ return /*html*/`
+
+ `
+}
+
+function anim_wave(id,attribute,max,dur){
+ return /*html*/`
+
+ `
+}
+
+function glow_anim(color,dur){
+ return /*html*/`
+
+
+ ${anim_wave("filter_ga-animate","radius",8,dur)}
+
+
+
+
+
+
+
+
+
+ `
+}
+
+function glow_filter(color){
+ return /*html*/`
+
+
+
+
+
+
+
+
+
+
+ `
+}
+
+function dur_to_ms(duration){
+ if(duration.endsWith("ms")){
+ return parseFloat(duration);
+ }else{
+ return parseFloat(duration)*1000;
+ }
+}
+
+/**
+ *
+ * @param {svg element} element : the element to attach a filter to
+ * @param {html filter element} filter
+ */
+function attachFilter(element,filter){
+ element.setAttribute("filter",`url(#${filter.id})`);
+}
+
+/**
+ *
+ * @param {svg element} element : the element to attach a filter to
+ * @param {html filter element} filter
+ */
+function detachFilter(element,filter){
+ element.removeAttribute("filter",`url(#${filter.id})`);
+}
+
+function createFilter(svg,type, color,dur = 500){
+ let filter_html;
+ if(type == "glow"){
+ filter_html = glow_filter(color);
+ }else if(type == "glow_anim"){
+ filter_html = glow_anim(color,dur);
+ }else{
+ console.warn(`unsupported filter type: '${type}'`);
+ return;
+ }
+ let parent = svg.querySelector("defs");
+ parent.insertAdjacentHTML("beforeend",filter_html);
+ let filters = parent.getElementsByTagName("filter")
+ return filters[filters.length-1];
+}
+
+function removeFilter(svg,filter){
+ const defs = svg.querySelector("defs");
+ defs.removeChild(filter);
+}
+
+function createGlowFilter(svg,color,dur){
+ return createFilter(svg,"glow_anim",color,dur)
+}
+
+/**
+ * attaches, plays animation then detaches if not indefinite specified in repeatCount
+ * @param {html filter element} filter
+ */
+function startAnimation(svg,element,filter){
+ attachFilter(element,filter);
+ const anim_id = filter.getAttribute("data-anim");
+ const anim = svg.getElementById(anim_id);
+ anim.beginElement();
+ if(!(anim.getAttribute("repeatDur") == "indefinite")){
+ const duration_ms = dur_to_ms(anim.getAttribute("dur"));
+ setTimeout(()=>{
+ detachFilter(element,filter);
+ },duration_ms);
+ }
+}
+
+function glow(svg, element, color, dur = 500){
+ const glow = createGlowFilter(svg,color,dur)
+ console.log(`playing glow animation for ${dur} ms`);
+ startAnimation(svg,element,glow)
+ setTimeout(()=>removeFilter(svg,glow),dur+100)
+}
+
+export {
+ glow,
+ createGlowFilter,
+ startAnimation,
+ createFilter,
+ removeFilter,
+ attachFilter,
+ detachFilter,
+};
diff --git a/src/components/panzoom/lib_svg_utils.js b/src/components/panzoom/lib_svg_utils.js
new file mode 100644
index 0000000..5a16d4f
--- /dev/null
+++ b/src/components/panzoom/lib_svg_utils.js
@@ -0,0 +1,148 @@
+import { glow } from './lib_svg_filters';
+
+const SVGjsModule = await import('@svgdotjs/svg.js');
+const SVGjs = SVGjsModule.SVG;
+
+function svg_fix_size(svg){
+ if (svg) {
+ // Check if 'width' and 'height' attributes are missing
+ if (!svg.hasAttribute('width') || !svg.hasAttribute('height')) {
+ const viewBox = svg.getAttribute('viewBox');
+ if (viewBox) {
+ const sizes = viewBox.split(' ');
+ // Typically, the viewBox is "minX minY width height"
+ const width = sizes[2];
+ const height = sizes[3];
+
+ svg.setAttribute('width', width);
+ svg.setAttribute('height', height);
+ console.log("fixed SVG width and heigh")
+ }else{
+ console.log("no viewBox")
+ }
+ }
+ }else{
+ console.log("failed to fix svg size as svg is invalid")
+ }
+}
+
+async function svg_add_links(svg,link_list){
+ const SVGjsModule = await import('@svgdotjs/svg.js');
+ const SVGjs = SVGjsModule.SVG;
+ let added_links = false
+ let draw = SVGjs(svg)
+ let text_nodes = draw.find('text')
+ let text_array = [ ...text_nodes ];
+ text_array.forEach((text)=>{
+ const html_text = text.node.innerHTML
+ link_list.forEach((entry)=>{
+ if(html_text == entry.label){
+ var isAbs = new RegExp('^(?:[a-z+]+:)?//', 'i');//isAbsolute https://stackoverflow.com/questions/10687099/how-to-test-if-a-url-string-is-absolute-or-relative
+ if(isAbs.test(entry.link)){
+ text.linkTo((link)=>{link.to(entry.link).target('_blank')})//link in new page
+ }else{
+ text.linkTo((link)=>{link.to(entry.link).target('_top')})//link outside the shadow DOM
+ }
+ text.css({'text-decoration': 'underline'})
+ //text.fill('#f06')
+ added_links = true
+ }
+ })
+ })
+ if(added_links){
+ console.log("added links")
+ }
+}
+
+async function svg_highlight(svg, highlights){
+ const SVGjsModule = await import('@svgdotjs/svg.js');
+ const SVGjs = SVGjsModule.SVG;
+ let draw = SVGjs(svg)
+ let text_nodes = draw.find('text')
+ let text_array = [...text_nodes];
+
+ // Loop through each entry object
+ highlights.forEach((entry) => {
+ text_array.forEach((text) => {
+ const html_text = text.node.innerHTML;
+
+ // Check if current text matches the label
+ if (html_text === entry.label) {
+ // Hover effect for the main label
+ text.on('mouseover', () => {
+ text_array.forEach((t) => {
+ // Highlight all texts that match the 'highlights' list
+ if (entry.highlights.includes(t.node.innerHTML)) {
+ t.fill({color: '#292'}); // Change the fill color on hover
+ t.css({'font-weight': '900'}); // Make text bold
+ }
+ });
+ });
+
+ // Reset on mouseout
+ text.on('mouseout', () => {
+ text_array.forEach((t) => {
+ if (entry.highlights.includes(t.node.innerHTML)) {
+ t.fill({color: '#000'}); // Reset the fill color
+ t.css({'font-weight': 'normal'}); // Reset the text style
+ }
+ });
+ });
+ }
+ });
+
+ console.log(` => Highlighting '${entry.label}'`);
+ });
+}
+async function svg_text_focus(modal_content,svg,text,pzref){
+ console.log(`modal_content (w:${modal_content.offsetWidth},h:${modal_content.offsetHeight})`)
+ let draw = SVGjs(svg)
+ let text_nodes = draw.find('text');
+ let span_nodes = draw.find('span');
+ let text_array = [ ...text_nodes,...span_nodes ];
+ let text_hits = text_array.filter(obj => obj.node.innerHTML == text);
+ if(text_hits.length > 0){
+ let targetText = text_hits[0]
+ let bbox
+ if(targetText.node.namespaceURI == "http://www.w3.org/2000/svg"){
+ bbox = targetText.bbox()
+ }else if(targetText.node.namespaceURI == "http://www.w3.org/1999/xhtml"){
+ const node_bbox = targetText.node.getBoundingClientRect()
+ // Adjust coordinates to SVG coordinate space
+ let svgRect = svg.getBoundingClientRect();
+ bbox = {
+ x: node_bbox.left - svgRect.left,
+ y: node_bbox.top - svgRect.top,
+ width: node_bbox.width,
+ height: node_bbox.height
+ };
+ }else{
+ console.warn(`not handled namespaceURI ${targetText.node.namespaceURI}`)
+ return
+ }
+ console.log(bbox)
+ let box_center_x = bbox.x + bbox.width / 2;
+ let box_center_y = bbox.y + bbox.height / 2;
+ const svg_cx = svg.getAttribute("width").replace(/px$/, '')/2
+ const svg_cy = svg.getAttribute("height").replace(/px$/, '')/2
+ console.log(`svg center (${svg_cx},${svg_cy})`)
+ const shift_x = svg_cx - box_center_x
+ const shift_y = svg_cy - box_center_y
+ console.log(`moveTo (${shift_x},${shift_y})`)
+ setTimeout(()=>{pzref.smoothMoveTo(shift_x, shift_y)}, 400)
+ setTimeout(()=>{pzref.smoothZoom(svg_cx, svg_cy, 1.4)}, 800)
+ if(targetText.node.namespaceURI == "http://www.w3.org/2000/svg"){
+ setTimeout(()=>{glow(svg, targetText.node, '#0f0');},1500)
+ }else{
+ setTimeout(()=>{targetText.node.classList.add("focus-effect");},1500)
+ setTimeout(()=>{targetText.node.classList.remove("focus-effect");},2000)
+ }
+ }
+}
+
+export{
+ svg_fix_size,
+ svg_add_links,
+ svg_highlight,
+ svg_text_focus
+}
diff --git a/src/components/panzoom/panzoom.astro b/src/components/panzoom/panzoom.astro
new file mode 100644
index 0000000..94d46da
--- /dev/null
+++ b/src/components/panzoom/panzoom.astro
@@ -0,0 +1,106 @@
+---
+//https://ellodave.dev/blog/article/using-svgs-as-astro-components-and-inline-css/
+import PanZoomModal from './panzoommodal.astro'
+import SvgIcons from '@/components/svgicons.astro'
+export interface Props {
+ src: string;
+ alt: string;
+ title: string;
+ meta: object;
+ hash: string;
+}
+
+const { src, alt, title, meta } = Astro.props as Props;
+const asseturl = src;
+const assetname = asseturl.substring(asseturl.lastIndexOf('/') + 1)
+let data_name: string;
+if(title){
+ data_name = title
+}else if(assetname != "diagram.svg"){
+ data_name = assetname
+}else{
+ data_name = "diagram.svg"
+}
+
+console.log(` * panzoom : '${data_name}' : '${src}'`)
+const isSvg = asseturl.endsWith(".svg")
+const isImg = !isSvg
+
+---
+
+
+
+
+
+
+
diff --git a/src/components/panzoom/panzoom.js b/src/components/panzoom/panzoom.js
new file mode 100644
index 0000000..d7c1995
--- /dev/null
+++ b/src/components/panzoom/panzoom.js
@@ -0,0 +1,20 @@
+import {event} from './client_utils.js'
+
+function init_clicks(){
+ const containers = document.querySelectorAll(".container.panzoom")
+ containers.forEach(container => {
+ const open = container.querySelector(".open")
+ if(open){
+ open.onclick = ()=>{
+ const modal = container.querySelector(".modal-background")
+ event(modal,"open")
+ }
+ }
+ })
+}
+
+if(document.readyState == "loading"){
+ document.addEventListener('DOMContentLoaded', init_clicks, false);
+}else{
+ init_clicks()
+}
diff --git a/src/components/panzoom/panzoom_common.js b/src/components/panzoom/panzoom_common.js
new file mode 100644
index 0000000..bf91e42
--- /dev/null
+++ b/src/components/panzoom/panzoom_common.js
@@ -0,0 +1,71 @@
+import {event} from './client_utils.js'
+import {initModalEvents} from './lib_panzoommodal.js'
+import { svg_fix_size, svg_add_links, svg_highlight } from './lib_svg_utils.js';
+
+function checkURLModal(){
+ //check if any modal needs to be opened
+ const params = new URL(location.href).searchParams;
+ const modal = params.get('modal');
+ if(modal){
+ console.log(`opening modal ${modal}`)
+ const container = document.querySelector(`.container.panzoom[data-name="${modal}"]`)
+ if(container){
+ const modal = container.querySelector(".modal-background")
+ event(modal,"open")
+ }
+ }
+}
+
+async function processSVG(svg,container){
+ svg_fix_size(svg);
+ const meta_string = container.getAttribute("data-meta");
+ if(meta_string){
+ const meta = JSON.parse(meta_string);
+ if(Object.hasOwn(meta,"links")){
+ await svg_add_links(svg, meta.links);
+ }
+ if(Object.hasOwn(meta,"highlights")){
+ await svg_highlight(svg, meta.highlights);
+ }
+ }
+}
+
+async function init_svgs() {
+ const containers = document.querySelectorAll(".container.panzoom");
+ await Promise.all(Array.from(containers).map(container => {
+ return new Promise(async (resolve) => { // Using async here
+ const eltype = container.getAttribute("data-type");
+ if (eltype === "svg") {
+ const obj = container.querySelector("object");
+ const svg = obj.contentDocument?.querySelector("svg");
+ if (svg) {
+ await processSVG(svg, container); // Await the processing of the SVG
+ resolve();
+ } else {
+ obj.addEventListener("load", async () => { // Async event handler
+ const svg = obj.contentDocument.querySelector("svg");
+ if (svg) {
+ await processSVG(svg, container); // Await the processing of the SVG
+ }
+ resolve();
+ });
+ }
+ } else {
+ resolve();
+ }
+ });
+ }));
+}
+
+async function init(){
+ console.log("panzoom_common> init()")
+ initModalEvents() //needed to be before handling url to open
+ await init_svgs() //needed before cloning the svg in modal
+ checkURLModal() //only first match will open, starting with SIDs
+}
+
+if(document.readyState == "loading"){
+ document.addEventListener('DOMContentLoaded', init, false);
+}else{
+ init()
+}
diff --git a/src/components/panzoom/panzoommodal.astro b/src/components/panzoom/panzoommodal.astro
new file mode 100644
index 0000000..fa18935
--- /dev/null
+++ b/src/components/panzoom/panzoommodal.astro
@@ -0,0 +1,86 @@
+---
+
+export interface Props {
+ url: string;
+}
+
+//TODO currently all pazoom items do create and ship html for svg
+//this is because the parent object .shadowRoot could be be captured to be cloned within a free Svg
+//A free Svg instead of object is important in panzoom to keep interactivity enbaled
+//object would require to pass mouse events to allow panzoom which disables interactivity
+
+const {url} = Astro.props as Props;
+
+---
+
+
+
diff --git a/src/components/svgicons.astro b/src/components/svgicons.astro
new file mode 100644
index 0000000..74ec3ef
--- /dev/null
+++ b/src/components/svgicons.astro
@@ -0,0 +1,17 @@
+---
+//https://ellodave.dev/blog/article/using-svgs-as-astro-components-and-inline-css/
+import {readFile} from 'fs/promises'
+import { exists } from '@/libs/utils';
+import { config } from '@/config';
+import {join} from 'node:path'
+export interface Props {
+ filename: string;
+}
+
+const { filename } = Astro.props as Props;
+const filepath = join(config.rootdir,`src/assets/${filename}.svg`)
+const innerHTML = await exists(filepath)?await readFile(filepath):"";
+---
+
+
+
diff --git a/src/env.d.ts b/src/env.d.ts
new file mode 100644
index 0000000..c13bd73
--- /dev/null
+++ b/src/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
\ No newline at end of file
diff --git a/src/layout/AppBar.astro b/src/layout/AppBar.astro
new file mode 100644
index 0000000..2eedfdd
--- /dev/null
+++ b/src/layout/AppBar.astro
@@ -0,0 +1,91 @@
+---
+import './colors.css';
+import Icon from "@/components/icon.astro"
+import { buildAppBarMenu } from './layout_utils.js';
+
+export interface Props {
+ menu?: Array<{
+ label: string;
+ link: string;
+ active_class?: string;
+ align?: string;
+ icon?: string;
+ }>;
+}
+
+const { menu: providedMenu = [] } = Astro.props as Props;
+const menu = providedMenu.length ? providedMenu : buildAppBarMenu(Astro.url.pathname);
+const left_side = menu.filter((item)=>(!Object.hasOwn(item,"align") || !(item.align=="right")))
+const right_side = menu.filter((item)=>(Object.hasOwn(item,"align") && (item.align=="right")))
+
+---
+
+
+
diff --git a/src/layout/Cache.astro b/src/layout/Cache.astro
new file mode 100644
index 0000000..1a245a9
--- /dev/null
+++ b/src/layout/Cache.astro
@@ -0,0 +1,19 @@
+---
+import { cache_has,cache_get,cache_set } from './cache';
+export interface Props {
+ name?: string;
+}
+
+const { name = "default" } = Astro.props;
+
+let html = ""
+if(cache_has(name) && import.meta.env.PROD){
+ html = cache_get(name)
+}else{
+ console.log(`Cache> rendering '${name}'`)
+ html = await Astro.slots.render("default");
+ cache_set(name,html)
+}
+
+---
+
diff --git a/src/layout/ClientNavMenu.astro b/src/layout/ClientNavMenu.astro
new file mode 100644
index 0000000..302f1e7
--- /dev/null
+++ b/src/layout/ClientNavMenu.astro
@@ -0,0 +1,129 @@
+---
+import './colors.css';
+
+export interface Props {
+ open: boolean;
+ hash: string;
+}
+
+const {hash, open} = Astro.props;
+const open_class = open?"open":"closed"
+const data_width = open?"20vw":"0vw"
+---
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/Layout.astro b/src/layout/Layout.astro
new file mode 100644
index 0000000..65d3f20
--- /dev/null
+++ b/src/layout/Layout.astro
@@ -0,0 +1,201 @@
+---
+import './colors.css';
+import AppBar from './AppBar.astro';
+import SideMenu from './SideMenu.astro';
+import { process_toc_list, buildNavigationMenus } from "./layout_utils";
+import {config} from '@/config'
+
+
+export interface Props {
+ title: string;
+ headings: Array