Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,60 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
if (process.platform !== "win32") {
process.exit(0);
}
// build-provisioner is invoked from the workspace root (frontend/); resolve
// src-tauri relative to this script so it doesn't depend on cwd.
const scriptDir = dirname(fileURLToPath(import.meta.url));
const editorDir = resolve(scriptDir, "..");
const tauriDir = resolve(editorDir, "src-tauri");
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
execFileSync(
"cargo",
["build", "--release", "--manifest-path", provisionerManifest],
{ stdio: "inherit" },
);
const provisionerExe = join(
tauriDir,
"provisioner",
"target",
"release",
"stirling-provisioner.exe",
);
if (!existsSync(provisionerExe)) {
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
}
const wixDir = join(tauriDir, "windows", "wix");
mkdirSync(wixDir, { recursive: true });
const destExe = join(wixDir, "stirling-provision.exe");
copyFileSync(provisionerExe, destExe);
// --- Thumbnail handler DLL ---
const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml");
execFileSync(
"cargo",
["build", "--release", "--manifest-path", thumbManifest],
{ stdio: "inherit" },
);
const thumbDll = join(
tauriDir,
"thumbnail-handler",
"target",
"release",
"stirling_thumbnail_handler.dll",
);
if (!existsSync(thumbDll)) {
throw new Error(`Thumbnail handler DLL not found at ${thumbDll}`);
}
copyFileSync(thumbDll, join(wixDir, "stirling_thumbnail_handler.dll"));
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Build a universal (arm64 + x86_64) JRE bundle for the macOS Tauri app.
#
# jlink can only emit a single-architecture runtime, but the Tauri shell is
# universal-apple-darwin. Without this merge the bundled JRE would only run
# on whichever arch the runner used. We jlink twice (once per target JDK's
# jmods) and lipo-fatten every Mach-O file in the result.
#
# Inputs (env):
# AARCH64_JAVA_HOME path to an aarch64 JDK with jmods/
# X64_JAVA_HOME path to an x86_64 JDK with jmods/
# JLINK_MODULES comma-separated module list (matches desktop.yml)
# OUTPUT_DIR target directory (will be wiped); defaults to
# frontend/src-tauri/runtime/jre
set -euo pipefail
: "${AARCH64_JAVA_HOME:?AARCH64_JAVA_HOME must be set}"
: "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}"
: "${JLINK_MODULES:?JLINK_MODULES must be set}"
OUTPUT_DIR="${OUTPUT_DIR:-frontend/src-tauri/runtime/jre}"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "build-universal-mac-jre.sh only runs on macOS" >&2
exit 1
fi
# x86_64 jlink runs under Rosetta on Apple Silicon. If Rosetta isn't
# installed the failure mode is a cryptic "Bad CPU type" from exec, so
# fail loudly up front when running on arm64 without it.
if [[ "$(uname -m)" == "arm64" ]] && ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "Rosetta 2 is required to run x86_64 jlink on Apple Silicon. Install with: softwareupdate --install-rosetta --agree-to-license" >&2
exit 1
fi
WORK_DIR="$(mktemp -d -t universal-jre)"
trap 'rm -rf "$WORK_DIR"' EXIT INT TERM
ARM_JRE="$WORK_DIR/jre-aarch64"
X64_JRE="$WORK_DIR/jre-x86_64"
# Each jlink invocation must run on the native arch of its JDK: jlink
# stamps out launcher executables (bin/java, bin/rmiregistry, etc.)
# matching the host binary, regardless of --module-path. So we run the
# arm64 jlink for the arm64 JRE and the x86_64 jlink for the x86_64
# JRE.
run_jlink() {
local java_home="$1"
local out="$2"
"$java_home/bin/jlink" \
--module-path "$java_home/jmods" \
--add-modules "$JLINK_MODULES" \
--strip-debug \
--compress=zip-6 \
--no-header-files \
--no-man-pages \
--output "$out"
}
echo "Building aarch64 JRE from $AARCH64_JAVA_HOME"
run_jlink "$AARCH64_JAVA_HOME" "$ARM_JRE"
echo "Building x86_64 JRE from $X64_JAVA_HOME (runs under Rosetta on Apple Silicon)"
run_jlink "$X64_JAVA_HOME" "$X64_JRE"
rm -rf "$OUTPUT_DIR"
mkdir -p "$(dirname "$OUTPUT_DIR")"
cp -R "$ARM_JRE" "$OUTPUT_DIR"
# Replace every Mach-O file in the copied tree with a fat binary.
# Files that aren't Mach-O (text, modules archive, etc.) are left as-is.
merged=0
skipped=0
while IFS= read -r -d '' arm_file; do
rel="${arm_file#"$ARM_JRE"/}"
x64_file="$X64_JRE/$rel"
out_file="$OUTPUT_DIR/$rel"
if [[ ! -f "$x64_file" ]]; then
skipped=$((skipped + 1))
continue
fi
if ! file "$arm_file" | grep -q "Mach-O"; then
continue
fi
lipo -create "$arm_file" "$x64_file" -output "$out_file"
merged=$((merged + 1))
done < <(find "$ARM_JRE" -type f -print0)
echo "Universal JRE written to $OUTPUT_DIR (merged $merged Mach-O files, skipped $skipped arm-only files)"
if [[ "$merged" -eq 0 ]]; then
echo "Refusing to ship a JRE with zero merged Mach-O binaries" >&2
exit 1
fi
# Sanity-check: the launcher must be a fat binary or the app crashes on the
# arch we didn't lipo for. `lipo -archs` prints just the arch names space-
# separated (avoiding fragile grep alternation across BSD/GNU grep).
java_archs="$(lipo -archs "$OUTPUT_DIR/bin/java" 2>/dev/null || true)"
if [[ "$java_archs" != *arm64* || "$java_archs" != *x86_64* ]]; then
echo "bin/java is not a universal binary (archs: ${java_archs:-unknown})" >&2
lipo -info "$OUTPUT_DIR/bin/java" >&2
exit 1
fi
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
const { icons } = require("@iconify-json/material-symbols");
const fs = require("fs");
const path = require("path");
// Check for verbose flag
const isVerbose =
process.argv.includes("--verbose") || process.argv.includes("-v");
// Logging functions
const info = (message) => console.log(message);
const debug = (message) => {
if (isVerbose) {
console.log(message);
}
};
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
const srcDir = path.join(__dirname, "..", "src");
info("🔍 Scanning codebase for LocalIcon usage...");
if (!fs.existsSync(srcDir)) {
console.error("❌ Source directory not found:", srcDir);
process.exit(1);
}
// Recursively scan all .tsx and .ts files
function scanDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach((file) => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
scanDirectory(filePath);
} else if (file.endsWith(".tsx") || file.endsWith(".ts")) {
const content = fs.readFileSync(filePath, "utf8");
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
const localIconMatches = content.match(
/<LocalIcon\s+[^>]*icon="([^"]+)"/g,
);
if (localIconMatches) {
localIconMatches.forEach((match) => {
const iconMatch = match.match(/icon="([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
debug(
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
);
}
});
}
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
const localIconSingleQuoteMatches = content.match(
/<LocalIcon\s+[^>]*icon='([^']+)'/g,
);
if (localIconSingleQuoteMatches) {
localIconSingleQuoteMatches.forEach((match) => {
const iconMatch = match.match(/icon='([^']+)'/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
debug(
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
);
}
});
}
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
const spanMatches = content.match(
/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g,
);
if (spanMatches) {
spanMatches.forEach((match) => {
const iconMatch = match.match(/>([^<]+)<\/span>/);
if (iconMatch && iconMatch[1].trim()) {
const iconName = iconMatch[1].trim();
usedIcons.add(iconName);
debug(
` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`,
);
}
});
}
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
const iconMatches = content.match(
/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g,
);
if (iconMatches) {
iconMatches.forEach((match) => {
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
if (iconMatch) {
usedIcons.add(iconMatch[1]);
debug(
` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
);
}
});
}
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
const iconPropertyMatches = content.match(
/icon:\s*(['"])([a-z0-9-]+)\1/g,
);
if (iconPropertyMatches) {
iconPropertyMatches.forEach((match) => {
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
if (iconMatch) {
usedIcons.add(iconMatch[2]);
debug(
` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`,
);
}
});
}
}
});
}
scanDirectory(srcDir);
const iconArray = Array.from(usedIcons).sort();
info(`📋 Found ${iconArray.length} unique icons across codebase`);
return iconArray;
}
// Main async function
async function main() {
// Auto-detect used icons
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(
__dirname,
"..",
"src",
"assets",
"material-symbols-icons.json",
);
let needsRegeneration = true;
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(
`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`,
);
}
} catch {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}
}
if (!needsRegeneration) {
info("🎉 No regeneration needed!");
process.exit(0);
}
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Dynamic import of ES module
const { getIcons } = await import("@iconify/utils");
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) {
console.error("❌ Failed to extract icons");
process.exit(1);
}
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(
(icon) => !extractedIconNames.includes(icon),
);
if (missingIcons.length > 0) {
info(
`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`,
);
info(
"💡 These icons don't exist in Material Symbols. Please use available alternatives.",
);
}
// Create output directory
const outputDir = path.join(__dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
info(
`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`,
);
info(
`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`,
);
info(`💾 Saved to: ${outputPath}`);
// Generate TypeScript types
const typesContent = `// Auto-generated icon types
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
export type MaterialSymbolIcon = ${usedIcons.map((icon) => `'${icon}'`).join(" | ")};
export interface IconSet {
prefix: string;
icons: Record<string, any>;
width?: number;
height?: number;
}
// Re-export the icon set as the default export with proper typing
declare const iconSet: IconSet;
export default iconSet;
`;
const typesPath = path.join(outputDir, "material-symbols-icons.d.ts");
fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`);
info(`🎉 Icon extraction complete!`);
}
// Run the main function
main().catch((error) => {
console.error("❌ Script failed:", error);
process.exit(1);
});
@@ -0,0 +1,520 @@
#!/usr/bin/env node
const { execSync } = require("node:child_process");
const {
existsSync,
mkdirSync,
writeFileSync,
readFileSync,
} = require("node:fs");
const path = require("node:path");
const { argv } = require("node:process");
const inputIdx = argv.indexOf("--input");
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
// __dirname is available in CommonJS by default
/**
* Generate 3rd party licenses for frontend dependencies
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(
__dirname,
"..",
"src",
"assets",
"3rdPartyLicenses.json",
);
// package.json lives at the workspace root (frontend/), not editor/. The
// script is at frontend/editor/scripts/, so walk up two levels.
const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
console.log("🔍 Generating frontend license report...");
try {
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
console.error(
"Fork PR detected: only --input (postprocess-only) mode is allowed.",
);
process.exit(2);
}
let licenseData;
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error("❌ --input file missing or not found");
process.exit(1);
}
licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8"));
} else {
const licenseReport = execSync(
// 'npx --yes [email protected] --production --json',
"npx --yes license-report --only=prod --output=json",
{
encoding: "utf8",
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" },
},
);
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error("❌ Failed to parse license data:", parseError.message);
console.error("Raw output:", licenseReport.substring(0, 500) + "...");
process.exit(1);
}
}
if (!Array.isArray(licenseData)) {
console.error("❌ Invalid license data structure");
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = licenseData.map((dep) => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = "Unknown";
}
// Handle empty string licenses
if (licenseType === "") {
licenseType = "Unknown";
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(" AND ");
}
// Handle object licenses (fallback)
if (typeof licenseType === "object" && licenseType !== null) {
licenseType = "Unknown";
}
if (
"posthog-js" === dep.name &&
licenseType.startsWith("SEE LICENSE IN LICENSE")
) {
licenseType =
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: dep.name,
version:
dep.installedVersion ||
dep.definedVersion ||
dep.remoteVersion ||
"unknown",
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link,
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map((dep) => {
const licenseType = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType || "Unknown";
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl:
dep.repository ||
dep.url ||
`https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl,
};
}),
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType || "Unknown";
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log("📊 License types found:");
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Log any complex or unusual license formats for debugging
const complexLicenses = licenseArray.filter(
(dep) =>
dep.licenseType &&
(dep.licenseType.includes("AND") ||
dep.licenseType.includes("OR") ||
dep.licenseType === "Unknown" ||
dep.licenseType.includes("SEE LICENSE")),
);
if (complexLicenses.length > 0) {
console.log("\n🔍 Complex/Edge case licenses detected:");
complexLicenses.forEach((dep) => {
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
});
}
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(
licenseSummary,
licenseArray,
);
if (problematicLicenses.length > 0) {
console.log("\n⚠️ License compatibility warnings:");
problematicLicenses.forEach((warning) => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(
__dirname,
"..",
"src",
"assets",
"license-warnings.json",
);
writeFileSync(
warningsFile,
JSON.stringify(
{
warnings: problematicLicenses,
generated: new Date().toISOString(),
},
null,
2,
),
);
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log("\n✅ All licenses appear to be corporate-friendly");
}
// Write to file
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 2) + "\n");
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error("❌ Error generating license report:", error.message);
process.exit(1);
}
/**
* Get standard license URLs for common licenses
*/
function getLicenseUrl(licenseType) {
if (!licenseType || licenseType === "Unknown") return "";
const licenseUrls = {
MIT: "https://opensource.org/licenses/MIT",
"MIT*": "https://opensource.org/licenses/MIT",
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"Apache License 2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause",
"BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause",
BSD: "https://opensource.org/licenses/BSD-3-Clause",
"GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html",
"GPL-2.0": "https://www.gnu.org/licenses/gpl-2.0.html",
"LGPL-2.1": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html",
"LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html",
ISC: "https://opensource.org/licenses/ISC",
"CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
Unlicense: "https://unlicense.org/",
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
WTFPL: "http://www.wtfpl.net/",
Zlib: "https://opensource.org/licenses/Zlib",
"Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0",
"EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html",
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
"CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0",
Ruby: "https://www.ruby-lang.org/en/about/license.txt",
"Python-2.0": "https://www.python.org/download/releases/2.0/license/",
"Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/",
UNLICENSED: "",
};
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
}
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes("AND") || licenseType.includes("OR")) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
}
// For non-standard licenses, return empty string (will use package link if available)
return "";
}
/**
* Check for potentially problematic licenses that may not be MIT/corporate compatible
*/
function checkLicenseCompatibility(licenseSummary, licenseArray) {
const warnings = [];
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
"LGPL-2.1":
"Weak copyleft license - may require source disclosure for modifications",
"LGPL-3.0":
"Weak copyleft license - may require source disclosure for modifications",
"AGPL-3.0":
"Network copyleft license - requires source disclosure for network use",
"AGPL-1.0":
"Network copyleft license - requires source disclosure for network use",
// Other potentially problematic licenses
WTFPL: "Potentially problematic license - legal uncertainty",
"CC-BY-SA-4.0":
"ShareAlike license - requires derivative works to use same license",
"CC-BY-SA-3.0":
"ShareAlike license - requires derivative works to use same license",
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
"EPL-1.0": "Weak copyleft license - may require source disclosure",
"EPL-2.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.1": "Weak copyleft license - may require source disclosure",
"CPL-1.0": "Weak copyleft license - may require source disclosure",
"MPL-1.1": "Weak copyleft license - may require source disclosure",
"EUPL-1.1": "Copyleft license - requires derivative works to be EUPL",
"EUPL-1.2": "Copyleft license - requires derivative works to be EUPL",
UNLICENSED: "No license specified - usage rights unclear",
Unknown: "License not detected - manual review required",
};
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
"MIT",
"MIT*",
"Apache-2.0",
"Apache License 2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD",
"ISC",
"CC0-1.0",
"Public Domain",
"Unlicense",
"0BSD",
"BlueOak-1.0.0",
"Zlib",
"Artistic-2.0",
"Python-2.0",
"Ruby",
"MPL-2.0",
"CC-BY-4.0",
"SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
]);
// Helper function to normalize license names for comparison
function normalizeLicense(license) {
return license
.replace(/-or-later$/, "") // Remove -or-later suffix
.replace(/\+$/, "") // Remove + suffix
.trim();
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType;
return depLicense === license;
});
const isOnlyOurPackages = affectedPackages.every(
(dep) =>
dep.name === "frontend" ||
dep.name.toLowerCase().includes("stirling-pdf") ||
dep.name.toLowerCase().includes("stirling_pdf") ||
dep.name.toLowerCase().includes("stirlingpdf"),
);
if (
isOnlyOurPackages &&
(license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))
) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes("AND") || license.includes("OR")) {
// For OR licenses, check if there's at least one acceptable license option
if (license.includes("OR")) {
// Extract license components from OR expression
const orComponents = license
.replace(/[()]/g, "") // Remove parentheses
.split(" OR ")
.map((component) => component.trim());
// Check if any component is in the goodLicenses set (with normalization)
const hasGoodLicense = orComponents.some((component) => {
const normalized = normalizeLicense(component);
return goodLicenses.has(component) || goodLicenses.has(normalized);
});
if (hasGoodLicense) {
return; // Skip warning - can use the good license option
}
}
// For AND licenses or OR licenses with no good options, check for problematic components
const hasProblematicComponent = Object.keys(problematicLicenses).some(
(problematic) => license.includes(problematic),
);
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url:
dep.repository ||
dep.url ||
`https://www.npmjs.com/package/${dep.name}`,
}));
const licenseType = license.includes("AND") ? "AND" : "OR";
const reason =
licenseType === "AND"
? "Compound license with AND requirement - all components must be compatible"
: "Compound license with potentially problematic components and no good fallback options";
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? "s" : ""} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: "",
reason: reason,
packageCount: count,
affectedDependencies: affectedPackages,
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url:
dep.repository ||
dep.url ||
`https://www.npmjs.com/package/${dep.name}`,
}));
const packageList =
affectedPackages
.map((pkg) => pkg.name)
.slice(0, 5)
.join(", ") +
(affectedPackages.length > 5
? `, and ${affectedPackages.length - 5} more`
: "");
const licenseUrl =
getLicenseUrl(license) || "https://opensource.org/licenses";
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages,
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url:
dep.repository ||
dep.url ||
`https://www.npmjs.com/package/${dep.name}`,
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? "s" : ""} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: "",
reason: "Unknown license type",
packageCount: count,
affectedDependencies: affectedPackages,
});
}
});
return warnings;
}
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Stirling PDF Sample Document Generator
*
* This script uses Puppeteer to generate a sample PDF from a HTML template.
* The output is used in the onboarding tour and as a demo document
* for users to experiment with Stirling PDF's features.
*/
import puppeteer from "puppeteer";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { existsSync, mkdirSync, statSync } from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const TEMPLATE_PATH = join(__dirname, "template.html");
const OUTPUT_DIR = join(__dirname, "../../public/samples");
const OUTPUT_PATH = join(OUTPUT_DIR, "Sample.pdf");
async function generatePDF() {
console.log("🚀 Starting Stirling PDF sample document generation...\n");
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`✅ Created output directory: ${OUTPUT_DIR}`);
}
// Check if template exists
if (!existsSync(TEMPLATE_PATH)) {
console.error(`❌ Template file not found: ${TEMPLATE_PATH}`);
process.exit(1);
}
console.log(`📄 Reading template: ${TEMPLATE_PATH}`);
let browser;
try {
// Launch Puppeteer
console.log("🌐 Launching browser...");
browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set viewport to match A4 proportions
await page.setViewport({
width: 794, // A4 width in pixels at 96 DPI
height: 1123, // A4 height in pixels at 96 DPI
deviceScaleFactor: 2, // Higher quality rendering
});
// Navigate to the template file
const fileUrl = `file://${TEMPLATE_PATH}`;
console.log("📖 Loading HTML template...");
await page.goto(fileUrl, {
waitUntil: "networkidle0", // Wait for all resources to load
});
// Generate PDF with A4 dimensions
console.log("📝 Generating PDF...");
await page.pdf({
path: OUTPUT_PATH,
format: "A4",
printBackground: true,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
preferCSSPageSize: true,
});
console.log("\n✅ PDF generated successfully!");
console.log(`📦 Output: ${OUTPUT_PATH}`);
// Get file size
const stats = statSync(OUTPUT_PATH);
const fileSizeInKB = (stats.size / 1024).toFixed(2);
console.log(`📊 File size: ${fileSizeInKB} KB`);
} catch (error) {
console.error("\n❌ Error generating PDF:", error.message);
process.exit(1);
} finally {
if (browser) {
await browser.close();
console.log("🔒 Browser closed.");
}
}
console.log("\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n");
}
// Run the generator
generatePDF().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -0,0 +1,433 @@
/* Stirling PDF Sample Document Styles */
:root {
/* Brand Colors */
--brand-red: #8e3231;
--brand-blue: #3b82f6;
/* Category Colors */
--color-general: #3b82f6;
--color-security: #f59e0b;
--color-formatting: #8b5cf6;
--color-automation: #ec4899;
/* Neutral Colors */
--color-black: #111827;
--color-gray-dark: #4b5563;
--color-gray-medium: #6b7280;
--color-gray-light: #e5e7eb;
--color-gray-lighter: #f3f4f6;
--color-white: #ffffff;
/* Font Stack */
--font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
color: var(--color-black);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Page Structure - A4 Dimensions */
.page {
width: 210mm;
height: 297mm;
background: white;
page-break-after: always;
position: relative;
overflow: hidden;
}
.page:last-child {
page-break-after: auto;
}
/* Page 1: Hero / Cover */
.page-1 {
background: var(--brand-red);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Decorative shapes container */
.decorative-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
}
/* Logo SVG shape - top-right */
.shape-1 {
top: -120px;
right: -100px;
width: 450px;
height: auto;
opacity: 0.12;
}
/* Logo SVG shape - top-left */
.shape-2 {
top: -80px;
left: -80px;
width: 350px;
height: auto;
opacity: 0.08;
}
/* Logo SVG shape - bottom-left */
.shape-3 {
bottom: -180px;
left: -150px;
width: 550px;
height: auto;
opacity: 0.15;
}
/* Logo SVG shape - bottom-right */
.shape-4 {
bottom: -100px;
right: -120px;
width: 400px;
height: auto;
opacity: 0.1;
}
/* Small accent shape center-right */
.shape-5 {
top: 50%;
right: -30px;
width: 200px;
height: auto;
opacity: 0.08;
transform: translateY(-50%);
}
.hero-content {
text-align: center;
padding: 60px;
position: relative;
z-index: 1;
}
.logo-container {
margin-bottom: 48px;
position: relative;
}
.hero-logo {
width: 280px;
height: auto;
}
.hero-tagline {
font-size: 32px;
font-weight: 600;
color: var(--color-white);
margin-bottom: 32px;
line-height: 1.3;
}
.hero-stats {
margin-bottom: 40px;
}
.stat-badge {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 24px 48px;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.stat-number {
font-size: 48px;
font-weight: 700;
color: var(--brand-red);
line-height: 1;
}
.stat-label {
font-size: 18px;
color: var(--color-gray-dark);
margin-top: 8px;
font-weight: 500;
}
.hero-features {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.feature-pill {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
/* Page 2: What is Stirling PDF */
.page-2 {
padding: 60px;
}
.content-wrapper {
max-width: 700px;
margin: 0 auto;
}
.page-title {
font-size: 36px;
font-weight: 700;
color: var(--brand-red);
margin-bottom: 24px;
border-bottom: 4px solid var(--brand-red);
padding-bottom: 16px;
}
.intro-text {
font-size: 16px;
color: var(--color-gray-dark);
margin-bottom: 48px;
line-height: 1.8;
}
.value-props {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.value-prop {
display: flex;
flex-direction: column;
gap: 12px;
}
.value-icon {
width: 48px;
height: 48px;
color: var(--brand-red);
margin-bottom: 8px;
}
.value-icon svg {
width: 100%;
height: 100%;
}
.value-prop h3 {
font-size: 20px;
font-weight: 600;
color: var(--color-black);
}
.value-prop p {
font-size: 14px;
color: var(--color-gray-dark);
line-height: 1.6;
}
/* Page 3: Key Features */
.page-3 {
padding: 60px;
}
.features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
}
.feature-card {
background: white;
border: 2px solid var(--color-gray-light);
border-radius: 12px;
padding: 24px;
transition: all 0.2s ease;
}
.feature-card[data-category="general"] {
border-color: var(--color-general);
}
.feature-card[data-category="security"] {
border-color: var(--color-security);
}
.feature-card[data-category="formatting"] {
border-color: var(--color-formatting);
}
.feature-card[data-category="automation"] {
border-color: var(--color-automation);
}
.feature-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.feature-icon-large {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.feature-card[data-category="general"] .feature-icon-large {
color: var(--color-general);
}
.feature-card[data-category="security"] .feature-icon-large {
color: var(--color-security);
}
.feature-card[data-category="formatting"] .feature-icon-large {
color: var(--color-formatting);
}
.feature-card[data-category="automation"] .feature-icon-large {
color: var(--color-automation);
}
.feature-icon-large svg {
width: 100%;
height: 100%;
}
.feature-card h3 {
font-size: 18px;
font-weight: 600;
color: var(--color-black);
}
.feature-list {
list-style: none;
padding: 0;
}
.feature-list li {
font-size: 14px;
color: var(--color-gray-dark);
padding: 6px 0;
padding-left: 20px;
position: relative;
}
.feature-list li::before {
content: "•";
position: absolute;
left: 0;
color: var(--brand-red);
font-weight: bold;
}
.additional-features {
background: white;
border: 2px solid var(--brand-red);
padding: 24px;
border-radius: 12px;
margin-top: 24px;
}
.additional-features-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.additional-features-icon {
width: 40px;
height: 40px;
color: var(--brand-red);
flex-shrink: 0;
}
.additional-features-icon svg {
width: 100%;
height: 100%;
}
.additional-features h3 {
font-size: 18px;
font-weight: 600;
color: var(--color-black);
margin: 0;
}
.additional-features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.additional-features-grid ul {
list-style: none;
padding: 0;
margin: 0;
}
.additional-features-grid li {
font-size: 15px;
color: var(--color-gray-dark);
padding: 4px 0;
padding-left: 24px;
position: relative;
line-height: 1.5;
}
.additional-features-grid li::before {
content: "•";
position: absolute;
left: 0;
color: var(--brand-red);
font-weight: bold;
font-size: 18px;
}
/* Print Styles */
@media print {
body {
margin: 0;
padding: 0;
}
.page {
margin: 0;
border: none;
box-shadow: none;
}
}
@@ -0,0 +1,325 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-1"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-2"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-3"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-4"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-5"
alt=""
/>
</div>
<div class="hero-content">
<div class="logo-container">
<img
src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg"
alt="Stirling PDF"
class="hero-logo"
/>
</div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
</div>
</div>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool. It enables
you to carry out various operations on PDF files, including splitting,
merging, converting, rearranging, adding images, rotating,
compressing, and more.
</p>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
</div>
<h3>50+ PDF Operations</h3>
<p>
Comprehensive toolkit covering all your PDF needs. From basic
operations to advanced processing.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Workflow Automation</h3>
<p>
Chain multiple operations together and save them as reusable
workflows. Perfect for recurring tasks.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
</div>
<h3>Multi-Language Support</h3>
<p>
Available in over 30 languages with community-contributed
translations. Accessible to users worldwide.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>Privacy First</h3>
<p>
Self-hosted solution means your data stays on your infrastructure.
You have full control over your documents.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Open Source</h3>
<p>
Transparent, community-driven development. Inspect the code,
contribute features, and adapt as needed.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>
RESTful API for integration with external tools and scripts.
Automate PDF operations programmatically.
</p>
</div>
</div>
</div>
</div>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<h3>Page Operations</h3>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3>Security & Signing</h3>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z"
/>
</svg>
</div>
<h3>File Conversions</h3>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Automation</h3>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8"
/>
</svg>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
/**
* Ensures `.env.local` (and mode-specific `.env.desktop.local` / `.env.saas.local`)
* files exist so developers have a place to put overrides (API keys, machine-specific
* settings) without touching the committed `.env` / `.env.desktop` / `.env.saas` files.
*
* Vite automatically layers these `.local` files on top of the committed ones.
*
* Usage:
* tsx scripts/setup-env.ts # ensures .env.local
* tsx scripts/setup-env.ts --desktop # also ensures .env.desktop.local
* tsx scripts/setup-env.ts --saas # also ensures .env.saas.local
*/
import { existsSync, writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
// .env files live next to the editor's vite.config.ts (frontend/editor/).
// Resolve relative to this script regardless of where the build was invoked.
// `import.meta.dirname` would be tidier but isn't available under tsx's CJS
// transpilation today, so go via fileURLToPath for portability.
const scriptDir = dirname(fileURLToPath(import.meta.url));
const root = resolve(scriptDir, "..");
const args = process.argv.slice(2);
const isDesktop = args.includes("--desktop");
const isSaas = args.includes("--saas");
function template(parent: string): string {
return [
"###############################################################################",
`# Local overrides for \`frontend/editor/${parent}\``,
"# Put API keys and machine-specific settings here. Any variable defined here",
`# takes precedence over the committed \`${parent}\``,
"###############################################################################",
"",
].join("\n");
}
function ensureLocalFile(localFile: string, parentFile: string): void {
const localPath = join(root, localFile);
if (!existsSync(localPath)) {
writeFileSync(localPath, template(parentFile));
console.log(`setup-env: created empty ${localFile} for local overrides`);
}
}
ensureLocalFile(".env.local", ".env");
if (isDesktop) ensureLocalFile(".env.desktop.local", ".env.desktop");
if (isSaas) ensureLocalFile(".env.saas.local", ".env.saas");
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.mts"],
"exclude": []
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* Cross-platform update:minor script
* Calculates date from 7 days ago and runs npm update/audit with that date
*/
const { spawn } = require("child_process");
// Calculate date from 7 days ago in YYYY-MM-DD format
const date = new Date();
date.setDate(date.getDate() - 7);
const beforeDate = date.toISOString().split("T")[0];
console.log(`Updating packages modified before: ${beforeDate}`);
let lastExitCode = 0;
// Run npm outdated first
const outdated = spawn("npm", ["outdated"], { stdio: "inherit", shell: true });
outdated.on("close", (_code) => {
// npm outdated returns exit code 1 if updates are available, so we ignore it
// Run npm update with before date
const update = spawn("npm", ["update", `--before=${beforeDate}`], {
stdio: "inherit",
shell: true,
});
update.on("close", (updateCode) => {
// Track update failures
if (updateCode !== 0) {
lastExitCode = updateCode;
}
// Run npm audit fix with before date
const audit = spawn("npm", ["audit", "fix", `--before=${beforeDate}`], {
stdio: "inherit",
shell: true,
});
audit.on("close", (auditCode) => {
// Track audit failures (but don't override critical update failures)
if (auditCode !== 0 && lastExitCode === 0) {
lastExitCode = auditCode;
}
// Update complete - report with tracked exit code
console.log("\nPackage update complete!");
process.exit(lastExitCode);
});
});
});