[
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: [bestony]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: npm\n  directory: \"/\"\n  schedule:\n    interval: daily\n    time: \"21:00\"\n  open-pull-requests-limit: 10\n  assignees:\n  - bestony\n  ignore:\n  - dependency-name: y18n\n    versions:\n    - 4.0.1\n  - dependency-name: eslint-plugin-vue\n    versions:\n    - 7.5.0\n    - 7.6.0\n    - 7.8.0\n  - dependency-name: stylus-loader\n    versions:\n    - 4.3.3\n"
  },
  {
    "path": ".github/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n    branches:\n      - master\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n          cache: npm\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Lint\n        run: npm run lint\n\n      - name: Test with coverage\n        run: npm run test\n        env:\n          CI: true\n\n      - name: Build\n        run: npm run build\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ncoverage\n*.local\n\n/cypress/videos/\n/cypress/screenshots/\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n*.tsbuildinfo\n\n.DS_Store\nnode_modules\n/dist\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw*"
  },
  {
    "path": ".prettierrc.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/prettierrc\",\n  \"semi\": true,\n  \"tabWidth\": 2,\n  \"singleQuote\": true,\n  \"printWidth\": 100,\n  \"trailingComma\": \"none\"\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as\ncontributors and maintainers pledge to making participation in our project and\nour community a harassment-free experience for everyone, regardless of age, body\nsize, disability, ethnicity, sex characteristics, gender identity and expression,\nlevel of experience, education, socio-economic status, nationality, personal\nappearance, race, religion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behavior that contributes to creating a positive environment\ninclude:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior by participants include:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or\n  advances\n- Trolling, insulting/derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or electronic\n  address, without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Our Responsibilities\n\nProject maintainers are responsible for clarifying the standards of acceptable\nbehavior and are expected to take appropriate and fair corrective action in\nresponse to any instances of unacceptable behavior.\n\nProject maintainers have the right and responsibility to remove, edit, or\nreject comments, commits, code, wiki edits, issues, and other contributions\nthat are not aligned to this Code of Conduct, or to ban temporarily or\npermanently any contributor for other behaviors that they deem inappropriate,\nthreatening, offensive, or harmful.\n\n## Scope\n\nThis Code of Conduct applies both within project spaces and in public spaces\nwhen an individual is representing the project or its community. Examples of\nrepresenting a project or community include using an official project e-mail\naddress, posting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event. Representation of a project may be\nfurther defined and clarified by project maintainers.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported by contacting the project team at xiqingongzi+logoly@gmail.com. All\ncomplaints will be reviewed and investigated and will result in a response that\nis deemed necessary and appropriate to the circumstances. The project team is\nobligated to maintain confidentiality with regard to the reporter of an incident.\nFurther details of specific enforcement policies may be posted separately.\n\nProject maintainers who do not follow or enforce the Code of Conduct in good\nfaith may face temporary or permanent repercussions as determined by other\nmembers of the project's leadership.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,\navailable at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see\nhttps://www.contributor-covenant.org/faq\n"
  },
  {
    "path": "Changelog.md",
    "content": "# Changelog\n\n## 2025.11.14\n\n- Added a Vitest-based test harness (jsdom environment, global setup, and 99% coverage gates) and expanded suites for stores, generators, and font utilities.\n- Refactored generators to share logic via new composables, centralized font metadata/configuration, and improved selection persistence plus font-loading behavior.\n- Cleaned up Vuetify imports, refreshed dependencies, and tuned the Vite build for compressed outputs.\n- Adopted Biome for consistent formatting/linting, standardized project styles, and committed a Bun lockfile for deterministic installs.\n- Introduced GitHub Actions CI driven by Bun to run install, lint, test, and build steps on every push/PR.\n\n## 2024.03.23\n\n- Upgraded to Vue 3\n- Added OnlyFans Generator\n- Added SVG Export\n\n## 2020.10.08\n\n- Added Font-Selector-Component\n\n## 2019.05.04\n\n- Fix Typo at Slogan.vue\n- Add vuex for keep text while switch layout\n- use Canvas export\n\n## 2019.03.26\n\n- add Vertical Pornhub Logo\n\n## 2019.03.25\n\n- Download Image with Dark Background.\n- Inverse prefix and suffix\n\n## 2019.03.23\n\n- Init Project\n- Download as PNG\n- Custom Font Style\n"
  },
  {
    "path": "Contributers.md",
    "content": "# Contributors\n\n- [Yovel Ovadia](https://github.com/yovelovadia)\n- [Daniel Yuldashev](https://github.com/yldshv)\n"
  },
  {
    "path": "Contributing.md",
    "content": "# Contribute to This Project\n\n## Feature Request\n\nIf you want to request for a new feature, you may open an issue and tell me what you want.\n\nIf possible, attach a screenshot or an image to help me understand.\n\n[**Open an Issue**](https://github.com/bestony/logoly/issues/new?assignees=&labels=&template=feature_request.md&title=)\n\n## Bug Report\n\nIf you want to report a bug, you may open an issue.\n\nIf possible, please include steps to reproduce the bug.\n\n[**Open an Issue**](https://github.com/bestony/logoly/issues/new?assignees=&labels=&template=bug_report.md&title=)\n\n## Make Contribution\n\nIf you want to contribute to this project, fork the project first, and then clone the project and make changes on your own repository,\n\nOnce you pushed into your own repository, you may open a pull request.\n\n## Local Development Setup\n\n1. Ensure you have Node.js 18+ and npm 10+ installed.\n2. Install dependencies with `npm install` (or `npm ci`).\n3. Use the npm scripts (`npm run dev`, `npm run test`, `npm run lint`, `npm run build`) to work locally.\n\n> The project standardizes on npm; please avoid using Bun, pnpm, or yarn so that the single `package-lock.json` stays the source of truth.\n"
  },
  {
    "path": "LICENSE",
    "content": "            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n                    Version 2, December 2004\n\n Copyright (C) 2012 Romain Lespinasse <romain.lespinasse@gmail.com>\n\n Everyone is permitted to copy and distribute verbatim or modified\n copies of this license document, and changing it is allowed as long\n as the name is changed.\n\n            DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. You just DO WHAT THE FUCK YOU WANT TO."
  },
  {
    "path": "README.md",
    "content": "> **new version is in active development at new-vue3 branch**\n<img align=\"right\" src=\"https://postimg.aliavv.com/mbp/adpsj.png\"/>\n\n# Logoly —— A Pornhub Flavour Logo Generator\n\n![](https://img.shields.io/badge/Deployed%20on-Vercel-9cf) ![GitHub last commit](https://img.shields.io/github/last-commit/bestony/logoly.svg) ![GitHub issues](https://img.shields.io/github/issues/bestony/logoly.svg) ![GitHub stars](https://img.shields.io/github/stars/bestony/logoly.svg?style=social)\n\n**A Simple Online Logo Generator for People Who Want to Design Logos Easily.**\n\n## Screenshot\n\n![](https://i.loli.net/2019/03/24/5c96e02e97aff.png)\n\n## Features\n\n- generate logo like **PornHub** or **OnlyFans**\n- download your own logo in PNG/SVG format\n- customize logo color\n- customize logo font size\n\n## How to Use\n\n1. open the Logoly website: [https://logoly.pro/](https://logoly.pro/)\n2. edit the text in the box\n3. change color & font size as you like\n4. click the **Export** button to download the image\n\n## TODO\n\n- share it on Facebook\n- customize fonts\n\n## Changelog\n\nSee [Changelog](Changelog.md)\n\n## How to Contribute\n\nFor those who want to request new features or submit bug reports, click [this link](https://github.com/bestony/logoly/issues/new/choose) to open a new issue.\n\nFor those who want to play around with this project, read the `Get Started` section.\n\nAt the end of this section, I suggest you read the [Contributing Guide](Contributing.md).\n\n## Requirements\n\n- Node.js 18+\n- npm 10+ (official package manager; please don't submit other lockfiles)\n\n## Get Started\n\n1. clone this project\n2. install dependencies with `npm install` (or `npm ci` for a clean install) at the project root directory\n3. start the development server with `npm run dev`\n4. make changes\n5. build with `npm run build`\n\nAll scripts and the CI pipeline run with npm. Using Bun, pnpm, or yarn may create mismatched lockfiles and is not supported.\n\n## Related Project\n\n- [Logoly.pro MiniProgram](https://github.com/GHLandy/logoly-pro)\n\n## Sponsors\n\n[<img src=\"https://postimg.aliavv.com/picgo/20190331211014.png\" height=40>](http://www.leancloud.app/)\n\n## LICENSE\n\n[WTFPL 2](LICENSE)\n"
  },
  {
    "path": "_redirects",
    "content": "# Netlify settings for single-page application\n/*    /index.html   200"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.2.0/schema.json\",\n  \"vcs\": {\n    \"enabled\": true,\n    \"clientKind\": \"git\",\n    \"useIgnoreFile\": true\n  },\n  \"files\": {\n    \"ignoreUnknown\": true,\n    \"ignore\": [\"src/assets/**\"]\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 100\n  },\n  \"css\": {},\n  \"javascript\": {\n    \"formatter\": {\n      \"quoteStyle\": \"single\",\n      \"jsxQuoteStyle\": \"double\",\n      \"semicolons\": \"always\",\n      \"trailingCommas\": \"none\"\n    }\n  },\n  \"json\": {\n    \"formatter\": {\n      \"trailingCommas\": \"none\"\n    }\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true,\n\n      \"complexity\": {\n        \"noExcessiveCognitiveComplexity\": {\n          \"options\": {\n            \"maxAllowedComplexity\": 15\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n    <meta\n      name=\"description\"\n      content=\"Logoly.Pro lets you create parody Pornhub-style logos with custom colors and export them as crisp PNG or SVG files for your next meme, presentation, or stream overlay.\"\n    />\n    <link rel=\"canonical\" href=\"https://logoly.pro/\" />\n    <link rel=\"icon\" href=\"/favicon.ico\" />\n    <link rel=\"apple-touch-icon\" href=\"/favicon.ico\" />\n    <link rel=\"manifest\" href=\"/site.webmanifest\" />\n    <meta name=\"google-adsense-account\" content=\"ca-pub-9877802927933140\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"theme-color\" content=\"#050505\" />\n    <meta property=\"og:type\" content=\"website\" />\n    <meta property=\"og:site_name\" content=\"Logoly.Pro\" />\n    <meta property=\"og:title\" content=\"Logoly.Pro — A creative Logo Generator\" />\n    <meta\n      property=\"og:description\"\n      content=\"Craft instantly recognizable Pornhub-style parody logos online, tweak fonts/colors interactively, then export share-ready assets.\"\n    />\n    <meta property=\"og:url\" content=\"https://logoly.pro/\" />\n    <meta property=\"og:image\" content=\"https://logoly.pro/social-share.png\" />\n    <meta property=\"og:locale\" content=\"en_US\" />\n    <meta name=\"twitter:card\" content=\"summary_large_image\" />\n    <meta name=\"twitter:title\" content=\"Logoly.Pro — A creative Logo Generator\" />\n    <meta\n      name=\"twitter:description\"\n      content=\"Create Pornhub-style parody logos with Logoly.Pro and export them instantly as PNG or SVG.\"\n    />\n    <meta name=\"twitter:site\" content=\"@xiqingongzi\" />\n    <meta name=\"twitter:image\" content=\"https://logoly.pro/social-share.png\" />\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <script\n      data-cfasync=\"false\"\n      defer\n      src=\"https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9877802927933140\"\n      crossorigin=\"anonymous\"\n    ></script>\n    <title>Logoly.Pro —— A creative Logo Generator</title>\n  </head>\n  <body>\n    <noscript>\n      <strong\n        >We're sorry but logoly doesn't work properly without JavaScript enabled. Please enable it\n        to continue.</strong\n      >\n    </noscript>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"logoly\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"format\": \"biome format --write .\",\n    \"lint\": \"biome lint .\",\n    \"lint:fix\": \"biome lint --write .\",\n    \"check\": \"biome check .\",\n    \"check:fix\": \"biome check --write .\",\n    \"test\": \"vitest run --coverage\"\n  },\n  \"dependencies\": {\n    \"@mdi/font\": \"^7.4.47\",\n    \"@vueuse/core\": \"^10.11.0\",\n    \"dom-to-image\": \"^2.6.0\",\n    \"pinia\": \"^2.1.7\",\n    \"vue\": \"^3.5.5\",\n    \"vue-gtag\": \"^2.0.1\",\n    \"vue-router\": \"^4.5.0\",\n    \"vuetify\": \"^3.10.11\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^1.9.4\",\n    \"@tailwindcss/postcss\": \"^4.1.17\",\n    \"@vitejs/plugin-vue\": \"^5.0.4\",\n    \"@vitest/coverage-istanbul\": \"^4.0.8\",\n    \"@vitest/coverage-v8\": \"^4.0.8\",\n    \"@vue/test-utils\": \"^2.4.6\",\n    \"autoprefixer\": \"^10.4.19\",\n    \"jsdom\": \"^27.2.0\",\n    \"postcss\": \"^8.4.38\",\n    \"prettier\": \"^3.3.3\",\n    \"sass\": \"^1.94.1\",\n    \"stylus\": \"^0.64.0\",\n    \"tailwindcss\": \"^4.1.17\",\n    \"vite\": \"^5.4.5\",\n    \"vite-plugin-compression\": \"^0.5.1\",\n    \"vite-plugin-vuetify\": \"^2.1.2\",\n    \"vitest\": \"^4.0.8\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n    autoprefixer: {}\n  }\n};\n"
  },
  {
    "path": "public/ads.txt",
    "content": "google.com, pub-9877802927933140, DIRECT, f08c47fec0942fa0\n"
  },
  {
    "path": "public/site.webmanifest",
    "content": "{\n  \"name\": \"Logoly.Pro\",\n  \"short_name\": \"Logoly\",\n  \"description\": \"Generate Pornhub-style parody logos right in the browser and download them as PNG or SVG in seconds.\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#050505\",\n  \"theme_color\": \"#050505\",\n  \"icons\": [\n    {\n      \"src\": \"/favicon.ico\",\n      \"sizes\": \"48x48 64x64 128x128 256x256\",\n      \"type\": \"image/x-icon\"\n    },\n    {\n      \"src\": \"/social-share.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\"\n    }\n  ]\n}\n"
  },
  {
    "path": "src/App.vue",
    "content": "<template>\n  <div id=\"app\" class=\"container mx-auto py-12 px-6 max-w-[893px]\">\n    <Ribbon class=\"z-50\"></Ribbon>\n    <div class=\"text-4xl font-black p-5 my-6 text-center\">\n      <logo></logo>\n    </div>\n    <div class=\"text-2xl mb-6 text-center font-extrabold\">\n      <Description></Description>\n    </div>\n    <div id=\"nav\">\n      <div\n        class=\"flex flex-col md:flex-row gap-2 md:gap-16 text-xl font-semibold items-center mb-12 justify-center\"\n      >\n        <router-link to=\"/about\">About</router-link>\n        <router-link to=\"/\" class=\"pb\">\n          <span class=\"prefix\">Porn</span>\n          <span class=\"postfix\">hub</span>\n        </router-link>\n        <router-link to=\"/vertical-ph\" class=\"vph\">\n          <p class=\"prefix\">Porn</p>\n          <p class=\"postfix\">hub</p>\n        </router-link>\n        <router-link to=\"/onlyfans\">\n          <span class=\"text-white\">Only</span>\n          <span class=\"text-[#00AFF0]\">Fans</span>\n        </router-link>\n        <span class=\"text-[#777] font-light\">More coming soon...</span>\n      </div>\n    </div>\n    <router-view />\n    <Slogan />\n    <Faq />\n    <Author />\n    <Copyright class=\"pb-4\" />\n  </div>\n</template>\n\n<script setup>\nimport Logo from '@/components/Logo.vue';\nimport Description from '@/components/Description.vue';\nimport Slogan from '@/components/Slogan.vue';\nimport Faq from '@/components/Faq.vue';\nimport Author from '@/components/Author.vue';\nimport Ribbon from '@/components/Ribbon.vue';\nimport Copyright from '@/components/Copyright.vue';\n</script>\n"
  },
  {
    "path": "src/__tests__/AboutView.test.js",
    "content": "import { describe, it, expect } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport AboutView from '@/views/AboutView.vue';\n\ndescribe('AboutView', () => {\n  it('links to the GitHub repository', () => {\n    const wrapper = mount(AboutView);\n    expect(wrapper.text()).toContain('Logoly.pro');\n    const link = wrapper.get('a');\n    expect(link.attributes('href')).toBe('https://github.com/bestony/logoly');\n    expect(link.attributes('target')).toBe('_blank');\n  });\n});\n"
  },
  {
    "path": "src/__tests__/ExportBtn.test.js",
    "content": "import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { createPinia, setActivePinia } from 'pinia';\nimport ExportBtn from '@/components/ExportBtn.vue';\nimport { useStore } from '@/stores/store';\n\nconst domToImageMock = vi.hoisted(() => ({\n  toPng: vi.fn(() => Promise.resolve('data:image/png;base64,mock')),\n  toSvg: vi.fn(() => Promise.resolve('data:image/svg+xml,mock'))\n}));\n\nconst onClickOutsideMock = vi.hoisted(() => vi.fn((_, handler) => handler()));\n\nvi.mock('dom-to-image', () => ({\n  __esModule: true,\n  default: domToImageMock\n}));\n\nvi.mock('@vueuse/core', () => ({\n  onClickOutside: onClickOutsideMock\n}));\ndescribe('ExportBtn', () => {\n  let OriginalImage;\n  let clickSpy;\n\n  beforeAll(() => {\n    OriginalImage = window.Image;\n    window.Image = class {\n      constructor() {\n        this.width = 100;\n        this.height = 50;\n      }\n      setAttribute() {}\n      set onload(handler) {\n        this._onload = handler;\n      }\n      get onload() {\n        return this._onload;\n      }\n      set src(value) {\n        this._src = value;\n        this._onload?.();\n      }\n    };\n    clickSpy = vi.spyOn(window.HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});\n  });\n\n  afterAll(() => {\n    window.Image = OriginalImage;\n    clickSpy.mockRestore();\n  });\n\n  beforeEach(() => {\n    document.body.innerHTML = '<div id=\"logo\"></div>';\n    setActivePinia(createPinia());\n    domToImageMock.toPng.mockClear();\n    domToImageMock.toSvg.mockClear();\n    onClickOutsideMock.mockClear();\n  });\n\n  const mountButton = () => mount(ExportBtn);\n  const flush = () => new Promise((resolve) => setTimeout(resolve));\n\n  it('exports the editable area as PNG', async () => {\n    const store = useStore();\n    store.prefix = 'Porn';\n    store.suffix = 'hub';\n    const wrapper = mountButton();\n\n    await wrapper.find('[value=\"png\"]').trigger('click');\n    expect(domToImageMock.toPng).toHaveBeenCalledWith(document.getElementById('logo'));\n\n    await flush();\n\n    expect(store.editable).toBe(true);\n    expect(onClickOutsideMock).toHaveBeenCalled();\n  });\n\n  it('exports the editable area as SVG', async () => {\n    const store = useStore();\n    store.prefix = 'Only';\n    store.suffix = 'Fans';\n    const wrapper = mountButton();\n\n    await wrapper.find('[value=\"svg\"]').trigger('click');\n    expect(domToImageMock.toSvg).toHaveBeenCalledWith(document.getElementById('logo'));\n\n    await flush();\n\n    expect(clickSpy).toHaveBeenCalled();\n    expect(store.editable).toBe(true);\n  });\n\n  it('skips exporting when the logo node is missing', async () => {\n    document.body.innerHTML = '';\n    setActivePinia(createPinia());\n    const wrapper = mountButton();\n\n    await wrapper.find('[value=\"png\"]').trigger('click');\n\n    expect(domToImageMock.toPng).not.toHaveBeenCalled();\n  });\n\n  it('falls back to the default filename when none is provided', () => {\n    const dispatchSpy = vi.spyOn(window.HTMLAnchorElement.prototype, 'dispatchEvent');\n    const wrapper = mountButton();\n    const { downloadImage } = wrapper.vm.$.setupState;\n\n    expect(typeof downloadImage).toBe('function');\n    downloadImage('data:image/png;base64,stub');\n\n    expect(dispatchSpy).toHaveBeenCalled();\n    dispatchSpy.mockRestore();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/FontSelectorComponent.test.js",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { defineComponent } from 'vue';\nimport { createPinia, setActivePinia } from 'pinia';\nimport FontSelector from '@/components/FontSelector.vue';\nimport { useStore } from '@/stores/store';\nimport { loadGoogleFont } from '@/utils/fontLoader';\n\nvi.mock('@/utils/fontLoader', () => ({\n  loadGoogleFont: vi.fn()\n}));\n\nconst VSelectStub = defineComponent({\n  name: 'VSelectStub',\n  props: {\n    modelValue: {\n      type: String,\n      default: ''\n    },\n    items: {\n      type: Array,\n      default: () => []\n    }\n  },\n  emits: ['update:modelValue'],\n  template: `<select class=\"v-select-stub\" :value=\"modelValue\" @change=\"$emit('update:modelValue', $event.target.value)\">\n    <option v-for=\"option in items\" :key=\"option\" :value=\"option\">{{ option }}</option>\n  </select>`\n});\n\ndescribe('FontSelector component', () => {\n  beforeEach(() => {\n    setActivePinia(createPinia());\n    vi.clearAllMocks();\n  });\n\n  it('loads the current font immediately and on selection changes', async () => {\n    const wrapper = mount(FontSelector, {\n      global: {\n        stubs: {\n          'v-select': VSelectStub\n        }\n      }\n    });\n    const store = useStore();\n\n    expect(loadGoogleFont).toHaveBeenCalledTimes(1);\n    expect(loadGoogleFont).toHaveBeenCalledWith(store.font);\n\n    loadGoogleFont.mockClear();\n    const select = wrapper.get('select');\n    await select.setValue('Lora');\n\n    expect(loadGoogleFont).toHaveBeenCalledTimes(1);\n    expect(loadGoogleFont).toHaveBeenCalledWith('Lora');\n    expect(wrapper.html()).toContain('Font');\n  });\n});\n"
  },
  {
    "path": "src/__tests__/fontLoader.test.js",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest';\n\nconst loadModule = async () => {\n  vi.resetModules();\n  const module = await import('@/utils/fontLoader.js');\n  return module;\n};\n\ndescribe('loadGoogleFont', () => {\n  beforeEach(() => {\n    document.head.innerHTML = '';\n  });\n\n  it('creates a preload link and converts it to stylesheet on load', async () => {\n    const { loadGoogleFont } = await loadModule();\n    loadGoogleFont('  Roboto  ');\n    const link = document.head.querySelector('link');\n    expect(link).toBeTruthy();\n    expect(link.rel).toBe('preload');\n    expect(link.href).toContain('Roboto');\n\n    link.onload();\n    expect(link.rel).toBe('stylesheet');\n  });\n\n  it('avoids duplicate requests while a font is pending or already loaded', async () => {\n    const { loadGoogleFont } = await loadModule();\n    loadGoogleFont('Lora');\n    loadGoogleFont('Lora');\n    expect(document.head.querySelectorAll('link').length).toBe(1);\n\n    const [first] = document.head.querySelectorAll('link');\n    first.onload();\n    loadGoogleFont('Lora');\n    expect(document.head.querySelectorAll('link').length).toBe(1);\n  });\n\n  it('retries after an error by cleaning the pending state', async () => {\n    const { loadGoogleFont } = await loadModule();\n    loadGoogleFont('Inter');\n    let link = document.head.querySelector('link');\n    expect(link).toBeTruthy();\n    link.onerror();\n    expect(document.head.querySelector('link')).toBeNull();\n\n    loadGoogleFont('Inter');\n    link = document.head.querySelector('link');\n    expect(link).toBeTruthy();\n  });\n\n  it('no-ops when the DOM is unavailable', async () => {\n    const originalWindow = globalThis.window;\n    const originalDocument = globalThis.document;\n    vi.resetModules();\n    try {\n      // @ts-expect-error override for test\n      globalThis.window = undefined;\n      // @ts-expect-error override for test\n      globalThis.document = undefined;\n      const { loadGoogleFont } = await import('@/utils/fontLoader.js');\n      expect(() => loadGoogleFont('Roboto')).not.toThrow();\n    } finally {\n      globalThis.window = originalWindow;\n      globalThis.document = originalDocument;\n      vi.resetModules();\n    }\n  });\n});\n"
  },
  {
    "path": "src/__tests__/fontsConfig.test.js",
    "content": "import { describe, it, expect } from 'vitest';\nimport { fonts } from '@/config/fonts';\n\ndescribe('fonts config', () => {\n  it('exports a de-duplicated, trimmed list', () => {\n    expect(fonts.length).toBe(new Set(fonts).size);\n    expect(fonts.every((font) => font === font.trim())).toBe(true);\n    expect(fonts).toContain('Roboto');\n    expect(fonts).not.toContain('Roboto ');\n  });\n});\n"
  },
  {
    "path": "src/__tests__/generators.test.js",
    "content": "import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { defineComponent, nextTick } from 'vue';\nimport { createPinia, setActivePinia } from 'pinia';\nimport Pornhub from '@/components/generator/Pornhub.vue';\nimport VerticalPornHub from '@/components/generator/VerticalPornHub.vue';\nimport Onlyfans from '@/components/generator/Onlyfans.vue';\nimport { useStore } from '@/stores/store';\n\nconst TooltipStub = defineComponent({\n  name: 'TooltipStub',\n  inheritAttrs: false,\n  template: `<div class=\"tooltip-stub\"><slot name=\"activator\" :props=\"{}\"></slot><slot name=\"text\"></slot><slot /></div>`\n});\n\nconst MenuStub = defineComponent({\n  name: 'MenuStub',\n  inheritAttrs: false,\n  template: `<div class=\"menu-stub\"><slot name=\"activator\" :props=\"{}\"></slot><slot /></div>`\n});\n\nconst ColorPickerStub = defineComponent({\n  name: 'ColorPickerStub',\n  inheritAttrs: false,\n  emits: ['update:modelValue'],\n  template: `<div class=\"color-picker-stub\"><slot /></div>`\n});\n\nconst SliderStub = defineComponent({\n  name: 'SliderStub',\n  inheritAttrs: false,\n  emits: ['update:modelValue'],\n  template: `<div class=\"slider-stub\"><slot /></div>`\n});\n\nconst CheckboxStub = defineComponent({\n  name: 'CheckboxStub',\n  inheritAttrs: false,\n  emits: ['update:modelValue'],\n  template: `<button class=\"checkbox-stub\"><slot /></button>`\n});\n\nconst ButtonStub = defineComponent({\n  name: 'ButtonStub',\n  inheritAttrs: false,\n  emits: ['click'],\n  template: `<button class=\"btn-stub\"><slot /></button>`\n});\n\nconst IconStub = defineComponent({\n  name: 'IconStub',\n  template: `<i class=\"icon-stub\"><slot /></i>`\n});\n\nconst generatorStubs = {\n  ExportBtn: { template: '<button class=\"export-btn-stub\" />' },\n  FontSelector: { template: '<div class=\"font-selector-stub\" />' },\n  'v-tooltip': TooltipStub,\n  'v-menu': MenuStub,\n  'v-color-picker': ColorPickerStub,\n  'v-slider': SliderStub,\n  'v-checkbox-btn': CheckboxStub,\n  'v-btn': ButtonStub,\n  'v-icon': IconStub\n};\n\nconst mountGenerator = (component) =>\n  mount(component, {\n    attachTo: document.body,\n    global: {\n      stubs: generatorStubs\n    }\n  });\n\nconst editContent = async (wrapper, selector, value) => {\n  const target = wrapper.get(selector);\n  target.element.textContent = value;\n  await target.trigger('input');\n  return target;\n};\n\ndescribe('generator components', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    setActivePinia(createPinia());\n    window.localStorage.clear();\n    window.history.replaceState(null, '', '/');\n  });\n\n  it('synchronizes text updates and highlight order in Pornhub generator', async () => {\n    const store = useStore();\n    const wrapper = mountGenerator(Pornhub);\n\n    await editContent(wrapper, '.prefix', 'Only');\n    await editContent(wrapper, '.postfix', 'Fans');\n\n    expect(store.prefix).toBe('Only');\n    expect(store.suffix).toBe('Fans');\n\n    wrapper.vm.reverseHighlight = true;\n    await nextTick();\n\n    const [prefixAfterToggle] = wrapper.findAll('.prefix');\n    expect(prefixAfterToggle.text().trim()).toBe('Fans');\n\n    await editContent(wrapper, '.postfix', 'SwappedPrefix');\n    await editContent(wrapper, '.prefix', 'SwappedSuffix');\n    expect(store.prefix).toBe('SwappedPrefix');\n    expect(store.suffix).toBe('SwappedSuffix');\n\n    wrapper.vm.transparentBg = true;\n    await nextTick();\n    expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');\n\n    wrapper.unmount();\n  });\n\n  it('supports vertical layout interactions', async () => {\n    const store = useStore();\n    const wrapper = mountGenerator(VerticalPornHub);\n\n    await editContent(wrapper, '.prefix', 'Logo');\n    await editContent(wrapper, '.postfix', 'Lab');\n\n    expect(store.prefix).toBe('Logo');\n    expect(store.suffix).toBe('Lab');\n\n    const colorPickers = wrapper.findAllComponents(ColorPickerStub);\n    await colorPickers[0].vm.$emit('update:modelValue', '#123123');\n    await colorPickers[1].vm.$emit('update:modelValue', '#abcdef');\n    await colorPickers[2].vm.$emit('update:modelValue', '#fedcba');\n    await nextTick();\n\n    const checkboxes = wrapper.findAllComponents(CheckboxStub);\n    await checkboxes[1].vm.$emit('update:modelValue', true);\n    await nextTick();\n    expect(wrapper.findAll('.prefix')[0].text().trim()).toBe('Lab');\n    await editContent(wrapper, '.postfix', 'VerticalPrefix');\n    await editContent(wrapper, '.prefix', 'VerticalSuffix');\n\n    const slider = wrapper.getComponent(SliderStub);\n    await checkboxes[0].vm.$emit('update:modelValue', true);\n    await slider.vm.$emit('update:modelValue', 120);\n    await nextTick();\n    expect(wrapper.get('#logo').attributes('style')).toContain('font-size: 120px');\n    expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');\n\n    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});\n    const button = wrapper.findComponent(ButtonStub);\n    await button.vm.$emit('click');\n    expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('twitter.com/intent/tweet'));\n    openSpy.mockRestore();\n\n    wrapper.unmount();\n  });\n\n  it('applies Onlyfans-specific defaults and suffix margins', async () => {\n    const store = useStore();\n    const wrapper = mountGenerator(Onlyfans);\n\n    expect(store.prefix).toBe('Only');\n    expect(store.suffix).toBe('Fans');\n\n    const postfix = wrapper.get('.postfix');\n    expect(postfix.attributes('style')).toContain('margin-left: -2rem');\n\n    const slider = wrapper.getComponent(SliderStub);\n    await slider.vm.$emit('update:modelValue', 100);\n    await nextTick();\n    expect(wrapper.get('#logo').attributes('style')).toContain('font-size: 100px');\n\n    postfix.element.textContent = 'Creators';\n    await postfix.trigger('input');\n\n    await editContent(wrapper, '.prefix', 'OnlyYou');\n    expect(store.prefix).toBe('OnlyYou');\n    expect(store.suffix).toBe('Creators');\n\n    const checkbox = wrapper.getComponent(CheckboxStub);\n    await checkbox.vm.$emit('update:modelValue', true);\n    await nextTick();\n    expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');\n\n    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});\n    const button = wrapper.findComponent(ButtonStub);\n    await button.vm.$emit('click');\n    expect(openSpy).toHaveBeenCalledTimes(1);\n    openSpy.mockRestore();\n\n    wrapper.unmount();\n  });\n\n  it('reacts to visual controls and share actions', async () => {\n    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});\n    const wrapper = mountGenerator(Pornhub);\n\n    const colorPickers = wrapper.findAllComponents(ColorPickerStub);\n    await colorPickers[0].vm.$emit('update:modelValue', '#ff0000');\n    await colorPickers[1].vm.$emit('update:modelValue', '#00ff00');\n    await colorPickers[2].vm.$emit('update:modelValue', '#123456');\n    await nextTick();\n\n    const slider = wrapper.getComponent(SliderStub);\n    await slider.vm.$emit('update:modelValue', 150);\n    await nextTick();\n\n    const checkboxes = wrapper.findAllComponents(CheckboxStub);\n    await checkboxes[0].vm.$emit('update:modelValue', true);\n    await checkboxes[1].vm.$emit('update:modelValue', true);\n    await nextTick();\n\n    const logoStyles = wrapper.get('#logo').attributes('style');\n    expect(logoStyles).toContain('font-size: 150px');\n    expect(logoStyles).toContain('background-color: transparent');\n\n    const spanStyles = wrapper.get('.postfix').attributes('style');\n    expect(spanStyles).toContain('rgb(18, 52, 86)');\n\n    const button = wrapper.findComponent(ButtonStub);\n    await button.vm.$emit('click');\n    expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('twitter.com/intent/tweet'));\n\n    openSpy.mockRestore();\n    wrapper.unmount();\n  });\n});\n"
  },
  {
    "path": "src/__tests__/persistentState.test.js",
    "content": "import { describe, it, expect, beforeEach } from 'vitest';\nimport {\n  loadGeneratorState,\n  saveGeneratorState,\n  GENERATOR_STATE_STORAGE_KEY\n} from '@/utils/persistentState';\n\ndescribe('persistentState utilities', () => {\n  beforeEach(() => {\n    window.localStorage.clear();\n    window.history.replaceState(null, '', '/');\n  });\n\n  it('parses boolean query params and preserves false values', () => {\n    window.history.replaceState(null, '', '/?transparentBg=0&reverseHighlight=yes');\n    const state = loadGeneratorState();\n    expect(state.transparentBg).toBe(false);\n    expect(state.reverseHighlight).toBe(true);\n  });\n\n  it('ignores malformed booleans and swallows storage parse errors', () => {\n    window.history.replaceState(null, '', '/?transparentBg=maybe&prefixColor=%23fff');\n    window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '{');\n\n    const state = loadGeneratorState();\n    expect(state.transparentBg).toBeUndefined();\n    expect(state.prefixColor).toBe('#fff');\n  });\n\n  it('normalizes payloads before saving and updates the URL', () => {\n    saveGeneratorState({\n      prefix: 'Share',\n      suffix: 'Logo',\n      font: 'Lora',\n      fontSize: '120',\n      transparentBg: '1',\n      reverseHighlight: 'no',\n      postfixBgColor: '#123456',\n      extraneous: 'ignore-me'\n    });\n\n    const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));\n    expect(saved.fontSize).toBe(120);\n    expect(saved.transparentBg).toBe(true);\n    expect(saved.reverseHighlight).toBe(false);\n    expect(saved.extraneous).toBeUndefined();\n\n    expect(window.location.search).toContain('prefix=Share');\n    expect(window.location.search).toContain('reverseHighlight=0');\n  });\n\n  it('no-ops when window APIs are unavailable', () => {\n    const originalWindow = window;\n    // eslint-disable-next-line no-global-assign\n    window = undefined;\n\n    expect(loadGeneratorState()).toEqual({});\n    expect(() => saveGeneratorState({ prefix: 'SSR' })).not.toThrow();\n\n    // eslint-disable-next-line no-global-assign\n    window = originalWindow;\n  });\n\n  it('ignores null, empty, or non-string values', () => {\n    saveGeneratorState({\n      prefix: 'Keep',\n      suffix: '',\n      transparentBg: null,\n      postfixBgColor: 123\n    });\n\n    const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));\n    expect(saved.prefix).toBe('Keep');\n    expect(saved.suffix).toBe('');\n    expect(saved.transparentBg).toBeUndefined();\n    expect(saved.postfixBgColor).toBeUndefined();\n\n    window.history.replaceState(null, '', '/?reverseHighlight=&fontSize=');\n    const state = loadGeneratorState();\n    expect(state.reverseHighlight).toBeUndefined();\n    expect(state.fontSize).toBeUndefined();\n  });\n\n  it('skips invalid storage payloads', () => {\n    window.localStorage.setItem(\n      GENERATOR_STATE_STORAGE_KEY,\n      JSON.stringify({\n        prefix: 123,\n        suffix: 'Share',\n        fontSize: 'huge'\n      })\n    );\n    const state = loadGeneratorState();\n    expect(state.prefix).toBeUndefined();\n    expect(state.suffix).toBe('Share');\n    expect(state.fontSize).toBeUndefined();\n  });\n\n  it('handles malformed JSON in storage as empty state', () => {\n    window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '\"no-object\"');\n    expect(loadGeneratorState()).toEqual({});\n  });\n});\n"
  },
  {
    "path": "src/__tests__/router.test.js",
    "content": "import { describe, it, expect } from 'vitest';\nimport router from '@/router';\n\ndescribe('router', () => {\n  it('exposes all generator routes', () => {\n    const names = router.getRoutes().map((route) => route.name);\n    expect(names).toEqual(\n      expect.arrayContaining(['pornhub', 'vertical-pornhub', 'onlyfans', 'about'])\n    );\n  });\n\n  it('provides analytics templates for every route', () => {\n    for (const route of router.getRoutes()) {\n      const template = route.meta?.analytics?.pageviewTemplate;\n      expect(template).toBeTypeOf('function');\n      const payload = template({ path: route.path });\n      expect(payload).toMatchObject({ page: route.path });\n      expect(payload.title.length).toBeGreaterThan(0);\n    }\n  });\n\n  it('resolves each lazy component definition', async () => {\n    const configs = router.options.routes;\n    const components = await Promise.all(configs.map((route) => route.component()));\n    for (const module of components) {\n      expect(module).toBeTruthy();\n    }\n  });\n});\n"
  },
  {
    "path": "src/__tests__/store.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { createPinia, setActivePinia } from 'pinia';\nimport { useStore } from '@/stores/store';\n\nconst getStoreInternals = () => {\n  const internals = globalThis.__LOGOLY_STORE_INTERNALS__;\n  if (!internals) {\n    throw new Error('Store internals were not exposed for testing');\n  }\n  return internals;\n};\n\nconst mountEditable = (content = 'editme', options = {}) => {\n  const container = document.createElement('div');\n  container.setAttribute('contenteditable', 'true');\n  container.id = `logo-${Math.random().toString(16).slice(2)}`;\n  if (options.raw) {\n    container.innerHTML = content;\n  } else {\n    container.textContent = content;\n  }\n  document.body.appendChild(container);\n  return container;\n};\n\nconst selectRange = (startNode, startOffset, endNode = startNode, endOffset = startOffset) => {\n  const range = document.createRange();\n  range.setStart(startNode, startOffset);\n  range.setEnd(endNode, endOffset);\n  const selection = window.getSelection();\n  selection.removeAllRanges();\n  selection.addRange(range);\n  return selection;\n};\n\ndescribe('store', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n    setActivePinia(createPinia());\n  });\n\n  it('updates prefix and restores the previous caret selection', async () => {\n    const editable = mountEditable();\n    const store = useStore();\n    const selection = selectRange(editable.firstChild, 1, editable.firstChild, 3);\n\n    await store.updatePrefix('changed');\n\n    expect(store.prefix).toBe('changed');\n    expect(selection.rangeCount).toBe(1);\n    const range = selection.getRangeAt(0);\n    expect(range.startOffset).toBe(1);\n    expect(range.endOffset).toBe(3);\n  });\n\n  it('handles selections that span multiple text nodes', async () => {\n    const editable = mountEditable('<span>hi</span><span>there</span>', {\n      raw: true\n    });\n    const spans = editable.querySelectorAll('span');\n    const store = useStore();\n    const selection = selectRange(spans[1].firstChild, 1, spans[1].firstChild, 4);\n\n    await store.updatePrefix('merged');\n\n    expect(selection.getRangeAt(0).startContainer).toBe(spans[1].firstChild);\n    expect(selection.getRangeAt(0).startOffset).toBe(1);\n  });\n\n  it('ignores unsupported node types when capturing selections', async () => {\n    const editable = mountEditable();\n    const comment = document.createComment('skip');\n    editable.appendChild(comment);\n    const selection = selectRange(comment, 0, comment, 0);\n    const removeSpy = vi.spyOn(selection, 'removeAllRanges');\n    const store = useStore();\n\n    await store.updatePrefix('noop');\n\n    expect(removeSpy).not.toHaveBeenCalled();\n    removeSpy.mockRestore();\n  });\n\n  it('skips restoration when the selection moves outside the editable root', async () => {\n    const editable = mountEditable();\n    const outside = document.createElement('p');\n    outside.textContent = 'outer';\n    document.body.appendChild(outside);\n    const selection = window.getSelection();\n    const range = document.createRange();\n    range.setStart(editable.firstChild, 0);\n    range.setEnd(outside.firstChild, 1);\n    selection.removeAllRanges();\n    selection.addRange(range);\n    const removeSpy = vi.spyOn(selection, 'removeAllRanges');\n\n    const store = useStore();\n    await store.updatePrefix('value');\n\n    expect(store.prefix).toBe('value');\n    expect(removeSpy).not.toHaveBeenCalled();\n    removeSpy.mockRestore();\n  });\n\n  it('avoids restoring when the editable element is removed', async () => {\n    const editable = mountEditable();\n    const store = useStore();\n    const spy = vi.spyOn(window, 'getSelection');\n    selectRange(editable.firstChild, 0, editable.firstChild, 2);\n\n    const promise = store.updatePrefix('gone');\n    editable.remove();\n    await promise;\n\n    expect(spy).toHaveBeenCalledTimes(2); // once from helper, once from capture\n    spy.mockRestore();\n  });\n\n  it('restores suffix selections even inside empty containers', async () => {\n    const editable = mountEditable('', { raw: true });\n    const store = useStore();\n    const selection = selectRange(editable, 0, editable, 0);\n\n    await store.updateSuffix('tail');\n\n    expect(store.suffix).toBe('tail');\n    const range = selection.getRangeAt(0);\n    expect(range.startContainer).toBe(editable);\n    expect(range.startOffset).toBe(0);\n  });\n\n  it('updates suffix without touching selection when nothing is focused', async () => {\n    const store = useStore();\n    const spy = vi.spyOn(window, 'getSelection').mockReturnValue(null);\n\n    await store.updateSuffix('tail');\n\n    expect(store.suffix).toBe('tail');\n    expect(spy).toHaveBeenCalled();\n    spy.mockRestore();\n  });\n\n  it('ignores selections that are missing a valid start container', async () => {\n    const store = useStore();\n    const fakeRange = {\n      startContainer: null,\n      endContainer: document.createElement('div'),\n      startOffset: 0,\n      endOffset: 0\n    };\n    const selection = {\n      rangeCount: 1,\n      getRangeAt: vi.fn(() => fakeRange)\n    };\n    const spy = vi.spyOn(window, 'getSelection').mockReturnValue(selection);\n\n    await store.updatePrefix('noop');\n\n    expect(selection.getRangeAt).toHaveBeenCalled();\n    spy.mockRestore();\n  });\n\n  it('skips restoring when selection disappears mid-update', async () => {\n    const editable = mountEditable();\n    const realSelection = selectRange(editable.firstChild, 0, editable.firstChild, 2);\n    const store = useStore();\n    const spy = vi.spyOn(window, 'getSelection');\n    spy.mockImplementationOnce(() => realSelection);\n    spy.mockImplementationOnce(() => null);\n    spy.mockImplementation(() => realSelection);\n\n    await store.updatePrefix('persist');\n\n    expect(store.prefix).toBe('persist');\n    expect(spy).toHaveBeenCalledTimes(2);\n    spy.mockRestore();\n  });\n\n  it('falls back to root positions when text nodes are removed', async () => {\n    const editable = mountEditable('ab');\n    const store = useStore();\n    selectRange(editable.firstChild, 0, editable.firstChild, 2);\n\n    const promise = store.updatePrefix('target');\n    editable.innerHTML = '<br />';\n    await promise;\n\n    const selection = window.getSelection();\n    const range = selection.getRangeAt(0);\n    expect(range.startContainer).toBe(editable);\n    expect(range.startOffset).toBeGreaterThanOrEqual(0);\n  });\n});\n\ndescribe('store internals', () => {\n  beforeEach(() => {\n    document.body.innerHTML = '';\n  });\n\n  afterEach(() => {\n    getStoreInternals().clearOverrides();\n  });\n\n  it('returns null for text nodes without editable ancestors', () => {\n    const internals = getStoreInternals();\n    const wrapper = document.createElement('div');\n    wrapper.textContent = 'plain';\n    document.body.appendChild(wrapper);\n\n    expect(internals.getEditableAncestor(wrapper.firstChild)).toBeNull();\n  });\n\n  it('returns null for element nodes without editable ancestors', () => {\n    const internals = getStoreInternals();\n    const wrapper = document.createElement('div');\n    const child = document.createElement('span');\n    wrapper.appendChild(child);\n    document.body.appendChild(wrapper);\n\n    expect(internals.getEditableAncestor(child)).toBeNull();\n  });\n\n  it('treats walker nodes without text content as zero length', () => {\n    const internals = getStoreInternals();\n    const originalWalker = document.createTreeWalker;\n    const fakeNode = { textContent: undefined };\n    const walker = {\n      currentNode: null,\n      called: false,\n      nextNode() {\n        if (this.called) return false;\n        this.called = true;\n        this.currentNode = fakeNode;\n        return true;\n      }\n    };\n    document.createTreeWalker = vi.fn(() => walker);\n\n    try {\n      const root = document.createElement('div');\n      const result = internals.resolvePosition(root, 0);\n      expect(result).toEqual({ node: fakeNode, offset: 0 });\n    } finally {\n      document.createTreeWalker = originalWalker;\n    }\n  });\n\n  it('falls back to length zero when editable textContent returns null', () => {\n    const internals = getStoreInternals();\n    const editable = mountEditable();\n    selectRange(editable.firstChild, 1, editable.firstChild, 1);\n    const snapshot = internals.captureSelectionSnapshot();\n    expect(snapshot).not.toBeNull();\n\n    const originalDescriptor = Object.getOwnPropertyDescriptor(editable, 'textContent');\n    Object.defineProperty(editable, 'textContent', {\n      configurable: true,\n      get() {\n        return null;\n      },\n      set(value) {\n        if (originalDescriptor?.set) {\n          originalDescriptor.set.call(editable, value);\n        }\n      }\n    });\n\n    const selection = window.getSelection();\n    selection.removeAllRanges();\n    internals.restoreSelectionSnapshot(snapshot);\n\n    expect(selection.rangeCount).toBe(1);\n    Reflect.deleteProperty(editable, 'textContent');\n  });\n\n  it('skips selection restoration when overrides return null positions', () => {\n    const internals = getStoreInternals();\n    const editable = mountEditable();\n    selectRange(editable.firstChild, 0, editable.firstChild, 2);\n    const snapshot = internals.captureSelectionSnapshot();\n    expect(snapshot).not.toBeNull();\n\n    internals.setResolvePositionOverride(() => null);\n    window.getSelection().removeAllRanges();\n\n    internals.restoreSelectionSnapshot(snapshot);\n\n    expect(window.getSelection().rangeCount).toBe(0);\n  });\n});\n\ndescribe('store without DOM APIs', () => {\n  it('short-circuits DOM helpers when window and document are missing', async () => {\n    const realWindow = globalThis.window;\n    const realDocument = globalThis.document;\n    const realNodeFilter = globalThis.NodeFilter;\n\n    globalThis.window = undefined;\n    globalThis.document = undefined;\n    globalThis.NodeFilter = undefined;\n\n    vi.resetModules();\n\n    const { useStore: useStoreWithoutDom } = await import('@/stores/store');\n    const internals = getStoreInternals();\n\n    setActivePinia(createPinia());\n    const store = useStoreWithoutDom();\n\n    await store.updatePrefix('noop');\n    await store.updateSuffix('noop');\n    expect(() =>\n      internals.restoreSelectionSnapshot({\n        editableElement: null,\n        startOffset: 0,\n        endOffset: 0\n      })\n    ).not.toThrow();\n\n    globalThis.window = realWindow;\n    globalThis.document = realDocument;\n    globalThis.NodeFilter = realNodeFilter;\n    vi.resetModules();\n    setActivePinia(createPinia());\n    await import('@/stores/store');\n  });\n});\n"
  },
  {
    "path": "src/__tests__/useGeneratorControls.test.js",
    "content": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { defineComponent, nextTick } from 'vue';\nimport { createPinia, setActivePinia } from 'pinia';\nimport { useGeneratorControls } from '@/composables/useGeneratorControls';\nimport { useStore } from '@/stores/store';\nimport { GENERATOR_STATE_STORAGE_KEY } from '@/utils/persistentState';\n\nconst mountedWrappers = new Set();\n\nconst mountComposable = (options) => {\n  let api;\n  const Comp = defineComponent({\n    template: '<div />',\n    setup() {\n      api = useGeneratorControls(options);\n      return api;\n    }\n  });\n\n  const wrapper = mount(Comp);\n  mountedWrappers.add(wrapper);\n  return { wrapper, api };\n};\n\ndescribe('useGeneratorControls', () => {\n  beforeEach(() => {\n    setActivePinia(createPinia());\n    window.localStorage.clear();\n    window.history.replaceState(null, '', '/');\n  });\n\n  afterEach(() => {\n    mountedWrappers.forEach((wrapper) => {\n      if (wrapper.exists()) {\n        wrapper.unmount();\n      }\n    });\n    mountedWrappers.clear();\n  });\n\n  it('provides sensible defaults and computed helpers', () => {\n    const { api } = mountComposable();\n\n    expect(api.prefixColor.value).toBe('#ffffff');\n    expect(api.suffixColor.value).toBe('#000000');\n    expect(api.transparentBgColor.value).toBe('#000000');\n    expect(api.suffixMargin.value).toBeUndefined();\n\n    api.transparentBg.value = true;\n    expect(api.transparentBgColor.value).toBe('transparent');\n\n    const custom = mountComposable({ suffixMarginScale: 30 });\n    custom.api.fontSize.value = 90;\n    expect(custom.api.suffixMargin.value).toBe('-3rem');\n  });\n\n  it('forwards text updates to the store', () => {\n    const store = useStore();\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const { api } = mountComposable();\n    api.updatePrefix({ target: { textContent: 'Porn' } });\n    api.updateSuffix({ target: { innerText: 'hub' } });\n    api.updatePrefix({ target: { innerText: 'Only' } });\n    api.updateSuffix({ target: {} });\n    api.updatePrefix(null);\n\n    expect(prefixSpy).toHaveBeenCalledWith('Porn');\n    expect(prefixSpy).toHaveBeenLastCalledWith('Only');\n    expect(suffixSpy).toHaveBeenCalledWith('hub');\n    expect(suffixSpy).toHaveBeenLastCalledWith('');\n  });\n\n  it('hydrates initial text on mount when nothing is persisted', async () => {\n    const store = useStore();\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const { wrapper } = mountComposable({\n      backgroundColor: '#222222',\n      initialText: { prefix: 'Only', suffix: 'Fans' },\n      resetText: { prefix: 'edit', suffix: 'me' }\n    });\n\n    await nextTick();\n    expect(prefixSpy).toHaveBeenCalledWith('Only');\n    expect(suffixSpy).toHaveBeenCalledWith('Fans');\n\n    wrapper.unmount();\n    await nextTick();\n    expect(prefixSpy).not.toHaveBeenCalledWith('edit');\n    expect(suffixSpy).not.toHaveBeenCalledWith('me');\n  });\n\n  it('handles partial initial payloads without clobbering other fields', async () => {\n    const store = useStore();\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const suffixOnly = mountComposable({ initialText: { suffix: 'Fans' } });\n    await nextTick();\n    expect(prefixSpy).not.toHaveBeenCalled();\n    expect(suffixSpy).toHaveBeenCalledWith('Fans');\n    suffixOnly.wrapper.unmount();\n    await nextTick();\n\n    prefixSpy.mockClear();\n    suffixSpy.mockClear();\n\n    const prefixOnly = mountComposable({ initialText: { prefix: 'Only' } });\n    await nextTick();\n    expect(prefixSpy).toHaveBeenCalledWith('Only');\n    expect(suffixSpy).not.toHaveBeenCalled();\n    prefixOnly.wrapper.unmount();\n    await nextTick();\n  });\n\n  it('persists generator state to localStorage and query params', async () => {\n    const store = useStore();\n    const { api } = mountComposable();\n\n    await store.updatePrefix('Share');\n    await store.updateSuffix('Logo');\n    store.font = 'Open Sans';\n    api.prefixColor.value = '#123123';\n    api.suffixColor.value = '#abcdef';\n    api.postfixBgColor.value = '#654321';\n    api.fontSize.value = 110;\n    api.transparentBg.value = true;\n    api.reverseHighlight.value = true;\n    await nextTick();\n\n    const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));\n    expect(saved.prefix).toBe('Share');\n    expect(saved.suffix).toBe('Logo');\n    expect(saved.font).toBe('Open Sans');\n    expect(saved.prefixColor).toBe('#123123');\n    expect(saved.transparentBg).toBe(true);\n    expect(window.location.search).toContain('prefix=Share');\n    expect(window.location.search).toContain('reverseHighlight=1');\n  });\n\n  it('restores state from storage and lets query params override it', async () => {\n    window.localStorage.setItem(\n      GENERATOR_STATE_STORAGE_KEY,\n      JSON.stringify({\n        prefix: 'StoredPrefix',\n        suffix: 'StoredSuffix',\n        font: 'Lora',\n        prefixColor: '#101010',\n        suffixColor: '#202020',\n        postfixBgColor: '#303030',\n        fontSize: 80,\n        transparentBg: true,\n        reverseHighlight: false\n      })\n    );\n    window.history.replaceState(\n      null,\n      '',\n      '/?prefix=QueryPrefix&suffixColor=%23aa00aa&reverseHighlight=1'\n    );\n\n    const { api } = mountComposable({ initialText: { prefix: 'Default', suffix: 'Values' } });\n    const store = useStore();\n    await nextTick();\n\n    expect(store.prefix).toBe('QueryPrefix');\n    expect(store.suffix).toBe('StoredSuffix');\n    expect(store.font).toBe('Lora');\n    expect(api.prefixColor.value).toBe('#101010');\n    expect(api.suffixColor.value).toBe('#aa00aa');\n    expect(api.postfixBgColor.value).toBe('#303030');\n    expect(api.fontSize.value).toBe(80);\n    expect(api.transparentBg.value).toBe(true);\n    expect(api.reverseHighlight.value).toBe(true);\n  });\n\n  it('builds the Twitter intent url with the expected payload', () => {\n    const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});\n    const { api } = mountComposable();\n\n    api.twitter();\n\n    expect(openSpy).toHaveBeenCalledTimes(1);\n    expect(openSpy.mock.calls[0][0]).toContain('twitter.com/intent/tweet');\n    expect(openSpy.mock.calls[0][0]).toContain('logoly.pro');\n\n    openSpy.mockRestore();\n  });\n\n  it('hydrates correctly even when store updates resolve synchronously', async () => {\n    const store = useStore();\n    const originalPrefix = store.updatePrefix;\n    const originalSuffix = store.updateSuffix;\n\n    store.updatePrefix = vi.fn((text) => {\n      store.prefix = text;\n      return text;\n    });\n    store.updateSuffix = vi.fn((text) => {\n      store.suffix = text;\n      return text;\n    });\n\n    mountComposable({ initialText: { prefix: 'Sync', suffix: 'State' } });\n    await nextTick();\n    expect(store.updatePrefix).toHaveBeenCalledWith('Sync');\n    expect(store.updateSuffix).toHaveBeenCalledWith('State');\n    expect(store.prefix).toBe('Sync');\n    expect(store.suffix).toBe('State');\n\n    store.updatePrefix = originalPrefix;\n    store.updateSuffix = originalSuffix;\n  });\n\n  it('resets text on unmount when persistence is disabled', async () => {\n    const store = useStore();\n    await store.updatePrefix('Tmp');\n    await store.updateSuffix('State');\n\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const { wrapper } = mountComposable({\n      resetText: { prefix: 'edit', suffix: 'me' },\n      persistenceEnabled: false\n    });\n\n    await nextTick();\n    prefixSpy.mockClear();\n    suffixSpy.mockClear();\n\n    wrapper.unmount();\n    await nextTick();\n\n    expect(prefixSpy).toHaveBeenCalledWith('edit');\n    expect(suffixSpy).toHaveBeenCalledWith('me');\n  });\n\n  it('does not reset prefix when resetText omits it', async () => {\n    const store = useStore();\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const { wrapper } = mountComposable({\n      resetText: { suffix: 'stay' },\n      persistenceEnabled: false\n    });\n\n    await nextTick();\n    prefixSpy.mockClear();\n    suffixSpy.mockClear();\n\n    wrapper.unmount();\n    await nextTick();\n\n    expect(prefixSpy).not.toHaveBeenCalled();\n    expect(suffixSpy).toHaveBeenCalledWith('stay');\n  });\n\n  it('does not reset suffix when resetText omits it', async () => {\n    const store = useStore();\n    const prefixSpy = vi.spyOn(store, 'updatePrefix');\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    const { wrapper } = mountComposable({\n      resetText: { prefix: 'back' },\n      persistenceEnabled: false\n    });\n\n    await nextTick();\n    prefixSpy.mockClear();\n    suffixSpy.mockClear();\n\n    wrapper.unmount();\n    await nextTick();\n\n    expect(prefixSpy).toHaveBeenCalledWith('back');\n    expect(suffixSpy).not.toHaveBeenCalled();\n  });\n\n  it('continues hydrating when a task rejects', async () => {\n    const store = useStore();\n    const originalPrefix = store.updatePrefix;\n    const rejection = new Error('hydrate failure');\n    store.updatePrefix = vi.fn(() => Promise.reject(rejection));\n    const suffixSpy = vi.spyOn(store, 'updateSuffix');\n\n    mountComposable({ initialText: { prefix: 'Only', suffix: 'Fans' } });\n    await new Promise((resolve) => setTimeout(resolve, 0));\n\n    expect(store.updatePrefix).toHaveBeenCalledWith('Only');\n    expect(suffixSpy).toHaveBeenCalledWith('Fans');\n\n    store.updatePrefix = originalPrefix;\n  });\n});\n"
  },
  {
    "path": "src/assets/iconfont/iconfont.css",
    "content": "@font-face {\n  font-family: \"iconfont\";\n  src: url(\"iconfont.eot?t=1553360818414\"); /* IE9 */\n  src: url(\"iconfont.eot?t=1553360818414#iefix\") format(\"embedded-opentype\"), /* IE6-IE8 */\n    url(\"data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAARsAAsAAAAACJgAAAQgAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDHAqEWIN1ATYCJAMQCwoABCAFhG0HQhthB8gusG3YKwNZ2SzZgKVQqXa7wds/5fP/W+vCw8IuzA5mxEmRrcjPzvrbPRj92m/P3zdthonX0wreNIk25mesmiWqWIV2kmgqPBCH+9ccoF2UDeAAszG2BZQFGmcaRuvPpIWgTtPv/4NjpovEgeWtbS5RyR1WOMDxgAYWUVoltIH8A9qz/zB2QUtcDxSAFRWKquWX1sIYy+xGAailoOyCccaMbSBRiI6vGBtGXRBBgmf6DjAv+D71j2rAGGhciNm3cpzXi8wv6SeyII9H00NAMp0TAJsEEEAogAFqWunuRqJQKMKu/g6jHGCMoOGd9G7nJ1mG0qOoifUvD6BRGKCizEwzABCLwgTeSQ4PaHi3wwMKfjKDQDTT2RhgCtgF+AGwbvk0CsmbRRhgYs3gLieLPVfcmDVX4yz3X/c0WHutlSX1epqNpHo9pqMutB6+9bwi3jLFK7p0z5R3GHerzlXDc0p7yt0DXNHtM/3vGo7X75UJ4estPYu3J6zPX7yzbF3d4pX1SzvuPIotBOmFKHmxGb10l//STQHLLoadTASs2u440hEnnIYsvRez/6795UTRMitu5x27jTtx18UlJzB+I+C+pdx7Hl68smypP0bVoYI7xVXfPYy0JJhcOYgr1tQNk68Wy7A6xBR2/VExyHieL79PPu05XWhuO90h0+u/ctayYJLwIJZ3nMCmom1J2k/Q09AXW328hjKF8hxq1OS8AuelwrDNiHpve5QWj1xmj+1oDA0iLSJPhH1L/UR6oruP24j/E8ts+E5tOCAvi/WWHZg6TqLu6ftzomb9qKkqXh6dgI0ymVJGzwzsHd5sVOBb2hnfcmLxRI5vKPVNv6V5+nYgmTmjrIF8DvVdz/7arMfukal9Rne1+LfVZ9W0zats9xacqS/K/br89EDOttW12DI3IjfaBLkwFSoBMvJLRn//z8AqE7GpwzL1L3GEAcCDs+m7AqOsu2cL/EOJKvnZBWEA8mGKy0wLE1bkjlhPZwwxhjdoBGvZgF8cA2w8m0Jr35kEUNpQAjRBBAgiFclgc8GAQykYIhqAlaNgsqkAEUGFE4BgUwBQvLYDze0SGF5XkSz2KXDCvgLPGyBgJwds53RIDwhsjsh3SXwv1zuCG+nRaRW2TWLD7GpeOUTdJaZlAbmeFwcFL1cUHpmPFvBaXpxiwMRQZfSkpOAGRZ2Gaweb8Wq1jhsVdf18jxQ+LEn65IIIRdWJwvt0GrBaiHhdJLxenG4jcAQ9dLQUXnUJW/h8NZ7SEGpdxIayKn89nmiQUD8nSrjIDsgCtbZT2a00HjSUUjSJRIE7KYh0NDj50MlTWyEdjr66VT9eD0m4vgEBvWQRqJKiqzp8ea3mDdcAsMw9mEAwEAqEAxHcRNMlqI2kYYIk8aKh0g/2DekGAA==\") format(\n      \"woff2\"\n    ), url(\"iconfont.woff?t=1553360818414\") format(\"woff\"), url(\"iconfont.ttf?t=1553360818414\")\n    format(\"truetype\"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */\n    url(\"iconfont.svg?t=1553360818414#iconfont\") format(\"svg\"); /* iOS 4.1- */\n}\n\n.iconfont {\n  font-family: \"iconfont\" !important;\n  font-size: 16px;\n  font-style: normal;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n.icon-gmail:before {\n  content: \"\\e6a5\";\n}\n\n.icon-twitter:before {\n  content: \"\\e655\";\n}\n\n.icon-github:before {\n  content: \"\\f1b4\";\n}\n"
  },
  {
    "path": "src/assets/iconfont/iconfont.js",
    "content": "!(function (a) {\n  var e,\n    n =\n      '<svg><symbol id=\"icon-gmail\" viewBox=\"0 0 1024 1024\"><path d=\"M853.333333 170.666667 170.666667 170.666667C123.733333 170.666667 85.333333 209.066667 85.333333 256l0 512c0 46.933333 38.4 85.333333 85.333333 85.333333l682.666667 0c46.933333 0 85.333333-38.4 85.333333-85.333333L938.666667 256C938.666667 209.066667 900.266667 170.666667 853.333333 170.666667zM853.333333 768l-85.333333 0L768 392.533333 512 554.666667 256 392.533333 256 768 170.666667 768 170.666667 256l51.2 0 290.133333 179.2L802.133333 256 853.333333 256 853.333333 768z\"  ></path></symbol><symbol id=\"icon-twitter\" viewBox=\"0 0 1024 1024\"><path d=\"M1022.037333 194.944a426.666667 426.666667 0 0 1-120.533333 33.066667 211.541333 211.541333 0 0 0 92.288-116.181334c-40.576 23.68-85.546667 40.917333-133.418667 50.517334a209.92 209.92 0 0 0-357.717333 191.232C328.149333 345.344 173.482667 261.546667 69.973333 134.869333a205.738667 205.738667 0 0 0-28.416 105.6c0 72.96 37.12 137.088 93.354667 174.762667a209.237333 209.237333 0 0 1-95.061333-26.282667v2.602667a210.048 210.048 0 0 0 168.362666 205.952 213.162667 213.162667 0 0 1-94.378666 3.626667 210.645333 210.645333 0 0 0 196.437333 145.792 421.034667 421.034667 0 0 1-260.352 89.813333c-16.64 0-33.237333-0.981333-49.92-2.858667a597.12 597.12 0 0 0 322.432 94.250667c386.304 0 597.290667-319.829333 597.290667-596.736 0-8.917333 0-17.92-0.64-26.88a423.936 423.936 0 0 0 104.96-108.714667l-2.005334-0.853333z\" fill=\"\" ></path></symbol><symbol id=\"icon-github\" viewBox=\"0 0 1024 1024\"><path d=\"M347.8 794.8c0 4-4.6 7.2-10.4 7.2-6.6 0.6-11.2-2.6-11.2-7.2 0-4 4.6-7.2 10.4-7.2 6-0.6 11.2 2.6 11.2 7.2z m-62.2-9c-1.4 4 2.6 8.6 8.6 9.8 5.2 2 11.2 0 12.4-4s-2.6-8.6-8.6-10.4c-5.2-1.4-11 0.6-12.4 4.6z m88.4-3.4c-5.8 1.4-9.8 5.2-9.2 9.8 0.6 4 5.8 6.6 11.8 5.2 5.8-1.4 9.8-5.2 9.2-9.2-0.6-3.8-6-6.4-11.8-5.8zM505.6 16C228.2 16 16 226.6 16 504c0 221.8 139.6 411.6 339 478.4 25.6 4.6 34.6-11.2 34.6-24.2 0-12.4-0.6-80.8-0.6-122.8 0 0-140 30-169.4-59.6 0 0-22.8-58.2-55.6-73.2 0 0-45.8-31.4 3.2-30.8 0 0 49.8 4 77.2 51.6 43.8 77.2 117.2 55 145.8 41.8 4.6-32 17.6-54.2 32-67.4-111.8-12.4-224.6-28.6-224.6-221 0-55 15.2-82.6 47.2-117.8-5.2-13-22.2-66.6 5.2-135.8 41.8-13 138 54 138 54 40-11.2 83-17 125.6-17s85.6 5.8 125.6 17c0 0 96.2-67.2 138-54 27.4 69.4 10.4 122.8 5.2 135.8 32 35.4 51.6 63 51.6 117.8 0 193-117.8 208.4-229.6 221 18.4 15.8 34 45.8 34 92.8 0 67.4-0.6 150.8-0.6 167.2 0 13 9.2 28.8 34.6 24.2C872.4 915.6 1008 725.8 1008 504 1008 226.6 783 16 505.6 16zM210.4 705.8c-2.6 2-2 6.6 1.4 10.4 3.2 3.2 7.8 4.6 10.4 2 2.6-2 2-6.6-1.4-10.4-3.2-3.2-7.8-4.6-10.4-2z m-21.6-16.2c-1.4 2.6 0.6 5.8 4.6 7.8 3.2 2 7.2 1.4 8.6-1.4 1.4-2.6-0.6-5.8-4.6-7.8-4-1.2-7.2-0.6-8.6 1.4z m64.8 71.2c-3.2 2.6-2 8.6 2.6 12.4 4.6 4.6 10.4 5.2 13 2 2.6-2.6 1.4-8.6-2.6-12.4-4.4-4.6-10.4-5.2-13-2z m-22.8-29.4c-3.2 2-3.2 7.2 0 11.8 3.2 4.6 8.6 6.6 11.2 4.6 3.2-2.6 3.2-7.8 0-12.4-2.8-4.6-8-6.6-11.2-4z\" fill=\"\" ></path></symbol></svg>',\n    t = (e = document.getElementsByTagName('script'))[e.length - 1].getAttribute('data-injectcss');\n  if (t && !a.__iconfont__svg__cssinject__) {\n    a.__iconfont__svg__cssinject__ = !0;\n    try {\n      document.write(\n        '<style>.svgfont {display: inline-block;width: 1em;height: 1em;fill: currentColor;vertical-align: -0.1em;font-size:16px;}</style>'\n      );\n    } catch (e) {\n      console && console.log(e);\n    }\n  }\n  !(function (e) {\n    if (document.addEventListener)\n      if (~['complete', 'loaded', 'interactive'].indexOf(document.readyState)) setTimeout(e, 0);\n      else {\n        var t = function () {\n          document.removeEventListener('DOMContentLoaded', t, !1), e();\n        };\n        document.addEventListener('DOMContentLoaded', t, !1);\n      }\n    else\n      document.attachEvent &&\n        ((n = e),\n        (o = a.document),\n        (i = !1),\n        (c = function () {\n          i || ((i = !0), n());\n        }),\n        (l = function () {\n          try {\n            o.documentElement.doScroll('left');\n          } catch (e) {\n            return void setTimeout(l, 50);\n          }\n          c();\n        })(),\n        (o.onreadystatechange = function () {\n          'complete' == o.readyState && ((o.onreadystatechange = null), c());\n        }));\n    var n, o, i, c, l;\n  })(function () {\n    var e, t;\n    ((e = document.createElement('div')).innerHTML = n),\n      (n = null),\n      (t = e.getElementsByTagName('svg')[0]) &&\n        (t.setAttribute('aria-hidden', 'true'),\n        (t.style.position = 'absolute'),\n        (t.style.width = 0),\n        (t.style.height = 0),\n        (t.style.overflow = 'hidden'),\n        (function (e, t) {\n          t.firstChild\n            ? (function (e, t) {\n                t.parentNode.insertBefore(e, t);\n              })(e, t.firstChild)\n            : t.appendChild(e);\n        })(t, document.body));\n  });\n})(window);\n"
  },
  {
    "path": "src/components/Author.vue",
    "content": "<template>\n  <div>\n    <div class=\"flex flex-col items-center pt-20 pb-12\">\n      <img class=\"max-w-24 rounded-full mb-3\" src=\"../assets/avatar.png\" alt=\"bestony\" />\n      <h2 class=\"text-2xl font-semibold py-2\">Bestony</h2>\n      <h4 class=\"font-semibold\">An indie developer / Focus on something interesting.</h4>\n    </div>\n    <div class=\"flex justify-around items-center py-12\">\n      <a\n        class=\"github\"\n        href=\"https://github.com/bestony\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        ><i class=\"iconfont icon-github\"></i> GitHub</a\n      >\n      <a\n        class=\"twitter\"\n        href=\"https://twitter.com/xiqingongzi\"\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        ><i class=\"iconfont icon-twitter\"></i> Twitter</a\n      >\n      <a class=\"gmail\" href=\"mailto:xiqingongzi+logoly@gmail.com\"\n        ><i class=\"iconfont icon-gmail\"></i> Email</a\n      >\n    </div>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/Copyright.vue",
    "content": "<template>\n  <p class=\"text-center text-[#666]\">\n    © Bestony {{ currentYear }}\n    <a href=\"https://www.ixiqin.com\" target=\"_blank\" rel=\"noopener noreferrer\">website</a>\n  </p>\n</template>\n\n<script setup>\nconst currentYear = new Date().getFullYear();\n</script>\n"
  },
  {
    "path": "src/components/Description.vue",
    "content": "<template>\n  <div>\n    <h2 class=\"text-white\">A Simple Online Logo Generator</h2>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/ExportBtn.vue",
    "content": "<template>\n  <div>\n    <v-tooltip text=\"Export your own logo\" location=\"top\" model-value>\n      <template v-slot:activator=\"{ props }\">\n        <v-btn color=\"#f90\" v-bind=\"props\">\n          <v-icon icon=\"mdi-download\"></v-icon>Export\n          <v-menu activator=\"parent\">\n            <v-list>\n              <v-list-item key=\"png\" value=\"png\" @click=\"download('png')\">PNG</v-list-item>\n              <v-list-item key=\"svg\" value=\"svg\" @click=\"download('svg')\">SVG</v-list-item>\n            </v-list>\n          </v-menu>\n        </v-btn>\n      </template>\n    </v-tooltip>\n  </div>\n</template>\n\n<script setup>\nimport { useStore } from '@/stores/store';\nimport domtoimage from 'dom-to-image';\nimport { ref } from 'vue';\nimport { event } from 'vue-gtag';\nimport { onClickOutside } from '@vueuse/core';\n\nconst store = useStore();\nconst showMenu = ref(false);\nconst btnRef = ref(null);\n\nonClickOutside(btnRef, () => {\n  showMenu.value = false;\n});\n\nconst downloadImage = (imgsrc, name) => {\n  //下载图片地址和图片名\n  const image = new Image();\n  // 解决跨域 Canvas 污染问题\n  image.setAttribute('crossOrigin', 'anonymous');\n  image.onload = () => {\n    const canvas = document.createElement('canvas');\n    canvas.width = image.width;\n    canvas.height = image.height;\n    const context = canvas.getContext('2d');\n    context.drawImage(image, 0, 0, image.width, image.height);\n    const url = canvas.toDataURL('image/png');\n    const link = document.createElement('a');\n    const clickEvent = new MouseEvent('click');\n    link.download = name || 'photo';\n    link.href = url;\n    link.dispatchEvent(clickEvent);\n  };\n  image.src = imgsrc;\n};\n\nconst download = (type) => {\n  showMenu.value = false;\n  store.editable = false;\n  event('download');\n  const node = document.getElementById('logo');\n  if (!node) return;\n\n  if (type === 'png') {\n    domtoimage.toPng(node).then((res) => {\n      downloadImage(res, `${store.prefix}-${store.suffix}.png`);\n      store.editable = true;\n    });\n  } else if (type === 'svg') {\n    domtoimage.toSvg(node).then((res) => {\n      const link = document.createElement('a');\n      link.download = `${store.prefix}-${store.suffix}.svg`;\n      link.href = res;\n      link.click();\n      store.editable = true;\n    });\n  }\n};\n</script>\n"
  },
  {
    "path": "src/components/Faq.vue",
    "content": "<template>\n  <div class=\"text-white mt-12\">\n    <h3 class=\"text-2xl font-semibold text-custom-primary mb-1\">FAQ</h3>\n    <v-expansion-panels variant=\"accordion\" mandatory=\"force\">\n      <v-expansion-panel title=\"How to use this generator?\">\n        <template v-slot:text>\n          The generator is very simple to use. You can get a logo in just 4 steps:\n          <ol class=\"list-decimal mt-2 ml-6 space-y-1\" aria-label=\"Steps to generate a logo\">\n            <li v-for=\"step in steps\" :key=\"step.id\">{{ step.text }}</li>\n          </ol>\n        </template>\n      </v-expansion-panel>\n      <v-expansion-panel\n        text=\"The generated logo fully belongs to you. You can use it freely for any purposes. Though credit is always appreciated.\"\n        title=\"Can I use the generated logo for personal or commercial purposes?\"\n      ></v-expansion-panel>\n    </v-expansion-panels>\n  </div>\n</template>\n\n<script setup>\nconst steps = [\n  {\n    id: 'choose-style',\n    text: 'Choose a style (e.g. Horizontal/Vertical, Pornhub/Youtube/...).'\n  },\n  {\n    id: 'edit-text',\n    text: 'Edit the text in the center box.'\n  },\n  {\n    id: 'customize',\n    text: 'Customize your logo (color, background, font size, etc.).'\n  },\n  {\n    id: 'export',\n    text: 'Click the Export button to download your logo as PNG or SVG.'\n  }\n];\n</script>\n"
  },
  {
    "path": "src/components/FontSelector.vue",
    "content": "<template>\n  <v-select\n    hide-details\n    v-model=\"store.font\"\n    label=\"Font\"\n    :items=\"fonts\"\n    color=\"#f90\"\n    variant=\"outlined\"\n  ></v-select>\n</template>\n\n<script setup>\nimport { watch } from 'vue';\nimport { useStore } from '@/stores/store';\nimport { loadGoogleFont } from '@/utils/fontLoader';\nimport { fonts } from '@/config/fonts';\n\nconst store = useStore();\n\nwatch(\n  () => store.font,\n  (font) => {\n    loadGoogleFont(font);\n  },\n  { immediate: true }\n);\n</script>\n"
  },
  {
    "path": "src/components/Logo.vue",
    "content": "<template>\n  <router-link to=\"/\" class=\"py-12\">\n    <h1>\n      <span class=\"text-white p-1\">Logoly</span>\n      <span class=\"text-black bg-custom-primary p-1 rounded-md\">Pro</span>\n    </h1>\n  </router-link>\n</template>\n"
  },
  {
    "path": "src/components/Ribbon.vue",
    "content": "<template>\n  <a\n    class=\"absolute md:fixed top-0 right-0\"\n    href=\"https://github.com/bestony/logoly\"\n    target=\"_blank\"\n    rel=\"noopener noreferrer\"\n  >\n    <img\n      width=\"149\"\n      height=\"149\"\n      src=\"https://github.blog/wp-content/uploads/2008/12/forkme_right_orange_ff7600.png?resize=149%2C149\"\n      class=\"attachment-full size-full\"\n      alt=\"Fork me on GitHub\"\n      data-recalc-dims=\"1\"\n  /></a>\n</template>\n"
  },
  {
    "path": "src/components/Slogan.vue",
    "content": "<template>\n  <div class=\"mt-12\">\n    <h3 class=\"text-xl font-semibold mb-2\">\n      <span class=\"text-white\">Logoly</span>\n      <span class=\"text-black bg-custom-primary p-1 rounded-md\">Pro</span>\n    </h3>\n    <p class=\"text-white\">\n      Logoly.pro is a creative logo generator, you can generate logo similar to Pornhub, YouTube,\n      and more.\n      <strong class=\"text-custom-primary flex gap-2 mt-2\">\n        If you think this project is funny, please\n        <a href=\"https://github.com/bestony/logoly\">\n          <img src=\"https://img.shields.io/badge/give%20me-a%20star-green.svg\" />\n        </a>\n      </strong>\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "src/components/generator/Onlyfans.vue",
    "content": "<template>\n  <div class=\"flex flex-col items-center\">\n    <v-tooltip text=\"Edit the text to create your own logo\" model-value location=\"top\">\n      <template v-slot:activator=\"{ props }\">\n        <div v-bind=\"props\" class=\"box\">\n          <div\n            class=\"editarea\"\n            id=\"logo\"\n            :style=\"{\n              'font-size': fontSize + 'px',\n              'background-color': transparentBgColor\n            }\"\n          >\n            <span\n              @input=\"updatePrefix\"\n              class=\"prefix\"\n              :style=\"{ color: prefixColor }\"\n              :contenteditable=\"store.editable\"\n              spellcheck=\"false\"\n            >\n              {{ store.prefix }}\n            </span>\n            <!-- HACK: meaningless text: \".\", just to split input area, see: #269 -->\n            <span style=\"font-size: 0\">.</span>\n            <span\n              class=\"postfix\"\n              :style=\"{\n                color: suffixColor,\n                'background-color': '#00AFF0',\n                'margin-left': suffixMargin + 10\n              }\"\n              :contenteditable=\"store.editable\"\n              @input=\"updateSuffix\"\n              spellcheck=\"false\"\n              >{{ store.suffix }}</span\n            >\n          </div>\n        </div>\n      </template>\n    </v-tooltip>\n\n    <div class=\"w-1/3 mb-12\">\n      <div class=\"flex flex-col\">\n        Font Size: {{ fontSize }}px\n        <div class=\"-ml-1\">\n          <v-slider\n            hide-details\n            min=\"30\"\n            max=\"200\"\n            step=\"1\"\n            color=\"#f90\"\n            v-model=\"fontSize\"\n          ></v-slider>\n        </div>\n      </div>\n      <div class=\"flex items-center\">\n        Transparent Background: <v-checkbox-btn v-model=\"transparentBg\"></v-checkbox-btn>\n      </div>\n    </div>\n\n    <div class=\"download-share\">\n      <ExportBtn />\n      <v-btn @click=\"twitter\" color=\"#1da1f2\"\n        ><v-icon icon=\"mdi-twitter\" class=\"mr-0.5\"></v-icon> Tweet</v-btn\n      >\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport ExportBtn from '@/components/ExportBtn.vue';\nimport { useGeneratorControls } from '@/composables/useGeneratorControls';\n\nconst {\n  store,\n  prefixColor,\n  suffixColor,\n  postfixBgColor,\n  fontSize,\n  transparentBg,\n  transparentBgColor,\n  suffixMargin,\n  updatePrefix,\n  updateSuffix,\n  twitter\n} = useGeneratorControls({\n  suffixMarginScale: 50,\n  postfixBgColor: 'transparent',\n  suffixColor: '#00AFF0',\n  backgroundColor: '#000000',\n  initialText: { prefix: 'Only', suffix: 'Fans' },\n  resetText: { prefix: 'edit', suffix: 'me' }\n});\n</script>\n\n<style lang=\"stylus\" scoped>\n.pornhub {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n}\n\n.box {\n  border: 1px solid #333;\n  border-radius: 10px;\n  padding: 40px;\n  margin: 40px 10px;\n  max-width: 100%;\n\n  .editarea {\n    padding: 20px 30px;\n    text-align: center;\n    font-size: 60px;\n    font-weight: 700;\n\n    .prefix {\n      color: #fff;\n      padding: 5px 5px;\n      font-family: \"Inter\", sans-serif;\n      font-optical-sizing: auto;\n      font-weight: 200;\n      font-style: normal;\n      z-index: 21;\n    }\n\n    .postfix {\n      color: #000;\n      background-color: transparent;\n      padding: 5px 10px;\n      margin-left: 0rem;\n      font-family: \"Arizonia\", cursive;\n      font-weight: 400;\n      font-style: normal;\n      z-index: 20;\n    }\n  }\n}\n\n.customize {\n  text-align: center;\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n  width: 100%;\n  margin-bottom: 50px;\n\n  .customize-color > div,\n  .customize-misc > div {\n    padding: 8px 0;\n  }\n}\n\n.download-share {\n  display: flex;\n  justify-content: space-around;\n  width: 80%;\n\n  & > div {\n    width: 100px;\n    height: 40px;\n    border-radius: 3px;\n    line-height: 40px;\n    text-align: center;\n    cursor: pointer;\n  }\n\n  .download {\n    color: black;\n    background: #f90;\n  }\n\n  .share {\n    color: #fff;\n    background: #1da1f2;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/generator/Pornhub.vue",
    "content": "<template>\n  <div class=\"pornhub\">\n    <v-tooltip text=\"Edit the text to create your own logo\" location=\"top\" model-value>\n      <template v-slot:activator=\"{ props }\">\n        <div v-bind=\"props\" class=\"box\">\n          <div\n            class=\"editarea\"\n            id=\"logo\"\n            :style=\"{\n              'font-size': fontSize + 'px',\n              'background-color': transparentBgColor,\n              'font-family': store.font\n            }\"\n          >\n            <template v-if=\"!reverseHighlight\">\n              <span\n                @input=\"updatePrefix\"\n                class=\"prefix\"\n                :style=\"{ color: prefixColor }\"\n                :contenteditable=\"store.editable\"\n                spellcheck=\"false\"\n              >\n                {{ store.prefix }}\n              </span>\n              <!-- HACK: meaningless text: \".\", just to split input area, see: #269 -->\n              <span style=\"font-size: 0\">.</span>\n              <span\n                class=\"postfix\"\n                :style=\"{ color: suffixColor, 'background-color': postfixBgColor }\"\n                :contenteditable=\"store.editable\"\n                @input=\"updateSuffix\"\n                spellcheck=\"false\"\n                >{{ store.suffix }}</span\n              >\n            </template>\n            <template v-else>\n              <span\n                class=\"postfix\"\n                :style=\"{ color: suffixColor, 'background-color': postfixBgColor }\"\n                :contenteditable=\"store.editable\"\n                @input=\"updatePrefix\"\n                spellcheck=\"false\"\n                >{{ store.prefix }}</span\n              >\n              <span\n                class=\"prefix\"\n                @input=\"updateSuffix\"\n                :style=\"{ color: prefixColor }\"\n                :contenteditable=\"store.editable\"\n                spellcheck=\"false\"\n              >\n                {{ store.suffix }}\n              </span>\n            </template>\n          </div>\n        </div>\n      </template>\n    </v-tooltip>\n\n    <div class=\"customize mt-3\">\n      <v-tooltip text=\"Pick a color you like\" location=\"top\" model-value>\n        <template v-slot:activator=\"{ props }\">\n          <div v-bind=\"props\" class=\"customize-color\" id=\"prefixColor\">\n            <div>\n              Prefix Text Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': prefixColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"prefixColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div>\n              Suffix Text Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': suffixColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"suffixColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div>\n              Suffix Background Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': postfixBgColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"postfixBgColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div class=\"flex items-center\">\n              Transparent Background: <v-checkbox-btn v-model=\"transparentBg\"></v-checkbox-btn>\n            </div>\n          </div>\n        </template>\n      </v-tooltip>\n\n      <div class=\"customize-misc\">\n        <div class=\"flex flex-col\">\n          Font Size: {{ fontSize }}px\n          <div class=\"-ml-1\">\n            <v-slider\n              hide-details\n              min=\"30\"\n              max=\"200\"\n              step=\"1\"\n              color=\"#f90\"\n              v-model=\"fontSize\"\n            ></v-slider>\n          </div>\n        </div>\n        <FontSelector />\n        <div class=\"flex items-center\">\n          Reverse Highlight: <v-checkbox-btn v-model=\"reverseHighlight\"></v-checkbox-btn>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"download-share\">\n      <ExportBtn />\n      <v-btn @click=\"twitter\" color=\"#1da1f2\"\n        ><v-icon icon=\"mdi-twitter\" class=\"mr-0.5\"></v-icon> Tweet</v-btn\n      >\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport FontSelector from '@/components/FontSelector.vue';\nimport ExportBtn from '@/components/ExportBtn.vue';\nimport { useGeneratorControls } from '@/composables/useGeneratorControls';\n\nconst {\n  store,\n  prefixColor,\n  suffixColor,\n  postfixBgColor,\n  fontSize,\n  transparentBg,\n  reverseHighlight,\n  transparentBgColor,\n  updatePrefix,\n  updateSuffix,\n  twitter\n} = useGeneratorControls({ backgroundColor: '#000000' });\n</script>\n\n<style lang=\"stylus\" scoped>\n.pornhub {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n}\n\n.box {\n  border: 1px solid #333;\n  border-radius: 10px;\n  padding: 40px;\n  margin: 40px 10px;\n  max-width: 100%;\n\n  .editarea {\n    padding: 20px;\n    text-align: center;\n    font-size: 60px;\n    font-weight: 700;\n\n    .prefix {\n      color: #fff;\n      padding: 5px 5px;\n    }\n\n    .postfix {\n      color: #000;\n      background-color: #f90;\n      padding: 5px 10px;\n      border-radius: 7px;\n    }\n  }\n}\n\n.customize {\n  display: flex;\n  justify-content: space-around;\n  width: 100%;\n  margin-bottom: 50px;\n\n  .customize-color > div,\n  .customize-misc > div {\n    padding: 8px 0;\n  }\n}\n\n.download-share {\n  display: flex;\n  justify-content: space-around;\n  width: 80%;\n\n  & > div {\n    width: 100px;\n    height: 40px;\n    border-radius: 3px;\n    line-height: 40px;\n    text-align: center;\n    cursor: pointer;\n  }\n\n  .download {\n    color: black;\n    background: #f90;\n  }\n\n  .share {\n    color: #fff;\n    background: #1da1f2;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/generator/VerticalPornHub.vue",
    "content": "<template>\n  <div class=\"pornhub\">\n    <v-tooltip text=\"Edit the text to create your own logo\" location=\"top\" model-value>\n      <template v-slot:activator=\"{ props }\">\n        <div v-bind=\"props\" class=\"box\">\n          <div\n            class=\"editarea\"\n            id=\"logo\"\n            :style=\"{\n              'font-size': fontSize + 'px',\n              'background-color': transparentBgColor,\n              'font-family': store.font\n            }\"\n          >\n            <template v-if=\"!reverseHighlight\">\n              <p\n                class=\"prefix\"\n                @input=\"updatePrefix\"\n                :style=\"{ color: prefixColor }\"\n                :contenteditable=\"store.editable\"\n              >\n                {{ store.prefix }}\n              </p>\n              <p\n                class=\"postfix\"\n                @input=\"updateSuffix\"\n                :style=\"{ color: suffixColor, 'background-color': postfixBgColor }\"\n                :contenteditable=\"store.editable\"\n              >\n                {{ store.suffix }}\n              </p>\n            </template>\n            <template v-else>\n              <p\n                class=\"postfix\"\n                @input=\"updatePrefix\"\n                :style=\"{ color: suffixColor, 'background-color': postfixBgColor }\"\n                :contenteditable=\"store.editable\"\n              >\n                {{ store.prefix }}\n              </p>\n              <p\n                class=\"prefix\"\n                @input=\"updateSuffix\"\n                :style=\"{ color: prefixColor }\"\n                :contenteditable=\"store.editable\"\n              >\n                {{ store.suffix }}\n              </p>\n            </template>\n          </div>\n        </div>\n      </template>\n    </v-tooltip>\n\n    <div class=\"customize mt-3\">\n      <v-tooltip text=\"Pick a color you like\" location=\"top\" model-value>\n        <template v-slot:activator=\"{ props }\">\n          <div v-bind=\"props\" class=\"customize-color\" id=\"prefixColor\">\n            <div>\n              Prefix Text Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': prefixColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"prefixColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div>\n              Suffix Text Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': suffixColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"suffixColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div>\n              Suffix Background Color:\n              <v-menu :close-on-content-click=\"false\" location=\"end\">\n                <template v-slot:activator=\"{ props }\">\n                  <button\n                    v-bind=\"props\"\n                    class=\"w-12 h-6 rounded ml-1 border-2 border-solid border-white\"\n                    :style=\"{ 'background-color': postfixBgColor }\"\n                  ></button>\n                </template>\n                <v-color-picker mode=\"hex\" hide-inputs v-model=\"postfixBgColor\"></v-color-picker>\n              </v-menu>\n            </div>\n            <div class=\"flex items-center\">\n              Transparent Background: <v-checkbox-btn v-model=\"transparentBg\"></v-checkbox-btn>\n            </div>\n          </div>\n        </template>\n      </v-tooltip>\n\n      <div class=\"customize-misc\">\n        <div class=\"flex flex-col\">\n          Font Size: {{ fontSize }}px\n          <div class=\"-ml-1\">\n            <v-slider\n              hide-details\n              min=\"30\"\n              max=\"200\"\n              step=\"1\"\n              color=\"#f90\"\n              v-model=\"fontSize\"\n            ></v-slider>\n          </div>\n        </div>\n        <FontSelector />\n        <div class=\"flex items-center\">\n          Reverse Highlight: <v-checkbox-btn v-model=\"reverseHighlight\"></v-checkbox-btn>\n        </div>\n      </div>\n    </div>\n\n    <div class=\"download-share\">\n      <ExportBtn />\n      <v-btn @click=\"twitter\" color=\"#1da1f2\"\n        ><v-icon icon=\"mdi-twitter\" class=\"mr-0.5\"></v-icon> Tweet</v-btn\n      >\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport FontSelector from '@/components/FontSelector.vue';\nimport ExportBtn from '../ExportBtn.vue';\nimport { useGeneratorControls } from '@/composables/useGeneratorControls';\n\nconst {\n  store,\n  prefixColor,\n  suffixColor,\n  postfixBgColor,\n  fontSize,\n  transparentBg,\n  reverseHighlight,\n  transparentBgColor,\n  updatePrefix,\n  updateSuffix,\n  twitter\n} = useGeneratorControls({ backgroundColor: '#000000' });\n</script>\n\n<style lang=\"stylus\" scoped>\n.pornhub\n    display flex\n    flex-direction  column\n    align-items center\n.box\n    border 2px solid #333\n    border-radius 10px\n    padding 40px\n    margin 40px 0px\n    max-width 100%\n    .editarea\n        padding 20px\n        text-align center\n        font-size 60px\n        font-weight 700\n        border-radius 10px\n\n        .prefix\n            color #fff\n            padding 5px 5px\n            margin 0\n\n        .postfix\n            color #000\n            background-color #f90\n            padding 5px 10px\n            border-radius 7px\n            margin 0\n.switch\n    display flex\n    flex-direction row\n    justify-content space-around\n    padding 40px 0px 0px 0px\n    width 80%\n\n// customize things\n.customize\n  display flex\n  justify-content space-around\n  width 100%\n  margin-bottom 50px\n  .customize-color > div,\n  .customize-misc > div\n    padding 8px 0\n\n// download and share buttons\n.download-share\n  display flex\n  justify-content space-around\n  width 80%\n  & > div\n      width 100px\n      height 40px\n      border-radius 3px\n      line-height 40px\n      text-align center\n      cursor pointer\n  .download\n      color black\n      background #f90\n  .share\n    color #fff\n    background #1da1f2\n</style>\n"
  },
  {
    "path": "src/composables/useGeneratorControls.js",
    "content": "import { computed, onBeforeUnmount, ref, watch } from 'vue';\nimport { useStore } from '@/stores/store';\nimport { loadGeneratorState, saveGeneratorState } from '@/utils/persistentState';\n\nexport function useGeneratorControls(options = {}) {\n  const store = useStore();\n  const persistenceAvailable =\n    typeof options.persistenceEnabled === 'boolean'\n      ? options.persistenceEnabled\n      : typeof window !== 'undefined';\n  const persistedState = persistenceAvailable ? loadGeneratorState() : {};\n  const backgroundColor = options.backgroundColor ?? '#000000';\n  let isHydrating = true;\n\n  const hasPersistedState = Object.keys(persistedState).length > 0;\n\n  const prefixColor = ref(persistedState.prefixColor ?? options.prefixColor ?? '#ffffff');\n  const suffixColor = ref(persistedState.suffixColor ?? options.suffixColor ?? '#000000');\n  const postfixBgColor = ref(persistedState.postfixBgColor ?? options.postfixBgColor ?? '#ff9900');\n  const fontSize = ref(persistedState.fontSize ?? options.fontSize ?? 60);\n  const transparentBg = ref(persistedState.transparentBg ?? options.transparentBg ?? false);\n  const reverseHighlight = ref(persistedState.reverseHighlight ?? options.reverseHighlight ?? false);\n\n  const updateText = (updater) => (event) => {\n    if (!event?.target) return;\n    const value = event.target.textContent ?? event.target.innerText ?? '';\n    updater(value);\n  };\n\n  const updatePrefix = updateText(store.updatePrefix);\n  const updateSuffix = updateText(store.updateSuffix);\n\n  const transparentBgColor = computed(() =>\n    transparentBg.value ? 'transparent' : backgroundColor\n  );\n\n  const suffixMargin = computed(() => {\n    if (!options.suffixMarginScale) return undefined;\n    return `-${fontSize.value / options.suffixMarginScale}rem`;\n  });\n\n  const twitter = () => {\n    const url = 'https://logoly.pro';\n    const text = encodeURIComponent(`Built with #LogolyPro, by @xiqingongzi ${url}`);\n    window.open(`https://twitter.com/intent/tweet?text=${text}`);\n  };\n\n  const persistState = () => {\n    if (!persistenceAvailable || isHydrating) return;\n    saveGeneratorState({\n      prefix: store.prefix,\n      suffix: store.suffix,\n      font: store.font,\n      prefixColor: prefixColor.value,\n      suffixColor: suffixColor.value,\n      postfixBgColor: postfixBgColor.value,\n      fontSize: fontSize.value,\n      transparentBg: transparentBg.value,\n      reverseHighlight: reverseHighlight.value\n    });\n  };\n\n  watch(\n    [\n      () => store.prefix,\n      () => store.suffix,\n      () => store.font,\n      prefixColor,\n      suffixColor,\n      postfixBgColor,\n      fontSize,\n      transparentBg,\n      reverseHighlight\n    ],\n    persistState\n  );\n\n  const hasPersistedPrefix = Object.prototype.hasOwnProperty.call(persistedState, 'prefix');\n  const hasPersistedSuffix = Object.prototype.hasOwnProperty.call(persistedState, 'suffix');\n  const hasPersistedFont = Object.prototype.hasOwnProperty.call(persistedState, 'font');\n\n  if (hasPersistedFont && typeof persistedState.font === 'string') {\n    store.font = persistedState.font;\n  }\n\n  const hydrateState = () => {\n    const hydrationTasks = [];\n    const queueTask = (task) => {\n      if (!task) return;\n      if (typeof task.then === 'function') {\n        hydrationTasks.push(task);\n        return;\n      }\n      hydrationTasks.push(Promise.resolve(task));\n    };\n\n    if (hasPersistedPrefix) {\n      queueTask(store.updatePrefix(persistedState.prefix));\n    } else if (options.initialText?.prefix !== undefined) {\n      queueTask(store.updatePrefix(options.initialText.prefix));\n    }\n\n    if (hasPersistedSuffix) {\n      queueTask(store.updateSuffix(persistedState.suffix));\n    } else if (options.initialText?.suffix !== undefined) {\n      queueTask(store.updateSuffix(options.initialText.suffix));\n    }\n\n    const finalizeHydration = () => {\n      isHydrating = false;\n      if (hasPersistedState) {\n        persistState();\n      }\n    };\n\n    if (hydrationTasks.length === 0) {\n      finalizeHydration();\n      return;\n    }\n\n    Promise.all(hydrationTasks).catch(() => {}).finally(finalizeHydration);\n  };\n  hydrateState();\n\n  if (options.resetText && !persistenceAvailable) {\n    onBeforeUnmount(() => {\n      if (options.resetText?.prefix) {\n        store.updatePrefix(options.resetText.prefix);\n      }\n      if (options.resetText?.suffix) {\n        store.updateSuffix(options.resetText.suffix);\n      }\n    });\n  }\n\n  return {\n    store,\n    prefixColor,\n    suffixColor,\n    postfixBgColor,\n    fontSize,\n    transparentBg,\n    reverseHighlight,\n    transparentBgColor,\n    suffixMargin,\n    updatePrefix,\n    updateSuffix,\n    twitter\n  };\n}\n"
  },
  {
    "path": "src/config/fonts.js",
    "content": "const rawFonts = [\n  'Abel',\n  'Abril Fatface',\n  'Acme',\n  'Alegreya',\n  'Alegreya Sans',\n  'Anton',\n  'Archivo',\n  'Archivo Black',\n  'Archivo Narrow',\n  'Arimo',\n  'Arvo',\n  'Asap',\n  'Asap Condensed',\n  'Bitter',\n  'Bowlby One SC',\n  'Bree Serif',\n  'Cabin',\n  'Cairo',\n  'Catamaran',\n  'Crete Round',\n  'Crimson Text',\n  'Cuprum',\n  'Dancing Script',\n  'Dosis',\n  'Droid Sans',\n  'Droid Serif',\n  'EB Garamond',\n  'Exo',\n  'Exo 2',\n  'Faustina',\n  'Fira Sans',\n  'Fjalla One',\n  'Francois One',\n  'Gloria Hallelujah',\n  'Hind',\n  'Inconsolata',\n  'Indie Flower',\n  'Josefin Sans',\n  'Julee',\n  'Karla',\n  'Lato',\n  'Libre Baskerville',\n  'Libre Franklin',\n  'Lobster',\n  'Lora',\n  'Mada',\n  'Manuale',\n  'Maven Pro',\n  'Merriweather',\n  'Merriweather Sans',\n  'Montserrat',\n  'Montserrat Subrayada',\n  'Mukta Vaani',\n  'Muli',\n  'Noto Sans',\n  'Noto Serif',\n  'Nunito',\n  'Open Sans',\n  'Open Sans Condensed',\n  'Oswald',\n  'Oxygen',\n  'PT Sans',\n  'PT Sans Caption',\n  'PT Sans Narrow',\n  'PT Serif',\n  'Pacifico',\n  'Passion One',\n  'Pathway Gothic One',\n  'Play',\n  'Playfair Display',\n  'Poppins',\n  'Questrial',\n  'Quicksand',\n  'Raleway',\n  'Roboto',\n  'Roboto Condensed',\n  'Roboto Mono',\n  'Roboto Slab',\n  'Ropa Sans',\n  'Rubik',\n  'Saira',\n  'Saira Condensed',\n  'Saira Extra Condensed',\n  'Saira Semi Condensed',\n  'Sedgwick Ave',\n  'Sedgwick Ave Display',\n  'Shadows Into Light',\n  'Signika',\n  'Slabo 27px',\n  'Source Code Pro',\n  'Source Sans Pro',\n  'Spectral',\n  'Titillium Web',\n  'Ubuntu',\n  'Ubuntu Condensed',\n  'Varela Round',\n  'Vollkorn',\n  'Work Sans',\n  'Yanone Kaffeesatz',\n  'Zilla Slab',\n  'Zilla Slab Highlight'\n];\n\nexport const fonts = [...new Set(rawFonts.map((font) => font.trim()).filter(Boolean))];\n"
  },
  {
    "path": "src/main.js",
    "content": "import './assets/iconfont/iconfont.css';\nimport './style.css';\nimport '@mdi/font/css/materialdesignicons.css';\nimport { createApp } from 'vue';\nimport { createPinia } from 'pinia';\nimport VueGtag from 'vue-gtag';\n// Vuetify\nimport 'vuetify/styles';\nimport { createVuetify } from 'vuetify';\nimport { Ripple } from 'vuetify/directives';\n\nimport App from './App.vue';\nimport router from './router';\n\nconst app = createApp(App);\n\nconst vuetify = createVuetify({\n  directives: {\n    Ripple\n  },\n  theme: {\n    defaultTheme: 'dark'\n  }\n});\n\napp.use(vuetify);\napp.use(createPinia());\napp.use(\n  VueGtag,\n  {\n    appName: 'Logoly',\n    pageTrackerScreenviewEnabled: true,\n    config: {\n      id: 'G-YX7X8HWGB1'\n    }\n  },\n  router\n);\napp.use(router);\n\napp.mount('#app');\n"
  },
  {
    "path": "src/router/index.js",
    "content": "import { createRouter, createWebHistory } from 'vue-router';\n\nconst router = createRouter({\n  history: createWebHistory(import.meta.env.BASE_URL),\n  routes: [\n    {\n      path: '/',\n      name: 'pornhub',\n      component: () => import('@/components/generator/Pornhub.vue'),\n      meta: {\n        analytics: {\n          pageviewTemplate(route) {\n            return {\n              title: 'Pornhub Generator',\n              page: route.path\n            };\n          }\n        }\n      }\n    },\n    {\n      path: '/vertical-ph',\n      name: 'vertical-pornhub',\n      component: () => import('@/components/generator/VerticalPornHub.vue'),\n      meta: {\n        analytics: {\n          pageviewTemplate(route) {\n            return {\n              title: 'VerticalPornHub Generator',\n              page: route.path\n            };\n          }\n        }\n      }\n    },\n    {\n      path: '/onlyfans',\n      name: 'onlyfans',\n      component: () => import('@/components/generator/Onlyfans.vue'),\n      meta: {\n        analytics: {\n          pageviewTemplate(route) {\n            return {\n              title: 'OnlyFans Generator',\n              page: route.path\n            };\n          }\n        }\n      }\n    },\n    {\n      path: '/about',\n      name: 'about',\n      component: () => import('@/views/AboutView.vue'),\n      meta: {\n        analytics: {\n          pageviewTemplate(route) {\n            return {\n              title: 'About',\n              page: route.path\n            };\n          }\n        }\n      }\n    }\n  ]\n});\n\nexport default router;\n"
  },
  {
    "path": "src/stores/store.js",
    "content": "import { nextTick, ref } from 'vue';\nimport { defineStore } from 'pinia';\n\nconst hasDom = typeof window !== 'undefined' && typeof document !== 'undefined';\nconst textNodeFilter = typeof NodeFilter === 'undefined' ? 4 : NodeFilter.SHOW_TEXT;\n\nfunction getEditableAncestor(node) {\n  if (!node) return null;\n  if (node.nodeType === 3) {\n    return node.parentElement?.closest?.(\"[contenteditable='true']\") ?? null;\n  }\n  if (node.nodeType === 1) {\n    return node.closest?.(\"[contenteditable='true']\") ?? null;\n  }\n  return null;\n}\n\nfunction getOffsetWithinRoot(root, node, offset) {\n  const range = document.createRange();\n  range.selectNodeContents(root);\n  range.setEnd(node, offset);\n  return range.toString().length;\n}\n\nfunction captureSelectionSnapshot() {\n  if (!hasDom) return null;\n  const selection = window.getSelection();\n  if (!selection?.rangeCount) return null;\n  const range = selection.getRangeAt(0);\n  const editableElement = getEditableAncestor(range.startContainer);\n\n  if (!editableElement || !editableElement.contains(range.endContainer)) {\n    return null;\n  }\n\n  return {\n    editableElement,\n    startOffset: getOffsetWithinRoot(editableElement, range.startContainer, range.startOffset),\n    endOffset: getOffsetWithinRoot(editableElement, range.endContainer, range.endOffset)\n  };\n}\n\nfunction resolvePosition(root, targetOffset) {\n  const walker = document.createTreeWalker(root, textNodeFilter, null);\n  let remaining = targetOffset;\n\n  while (walker.nextNode()) {\n    const node = walker.currentNode;\n    const length = node.textContent?.length ?? 0;\n\n    if (remaining <= length) {\n      return { node, offset: remaining };\n    }\n\n    remaining -= length;\n  }\n\n  return { node: root, offset: root.childNodes.length };\n}\n\nfunction restoreSelectionSnapshot(snapshot) {\n  if (!hasDom) return;\n  const { editableElement } = snapshot;\n  if (!editableElement?.isConnected) return;\n\n  const selection = window.getSelection();\n  if (!selection) return;\n\n  const totalLength = editableElement.textContent?.length ?? 0;\n  const start = Math.min(snapshot.startOffset, totalLength);\n  const end = Math.min(snapshot.endOffset, totalLength);\n\n  const startPosition = resolvePosition(editableElement, start);\n  const endPosition = resolvePosition(editableElement, end);\n\n  if (!startPosition || !endPosition) return;\n\n  const range = document.createRange();\n  range.setStart(startPosition.node, startPosition.offset);\n  range.setEnd(endPosition.node, endPosition.offset);\n\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\nexport const useStore = defineStore('store', () => {\n  const prefix = ref('edit');\n  const suffix = ref('me');\n  const font = ref('Roboto');\n  //Needed for the SVG Export (otherwise you can edit the SVG in the browser which breaks and and leads into new issues)\n  const editable = ref(true);\n\n  async function updatePrefix(text) {\n    const selectionSnapshot = captureSelectionSnapshot();\n    prefix.value = text;\n    if (!selectionSnapshot) return;\n    await nextTick();\n    restoreSelectionSnapshot(selectionSnapshot);\n  }\n\n  async function updateSuffix(text) {\n    const selectionSnapshot = captureSelectionSnapshot();\n    suffix.value = text;\n    if (!selectionSnapshot) return;\n    await nextTick();\n    restoreSelectionSnapshot(selectionSnapshot);\n  }\n\n  return { prefix, suffix, font, editable, updatePrefix, updateSuffix };\n});\n"
  },
  {
    "path": "src/style.css",
    "content": "@import url(\"https://fonts.googleapis.com/css2?family=Arizonia&family=Inter:wght@100&display=swap\");\n@import 'tailwindcss';\n@config '../tailwind.config.js';\n\nhtml,\nbody,\n#app {\n  height: 100vh;\n  margin: 0;\n  background-color: #000;\n  color: #f90;\n  font-family: 'Arial', sans-serif;\n}\n\na {\n  color: #f90;\n  text-decoration: none;\n}\n\n.pb .prefix {\n  color: #fff;\n  padding: 0.125rem;\n}\n\n.pb .postfix {\n  color: #000;\n  background-color: #f90;\n  padding: 0.125rem;\n  border-radius: 0.375rem;\n}\n\n.of .prefix {\n  color: #fff;\n}\n\n.of .postfix {\n  color: #00aff0;\n}\n\n.vph {\n  text-align: center;\n}\n\n.vph .prefix {\n  color: #fff;\n  margin: 0;\n}\n\n.vph .postfix {\n  margin: 0;\n  color: #000;\n  background-color: #f90;\n  padding: 0.125rem;\n  border-radius: 0.375rem;\n}\n\n/* We need that Shizzle for dom-to-image otherwise the exported Img will have wrong fonts */\n/* vietnamese */\n@font-face {\n  font-family: \"Arizonia\";\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WOFqwYUp31kXI.woff2)\n    format(\"woff2\");\n  unicode-range:\n    U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0,\n    U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n  font-family: \"Arizonia\";\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WPFqwYUp31kXI.woff2)\n    format(\"woff2\");\n  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,\n    U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n  font-family: \"Arizonia\";\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WBFqwYUp31.woff2)\n    format(\"woff2\");\n  unicode-range:\n    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,\n    U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,\n    U+FFFD;\n}\n/* cyrillic-ext */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZJhiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* cyrillic */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZthiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* greek-ext */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZNhiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range: U+1F00-1FFF;\n}\n/* greek */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZxhiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;\n}\n/* vietnamese */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZBhiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range:\n    U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0,\n    U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZFhiJ-Ek-_EeAmM.woff2)\n    format(\"woff2\");\n  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,\n    U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n  font-family: \"Inter\";\n  font-style: normal;\n  font-weight: 100;\n  font-display: swap;\n  src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZ9hiJ-Ek-_EeA.woff2)\n    format(\"woff2\");\n  unicode-range:\n    U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,\n    U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,\n    U+FFFD;\n}\n"
  },
  {
    "path": "src/styles/vuetify-settings.scss",
    "content": "// Disable optional Vuetify packs to keep the generated CSS minimal.\n@forward 'vuetify/settings' with (\n  $color-pack: false,\n  $utilities: false\n);\n"
  },
  {
    "path": "src/utils/fontLoader.js",
    "content": "const loadedFonts = new Set();\nconst pendingFonts = new Set();\nconst hasDOM = typeof window !== 'undefined' && typeof document !== 'undefined';\n\nconst buildFontUrl = (fontName) => {\n  const family = fontName.trim().split(/\\s+/).join('+');\n  return `https://fonts.googleapis.com/css2?family=${family}&display=swap`;\n};\n\nexport function loadGoogleFont(fontName) {\n  if (!hasDOM) return;\n  const normalized = fontName?.trim();\n  if (!normalized || loadedFonts.has(normalized) || pendingFonts.has(normalized)) return;\n\n  const href = buildFontUrl(normalized);\n  const link = document.createElement('link');\n  link.rel = 'preload';\n  link.as = 'style';\n  link.href = href;\n  link.crossOrigin = 'anonymous';\n  link.onload = () => {\n    link.rel = 'stylesheet';\n    pendingFonts.delete(normalized);\n    loadedFonts.add(normalized);\n  };\n  link.onerror = () => {\n    pendingFonts.delete(normalized);\n    link.remove();\n  };\n\n  pendingFonts.add(normalized);\n  document.head.appendChild(link);\n}\n"
  },
  {
    "path": "src/utils/persistentState.js",
    "content": "const GENERATOR_STATE_STORAGE_KEY = 'logoly-generator-state';\nconst PERSISTED_FIELDS = [\n  'prefix',\n  'suffix',\n  'font',\n  'prefixColor',\n  'suffixColor',\n  'postfixBgColor',\n  'fontSize',\n  'transparentBg',\n  'reverseHighlight'\n];\n\nconst hasWindow = () => typeof window !== 'undefined';\nconst hasLocation = () => hasWindow() && typeof window.location !== 'undefined';\nconst hasHistory = () => hasWindow() && typeof window.history?.replaceState === 'function';\nconst hasLocalStorage = () => hasWindow() && typeof window.localStorage !== 'undefined';\n\nconst isString = (value) => typeof value === 'string';\n\nfunction parseBoolean(value) {\n  if (typeof value === 'boolean') return value;\n  const normalized = String(value).trim().toLowerCase();\n  if (!normalized) return undefined;\n  if (['1', 'true', 'yes', 'y'].includes(normalized)) return true;\n  if (['0', 'false', 'no', 'n'].includes(normalized)) return false;\n  return undefined;\n}\n\nfunction parseNumber(value) {\n  if (value === null || value === undefined || value === '') return undefined;\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : undefined;\n}\n\nfunction pickValue(field, value) {\n  if (value === undefined || value === null) return undefined;\n  switch (field) {\n    case 'fontSize': {\n      const parsed = parseNumber(value);\n      return parsed === undefined ? undefined : parsed;\n    }\n    case 'transparentBg':\n    case 'reverseHighlight': {\n      const parsed = parseBoolean(value);\n      return typeof parsed === 'boolean' ? parsed : undefined;\n    }\n    default: {\n      if (!isString(value)) return undefined;\n      return value;\n    }\n  }\n}\n\nfunction readFromQuery() {\n  if (!hasLocation()) return {};\n  const params = new URLSearchParams(window.location.search);\n  const state = {};\n\n  PERSISTED_FIELDS.forEach((field) => {\n    if (!params.has(field)) return;\n    const value = params.get(field);\n    const parsed = pickValue(field, value);\n    if (parsed !== undefined) {\n      state[field] = parsed;\n    }\n  });\n\n  return state;\n}\n\nfunction readFromStorage() {\n  if (!hasLocalStorage()) return {};\n  try {\n    const raw = window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY);\n    if (!raw) return {};\n    const parsed = JSON.parse(raw);\n    if (!parsed || typeof parsed !== 'object') return {};\n\n    const state = {};\n    PERSISTED_FIELDS.forEach((field) => {\n      const value = pickValue(field, parsed[field]);\n      if (value !== undefined) {\n        state[field] = value;\n      }\n    });\n    return state;\n  } catch {\n    return {};\n  }\n}\n\nfunction formatForQuery(value) {\n  if (typeof value === 'boolean') {\n    return value ? '1' : '0';\n  }\n  return String(value);\n}\n\nfunction updateQueryString(state) {\n  if (!hasLocation() || !hasHistory()) return;\n\n  const params = new URLSearchParams(window.location.search);\n  PERSISTED_FIELDS.forEach((field) => params.delete(field));\n\n  Object.entries(state).forEach(([field, value]) => {\n    const formatted = formatForQuery(value);\n    if (formatted === undefined || formatted === '') return;\n    params.set(field, formatted);\n  });\n\n  const search = params.toString();\n  const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;\n  window.history.replaceState(null, '', newUrl);\n}\n\nexport function loadGeneratorState() {\n  return {\n    ...readFromStorage(),\n    ...readFromQuery()\n  };\n}\n\nexport function saveGeneratorState(state) {\n  const normalized = {};\n  PERSISTED_FIELDS.forEach((field) => {\n    const value = pickValue(field, state[field]);\n    if (value !== undefined) {\n      normalized[field] = value;\n    }\n  });\n\n  if (hasLocalStorage()) {\n    try {\n      window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, JSON.stringify(normalized));\n    } catch {\n      // ignore quota errors\n    }\n  }\n\n  updateQueryString(normalized);\n}\n\nexport { GENERATOR_STATE_STORAGE_KEY };\n"
  },
  {
    "path": "src/views/AboutView.vue",
    "content": "<template>\n  <div>\n    <h3 class=\"text-xl font-semibold mb-1\">Logoly.pro</h3>\n    <p class=\"text-white\">\n      This project is an open source project, you can find it on\n      <a href=\"https://github.com/bestony/logoly\" target=\"_blank\" rel=\"noopener noreferrer\">\n        GitHub\n      </a>\n    </p>\n  </div>\n</template>\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],\n  theme: {\n    extend: {\n      colors: {\n        custom: {\n          primary: '#f90'\n        }\n      }\n    }\n  }\n};\n"
  },
  {
    "path": "vite.config.js",
    "content": "import process from 'node:process';\nimport { fileURLToPath, URL } from 'node:url';\n\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport viteCompression from 'vite-plugin-compression';\nimport vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';\n\nfunction exposeStoreInternals() {\n  const storePath = fileURLToPath(new URL('./src/stores/store.js', import.meta.url)).replace(\n    /\\\\/g,\n    '/'\n  );\n\n  return {\n    name: 'expose-store-internals',\n    enforce: 'post',\n    transform(code, id) {\n      if (!process.env.VITEST) return null;\n      const normalizedId = id.replace(/\\\\/g, '/');\n      if (normalizedId !== storePath) return null;\n      if (code.includes('__LOGOLY_STORE_INTERNALS__')) return null;\n\n      const marker =\n        'return { node: root, offset: root.childNodes.length };\\n}\\n\\nfunction restoreSelectionSnapshot';\n      if (!code.includes(marker)) return null;\n\n      let transformed = code.replace(\n        marker,\n        'return { node: root, offset: root.childNodes.length };\\n}\\n\\nconst __storeTestOverrides = { resolvePosition: null };\\nfunction __callResolvePosition(root, targetOffset) {\\n  return typeof __storeTestOverrides.resolvePosition === \"function\"\\n    ? __storeTestOverrides.resolvePosition(root, targetOffset)\\n    : resolvePosition(root, targetOffset);\\n}\\n\\nfunction restoreSelectionSnapshot'\n      );\n\n      transformed = transformed\n        .replace(\n          'const startPosition = resolvePosition(editableElement, start);',\n          'const startPosition = __callResolvePosition(editableElement, start);'\n        )\n        .replace(\n          'const endPosition = resolvePosition(editableElement, end);',\n          'const endPosition = __callResolvePosition(editableElement, end);'\n        );\n\n      const exposure = `\\nif (typeof globalThis !== \"undefined\") {\\n  globalThis.__LOGOLY_STORE_INTERNALS__ = {\\n    getEditableAncestor,\\n    captureSelectionSnapshot,\\n    resolvePosition,\\n    restoreSelectionSnapshot,\\n    setResolvePositionOverride(fn) {\\n      __storeTestOverrides.resolvePosition = fn;\\n    },\\n    clearOverrides() {\\n      __storeTestOverrides.resolvePosition = null;\\n    },\\n  };\\n}\\n`;\n\n      return {\n        code: `${transformed}${exposure}`,\n        map: null\n      };\n    }\n  };\n}\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    vue({\n      template: {\n        transformAssetUrls\n      }\n    }),\n    vuetify({\n      autoImport: true,\n      styles: {\n        configFile: 'src/styles/vuetify-settings.scss'\n      }\n    }),\n    exposeStoreInternals(),\n    viteCompression({\n      algorithm: 'brotliCompress',\n      ext: '.br',\n      threshold: 10240\n    }),\n    viteCompression({\n      algorithm: 'gzip',\n      ext: '.gz',\n      threshold: 10240\n    })\n  ],\n  resolve: {\n    alias: {\n      '@': fileURLToPath(new URL('./src', import.meta.url))\n    }\n  },\n  build: {\n    sourcemap: false,\n    reportCompressedSize: false,\n    chunkSizeWarningLimit: 600,\n    rollupOptions: {\n      output: {\n        manualChunks(id) {\n          if (id.includes('node_modules')) {\n            if (id.includes('vuetify')) return 'vuetify';\n            if (id.includes('vue')) return 'vue';\n            return 'vendor';\n          }\n        },\n        chunkFileNames: 'assets/js/[name]-[hash].js',\n        entryFileNames: 'assets/js/[name]-[hash].js',\n        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'\n      }\n    },\n    esbuild: {\n      drop: ['console', 'debugger']\n    }\n  },\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: ['./vitest.setup.js'],\n    coverage: {\n      provider: 'istanbul',\n      reporter: ['text', 'json-summary', 'html'],\n      thresholds: {\n        lines: 99,\n        functions: 99,\n        branches: 99,\n        statements: 99\n      }\n    }\n  }\n});\n"
  },
  {
    "path": "vitest.setup.js",
    "content": "import { config } from '@vue/test-utils';\nimport { vi } from 'vitest';\n\nconst slotStub = {\n  inheritAttrs: false,\n  template: `\n    <div v-bind=\"$attrs\">\n      <slot name=\"activator\"></slot>\n      <slot name=\"text\"></slot>\n      <slot></slot>\n    </div>\n  `\n};\n\nconst vuetifyTags = [\n  'v-btn',\n  'v-checkbox-btn',\n  'v-color-picker',\n  'v-expansion-panel',\n  'v-expansion-panels',\n  'v-icon',\n  'v-list',\n  'v-list-item',\n  'v-menu',\n  'v-select',\n  'v-slider',\n  'v-tooltip'\n];\n\nconfig.global.stubs = {\n  ...(config.global.stubs ?? {}),\n  ...Object.fromEntries(vuetifyTags.map((tag) => [tag, slotStub])),\n  'router-link': {\n    template: '<a><slot /></a>'\n  },\n  'router-view': {\n    template: '<div><slot /></div>'\n  }\n};\n\nif (typeof window !== 'undefined') {\n  if (!window.matchMedia) {\n    window.matchMedia = () => ({\n      matches: false,\n      addListener() {},\n      removeListener() {},\n      addEventListener() {},\n      removeEventListener() {},\n      dispatchEvent() {\n        return false;\n      },\n      media: ''\n    });\n  }\n\n  if (!window.scrollTo) {\n    window.scrollTo = () => {};\n  }\n\n  if (!window.HTMLElement.prototype.scrollIntoView) {\n    window.HTMLElement.prototype.scrollIntoView = () => {};\n  }\n}\n\nclass ResizeObserver {\n  observe() {}\n  unobserve() {}\n  disconnect() {}\n}\n\nclass IntersectionObserver {\n  observe() {}\n  unobserve() {}\n  disconnect() {}\n  takeRecords() {\n    return [];\n  }\n}\n\nglobalThis.ResizeObserver = globalThis.ResizeObserver ?? ResizeObserver;\nglobalThis.IntersectionObserver = globalThis.IntersectionObserver ?? IntersectionObserver;\n\nif (typeof HTMLCanvasElement !== 'undefined') {\n  HTMLCanvasElement.prototype.getContext = () => ({\n    drawImage() {},\n    clearRect() {}\n  });\n  HTMLCanvasElement.prototype.toDataURL = () => 'data:image/png;base64,stub';\n}\n\nvi.mock('vue-gtag', () => ({\n  event: vi.fn()\n}));\n\nglobalThis.open = globalThis.open ?? (() => {});\n"
  }
]