Repository: bestony/logoly
Branch: master
Commit: 9603ac669f55
Files: 61
Total size: 116.3 KB
Directory structure:
gitextract_5zqv3n35/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── bug_report.md
│ ├── dependabot.yml
│ ├── feature_request.md
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .prettierrc.json
├── CODE_OF_CONDUCT.md
├── Changelog.md
├── Contributers.md
├── Contributing.md
├── LICENSE
├── README.md
├── _redirects
├── biome.json
├── index.html
├── jsconfig.json
├── package.json
├── postcss.config.js
├── public/
│ ├── ads.txt
│ └── site.webmanifest
├── src/
│ ├── App.vue
│ ├── __tests__/
│ │ ├── AboutView.test.js
│ │ ├── ExportBtn.test.js
│ │ ├── FontSelectorComponent.test.js
│ │ ├── fontLoader.test.js
│ │ ├── fontsConfig.test.js
│ │ ├── generators.test.js
│ │ ├── persistentState.test.js
│ │ ├── router.test.js
│ │ ├── store.test.js
│ │ └── useGeneratorControls.test.js
│ ├── assets/
│ │ └── iconfont/
│ │ ├── iconfont.css
│ │ └── iconfont.js
│ ├── components/
│ │ ├── Author.vue
│ │ ├── Copyright.vue
│ │ ├── Description.vue
│ │ ├── ExportBtn.vue
│ │ ├── Faq.vue
│ │ ├── FontSelector.vue
│ │ ├── Logo.vue
│ │ ├── Ribbon.vue
│ │ ├── Slogan.vue
│ │ └── generator/
│ │ ├── Onlyfans.vue
│ │ ├── Pornhub.vue
│ │ └── VerticalPornHub.vue
│ ├── composables/
│ │ └── useGeneratorControls.js
│ ├── config/
│ │ └── fonts.js
│ ├── main.js
│ ├── router/
│ │ └── index.js
│ ├── stores/
│ │ └── store.js
│ ├── style.css
│ ├── styles/
│ │ └── vuetify-settings.scss
│ ├── utils/
│ │ ├── fontLoader.js
│ │ └── persistentState.js
│ └── views/
│ └── AboutView.vue
├── tailwind.config.js
├── vite.config.js
└── vitest.setup.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [bestony]
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
time: "21:00"
open-pull-requests-limit: 10
assignees:
- bestony
ignore:
- dependency-name: y18n
versions:
- 4.0.1
- dependency-name: eslint-plugin-vue
versions:
- 7.5.0
- 7.6.0
- 7.8.0
- dependency-name: stylus-loader
versions:
- 4.3.3
================================================
FILE: .github/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test with coverage
run: npm run test
env:
CI: true
- name: Build
run: npm run build
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
================================================
FILE: .prettierrc.json
================================================
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at xiqingongzi+logoly@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: Changelog.md
================================================
# Changelog
## 2025.11.14
- Added a Vitest-based test harness (jsdom environment, global setup, and 99% coverage gates) and expanded suites for stores, generators, and font utilities.
- Refactored generators to share logic via new composables, centralized font metadata/configuration, and improved selection persistence plus font-loading behavior.
- Cleaned up Vuetify imports, refreshed dependencies, and tuned the Vite build for compressed outputs.
- Adopted Biome for consistent formatting/linting, standardized project styles, and committed a Bun lockfile for deterministic installs.
- Introduced GitHub Actions CI driven by Bun to run install, lint, test, and build steps on every push/PR.
## 2024.03.23
- Upgraded to Vue 3
- Added OnlyFans Generator
- Added SVG Export
## 2020.10.08
- Added Font-Selector-Component
## 2019.05.04
- Fix Typo at Slogan.vue
- Add vuex for keep text while switch layout
- use Canvas export
## 2019.03.26
- add Vertical Pornhub Logo
## 2019.03.25
- Download Image with Dark Background.
- Inverse prefix and suffix
## 2019.03.23
- Init Project
- Download as PNG
- Custom Font Style
================================================
FILE: Contributers.md
================================================
# Contributors
- [Yovel Ovadia](https://github.com/yovelovadia)
- [Daniel Yuldashev](https://github.com/yldshv)
================================================
FILE: Contributing.md
================================================
# Contribute to This Project
## Feature Request
If you want to request for a new feature, you may open an issue and tell me what you want.
If possible, attach a screenshot or an image to help me understand.
[**Open an Issue**](https://github.com/bestony/logoly/issues/new?assignees=&labels=&template=feature_request.md&title=)
## Bug Report
If you want to report a bug, you may open an issue.
If possible, please include steps to reproduce the bug.
[**Open an Issue**](https://github.com/bestony/logoly/issues/new?assignees=&labels=&template=bug_report.md&title=)
## Make Contribution
If you want to contribute to this project, fork the project first, and then clone the project and make changes on your own repository,
Once you pushed into your own repository, you may open a pull request.
## Local Development Setup
1. Ensure you have Node.js 18+ and npm 10+ installed.
2. Install dependencies with `npm install` (or `npm ci`).
3. Use the npm scripts (`npm run dev`, `npm run test`, `npm run lint`, `npm run build`) to work locally.
> The project standardizes on npm; please avoid using Bun, pnpm, or yarn so that the single `package-lock.json` stays the source of truth.
================================================
FILE: LICENSE
================================================
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2012 Romain Lespinasse <romain.lespinasse@gmail.com>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
================================================
FILE: README.md
================================================
> **new version is in active development at new-vue3 branch**
<img align="right" src="https://postimg.aliavv.com/mbp/adpsj.png"/>
# Logoly —— A Pornhub Flavour Logo Generator
   
**A Simple Online Logo Generator for People Who Want to Design Logos Easily.**
## Screenshot

## Features
- generate logo like **PornHub** or **OnlyFans**
- download your own logo in PNG/SVG format
- customize logo color
- customize logo font size
## How to Use
1. open the Logoly website: [https://logoly.pro/](https://logoly.pro/)
2. edit the text in the box
3. change color & font size as you like
4. click the **Export** button to download the image
## TODO
- share it on Facebook
- customize fonts
## Changelog
See [Changelog](Changelog.md)
## How to Contribute
For 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.
For those who want to play around with this project, read the `Get Started` section.
At the end of this section, I suggest you read the [Contributing Guide](Contributing.md).
## Requirements
- Node.js 18+
- npm 10+ (official package manager; please don't submit other lockfiles)
## Get Started
1. clone this project
2. install dependencies with `npm install` (or `npm ci` for a clean install) at the project root directory
3. start the development server with `npm run dev`
4. make changes
5. build with `npm run build`
All scripts and the CI pipeline run with npm. Using Bun, pnpm, or yarn may create mismatched lockfiles and is not supported.
## Related Project
- [Logoly.pro MiniProgram](https://github.com/GHLandy/logoly-pro)
## Sponsors
[<img src="https://postimg.aliavv.com/picgo/20190331211014.png" height=40>](http://www.leancloud.app/)
## LICENSE
[WTFPL 2](LICENSE)
================================================
FILE: _redirects
================================================
# Netlify settings for single-page application
/* /index.html 200
================================================
FILE: biome.json
================================================
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": ["src/assets/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"css": {},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "always",
"trailingCommas": "none"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": {
"options": {
"maxAllowedComplexity": 15
}
}
}
}
}
}
================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="description"
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."
/>
<link rel="canonical" href="https://logoly.pro/" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/favicon.ico" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="google-adsense-account" content="ca-pub-9877802927933140">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#050505" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Logoly.Pro" />
<meta property="og:title" content="Logoly.Pro — A creative Logo Generator" />
<meta
property="og:description"
content="Craft instantly recognizable Pornhub-style parody logos online, tweak fonts/colors interactively, then export share-ready assets."
/>
<meta property="og:url" content="https://logoly.pro/" />
<meta property="og:image" content="https://logoly.pro/social-share.png" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Logoly.Pro — A creative Logo Generator" />
<meta
name="twitter:description"
content="Create Pornhub-style parody logos with Logoly.Pro and export them instantly as PNG or SVG."
/>
<meta name="twitter:site" content="@xiqingongzi" />
<meta name="twitter:image" content="https://logoly.pro/social-share.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<script
data-cfasync="false"
defer
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9877802927933140"
crossorigin="anonymous"
></script>
<title>Logoly.Pro —— A creative Logo Generator</title>
</head>
<body>
<noscript>
<strong
>We're sorry but logoly doesn't work properly without JavaScript enabled. Please enable it
to continue.</strong
>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
================================================
FILE: jsconfig.json
================================================
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
================================================
FILE: package.json
================================================
{
"name": "logoly",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "biome format --write .",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"check": "biome check .",
"check:fix": "biome check --write .",
"test": "vitest run --coverage"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@vueuse/core": "^10.11.0",
"dom-to-image": "^2.6.0",
"pinia": "^2.1.7",
"vue": "^3.5.5",
"vue-gtag": "^2.0.1",
"vue-router": "^4.5.0",
"vuetify": "^3.10.11"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@tailwindcss/postcss": "^4.1.17",
"@vitejs/plugin-vue": "^5.0.4",
"@vitest/coverage-istanbul": "^4.0.8",
"@vitest/coverage-v8": "^4.0.8",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"jsdom": "^27.2.0",
"postcss": "^8.4.38",
"prettier": "^3.3.3",
"sass": "^1.94.1",
"stylus": "^0.64.0",
"tailwindcss": "^4.1.17",
"vite": "^5.4.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vuetify": "^2.1.2",
"vitest": "^4.0.8"
}
}
================================================
FILE: postcss.config.js
================================================
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {}
}
};
================================================
FILE: public/ads.txt
================================================
google.com, pub-9877802927933140, DIRECT, f08c47fec0942fa0
================================================
FILE: public/site.webmanifest
================================================
{
"name": "Logoly.Pro",
"short_name": "Logoly",
"description": "Generate Pornhub-style parody logos right in the browser and download them as PNG or SVG in seconds.",
"start_url": "/",
"display": "standalone",
"background_color": "#050505",
"theme_color": "#050505",
"icons": [
{
"src": "/favicon.ico",
"sizes": "48x48 64x64 128x128 256x256",
"type": "image/x-icon"
},
{
"src": "/social-share.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
================================================
FILE: src/App.vue
================================================
<template>
<div id="app" class="container mx-auto py-12 px-6 max-w-[893px]">
<Ribbon class="z-50"></Ribbon>
<div class="text-4xl font-black p-5 my-6 text-center">
<logo></logo>
</div>
<div class="text-2xl mb-6 text-center font-extrabold">
<Description></Description>
</div>
<div id="nav">
<div
class="flex flex-col md:flex-row gap-2 md:gap-16 text-xl font-semibold items-center mb-12 justify-center"
>
<router-link to="/about">About</router-link>
<router-link to="/" class="pb">
<span class="prefix">Porn</span>
<span class="postfix">hub</span>
</router-link>
<router-link to="/vertical-ph" class="vph">
<p class="prefix">Porn</p>
<p class="postfix">hub</p>
</router-link>
<router-link to="/onlyfans">
<span class="text-white">Only</span>
<span class="text-[#00AFF0]">Fans</span>
</router-link>
<span class="text-[#777] font-light">More coming soon...</span>
</div>
</div>
<router-view />
<Slogan />
<Faq />
<Author />
<Copyright class="pb-4" />
</div>
</template>
<script setup>
import Logo from '@/components/Logo.vue';
import Description from '@/components/Description.vue';
import Slogan from '@/components/Slogan.vue';
import Faq from '@/components/Faq.vue';
import Author from '@/components/Author.vue';
import Ribbon from '@/components/Ribbon.vue';
import Copyright from '@/components/Copyright.vue';
</script>
================================================
FILE: src/__tests__/AboutView.test.js
================================================
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import AboutView from '@/views/AboutView.vue';
describe('AboutView', () => {
it('links to the GitHub repository', () => {
const wrapper = mount(AboutView);
expect(wrapper.text()).toContain('Logoly.pro');
const link = wrapper.get('a');
expect(link.attributes('href')).toBe('https://github.com/bestony/logoly');
expect(link.attributes('target')).toBe('_blank');
});
});
================================================
FILE: src/__tests__/ExportBtn.test.js
================================================
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import ExportBtn from '@/components/ExportBtn.vue';
import { useStore } from '@/stores/store';
const domToImageMock = vi.hoisted(() => ({
toPng: vi.fn(() => Promise.resolve('data:image/png;base64,mock')),
toSvg: vi.fn(() => Promise.resolve('data:image/svg+xml,mock'))
}));
const onClickOutsideMock = vi.hoisted(() => vi.fn((_, handler) => handler()));
vi.mock('dom-to-image', () => ({
__esModule: true,
default: domToImageMock
}));
vi.mock('@vueuse/core', () => ({
onClickOutside: onClickOutsideMock
}));
describe('ExportBtn', () => {
let OriginalImage;
let clickSpy;
beforeAll(() => {
OriginalImage = window.Image;
window.Image = class {
constructor() {
this.width = 100;
this.height = 50;
}
setAttribute() {}
set onload(handler) {
this._onload = handler;
}
get onload() {
return this._onload;
}
set src(value) {
this._src = value;
this._onload?.();
}
};
clickSpy = vi.spyOn(window.HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
});
afterAll(() => {
window.Image = OriginalImage;
clickSpy.mockRestore();
});
beforeEach(() => {
document.body.innerHTML = '<div id="logo"></div>';
setActivePinia(createPinia());
domToImageMock.toPng.mockClear();
domToImageMock.toSvg.mockClear();
onClickOutsideMock.mockClear();
});
const mountButton = () => mount(ExportBtn);
const flush = () => new Promise((resolve) => setTimeout(resolve));
it('exports the editable area as PNG', async () => {
const store = useStore();
store.prefix = 'Porn';
store.suffix = 'hub';
const wrapper = mountButton();
await wrapper.find('[value="png"]').trigger('click');
expect(domToImageMock.toPng).toHaveBeenCalledWith(document.getElementById('logo'));
await flush();
expect(store.editable).toBe(true);
expect(onClickOutsideMock).toHaveBeenCalled();
});
it('exports the editable area as SVG', async () => {
const store = useStore();
store.prefix = 'Only';
store.suffix = 'Fans';
const wrapper = mountButton();
await wrapper.find('[value="svg"]').trigger('click');
expect(domToImageMock.toSvg).toHaveBeenCalledWith(document.getElementById('logo'));
await flush();
expect(clickSpy).toHaveBeenCalled();
expect(store.editable).toBe(true);
});
it('skips exporting when the logo node is missing', async () => {
document.body.innerHTML = '';
setActivePinia(createPinia());
const wrapper = mountButton();
await wrapper.find('[value="png"]').trigger('click');
expect(domToImageMock.toPng).not.toHaveBeenCalled();
});
it('falls back to the default filename when none is provided', () => {
const dispatchSpy = vi.spyOn(window.HTMLAnchorElement.prototype, 'dispatchEvent');
const wrapper = mountButton();
const { downloadImage } = wrapper.vm.$.setupState;
expect(typeof downloadImage).toBe('function');
downloadImage('data:image/png;base64,stub');
expect(dispatchSpy).toHaveBeenCalled();
dispatchSpy.mockRestore();
});
});
================================================
FILE: src/__tests__/FontSelectorComponent.test.js
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import FontSelector from '@/components/FontSelector.vue';
import { useStore } from '@/stores/store';
import { loadGoogleFont } from '@/utils/fontLoader';
vi.mock('@/utils/fontLoader', () => ({
loadGoogleFont: vi.fn()
}));
const VSelectStub = defineComponent({
name: 'VSelectStub',
props: {
modelValue: {
type: String,
default: ''
},
items: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `<select class="v-select-stub" :value="modelValue" @change="$emit('update:modelValue', $event.target.value)">
<option v-for="option in items" :key="option" :value="option">{{ option }}</option>
</select>`
});
describe('FontSelector component', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('loads the current font immediately and on selection changes', async () => {
const wrapper = mount(FontSelector, {
global: {
stubs: {
'v-select': VSelectStub
}
}
});
const store = useStore();
expect(loadGoogleFont).toHaveBeenCalledTimes(1);
expect(loadGoogleFont).toHaveBeenCalledWith(store.font);
loadGoogleFont.mockClear();
const select = wrapper.get('select');
await select.setValue('Lora');
expect(loadGoogleFont).toHaveBeenCalledTimes(1);
expect(loadGoogleFont).toHaveBeenCalledWith('Lora');
expect(wrapper.html()).toContain('Font');
});
});
================================================
FILE: src/__tests__/fontLoader.test.js
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
const loadModule = async () => {
vi.resetModules();
const module = await import('@/utils/fontLoader.js');
return module;
};
describe('loadGoogleFont', () => {
beforeEach(() => {
document.head.innerHTML = '';
});
it('creates a preload link and converts it to stylesheet on load', async () => {
const { loadGoogleFont } = await loadModule();
loadGoogleFont(' Roboto ');
const link = document.head.querySelector('link');
expect(link).toBeTruthy();
expect(link.rel).toBe('preload');
expect(link.href).toContain('Roboto');
link.onload();
expect(link.rel).toBe('stylesheet');
});
it('avoids duplicate requests while a font is pending or already loaded', async () => {
const { loadGoogleFont } = await loadModule();
loadGoogleFont('Lora');
loadGoogleFont('Lora');
expect(document.head.querySelectorAll('link').length).toBe(1);
const [first] = document.head.querySelectorAll('link');
first.onload();
loadGoogleFont('Lora');
expect(document.head.querySelectorAll('link').length).toBe(1);
});
it('retries after an error by cleaning the pending state', async () => {
const { loadGoogleFont } = await loadModule();
loadGoogleFont('Inter');
let link = document.head.querySelector('link');
expect(link).toBeTruthy();
link.onerror();
expect(document.head.querySelector('link')).toBeNull();
loadGoogleFont('Inter');
link = document.head.querySelector('link');
expect(link).toBeTruthy();
});
it('no-ops when the DOM is unavailable', async () => {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
vi.resetModules();
try {
// @ts-expect-error override for test
globalThis.window = undefined;
// @ts-expect-error override for test
globalThis.document = undefined;
const { loadGoogleFont } = await import('@/utils/fontLoader.js');
expect(() => loadGoogleFont('Roboto')).not.toThrow();
} finally {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
vi.resetModules();
}
});
});
================================================
FILE: src/__tests__/fontsConfig.test.js
================================================
import { describe, it, expect } from 'vitest';
import { fonts } from '@/config/fonts';
describe('fonts config', () => {
it('exports a de-duplicated, trimmed list', () => {
expect(fonts.length).toBe(new Set(fonts).size);
expect(fonts.every((font) => font === font.trim())).toBe(true);
expect(fonts).toContain('Roboto');
expect(fonts).not.toContain('Roboto ');
});
});
================================================
FILE: src/__tests__/generators.test.js
================================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import Pornhub from '@/components/generator/Pornhub.vue';
import VerticalPornHub from '@/components/generator/VerticalPornHub.vue';
import Onlyfans from '@/components/generator/Onlyfans.vue';
import { useStore } from '@/stores/store';
const TooltipStub = defineComponent({
name: 'TooltipStub',
inheritAttrs: false,
template: `<div class="tooltip-stub"><slot name="activator" :props="{}"></slot><slot name="text"></slot><slot /></div>`
});
const MenuStub = defineComponent({
name: 'MenuStub',
inheritAttrs: false,
template: `<div class="menu-stub"><slot name="activator" :props="{}"></slot><slot /></div>`
});
const ColorPickerStub = defineComponent({
name: 'ColorPickerStub',
inheritAttrs: false,
emits: ['update:modelValue'],
template: `<div class="color-picker-stub"><slot /></div>`
});
const SliderStub = defineComponent({
name: 'SliderStub',
inheritAttrs: false,
emits: ['update:modelValue'],
template: `<div class="slider-stub"><slot /></div>`
});
const CheckboxStub = defineComponent({
name: 'CheckboxStub',
inheritAttrs: false,
emits: ['update:modelValue'],
template: `<button class="checkbox-stub"><slot /></button>`
});
const ButtonStub = defineComponent({
name: 'ButtonStub',
inheritAttrs: false,
emits: ['click'],
template: `<button class="btn-stub"><slot /></button>`
});
const IconStub = defineComponent({
name: 'IconStub',
template: `<i class="icon-stub"><slot /></i>`
});
const generatorStubs = {
ExportBtn: { template: '<button class="export-btn-stub" />' },
FontSelector: { template: '<div class="font-selector-stub" />' },
'v-tooltip': TooltipStub,
'v-menu': MenuStub,
'v-color-picker': ColorPickerStub,
'v-slider': SliderStub,
'v-checkbox-btn': CheckboxStub,
'v-btn': ButtonStub,
'v-icon': IconStub
};
const mountGenerator = (component) =>
mount(component, {
attachTo: document.body,
global: {
stubs: generatorStubs
}
});
const editContent = async (wrapper, selector, value) => {
const target = wrapper.get(selector);
target.element.textContent = value;
await target.trigger('input');
return target;
};
describe('generator components', () => {
beforeEach(() => {
document.body.innerHTML = '';
setActivePinia(createPinia());
window.localStorage.clear();
window.history.replaceState(null, '', '/');
});
it('synchronizes text updates and highlight order in Pornhub generator', async () => {
const store = useStore();
const wrapper = mountGenerator(Pornhub);
await editContent(wrapper, '.prefix', 'Only');
await editContent(wrapper, '.postfix', 'Fans');
expect(store.prefix).toBe('Only');
expect(store.suffix).toBe('Fans');
wrapper.vm.reverseHighlight = true;
await nextTick();
const [prefixAfterToggle] = wrapper.findAll('.prefix');
expect(prefixAfterToggle.text().trim()).toBe('Fans');
await editContent(wrapper, '.postfix', 'SwappedPrefix');
await editContent(wrapper, '.prefix', 'SwappedSuffix');
expect(store.prefix).toBe('SwappedPrefix');
expect(store.suffix).toBe('SwappedSuffix');
wrapper.vm.transparentBg = true;
await nextTick();
expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');
wrapper.unmount();
});
it('supports vertical layout interactions', async () => {
const store = useStore();
const wrapper = mountGenerator(VerticalPornHub);
await editContent(wrapper, '.prefix', 'Logo');
await editContent(wrapper, '.postfix', 'Lab');
expect(store.prefix).toBe('Logo');
expect(store.suffix).toBe('Lab');
const colorPickers = wrapper.findAllComponents(ColorPickerStub);
await colorPickers[0].vm.$emit('update:modelValue', '#123123');
await colorPickers[1].vm.$emit('update:modelValue', '#abcdef');
await colorPickers[2].vm.$emit('update:modelValue', '#fedcba');
await nextTick();
const checkboxes = wrapper.findAllComponents(CheckboxStub);
await checkboxes[1].vm.$emit('update:modelValue', true);
await nextTick();
expect(wrapper.findAll('.prefix')[0].text().trim()).toBe('Lab');
await editContent(wrapper, '.postfix', 'VerticalPrefix');
await editContent(wrapper, '.prefix', 'VerticalSuffix');
const slider = wrapper.getComponent(SliderStub);
await checkboxes[0].vm.$emit('update:modelValue', true);
await slider.vm.$emit('update:modelValue', 120);
await nextTick();
expect(wrapper.get('#logo').attributes('style')).toContain('font-size: 120px');
expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
const button = wrapper.findComponent(ButtonStub);
await button.vm.$emit('click');
expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('twitter.com/intent/tweet'));
openSpy.mockRestore();
wrapper.unmount();
});
it('applies Onlyfans-specific defaults and suffix margins', async () => {
const store = useStore();
const wrapper = mountGenerator(Onlyfans);
expect(store.prefix).toBe('Only');
expect(store.suffix).toBe('Fans');
const postfix = wrapper.get('.postfix');
expect(postfix.attributes('style')).toContain('margin-left: -2rem');
const slider = wrapper.getComponent(SliderStub);
await slider.vm.$emit('update:modelValue', 100);
await nextTick();
expect(wrapper.get('#logo').attributes('style')).toContain('font-size: 100px');
postfix.element.textContent = 'Creators';
await postfix.trigger('input');
await editContent(wrapper, '.prefix', 'OnlyYou');
expect(store.prefix).toBe('OnlyYou');
expect(store.suffix).toBe('Creators');
const checkbox = wrapper.getComponent(CheckboxStub);
await checkbox.vm.$emit('update:modelValue', true);
await nextTick();
expect(wrapper.get('#logo').attributes('style')).toContain('background-color: transparent');
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
const button = wrapper.findComponent(ButtonStub);
await button.vm.$emit('click');
expect(openSpy).toHaveBeenCalledTimes(1);
openSpy.mockRestore();
wrapper.unmount();
});
it('reacts to visual controls and share actions', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
const wrapper = mountGenerator(Pornhub);
const colorPickers = wrapper.findAllComponents(ColorPickerStub);
await colorPickers[0].vm.$emit('update:modelValue', '#ff0000');
await colorPickers[1].vm.$emit('update:modelValue', '#00ff00');
await colorPickers[2].vm.$emit('update:modelValue', '#123456');
await nextTick();
const slider = wrapper.getComponent(SliderStub);
await slider.vm.$emit('update:modelValue', 150);
await nextTick();
const checkboxes = wrapper.findAllComponents(CheckboxStub);
await checkboxes[0].vm.$emit('update:modelValue', true);
await checkboxes[1].vm.$emit('update:modelValue', true);
await nextTick();
const logoStyles = wrapper.get('#logo').attributes('style');
expect(logoStyles).toContain('font-size: 150px');
expect(logoStyles).toContain('background-color: transparent');
const spanStyles = wrapper.get('.postfix').attributes('style');
expect(spanStyles).toContain('rgb(18, 52, 86)');
const button = wrapper.findComponent(ButtonStub);
await button.vm.$emit('click');
expect(openSpy).toHaveBeenCalledWith(expect.stringContaining('twitter.com/intent/tweet'));
openSpy.mockRestore();
wrapper.unmount();
});
});
================================================
FILE: src/__tests__/persistentState.test.js
================================================
import { describe, it, expect, beforeEach } from 'vitest';
import {
loadGeneratorState,
saveGeneratorState,
GENERATOR_STATE_STORAGE_KEY
} from '@/utils/persistentState';
describe('persistentState utilities', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState(null, '', '/');
});
it('parses boolean query params and preserves false values', () => {
window.history.replaceState(null, '', '/?transparentBg=0&reverseHighlight=yes');
const state = loadGeneratorState();
expect(state.transparentBg).toBe(false);
expect(state.reverseHighlight).toBe(true);
});
it('ignores malformed booleans and swallows storage parse errors', () => {
window.history.replaceState(null, '', '/?transparentBg=maybe&prefixColor=%23fff');
window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '{');
const state = loadGeneratorState();
expect(state.transparentBg).toBeUndefined();
expect(state.prefixColor).toBe('#fff');
});
it('normalizes payloads before saving and updates the URL', () => {
saveGeneratorState({
prefix: 'Share',
suffix: 'Logo',
font: 'Lora',
fontSize: '120',
transparentBg: '1',
reverseHighlight: 'no',
postfixBgColor: '#123456',
extraneous: 'ignore-me'
});
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
expect(saved.fontSize).toBe(120);
expect(saved.transparentBg).toBe(true);
expect(saved.reverseHighlight).toBe(false);
expect(saved.extraneous).toBeUndefined();
expect(window.location.search).toContain('prefix=Share');
expect(window.location.search).toContain('reverseHighlight=0');
});
it('no-ops when window APIs are unavailable', () => {
const originalWindow = window;
// eslint-disable-next-line no-global-assign
window = undefined;
expect(loadGeneratorState()).toEqual({});
expect(() => saveGeneratorState({ prefix: 'SSR' })).not.toThrow();
// eslint-disable-next-line no-global-assign
window = originalWindow;
});
it('ignores null, empty, or non-string values', () => {
saveGeneratorState({
prefix: 'Keep',
suffix: '',
transparentBg: null,
postfixBgColor: 123
});
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
expect(saved.prefix).toBe('Keep');
expect(saved.suffix).toBe('');
expect(saved.transparentBg).toBeUndefined();
expect(saved.postfixBgColor).toBeUndefined();
window.history.replaceState(null, '', '/?reverseHighlight=&fontSize=');
const state = loadGeneratorState();
expect(state.reverseHighlight).toBeUndefined();
expect(state.fontSize).toBeUndefined();
});
it('skips invalid storage payloads', () => {
window.localStorage.setItem(
GENERATOR_STATE_STORAGE_KEY,
JSON.stringify({
prefix: 123,
suffix: 'Share',
fontSize: 'huge'
})
);
const state = loadGeneratorState();
expect(state.prefix).toBeUndefined();
expect(state.suffix).toBe('Share');
expect(state.fontSize).toBeUndefined();
});
it('handles malformed JSON in storage as empty state', () => {
window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, '"no-object"');
expect(loadGeneratorState()).toEqual({});
});
});
================================================
FILE: src/__tests__/router.test.js
================================================
import { describe, it, expect } from 'vitest';
import router from '@/router';
describe('router', () => {
it('exposes all generator routes', () => {
const names = router.getRoutes().map((route) => route.name);
expect(names).toEqual(
expect.arrayContaining(['pornhub', 'vertical-pornhub', 'onlyfans', 'about'])
);
});
it('provides analytics templates for every route', () => {
for (const route of router.getRoutes()) {
const template = route.meta?.analytics?.pageviewTemplate;
expect(template).toBeTypeOf('function');
const payload = template({ path: route.path });
expect(payload).toMatchObject({ page: route.path });
expect(payload.title.length).toBeGreaterThan(0);
}
});
it('resolves each lazy component definition', async () => {
const configs = router.options.routes;
const components = await Promise.all(configs.map((route) => route.component()));
for (const module of components) {
expect(module).toBeTruthy();
}
});
});
================================================
FILE: src/__tests__/store.test.js
================================================
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useStore } from '@/stores/store';
const getStoreInternals = () => {
const internals = globalThis.__LOGOLY_STORE_INTERNALS__;
if (!internals) {
throw new Error('Store internals were not exposed for testing');
}
return internals;
};
const mountEditable = (content = 'editme', options = {}) => {
const container = document.createElement('div');
container.setAttribute('contenteditable', 'true');
container.id = `logo-${Math.random().toString(16).slice(2)}`;
if (options.raw) {
container.innerHTML = content;
} else {
container.textContent = content;
}
document.body.appendChild(container);
return container;
};
const selectRange = (startNode, startOffset, endNode = startNode, endOffset = startOffset) => {
const range = document.createRange();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return selection;
};
describe('store', () => {
beforeEach(() => {
document.body.innerHTML = '';
setActivePinia(createPinia());
});
it('updates prefix and restores the previous caret selection', async () => {
const editable = mountEditable();
const store = useStore();
const selection = selectRange(editable.firstChild, 1, editable.firstChild, 3);
await store.updatePrefix('changed');
expect(store.prefix).toBe('changed');
expect(selection.rangeCount).toBe(1);
const range = selection.getRangeAt(0);
expect(range.startOffset).toBe(1);
expect(range.endOffset).toBe(3);
});
it('handles selections that span multiple text nodes', async () => {
const editable = mountEditable('<span>hi</span><span>there</span>', {
raw: true
});
const spans = editable.querySelectorAll('span');
const store = useStore();
const selection = selectRange(spans[1].firstChild, 1, spans[1].firstChild, 4);
await store.updatePrefix('merged');
expect(selection.getRangeAt(0).startContainer).toBe(spans[1].firstChild);
expect(selection.getRangeAt(0).startOffset).toBe(1);
});
it('ignores unsupported node types when capturing selections', async () => {
const editable = mountEditable();
const comment = document.createComment('skip');
editable.appendChild(comment);
const selection = selectRange(comment, 0, comment, 0);
const removeSpy = vi.spyOn(selection, 'removeAllRanges');
const store = useStore();
await store.updatePrefix('noop');
expect(removeSpy).not.toHaveBeenCalled();
removeSpy.mockRestore();
});
it('skips restoration when the selection moves outside the editable root', async () => {
const editable = mountEditable();
const outside = document.createElement('p');
outside.textContent = 'outer';
document.body.appendChild(outside);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(editable.firstChild, 0);
range.setEnd(outside.firstChild, 1);
selection.removeAllRanges();
selection.addRange(range);
const removeSpy = vi.spyOn(selection, 'removeAllRanges');
const store = useStore();
await store.updatePrefix('value');
expect(store.prefix).toBe('value');
expect(removeSpy).not.toHaveBeenCalled();
removeSpy.mockRestore();
});
it('avoids restoring when the editable element is removed', async () => {
const editable = mountEditable();
const store = useStore();
const spy = vi.spyOn(window, 'getSelection');
selectRange(editable.firstChild, 0, editable.firstChild, 2);
const promise = store.updatePrefix('gone');
editable.remove();
await promise;
expect(spy).toHaveBeenCalledTimes(2); // once from helper, once from capture
spy.mockRestore();
});
it('restores suffix selections even inside empty containers', async () => {
const editable = mountEditable('', { raw: true });
const store = useStore();
const selection = selectRange(editable, 0, editable, 0);
await store.updateSuffix('tail');
expect(store.suffix).toBe('tail');
const range = selection.getRangeAt(0);
expect(range.startContainer).toBe(editable);
expect(range.startOffset).toBe(0);
});
it('updates suffix without touching selection when nothing is focused', async () => {
const store = useStore();
const spy = vi.spyOn(window, 'getSelection').mockReturnValue(null);
await store.updateSuffix('tail');
expect(store.suffix).toBe('tail');
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it('ignores selections that are missing a valid start container', async () => {
const store = useStore();
const fakeRange = {
startContainer: null,
endContainer: document.createElement('div'),
startOffset: 0,
endOffset: 0
};
const selection = {
rangeCount: 1,
getRangeAt: vi.fn(() => fakeRange)
};
const spy = vi.spyOn(window, 'getSelection').mockReturnValue(selection);
await store.updatePrefix('noop');
expect(selection.getRangeAt).toHaveBeenCalled();
spy.mockRestore();
});
it('skips restoring when selection disappears mid-update', async () => {
const editable = mountEditable();
const realSelection = selectRange(editable.firstChild, 0, editable.firstChild, 2);
const store = useStore();
const spy = vi.spyOn(window, 'getSelection');
spy.mockImplementationOnce(() => realSelection);
spy.mockImplementationOnce(() => null);
spy.mockImplementation(() => realSelection);
await store.updatePrefix('persist');
expect(store.prefix).toBe('persist');
expect(spy).toHaveBeenCalledTimes(2);
spy.mockRestore();
});
it('falls back to root positions when text nodes are removed', async () => {
const editable = mountEditable('ab');
const store = useStore();
selectRange(editable.firstChild, 0, editable.firstChild, 2);
const promise = store.updatePrefix('target');
editable.innerHTML = '<br />';
await promise;
const selection = window.getSelection();
const range = selection.getRangeAt(0);
expect(range.startContainer).toBe(editable);
expect(range.startOffset).toBeGreaterThanOrEqual(0);
});
});
describe('store internals', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
getStoreInternals().clearOverrides();
});
it('returns null for text nodes without editable ancestors', () => {
const internals = getStoreInternals();
const wrapper = document.createElement('div');
wrapper.textContent = 'plain';
document.body.appendChild(wrapper);
expect(internals.getEditableAncestor(wrapper.firstChild)).toBeNull();
});
it('returns null for element nodes without editable ancestors', () => {
const internals = getStoreInternals();
const wrapper = document.createElement('div');
const child = document.createElement('span');
wrapper.appendChild(child);
document.body.appendChild(wrapper);
expect(internals.getEditableAncestor(child)).toBeNull();
});
it('treats walker nodes without text content as zero length', () => {
const internals = getStoreInternals();
const originalWalker = document.createTreeWalker;
const fakeNode = { textContent: undefined };
const walker = {
currentNode: null,
called: false,
nextNode() {
if (this.called) return false;
this.called = true;
this.currentNode = fakeNode;
return true;
}
};
document.createTreeWalker = vi.fn(() => walker);
try {
const root = document.createElement('div');
const result = internals.resolvePosition(root, 0);
expect(result).toEqual({ node: fakeNode, offset: 0 });
} finally {
document.createTreeWalker = originalWalker;
}
});
it('falls back to length zero when editable textContent returns null', () => {
const internals = getStoreInternals();
const editable = mountEditable();
selectRange(editable.firstChild, 1, editable.firstChild, 1);
const snapshot = internals.captureSelectionSnapshot();
expect(snapshot).not.toBeNull();
const originalDescriptor = Object.getOwnPropertyDescriptor(editable, 'textContent');
Object.defineProperty(editable, 'textContent', {
configurable: true,
get() {
return null;
},
set(value) {
if (originalDescriptor?.set) {
originalDescriptor.set.call(editable, value);
}
}
});
const selection = window.getSelection();
selection.removeAllRanges();
internals.restoreSelectionSnapshot(snapshot);
expect(selection.rangeCount).toBe(1);
Reflect.deleteProperty(editable, 'textContent');
});
it('skips selection restoration when overrides return null positions', () => {
const internals = getStoreInternals();
const editable = mountEditable();
selectRange(editable.firstChild, 0, editable.firstChild, 2);
const snapshot = internals.captureSelectionSnapshot();
expect(snapshot).not.toBeNull();
internals.setResolvePositionOverride(() => null);
window.getSelection().removeAllRanges();
internals.restoreSelectionSnapshot(snapshot);
expect(window.getSelection().rangeCount).toBe(0);
});
});
describe('store without DOM APIs', () => {
it('short-circuits DOM helpers when window and document are missing', async () => {
const realWindow = globalThis.window;
const realDocument = globalThis.document;
const realNodeFilter = globalThis.NodeFilter;
globalThis.window = undefined;
globalThis.document = undefined;
globalThis.NodeFilter = undefined;
vi.resetModules();
const { useStore: useStoreWithoutDom } = await import('@/stores/store');
const internals = getStoreInternals();
setActivePinia(createPinia());
const store = useStoreWithoutDom();
await store.updatePrefix('noop');
await store.updateSuffix('noop');
expect(() =>
internals.restoreSelectionSnapshot({
editableElement: null,
startOffset: 0,
endOffset: 0
})
).not.toThrow();
globalThis.window = realWindow;
globalThis.document = realDocument;
globalThis.NodeFilter = realNodeFilter;
vi.resetModules();
setActivePinia(createPinia());
await import('@/stores/store');
});
});
================================================
FILE: src/__tests__/useGeneratorControls.test.js
================================================
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { useGeneratorControls } from '@/composables/useGeneratorControls';
import { useStore } from '@/stores/store';
import { GENERATOR_STATE_STORAGE_KEY } from '@/utils/persistentState';
const mountedWrappers = new Set();
const mountComposable = (options) => {
let api;
const Comp = defineComponent({
template: '<div />',
setup() {
api = useGeneratorControls(options);
return api;
}
});
const wrapper = mount(Comp);
mountedWrappers.add(wrapper);
return { wrapper, api };
};
describe('useGeneratorControls', () => {
beforeEach(() => {
setActivePinia(createPinia());
window.localStorage.clear();
window.history.replaceState(null, '', '/');
});
afterEach(() => {
mountedWrappers.forEach((wrapper) => {
if (wrapper.exists()) {
wrapper.unmount();
}
});
mountedWrappers.clear();
});
it('provides sensible defaults and computed helpers', () => {
const { api } = mountComposable();
expect(api.prefixColor.value).toBe('#ffffff');
expect(api.suffixColor.value).toBe('#000000');
expect(api.transparentBgColor.value).toBe('#000000');
expect(api.suffixMargin.value).toBeUndefined();
api.transparentBg.value = true;
expect(api.transparentBgColor.value).toBe('transparent');
const custom = mountComposable({ suffixMarginScale: 30 });
custom.api.fontSize.value = 90;
expect(custom.api.suffixMargin.value).toBe('-3rem');
});
it('forwards text updates to the store', () => {
const store = useStore();
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const { api } = mountComposable();
api.updatePrefix({ target: { textContent: 'Porn' } });
api.updateSuffix({ target: { innerText: 'hub' } });
api.updatePrefix({ target: { innerText: 'Only' } });
api.updateSuffix({ target: {} });
api.updatePrefix(null);
expect(prefixSpy).toHaveBeenCalledWith('Porn');
expect(prefixSpy).toHaveBeenLastCalledWith('Only');
expect(suffixSpy).toHaveBeenCalledWith('hub');
expect(suffixSpy).toHaveBeenLastCalledWith('');
});
it('hydrates initial text on mount when nothing is persisted', async () => {
const store = useStore();
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const { wrapper } = mountComposable({
backgroundColor: '#222222',
initialText: { prefix: 'Only', suffix: 'Fans' },
resetText: { prefix: 'edit', suffix: 'me' }
});
await nextTick();
expect(prefixSpy).toHaveBeenCalledWith('Only');
expect(suffixSpy).toHaveBeenCalledWith('Fans');
wrapper.unmount();
await nextTick();
expect(prefixSpy).not.toHaveBeenCalledWith('edit');
expect(suffixSpy).not.toHaveBeenCalledWith('me');
});
it('handles partial initial payloads without clobbering other fields', async () => {
const store = useStore();
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const suffixOnly = mountComposable({ initialText: { suffix: 'Fans' } });
await nextTick();
expect(prefixSpy).not.toHaveBeenCalled();
expect(suffixSpy).toHaveBeenCalledWith('Fans');
suffixOnly.wrapper.unmount();
await nextTick();
prefixSpy.mockClear();
suffixSpy.mockClear();
const prefixOnly = mountComposable({ initialText: { prefix: 'Only' } });
await nextTick();
expect(prefixSpy).toHaveBeenCalledWith('Only');
expect(suffixSpy).not.toHaveBeenCalled();
prefixOnly.wrapper.unmount();
await nextTick();
});
it('persists generator state to localStorage and query params', async () => {
const store = useStore();
const { api } = mountComposable();
await store.updatePrefix('Share');
await store.updateSuffix('Logo');
store.font = 'Open Sans';
api.prefixColor.value = '#123123';
api.suffixColor.value = '#abcdef';
api.postfixBgColor.value = '#654321';
api.fontSize.value = 110;
api.transparentBg.value = true;
api.reverseHighlight.value = true;
await nextTick();
const saved = JSON.parse(window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY));
expect(saved.prefix).toBe('Share');
expect(saved.suffix).toBe('Logo');
expect(saved.font).toBe('Open Sans');
expect(saved.prefixColor).toBe('#123123');
expect(saved.transparentBg).toBe(true);
expect(window.location.search).toContain('prefix=Share');
expect(window.location.search).toContain('reverseHighlight=1');
});
it('restores state from storage and lets query params override it', async () => {
window.localStorage.setItem(
GENERATOR_STATE_STORAGE_KEY,
JSON.stringify({
prefix: 'StoredPrefix',
suffix: 'StoredSuffix',
font: 'Lora',
prefixColor: '#101010',
suffixColor: '#202020',
postfixBgColor: '#303030',
fontSize: 80,
transparentBg: true,
reverseHighlight: false
})
);
window.history.replaceState(
null,
'',
'/?prefix=QueryPrefix&suffixColor=%23aa00aa&reverseHighlight=1'
);
const { api } = mountComposable({ initialText: { prefix: 'Default', suffix: 'Values' } });
const store = useStore();
await nextTick();
expect(store.prefix).toBe('QueryPrefix');
expect(store.suffix).toBe('StoredSuffix');
expect(store.font).toBe('Lora');
expect(api.prefixColor.value).toBe('#101010');
expect(api.suffixColor.value).toBe('#aa00aa');
expect(api.postfixBgColor.value).toBe('#303030');
expect(api.fontSize.value).toBe(80);
expect(api.transparentBg.value).toBe(true);
expect(api.reverseHighlight.value).toBe(true);
});
it('builds the Twitter intent url with the expected payload', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
const { api } = mountComposable();
api.twitter();
expect(openSpy).toHaveBeenCalledTimes(1);
expect(openSpy.mock.calls[0][0]).toContain('twitter.com/intent/tweet');
expect(openSpy.mock.calls[0][0]).toContain('logoly.pro');
openSpy.mockRestore();
});
it('hydrates correctly even when store updates resolve synchronously', async () => {
const store = useStore();
const originalPrefix = store.updatePrefix;
const originalSuffix = store.updateSuffix;
store.updatePrefix = vi.fn((text) => {
store.prefix = text;
return text;
});
store.updateSuffix = vi.fn((text) => {
store.suffix = text;
return text;
});
mountComposable({ initialText: { prefix: 'Sync', suffix: 'State' } });
await nextTick();
expect(store.updatePrefix).toHaveBeenCalledWith('Sync');
expect(store.updateSuffix).toHaveBeenCalledWith('State');
expect(store.prefix).toBe('Sync');
expect(store.suffix).toBe('State');
store.updatePrefix = originalPrefix;
store.updateSuffix = originalSuffix;
});
it('resets text on unmount when persistence is disabled', async () => {
const store = useStore();
await store.updatePrefix('Tmp');
await store.updateSuffix('State');
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const { wrapper } = mountComposable({
resetText: { prefix: 'edit', suffix: 'me' },
persistenceEnabled: false
});
await nextTick();
prefixSpy.mockClear();
suffixSpy.mockClear();
wrapper.unmount();
await nextTick();
expect(prefixSpy).toHaveBeenCalledWith('edit');
expect(suffixSpy).toHaveBeenCalledWith('me');
});
it('does not reset prefix when resetText omits it', async () => {
const store = useStore();
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const { wrapper } = mountComposable({
resetText: { suffix: 'stay' },
persistenceEnabled: false
});
await nextTick();
prefixSpy.mockClear();
suffixSpy.mockClear();
wrapper.unmount();
await nextTick();
expect(prefixSpy).not.toHaveBeenCalled();
expect(suffixSpy).toHaveBeenCalledWith('stay');
});
it('does not reset suffix when resetText omits it', async () => {
const store = useStore();
const prefixSpy = vi.spyOn(store, 'updatePrefix');
const suffixSpy = vi.spyOn(store, 'updateSuffix');
const { wrapper } = mountComposable({
resetText: { prefix: 'back' },
persistenceEnabled: false
});
await nextTick();
prefixSpy.mockClear();
suffixSpy.mockClear();
wrapper.unmount();
await nextTick();
expect(prefixSpy).toHaveBeenCalledWith('back');
expect(suffixSpy).not.toHaveBeenCalled();
});
it('continues hydrating when a task rejects', async () => {
const store = useStore();
const originalPrefix = store.updatePrefix;
const rejection = new Error('hydrate failure');
store.updatePrefix = vi.fn(() => Promise.reject(rejection));
const suffixSpy = vi.spyOn(store, 'updateSuffix');
mountComposable({ initialText: { prefix: 'Only', suffix: 'Fans' } });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(store.updatePrefix).toHaveBeenCalledWith('Only');
expect(suffixSpy).toHaveBeenCalledWith('Fans');
store.updatePrefix = originalPrefix;
});
});
================================================
FILE: src/assets/iconfont/iconfont.css
================================================
@font-face {
font-family: "iconfont";
src: url("iconfont.eot?t=1553360818414"); /* IE9 */
src: url("iconfont.eot?t=1553360818414#iefix") format("embedded-opentype"), /* IE6-IE8 */
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(
"woff2"
), url("iconfont.woff?t=1553360818414") format("woff"), url("iconfont.ttf?t=1553360818414")
format("truetype"), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */
url("iconfont.svg?t=1553360818414#iconfont") format("svg"); /* iOS 4.1- */
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-gmail:before {
content: "\e6a5";
}
.icon-twitter:before {
content: "\e655";
}
.icon-github:before {
content: "\f1b4";
}
================================================
FILE: src/assets/iconfont/iconfont.js
================================================
!(function (a) {
var e,
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>',
t = (e = document.getElementsByTagName('script'))[e.length - 1].getAttribute('data-injectcss');
if (t && !a.__iconfont__svg__cssinject__) {
a.__iconfont__svg__cssinject__ = !0;
try {
document.write(
'<style>.svgfont {display: inline-block;width: 1em;height: 1em;fill: currentColor;vertical-align: -0.1em;font-size:16px;}</style>'
);
} catch (e) {
console && console.log(e);
}
}
!(function (e) {
if (document.addEventListener)
if (~['complete', 'loaded', 'interactive'].indexOf(document.readyState)) setTimeout(e, 0);
else {
var t = function () {
document.removeEventListener('DOMContentLoaded', t, !1), e();
};
document.addEventListener('DOMContentLoaded', t, !1);
}
else
document.attachEvent &&
((n = e),
(o = a.document),
(i = !1),
(c = function () {
i || ((i = !0), n());
}),
(l = function () {
try {
o.documentElement.doScroll('left');
} catch (e) {
return void setTimeout(l, 50);
}
c();
})(),
(o.onreadystatechange = function () {
'complete' == o.readyState && ((o.onreadystatechange = null), c());
}));
var n, o, i, c, l;
})(function () {
var e, t;
((e = document.createElement('div')).innerHTML = n),
(n = null),
(t = e.getElementsByTagName('svg')[0]) &&
(t.setAttribute('aria-hidden', 'true'),
(t.style.position = 'absolute'),
(t.style.width = 0),
(t.style.height = 0),
(t.style.overflow = 'hidden'),
(function (e, t) {
t.firstChild
? (function (e, t) {
t.parentNode.insertBefore(e, t);
})(e, t.firstChild)
: t.appendChild(e);
})(t, document.body));
});
})(window);
================================================
FILE: src/components/Author.vue
================================================
<template>
<div>
<div class="flex flex-col items-center pt-20 pb-12">
<img class="max-w-24 rounded-full mb-3" src="../assets/avatar.png" alt="bestony" />
<h2 class="text-2xl font-semibold py-2">Bestony</h2>
<h4 class="font-semibold">An indie developer / Focus on something interesting.</h4>
</div>
<div class="flex justify-around items-center py-12">
<a
class="github"
href="https://github.com/bestony"
target="_blank"
rel="noopener noreferrer"
><i class="iconfont icon-github"></i> GitHub</a
>
<a
class="twitter"
href="https://twitter.com/xiqingongzi"
target="_blank"
rel="noopener noreferrer"
><i class="iconfont icon-twitter"></i> Twitter</a
>
<a class="gmail" href="mailto:xiqingongzi+logoly@gmail.com"
><i class="iconfont icon-gmail"></i> Email</a
>
</div>
</div>
</template>
================================================
FILE: src/components/Copyright.vue
================================================
<template>
<p class="text-center text-[#666]">
© Bestony {{ currentYear }}
<a href="https://www.ixiqin.com" target="_blank" rel="noopener noreferrer">website</a>
</p>
</template>
<script setup>
const currentYear = new Date().getFullYear();
</script>
================================================
FILE: src/components/Description.vue
================================================
<template>
<div>
<h2 class="text-white">A Simple Online Logo Generator</h2>
</div>
</template>
================================================
FILE: src/components/ExportBtn.vue
================================================
<template>
<div>
<v-tooltip text="Export your own logo" location="top" model-value>
<template v-slot:activator="{ props }">
<v-btn color="#f90" v-bind="props">
<v-icon icon="mdi-download"></v-icon>Export
<v-menu activator="parent">
<v-list>
<v-list-item key="png" value="png" @click="download('png')">PNG</v-list-item>
<v-list-item key="svg" value="svg" @click="download('svg')">SVG</v-list-item>
</v-list>
</v-menu>
</v-btn>
</template>
</v-tooltip>
</div>
</template>
<script setup>
import { useStore } from '@/stores/store';
import domtoimage from 'dom-to-image';
import { ref } from 'vue';
import { event } from 'vue-gtag';
import { onClickOutside } from '@vueuse/core';
const store = useStore();
const showMenu = ref(false);
const btnRef = ref(null);
onClickOutside(btnRef, () => {
showMenu.value = false;
});
const downloadImage = (imgsrc, name) => {
//下载图片地址和图片名
const image = new Image();
// 解决跨域 Canvas 污染问题
image.setAttribute('crossOrigin', 'anonymous');
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0, image.width, image.height);
const url = canvas.toDataURL('image/png');
const link = document.createElement('a');
const clickEvent = new MouseEvent('click');
link.download = name || 'photo';
link.href = url;
link.dispatchEvent(clickEvent);
};
image.src = imgsrc;
};
const download = (type) => {
showMenu.value = false;
store.editable = false;
event('download');
const node = document.getElementById('logo');
if (!node) return;
if (type === 'png') {
domtoimage.toPng(node).then((res) => {
downloadImage(res, `${store.prefix}-${store.suffix}.png`);
store.editable = true;
});
} else if (type === 'svg') {
domtoimage.toSvg(node).then((res) => {
const link = document.createElement('a');
link.download = `${store.prefix}-${store.suffix}.svg`;
link.href = res;
link.click();
store.editable = true;
});
}
};
</script>
================================================
FILE: src/components/Faq.vue
================================================
<template>
<div class="text-white mt-12">
<h3 class="text-2xl font-semibold text-custom-primary mb-1">FAQ</h3>
<v-expansion-panels variant="accordion" mandatory="force">
<v-expansion-panel title="How to use this generator?">
<template v-slot:text>
The generator is very simple to use. You can get a logo in just 4 steps:
<ol class="list-decimal mt-2 ml-6 space-y-1" aria-label="Steps to generate a logo">
<li v-for="step in steps" :key="step.id">{{ step.text }}</li>
</ol>
</template>
</v-expansion-panel>
<v-expansion-panel
text="The generated logo fully belongs to you. You can use it freely for any purposes. Though credit is always appreciated."
title="Can I use the generated logo for personal or commercial purposes?"
></v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script setup>
const steps = [
{
id: 'choose-style',
text: 'Choose a style (e.g. Horizontal/Vertical, Pornhub/Youtube/...).'
},
{
id: 'edit-text',
text: 'Edit the text in the center box.'
},
{
id: 'customize',
text: 'Customize your logo (color, background, font size, etc.).'
},
{
id: 'export',
text: 'Click the Export button to download your logo as PNG or SVG.'
}
];
</script>
================================================
FILE: src/components/FontSelector.vue
================================================
<template>
<v-select
hide-details
v-model="store.font"
label="Font"
:items="fonts"
color="#f90"
variant="outlined"
></v-select>
</template>
<script setup>
import { watch } from 'vue';
import { useStore } from '@/stores/store';
import { loadGoogleFont } from '@/utils/fontLoader';
import { fonts } from '@/config/fonts';
const store = useStore();
watch(
() => store.font,
(font) => {
loadGoogleFont(font);
},
{ immediate: true }
);
</script>
================================================
FILE: src/components/Logo.vue
================================================
<template>
<router-link to="/" class="py-12">
<h1>
<span class="text-white p-1">Logoly</span>
<span class="text-black bg-custom-primary p-1 rounded-md">Pro</span>
</h1>
</router-link>
</template>
================================================
FILE: src/components/Ribbon.vue
================================================
<template>
<a
class="absolute md:fixed top-0 right-0"
href="https://github.com/bestony/logoly"
target="_blank"
rel="noopener noreferrer"
>
<img
width="149"
height="149"
src="https://github.blog/wp-content/uploads/2008/12/forkme_right_orange_ff7600.png?resize=149%2C149"
class="attachment-full size-full"
alt="Fork me on GitHub"
data-recalc-dims="1"
/></a>
</template>
================================================
FILE: src/components/Slogan.vue
================================================
<template>
<div class="mt-12">
<h3 class="text-xl font-semibold mb-2">
<span class="text-white">Logoly</span>
<span class="text-black bg-custom-primary p-1 rounded-md">Pro</span>
</h3>
<p class="text-white">
Logoly.pro is a creative logo generator, you can generate logo similar to Pornhub, YouTube,
and more.
<strong class="text-custom-primary flex gap-2 mt-2">
If you think this project is funny, please
<a href="https://github.com/bestony/logoly">
<img src="https://img.shields.io/badge/give%20me-a%20star-green.svg" />
</a>
</strong>
</p>
</div>
</template>
================================================
FILE: src/components/generator/Onlyfans.vue
================================================
<template>
<div class="flex flex-col items-center">
<v-tooltip text="Edit the text to create your own logo" model-value location="top">
<template v-slot:activator="{ props }">
<div v-bind="props" class="box">
<div
class="editarea"
id="logo"
:style="{
'font-size': fontSize + 'px',
'background-color': transparentBgColor
}"
>
<span
@input="updatePrefix"
class="prefix"
:style="{ color: prefixColor }"
:contenteditable="store.editable"
spellcheck="false"
>
{{ store.prefix }}
</span>
<!-- HACK: meaningless text: ".", just to split input area, see: #269 -->
<span style="font-size: 0">.</span>
<span
class="postfix"
:style="{
color: suffixColor,
'background-color': '#00AFF0',
'margin-left': suffixMargin + 10
}"
:contenteditable="store.editable"
@input="updateSuffix"
spellcheck="false"
>{{ store.suffix }}</span
>
</div>
</div>
</template>
</v-tooltip>
<div class="w-1/3 mb-12">
<div class="flex flex-col">
Font Size: {{ fontSize }}px
<div class="-ml-1">
<v-slider
hide-details
min="30"
max="200"
step="1"
color="#f90"
v-model="fontSize"
></v-slider>
</div>
</div>
<div class="flex items-center">
Transparent Background: <v-checkbox-btn v-model="transparentBg"></v-checkbox-btn>
</div>
</div>
<div class="download-share">
<ExportBtn />
<v-btn @click="twitter" color="#1da1f2"
><v-icon icon="mdi-twitter" class="mr-0.5"></v-icon> Tweet</v-btn
>
</div>
</div>
</template>
<script setup>
import ExportBtn from '@/components/ExportBtn.vue';
import { useGeneratorControls } from '@/composables/useGeneratorControls';
const {
store,
prefixColor,
suffixColor,
postfixBgColor,
fontSize,
transparentBg,
transparentBgColor,
suffixMargin,
updatePrefix,
updateSuffix,
twitter
} = useGeneratorControls({
suffixMarginScale: 50,
postfixBgColor: 'transparent',
suffixColor: '#00AFF0',
backgroundColor: '#000000',
initialText: { prefix: 'Only', suffix: 'Fans' },
resetText: { prefix: 'edit', suffix: 'me' }
});
</script>
<style lang="stylus" scoped>
.pornhub {
display: flex;
flex-direction: column;
align-items: center;
}
.box {
border: 1px solid #333;
border-radius: 10px;
padding: 40px;
margin: 40px 10px;
max-width: 100%;
.editarea {
padding: 20px 30px;
text-align: center;
font-size: 60px;
font-weight: 700;
.prefix {
color: #fff;
padding: 5px 5px;
font-family: "Inter", sans-serif;
font-optical-sizing: auto;
font-weight: 200;
font-style: normal;
z-index: 21;
}
.postfix {
color: #000;
background-color: transparent;
padding: 5px 10px;
margin-left: 0rem;
font-family: "Arizonia", cursive;
font-weight: 400;
font-style: normal;
z-index: 20;
}
}
}
.customize {
text-align: center;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin-bottom: 50px;
.customize-color > div,
.customize-misc > div {
padding: 8px 0;
}
}
.download-share {
display: flex;
justify-content: space-around;
width: 80%;
& > div {
width: 100px;
height: 40px;
border-radius: 3px;
line-height: 40px;
text-align: center;
cursor: pointer;
}
.download {
color: black;
background: #f90;
}
.share {
color: #fff;
background: #1da1f2;
}
}
</style>
================================================
FILE: src/components/generator/Pornhub.vue
================================================
<template>
<div class="pornhub">
<v-tooltip text="Edit the text to create your own logo" location="top" model-value>
<template v-slot:activator="{ props }">
<div v-bind="props" class="box">
<div
class="editarea"
id="logo"
:style="{
'font-size': fontSize + 'px',
'background-color': transparentBgColor,
'font-family': store.font
}"
>
<template v-if="!reverseHighlight">
<span
@input="updatePrefix"
class="prefix"
:style="{ color: prefixColor }"
:contenteditable="store.editable"
spellcheck="false"
>
{{ store.prefix }}
</span>
<!-- HACK: meaningless text: ".", just to split input area, see: #269 -->
<span style="font-size: 0">.</span>
<span
class="postfix"
:style="{ color: suffixColor, 'background-color': postfixBgColor }"
:contenteditable="store.editable"
@input="updateSuffix"
spellcheck="false"
>{{ store.suffix }}</span
>
</template>
<template v-else>
<span
class="postfix"
:style="{ color: suffixColor, 'background-color': postfixBgColor }"
:contenteditable="store.editable"
@input="updatePrefix"
spellcheck="false"
>{{ store.prefix }}</span
>
<span
class="prefix"
@input="updateSuffix"
:style="{ color: prefixColor }"
:contenteditable="store.editable"
spellcheck="false"
>
{{ store.suffix }}
</span>
</template>
</div>
</div>
</template>
</v-tooltip>
<div class="customize mt-3">
<v-tooltip text="Pick a color you like" location="top" model-value>
<template v-slot:activator="{ props }">
<div v-bind="props" class="customize-color" id="prefixColor">
<div>
Prefix Text Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': prefixColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="prefixColor"></v-color-picker>
</v-menu>
</div>
<div>
Suffix Text Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': suffixColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="suffixColor"></v-color-picker>
</v-menu>
</div>
<div>
Suffix Background Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': postfixBgColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="postfixBgColor"></v-color-picker>
</v-menu>
</div>
<div class="flex items-center">
Transparent Background: <v-checkbox-btn v-model="transparentBg"></v-checkbox-btn>
</div>
</div>
</template>
</v-tooltip>
<div class="customize-misc">
<div class="flex flex-col">
Font Size: {{ fontSize }}px
<div class="-ml-1">
<v-slider
hide-details
min="30"
max="200"
step="1"
color="#f90"
v-model="fontSize"
></v-slider>
</div>
</div>
<FontSelector />
<div class="flex items-center">
Reverse Highlight: <v-checkbox-btn v-model="reverseHighlight"></v-checkbox-btn>
</div>
</div>
</div>
<div class="download-share">
<ExportBtn />
<v-btn @click="twitter" color="#1da1f2"
><v-icon icon="mdi-twitter" class="mr-0.5"></v-icon> Tweet</v-btn
>
</div>
</div>
</template>
<script setup>
import FontSelector from '@/components/FontSelector.vue';
import ExportBtn from '@/components/ExportBtn.vue';
import { useGeneratorControls } from '@/composables/useGeneratorControls';
const {
store,
prefixColor,
suffixColor,
postfixBgColor,
fontSize,
transparentBg,
reverseHighlight,
transparentBgColor,
updatePrefix,
updateSuffix,
twitter
} = useGeneratorControls({ backgroundColor: '#000000' });
</script>
<style lang="stylus" scoped>
.pornhub {
display: flex;
flex-direction: column;
align-items: center;
}
.box {
border: 1px solid #333;
border-radius: 10px;
padding: 40px;
margin: 40px 10px;
max-width: 100%;
.editarea {
padding: 20px;
text-align: center;
font-size: 60px;
font-weight: 700;
.prefix {
color: #fff;
padding: 5px 5px;
}
.postfix {
color: #000;
background-color: #f90;
padding: 5px 10px;
border-radius: 7px;
}
}
}
.customize {
display: flex;
justify-content: space-around;
width: 100%;
margin-bottom: 50px;
.customize-color > div,
.customize-misc > div {
padding: 8px 0;
}
}
.download-share {
display: flex;
justify-content: space-around;
width: 80%;
& > div {
width: 100px;
height: 40px;
border-radius: 3px;
line-height: 40px;
text-align: center;
cursor: pointer;
}
.download {
color: black;
background: #f90;
}
.share {
color: #fff;
background: #1da1f2;
}
}
</style>
================================================
FILE: src/components/generator/VerticalPornHub.vue
================================================
<template>
<div class="pornhub">
<v-tooltip text="Edit the text to create your own logo" location="top" model-value>
<template v-slot:activator="{ props }">
<div v-bind="props" class="box">
<div
class="editarea"
id="logo"
:style="{
'font-size': fontSize + 'px',
'background-color': transparentBgColor,
'font-family': store.font
}"
>
<template v-if="!reverseHighlight">
<p
class="prefix"
@input="updatePrefix"
:style="{ color: prefixColor }"
:contenteditable="store.editable"
>
{{ store.prefix }}
</p>
<p
class="postfix"
@input="updateSuffix"
:style="{ color: suffixColor, 'background-color': postfixBgColor }"
:contenteditable="store.editable"
>
{{ store.suffix }}
</p>
</template>
<template v-else>
<p
class="postfix"
@input="updatePrefix"
:style="{ color: suffixColor, 'background-color': postfixBgColor }"
:contenteditable="store.editable"
>
{{ store.prefix }}
</p>
<p
class="prefix"
@input="updateSuffix"
:style="{ color: prefixColor }"
:contenteditable="store.editable"
>
{{ store.suffix }}
</p>
</template>
</div>
</div>
</template>
</v-tooltip>
<div class="customize mt-3">
<v-tooltip text="Pick a color you like" location="top" model-value>
<template v-slot:activator="{ props }">
<div v-bind="props" class="customize-color" id="prefixColor">
<div>
Prefix Text Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': prefixColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="prefixColor"></v-color-picker>
</v-menu>
</div>
<div>
Suffix Text Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': suffixColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="suffixColor"></v-color-picker>
</v-menu>
</div>
<div>
Suffix Background Color:
<v-menu :close-on-content-click="false" location="end">
<template v-slot:activator="{ props }">
<button
v-bind="props"
class="w-12 h-6 rounded ml-1 border-2 border-solid border-white"
:style="{ 'background-color': postfixBgColor }"
></button>
</template>
<v-color-picker mode="hex" hide-inputs v-model="postfixBgColor"></v-color-picker>
</v-menu>
</div>
<div class="flex items-center">
Transparent Background: <v-checkbox-btn v-model="transparentBg"></v-checkbox-btn>
</div>
</div>
</template>
</v-tooltip>
<div class="customize-misc">
<div class="flex flex-col">
Font Size: {{ fontSize }}px
<div class="-ml-1">
<v-slider
hide-details
min="30"
max="200"
step="1"
color="#f90"
v-model="fontSize"
></v-slider>
</div>
</div>
<FontSelector />
<div class="flex items-center">
Reverse Highlight: <v-checkbox-btn v-model="reverseHighlight"></v-checkbox-btn>
</div>
</div>
</div>
<div class="download-share">
<ExportBtn />
<v-btn @click="twitter" color="#1da1f2"
><v-icon icon="mdi-twitter" class="mr-0.5"></v-icon> Tweet</v-btn
>
</div>
</div>
</template>
<script setup>
import FontSelector from '@/components/FontSelector.vue';
import ExportBtn from '../ExportBtn.vue';
import { useGeneratorControls } from '@/composables/useGeneratorControls';
const {
store,
prefixColor,
suffixColor,
postfixBgColor,
fontSize,
transparentBg,
reverseHighlight,
transparentBgColor,
updatePrefix,
updateSuffix,
twitter
} = useGeneratorControls({ backgroundColor: '#000000' });
</script>
<style lang="stylus" scoped>
.pornhub
display flex
flex-direction column
align-items center
.box
border 2px solid #333
border-radius 10px
padding 40px
margin 40px 0px
max-width 100%
.editarea
padding 20px
text-align center
font-size 60px
font-weight 700
border-radius 10px
.prefix
color #fff
padding 5px 5px
margin 0
.postfix
color #000
background-color #f90
padding 5px 10px
border-radius 7px
margin 0
.switch
display flex
flex-direction row
justify-content space-around
padding 40px 0px 0px 0px
width 80%
// customize things
.customize
display flex
justify-content space-around
width 100%
margin-bottom 50px
.customize-color > div,
.customize-misc > div
padding 8px 0
// download and share buttons
.download-share
display flex
justify-content space-around
width 80%
& > div
width 100px
height 40px
border-radius 3px
line-height 40px
text-align center
cursor pointer
.download
color black
background #f90
.share
color #fff
background #1da1f2
</style>
================================================
FILE: src/composables/useGeneratorControls.js
================================================
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useStore } from '@/stores/store';
import { loadGeneratorState, saveGeneratorState } from '@/utils/persistentState';
export function useGeneratorControls(options = {}) {
const store = useStore();
const persistenceAvailable =
typeof options.persistenceEnabled === 'boolean'
? options.persistenceEnabled
: typeof window !== 'undefined';
const persistedState = persistenceAvailable ? loadGeneratorState() : {};
const backgroundColor = options.backgroundColor ?? '#000000';
let isHydrating = true;
const hasPersistedState = Object.keys(persistedState).length > 0;
const prefixColor = ref(persistedState.prefixColor ?? options.prefixColor ?? '#ffffff');
const suffixColor = ref(persistedState.suffixColor ?? options.suffixColor ?? '#000000');
const postfixBgColor = ref(persistedState.postfixBgColor ?? options.postfixBgColor ?? '#ff9900');
const fontSize = ref(persistedState.fontSize ?? options.fontSize ?? 60);
const transparentBg = ref(persistedState.transparentBg ?? options.transparentBg ?? false);
const reverseHighlight = ref(persistedState.reverseHighlight ?? options.reverseHighlight ?? false);
const updateText = (updater) => (event) => {
if (!event?.target) return;
const value = event.target.textContent ?? event.target.innerText ?? '';
updater(value);
};
const updatePrefix = updateText(store.updatePrefix);
const updateSuffix = updateText(store.updateSuffix);
const transparentBgColor = computed(() =>
transparentBg.value ? 'transparent' : backgroundColor
);
const suffixMargin = computed(() => {
if (!options.suffixMarginScale) return undefined;
return `-${fontSize.value / options.suffixMarginScale}rem`;
});
const twitter = () => {
const url = 'https://logoly.pro';
const text = encodeURIComponent(`Built with #LogolyPro, by @xiqingongzi ${url}`);
window.open(`https://twitter.com/intent/tweet?text=${text}`);
};
const persistState = () => {
if (!persistenceAvailable || isHydrating) return;
saveGeneratorState({
prefix: store.prefix,
suffix: store.suffix,
font: store.font,
prefixColor: prefixColor.value,
suffixColor: suffixColor.value,
postfixBgColor: postfixBgColor.value,
fontSize: fontSize.value,
transparentBg: transparentBg.value,
reverseHighlight: reverseHighlight.value
});
};
watch(
[
() => store.prefix,
() => store.suffix,
() => store.font,
prefixColor,
suffixColor,
postfixBgColor,
fontSize,
transparentBg,
reverseHighlight
],
persistState
);
const hasPersistedPrefix = Object.prototype.hasOwnProperty.call(persistedState, 'prefix');
const hasPersistedSuffix = Object.prototype.hasOwnProperty.call(persistedState, 'suffix');
const hasPersistedFont = Object.prototype.hasOwnProperty.call(persistedState, 'font');
if (hasPersistedFont && typeof persistedState.font === 'string') {
store.font = persistedState.font;
}
const hydrateState = () => {
const hydrationTasks = [];
const queueTask = (task) => {
if (!task) return;
if (typeof task.then === 'function') {
hydrationTasks.push(task);
return;
}
hydrationTasks.push(Promise.resolve(task));
};
if (hasPersistedPrefix) {
queueTask(store.updatePrefix(persistedState.prefix));
} else if (options.initialText?.prefix !== undefined) {
queueTask(store.updatePrefix(options.initialText.prefix));
}
if (hasPersistedSuffix) {
queueTask(store.updateSuffix(persistedState.suffix));
} else if (options.initialText?.suffix !== undefined) {
queueTask(store.updateSuffix(options.initialText.suffix));
}
const finalizeHydration = () => {
isHydrating = false;
if (hasPersistedState) {
persistState();
}
};
if (hydrationTasks.length === 0) {
finalizeHydration();
return;
}
Promise.all(hydrationTasks).catch(() => {}).finally(finalizeHydration);
};
hydrateState();
if (options.resetText && !persistenceAvailable) {
onBeforeUnmount(() => {
if (options.resetText?.prefix) {
store.updatePrefix(options.resetText.prefix);
}
if (options.resetText?.suffix) {
store.updateSuffix(options.resetText.suffix);
}
});
}
return {
store,
prefixColor,
suffixColor,
postfixBgColor,
fontSize,
transparentBg,
reverseHighlight,
transparentBgColor,
suffixMargin,
updatePrefix,
updateSuffix,
twitter
};
}
================================================
FILE: src/config/fonts.js
================================================
const rawFonts = [
'Abel',
'Abril Fatface',
'Acme',
'Alegreya',
'Alegreya Sans',
'Anton',
'Archivo',
'Archivo Black',
'Archivo Narrow',
'Arimo',
'Arvo',
'Asap',
'Asap Condensed',
'Bitter',
'Bowlby One SC',
'Bree Serif',
'Cabin',
'Cairo',
'Catamaran',
'Crete Round',
'Crimson Text',
'Cuprum',
'Dancing Script',
'Dosis',
'Droid Sans',
'Droid Serif',
'EB Garamond',
'Exo',
'Exo 2',
'Faustina',
'Fira Sans',
'Fjalla One',
'Francois One',
'Gloria Hallelujah',
'Hind',
'Inconsolata',
'Indie Flower',
'Josefin Sans',
'Julee',
'Karla',
'Lato',
'Libre Baskerville',
'Libre Franklin',
'Lobster',
'Lora',
'Mada',
'Manuale',
'Maven Pro',
'Merriweather',
'Merriweather Sans',
'Montserrat',
'Montserrat Subrayada',
'Mukta Vaani',
'Muli',
'Noto Sans',
'Noto Serif',
'Nunito',
'Open Sans',
'Open Sans Condensed',
'Oswald',
'Oxygen',
'PT Sans',
'PT Sans Caption',
'PT Sans Narrow',
'PT Serif',
'Pacifico',
'Passion One',
'Pathway Gothic One',
'Play',
'Playfair Display',
'Poppins',
'Questrial',
'Quicksand',
'Raleway',
'Roboto',
'Roboto Condensed',
'Roboto Mono',
'Roboto Slab',
'Ropa Sans',
'Rubik',
'Saira',
'Saira Condensed',
'Saira Extra Condensed',
'Saira Semi Condensed',
'Sedgwick Ave',
'Sedgwick Ave Display',
'Shadows Into Light',
'Signika',
'Slabo 27px',
'Source Code Pro',
'Source Sans Pro',
'Spectral',
'Titillium Web',
'Ubuntu',
'Ubuntu Condensed',
'Varela Round',
'Vollkorn',
'Work Sans',
'Yanone Kaffeesatz',
'Zilla Slab',
'Zilla Slab Highlight'
];
export const fonts = [...new Set(rawFonts.map((font) => font.trim()).filter(Boolean))];
================================================
FILE: src/main.js
================================================
import './assets/iconfont/iconfont.css';
import './style.css';
import '@mdi/font/css/materialdesignicons.css';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import VueGtag from 'vue-gtag';
// Vuetify
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { Ripple } from 'vuetify/directives';
import App from './App.vue';
import router from './router';
const app = createApp(App);
const vuetify = createVuetify({
directives: {
Ripple
},
theme: {
defaultTheme: 'dark'
}
});
app.use(vuetify);
app.use(createPinia());
app.use(
VueGtag,
{
appName: 'Logoly',
pageTrackerScreenviewEnabled: true,
config: {
id: 'G-YX7X8HWGB1'
}
},
router
);
app.use(router);
app.mount('#app');
================================================
FILE: src/router/index.js
================================================
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'pornhub',
component: () => import('@/components/generator/Pornhub.vue'),
meta: {
analytics: {
pageviewTemplate(route) {
return {
title: 'Pornhub Generator',
page: route.path
};
}
}
}
},
{
path: '/vertical-ph',
name: 'vertical-pornhub',
component: () => import('@/components/generator/VerticalPornHub.vue'),
meta: {
analytics: {
pageviewTemplate(route) {
return {
title: 'VerticalPornHub Generator',
page: route.path
};
}
}
}
},
{
path: '/onlyfans',
name: 'onlyfans',
component: () => import('@/components/generator/Onlyfans.vue'),
meta: {
analytics: {
pageviewTemplate(route) {
return {
title: 'OnlyFans Generator',
page: route.path
};
}
}
}
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue'),
meta: {
analytics: {
pageviewTemplate(route) {
return {
title: 'About',
page: route.path
};
}
}
}
}
]
});
export default router;
================================================
FILE: src/stores/store.js
================================================
import { nextTick, ref } from 'vue';
import { defineStore } from 'pinia';
const hasDom = typeof window !== 'undefined' && typeof document !== 'undefined';
const textNodeFilter = typeof NodeFilter === 'undefined' ? 4 : NodeFilter.SHOW_TEXT;
function getEditableAncestor(node) {
if (!node) return null;
if (node.nodeType === 3) {
return node.parentElement?.closest?.("[contenteditable='true']") ?? null;
}
if (node.nodeType === 1) {
return node.closest?.("[contenteditable='true']") ?? null;
}
return null;
}
function getOffsetWithinRoot(root, node, offset) {
const range = document.createRange();
range.selectNodeContents(root);
range.setEnd(node, offset);
return range.toString().length;
}
function captureSelectionSnapshot() {
if (!hasDom) return null;
const selection = window.getSelection();
if (!selection?.rangeCount) return null;
const range = selection.getRangeAt(0);
const editableElement = getEditableAncestor(range.startContainer);
if (!editableElement || !editableElement.contains(range.endContainer)) {
return null;
}
return {
editableElement,
startOffset: getOffsetWithinRoot(editableElement, range.startContainer, range.startOffset),
endOffset: getOffsetWithinRoot(editableElement, range.endContainer, range.endOffset)
};
}
function resolvePosition(root, targetOffset) {
const walker = document.createTreeWalker(root, textNodeFilter, null);
let remaining = targetOffset;
while (walker.nextNode()) {
const node = walker.currentNode;
const length = node.textContent?.length ?? 0;
if (remaining <= length) {
return { node, offset: remaining };
}
remaining -= length;
}
return { node: root, offset: root.childNodes.length };
}
function restoreSelectionSnapshot(snapshot) {
if (!hasDom) return;
const { editableElement } = snapshot;
if (!editableElement?.isConnected) return;
const selection = window.getSelection();
if (!selection) return;
const totalLength = editableElement.textContent?.length ?? 0;
const start = Math.min(snapshot.startOffset, totalLength);
const end = Math.min(snapshot.endOffset, totalLength);
const startPosition = resolvePosition(editableElement, start);
const endPosition = resolvePosition(editableElement, end);
if (!startPosition || !endPosition) return;
const range = document.createRange();
range.setStart(startPosition.node, startPosition.offset);
range.setEnd(endPosition.node, endPosition.offset);
selection.removeAllRanges();
selection.addRange(range);
}
export const useStore = defineStore('store', () => {
const prefix = ref('edit');
const suffix = ref('me');
const font = ref('Roboto');
//Needed for the SVG Export (otherwise you can edit the SVG in the browser which breaks and and leads into new issues)
const editable = ref(true);
async function updatePrefix(text) {
const selectionSnapshot = captureSelectionSnapshot();
prefix.value = text;
if (!selectionSnapshot) return;
await nextTick();
restoreSelectionSnapshot(selectionSnapshot);
}
async function updateSuffix(text) {
const selectionSnapshot = captureSelectionSnapshot();
suffix.value = text;
if (!selectionSnapshot) return;
await nextTick();
restoreSelectionSnapshot(selectionSnapshot);
}
return { prefix, suffix, font, editable, updatePrefix, updateSuffix };
});
================================================
FILE: src/style.css
================================================
@import url("https://fonts.googleapis.com/css2?family=Arizonia&family=Inter:wght@100&display=swap");
@import 'tailwindcss';
@config '../tailwind.config.js';
html,
body,
#app {
height: 100vh;
margin: 0;
background-color: #000;
color: #f90;
font-family: 'Arial', sans-serif;
}
a {
color: #f90;
text-decoration: none;
}
.pb .prefix {
color: #fff;
padding: 0.125rem;
}
.pb .postfix {
color: #000;
background-color: #f90;
padding: 0.125rem;
border-radius: 0.375rem;
}
.of .prefix {
color: #fff;
}
.of .postfix {
color: #00aff0;
}
.vph {
text-align: center;
}
.vph .prefix {
color: #fff;
margin: 0;
}
.vph .postfix {
margin: 0;
color: #000;
background-color: #f90;
padding: 0.125rem;
border-radius: 0.375rem;
}
/* We need that Shizzle for dom-to-image otherwise the exported Img will have wrong fonts */
/* vietnamese */
@font-face {
font-family: "Arizonia";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WOFqwYUp31kXI.woff2)
format("woff2");
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0,
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: "Arizonia";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WPFqwYUp31kXI.woff2)
format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Arizonia";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/arizonia/v21/neIIzCemt4A5qa7mv5WBFqwYUp31.woff2)
format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZJhiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZthiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZNhiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZxhiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* vietnamese */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZBhiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range:
U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0,
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZFhiJ-Ek-_EeAmM.woff2)
format("woff2");
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100;
font-display: swap;
src: url(https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeAZ9hiJ-Ek-_EeA.woff2)
format("woff2");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
================================================
FILE: src/styles/vuetify-settings.scss
================================================
// Disable optional Vuetify packs to keep the generated CSS minimal.
@forward 'vuetify/settings' with (
$color-pack: false,
$utilities: false
);
================================================
FILE: src/utils/fontLoader.js
================================================
const loadedFonts = new Set();
const pendingFonts = new Set();
const hasDOM = typeof window !== 'undefined' && typeof document !== 'undefined';
const buildFontUrl = (fontName) => {
const family = fontName.trim().split(/\s+/).join('+');
return `https://fonts.googleapis.com/css2?family=${family}&display=swap`;
};
export function loadGoogleFont(fontName) {
if (!hasDOM) return;
const normalized = fontName?.trim();
if (!normalized || loadedFonts.has(normalized) || pendingFonts.has(normalized)) return;
const href = buildFontUrl(normalized);
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = href;
link.crossOrigin = 'anonymous';
link.onload = () => {
link.rel = 'stylesheet';
pendingFonts.delete(normalized);
loadedFonts.add(normalized);
};
link.onerror = () => {
pendingFonts.delete(normalized);
link.remove();
};
pendingFonts.add(normalized);
document.head.appendChild(link);
}
================================================
FILE: src/utils/persistentState.js
================================================
const GENERATOR_STATE_STORAGE_KEY = 'logoly-generator-state';
const PERSISTED_FIELDS = [
'prefix',
'suffix',
'font',
'prefixColor',
'suffixColor',
'postfixBgColor',
'fontSize',
'transparentBg',
'reverseHighlight'
];
const hasWindow = () => typeof window !== 'undefined';
const hasLocation = () => hasWindow() && typeof window.location !== 'undefined';
const hasHistory = () => hasWindow() && typeof window.history?.replaceState === 'function';
const hasLocalStorage = () => hasWindow() && typeof window.localStorage !== 'undefined';
const isString = (value) => typeof value === 'string';
function parseBoolean(value) {
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (!normalized) return undefined;
if (['1', 'true', 'yes', 'y'].includes(normalized)) return true;
if (['0', 'false', 'no', 'n'].includes(normalized)) return false;
return undefined;
}
function parseNumber(value) {
if (value === null || value === undefined || value === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function pickValue(field, value) {
if (value === undefined || value === null) return undefined;
switch (field) {
case 'fontSize': {
const parsed = parseNumber(value);
return parsed === undefined ? undefined : parsed;
}
case 'transparentBg':
case 'reverseHighlight': {
const parsed = parseBoolean(value);
return typeof parsed === 'boolean' ? parsed : undefined;
}
default: {
if (!isString(value)) return undefined;
return value;
}
}
}
function readFromQuery() {
if (!hasLocation()) return {};
const params = new URLSearchParams(window.location.search);
const state = {};
PERSISTED_FIELDS.forEach((field) => {
if (!params.has(field)) return;
const value = params.get(field);
const parsed = pickValue(field, value);
if (parsed !== undefined) {
state[field] = parsed;
}
});
return state;
}
function readFromStorage() {
if (!hasLocalStorage()) return {};
try {
const raw = window.localStorage.getItem(GENERATOR_STATE_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
const state = {};
PERSISTED_FIELDS.forEach((field) => {
const value = pickValue(field, parsed[field]);
if (value !== undefined) {
state[field] = value;
}
});
return state;
} catch {
return {};
}
}
function formatForQuery(value) {
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
return String(value);
}
function updateQueryString(state) {
if (!hasLocation() || !hasHistory()) return;
const params = new URLSearchParams(window.location.search);
PERSISTED_FIELDS.forEach((field) => params.delete(field));
Object.entries(state).forEach(([field, value]) => {
const formatted = formatForQuery(value);
if (formatted === undefined || formatted === '') return;
params.set(field, formatted);
});
const search = params.toString();
const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;
window.history.replaceState(null, '', newUrl);
}
export function loadGeneratorState() {
return {
...readFromStorage(),
...readFromQuery()
};
}
export function saveGeneratorState(state) {
const normalized = {};
PERSISTED_FIELDS.forEach((field) => {
const value = pickValue(field, state[field]);
if (value !== undefined) {
normalized[field] = value;
}
});
if (hasLocalStorage()) {
try {
window.localStorage.setItem(GENERATOR_STATE_STORAGE_KEY, JSON.stringify(normalized));
} catch {
// ignore quota errors
}
}
updateQueryString(normalized);
}
export { GENERATOR_STATE_STORAGE_KEY };
================================================
FILE: src/views/AboutView.vue
================================================
<template>
<div>
<h3 class="text-xl font-semibold mb-1">Logoly.pro</h3>
<p class="text-white">
This project is an open source project, you can find it on
<a href="https://github.com/bestony/logoly" target="_blank" rel="noopener noreferrer">
GitHub
</a>
</p>
</div>
</template>
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
custom: {
primary: '#f90'
}
}
}
}
};
================================================
FILE: vite.config.js
================================================
import process from 'node:process';
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import viteCompression from 'vite-plugin-compression';
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify';
function exposeStoreInternals() {
const storePath = fileURLToPath(new URL('./src/stores/store.js', import.meta.url)).replace(
/\\/g,
'/'
);
return {
name: 'expose-store-internals',
enforce: 'post',
transform(code, id) {
if (!process.env.VITEST) return null;
const normalizedId = id.replace(/\\/g, '/');
if (normalizedId !== storePath) return null;
if (code.includes('__LOGOLY_STORE_INTERNALS__')) return null;
const marker =
'return { node: root, offset: root.childNodes.length };\n}\n\nfunction restoreSelectionSnapshot';
if (!code.includes(marker)) return null;
let transformed = code.replace(
marker,
'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'
);
transformed = transformed
.replace(
'const startPosition = resolvePosition(editableElement, start);',
'const startPosition = __callResolvePosition(editableElement, start);'
)
.replace(
'const endPosition = resolvePosition(editableElement, end);',
'const endPosition = __callResolvePosition(editableElement, end);'
);
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`;
return {
code: `${transformed}${exposure}`,
map: null
};
}
};
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
transformAssetUrls
}
}),
vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/vuetify-settings.scss'
}
}),
exposeStoreInternals(),
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240
}),
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
sourcemap: false,
reportCompressedSize: false,
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vuetify')) return 'vuetify';
if (id.includes('vue')) return 'vue';
return 'vendor';
}
},
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
}
},
esbuild: {
drop: ['console', 'debugger']
}
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.js'],
coverage: {
provider: 'istanbul',
reporter: ['text', 'json-summary', 'html'],
thresholds: {
lines: 99,
functions: 99,
branches: 99,
statements: 99
}
}
}
});
================================================
FILE: vitest.setup.js
================================================
import { config } from '@vue/test-utils';
import { vi } from 'vitest';
const slotStub = {
inheritAttrs: false,
template: `
<div v-bind="$attrs">
<slot name="activator"></slot>
<slot name="text"></slot>
<slot></slot>
</div>
`
};
const vuetifyTags = [
'v-btn',
'v-checkbox-btn',
'v-color-picker',
'v-expansion-panel',
'v-expansion-panels',
'v-icon',
'v-list',
'v-list-item',
'v-menu',
'v-select',
'v-slider',
'v-tooltip'
];
config.global.stubs = {
...(config.global.stubs ?? {}),
...Object.fromEntries(vuetifyTags.map((tag) => [tag, slotStub])),
'router-link': {
template: '<a><slot /></a>'
},
'router-view': {
template: '<div><slot /></div>'
}
};
if (typeof window !== 'undefined') {
if (!window.matchMedia) {
window.matchMedia = () => ({
matches: false,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
media: ''
});
}
if (!window.scrollTo) {
window.scrollTo = () => {};
}
if (!window.HTMLElement.prototype.scrollIntoView) {
window.HTMLElement.prototype.scrollIntoView = () => {};
}
}
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver = globalThis.ResizeObserver ?? ResizeObserver;
globalThis.IntersectionObserver = globalThis.IntersectionObserver ?? IntersectionObserver;
if (typeof HTMLCanvasElement !== 'undefined') {
HTMLCanvasElement.prototype.getContext = () => ({
drawImage() {},
clearRect() {}
});
HTMLCanvasElement.prototype.toDataURL = () => 'data:image/png;base64,stub';
}
vi.mock('vue-gtag', () => ({
event: vi.fn()
}));
globalThis.open = globalThis.open ?? (() => {});
gitextract_5zqv3n35/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── bug_report.md │ ├── dependabot.yml │ ├── feature_request.md │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── Changelog.md ├── Contributers.md ├── Contributing.md ├── LICENSE ├── README.md ├── _redirects ├── biome.json ├── index.html ├── jsconfig.json ├── package.json ├── postcss.config.js ├── public/ │ ├── ads.txt │ └── site.webmanifest ├── src/ │ ├── App.vue │ ├── __tests__/ │ │ ├── AboutView.test.js │ │ ├── ExportBtn.test.js │ │ ├── FontSelectorComponent.test.js │ │ ├── fontLoader.test.js │ │ ├── fontsConfig.test.js │ │ ├── generators.test.js │ │ ├── persistentState.test.js │ │ ├── router.test.js │ │ ├── store.test.js │ │ └── useGeneratorControls.test.js │ ├── assets/ │ │ └── iconfont/ │ │ ├── iconfont.css │ │ └── iconfont.js │ ├── components/ │ │ ├── Author.vue │ │ ├── Copyright.vue │ │ ├── Description.vue │ │ ├── ExportBtn.vue │ │ ├── Faq.vue │ │ ├── FontSelector.vue │ │ ├── Logo.vue │ │ ├── Ribbon.vue │ │ ├── Slogan.vue │ │ └── generator/ │ │ ├── Onlyfans.vue │ │ ├── Pornhub.vue │ │ └── VerticalPornHub.vue │ ├── composables/ │ │ └── useGeneratorControls.js │ ├── config/ │ │ └── fonts.js │ ├── main.js │ ├── router/ │ │ └── index.js │ ├── stores/ │ │ └── store.js │ ├── style.css │ ├── styles/ │ │ └── vuetify-settings.scss │ ├── utils/ │ │ ├── fontLoader.js │ │ └── persistentState.js │ └── views/ │ └── AboutView.vue ├── tailwind.config.js ├── vite.config.js └── vitest.setup.js
SYMBOL INDEX (51 symbols across 10 files)
FILE: src/__tests__/ExportBtn.test.js
method constructor (line 29) | constructor() {
method setAttribute (line 33) | setAttribute() {}
method onload (line 34) | set onload(handler) {
method onload (line 37) | get onload() {
method src (line 40) | set src(value) {
FILE: src/__tests__/store.test.js
method nextNode (line 230) | nextNode() {
method get (line 258) | get() {
method set (line 261) | set(value) {
FILE: src/__tests__/useGeneratorControls.test.js
method setup (line 15) | setup() {
FILE: src/composables/useGeneratorControls.js
function useGeneratorControls (line 5) | function useGeneratorControls(options = {}) {
FILE: src/router/index.js
method pageviewTemplate (line 12) | pageviewTemplate(route) {
method pageviewTemplate (line 27) | pageviewTemplate(route) {
method pageviewTemplate (line 42) | pageviewTemplate(route) {
method pageviewTemplate (line 57) | pageviewTemplate(route) {
FILE: src/stores/store.js
function getEditableAncestor (line 7) | function getEditableAncestor(node) {
function getOffsetWithinRoot (line 18) | function getOffsetWithinRoot(root, node, offset) {
function captureSelectionSnapshot (line 25) | function captureSelectionSnapshot() {
function resolvePosition (line 43) | function resolvePosition(root, targetOffset) {
function restoreSelectionSnapshot (line 61) | function restoreSelectionSnapshot(snapshot) {
function updatePrefix (line 93) | async function updatePrefix(text) {
function updateSuffix (line 101) | async function updateSuffix(text) {
FILE: src/utils/fontLoader.js
function loadGoogleFont (line 10) | function loadGoogleFont(fontName) {
FILE: src/utils/persistentState.js
constant GENERATOR_STATE_STORAGE_KEY (line 1) | const GENERATOR_STATE_STORAGE_KEY = 'logoly-generator-state';
constant PERSISTED_FIELDS (line 2) | const PERSISTED_FIELDS = [
function parseBoolean (line 21) | function parseBoolean(value) {
function parseNumber (line 30) | function parseNumber(value) {
function pickValue (line 36) | function pickValue(field, value) {
function readFromQuery (line 55) | function readFromQuery() {
function readFromStorage (line 72) | function readFromStorage() {
function formatForQuery (line 93) | function formatForQuery(value) {
function updateQueryString (line 100) | function updateQueryString(state) {
function loadGeneratorState (line 117) | function loadGeneratorState() {
function saveGeneratorState (line 124) | function saveGeneratorState(state) {
FILE: vite.config.js
function exposeStoreInternals (line 9) | function exposeStoreInternals() {
method manualChunks (line 90) | manualChunks(id) {
FILE: vitest.setup.js
method addListener (line 45) | addListener() {}
method removeListener (line 46) | removeListener() {}
method addEventListener (line 47) | addEventListener() {}
method removeEventListener (line 48) | removeEventListener() {}
method dispatchEvent (line 49) | dispatchEvent() {
class ResizeObserver (line 65) | class ResizeObserver {
method observe (line 66) | observe() {}
method unobserve (line 67) | unobserve() {}
method disconnect (line 68) | disconnect() {}
class IntersectionObserver (line 71) | class IntersectionObserver {
method observe (line 72) | observe() {}
method unobserve (line 73) | unobserve() {}
method disconnect (line 74) | disconnect() {}
method takeRecords (line 75) | takeRecords() {
method drawImage (line 85) | drawImage() {}
method clearRect (line 86) | clearRect() {}
Condensed preview — 61 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (127K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 65,
"preview": "# These are supported funding model platforms\n\ngithub: [bestony]\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/dependabot.yml",
"chars": 380,
"preview": "version: 2\nupdates:\n- package-ecosystem: npm\n directory: \"/\"\n schedule:\n interval: daily\n time: \"21:00\"\n open-p"
},
{
"path": ".github/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/workflows/ci.yml",
"chars": 586,
"preview": "name: CI\n\non:\n push:\n branches:\n - master\n pull_request:\n branches:\n - master\n\njobs:\n test:\n runs-"
},
{
"path": ".gitignore",
"chars": 531,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Stor"
},
{
"path": ".prettierrc.json",
"chars": 163,
"preview": "{\n \"$schema\": \"https://json.schemastore.org/prettierrc\",\n \"semi\": true,\n \"tabWidth\": 2,\n \"singleQuote\": true,\n \"pri"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 3363,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
},
{
"path": "Changelog.md",
"chars": 1129,
"preview": "# Changelog\n\n## 2025.11.14\n\n- Added a Vitest-based test harness (jsdom environment, global setup, and 99% coverage gates"
},
{
"path": "Contributers.md",
"chars": 113,
"preview": "# Contributors\n\n- [Yovel Ovadia](https://github.com/yovelovadia)\n- [Daniel Yuldashev](https://github.com/yldshv)\n"
},
{
"path": "Contributing.md",
"chars": 1188,
"preview": "# Contribute to This Project\n\n## Feature Request\n\nIf you want to request for a new feature, you may open an issue and te"
},
{
"path": "LICENSE",
"chars": 500,
"preview": " DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE\n Version 2, December 2004\n\n Copyright (C) 201"
},
{
"path": "README.md",
"chars": 2157,
"preview": "> **new version is in active development at new-vue3 branch**\n<img align=\"right\" src=\"https://postimg.aliavv.com/mbp/adp"
},
{
"path": "_redirects",
"chars": 70,
"preview": "# Netlify settings for single-page application\n/* /index.html 200"
},
{
"path": "biome.json",
"chars": 849,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/2.2.0/schema.json\",\n \"vcs\": {\n \"enabled\": true,\n \"clientKind\": \"git\","
},
{
"path": "index.html",
"chars": 2482,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta http-equiv=\"X-UA-Compatible\" content=\"I"
},
{
"path": "jsconfig.json",
"chars": 116,
"preview": "{\n \"compilerOptions\": {\n \"paths\": {\n \"@/*\": [\"./src/*\"]\n }\n },\n \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
},
{
"path": "package.json",
"chars": 1193,
"preview": "{\n \"name\": \"logoly\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n "
},
{
"path": "postcss.config.js",
"chars": 90,
"preview": "export default {\n plugins: {\n '@tailwindcss/postcss': {},\n autoprefixer: {}\n }\n};\n"
},
{
"path": "public/ads.txt",
"chars": 59,
"preview": "google.com, pub-9877802927933140, DIRECT, f08c47fec0942fa0\n"
},
{
"path": "public/site.webmanifest",
"chars": 515,
"preview": "{\n \"name\": \"Logoly.Pro\",\n \"short_name\": \"Logoly\",\n \"description\": \"Generate Pornhub-style parody logos right in the b"
},
{
"path": "src/App.vue",
"chars": 1533,
"preview": "<template>\n <div id=\"app\" class=\"container mx-auto py-12 px-6 max-w-[893px]\">\n <Ribbon class=\"z-50\"></Ribbon>\n <d"
},
{
"path": "src/__tests__/AboutView.test.js",
"chars": 481,
"preview": "import { describe, it, expect } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport AboutView from '@/views/A"
},
{
"path": "src/__tests__/ExportBtn.test.js",
"chars": 3309,
"preview": "import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-uti"
},
{
"path": "src/__tests__/FontSelectorComponent.test.js",
"chars": 1653,
"preview": "import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { defineC"
},
{
"path": "src/__tests__/fontLoader.test.js",
"chars": 2202,
"preview": "import { describe, it, expect, beforeEach, vi } from 'vitest';\n\nconst loadModule = async () => {\n vi.resetModules();\n "
},
{
"path": "src/__tests__/fontsConfig.test.js",
"chars": 388,
"preview": "import { describe, it, expect } from 'vitest';\nimport { fonts } from '@/config/fonts';\n\ndescribe('fonts config', () => {"
},
{
"path": "src/__tests__/generators.test.js",
"chars": 7804,
"preview": "import { describe, it, expect, beforeEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { defineC"
},
{
"path": "src/__tests__/persistentState.test.js",
"chars": 3342,
"preview": "import { describe, it, expect, beforeEach } from 'vitest';\nimport {\n loadGeneratorState,\n saveGeneratorState,\n GENERA"
},
{
"path": "src/__tests__/router.test.js",
"chars": 1019,
"preview": "import { describe, it, expect } from 'vitest';\nimport router from '@/router';\n\ndescribe('router', () => {\n it('exposes "
},
{
"path": "src/__tests__/store.test.js",
"chars": 10493,
"preview": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { createPinia, setActivePinia } from 'p"
},
{
"path": "src/__tests__/useGeneratorControls.test.js",
"chars": 9574,
"preview": "import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimpor"
},
{
"path": "src/assets/iconfont/iconfont.css",
"chars": 2346,
"preview": "@font-face {\n font-family: \"iconfont\";\n src: url(\"iconfont.eot?t=1553360818414\"); /* IE9 */\n src: url(\"iconfont.eot?t"
},
{
"path": "src/assets/iconfont/iconfont.js",
"chars": 4871,
"preview": "!(function (a) {\n var e,\n n =\n '<svg><symbol id=\"icon-gmail\" viewBox=\"0 0 1024 1024\"><path d=\"M853.333333 170.6"
},
{
"path": "src/components/Author.vue",
"chars": 943,
"preview": "<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"
},
{
"path": "src/components/Copyright.vue",
"chars": 263,
"preview": "<template>\n <p class=\"text-center text-[#666]\">\n © Bestony {{ currentYear }}\n <a href=\"https://www.ixiqin.com\" ta"
},
{
"path": "src/components/Description.vue",
"chars": 103,
"preview": "<template>\n <div>\n <h2 class=\"text-white\">A Simple Online Logo Generator</h2>\n </div>\n</template>\n"
},
{
"path": "src/components/ExportBtn.vue",
"chars": 2240,
"preview": "<template>\n <div>\n <v-tooltip text=\"Export your own logo\" location=\"top\" model-value>\n <template v-slot:activat"
},
{
"path": "src/components/Faq.vue",
"chars": 1329,
"preview": "<template>\n <div class=\"text-white mt-12\">\n <h3 class=\"text-2xl font-semibold text-custom-primary mb-1\">FAQ</h3>\n "
},
{
"path": "src/components/FontSelector.vue",
"chars": 484,
"preview": "<template>\n <v-select\n hide-details\n v-model=\"store.font\"\n label=\"Font\"\n :items=\"fonts\"\n color=\"#f90\"\n "
},
{
"path": "src/components/Logo.vue",
"chars": 220,
"preview": "<template>\n <router-link to=\"/\" class=\"py-12\">\n <h1>\n <span class=\"text-white p-1\">Logoly</span>\n <span cl"
},
{
"path": "src/components/Ribbon.vue",
"chars": 430,
"preview": "<template>\n <a\n class=\"absolute md:fixed top-0 right-0\"\n href=\"https://github.com/bestony/logoly\"\n target=\"_bl"
},
{
"path": "src/components/Slogan.vue",
"chars": 652,
"preview": "<template>\n <div class=\"mt-12\">\n <h3 class=\"text-xl font-semibold mb-2\">\n <span class=\"text-white\">Logoly</span"
},
{
"path": "src/components/generator/Onlyfans.vue",
"chars": 3935,
"preview": "<template>\n <div class=\"flex flex-col items-center\">\n <v-tooltip text=\"Edit the text to create your own logo\" model-"
},
{
"path": "src/components/generator/Pornhub.vue",
"chars": 6507,
"preview": "<template>\n <div class=\"pornhub\">\n <v-tooltip text=\"Edit the text to create your own logo\" location=\"top\" model-valu"
},
{
"path": "src/components/generator/VerticalPornHub.vue",
"chars": 6422,
"preview": "<template>\n <div class=\"pornhub\">\n <v-tooltip text=\"Edit the text to create your own logo\" location=\"top\" model-valu"
},
{
"path": "src/composables/useGeneratorControls.js",
"chars": 4647,
"preview": "import { computed, onBeforeUnmount, ref, watch } from 'vue';\nimport { useStore } from '@/stores/store';\nimport { loadGen"
},
{
"path": "src/config/fonts.js",
"chars": 1738,
"preview": "const rawFonts = [\n 'Abel',\n 'Abril Fatface',\n 'Acme',\n 'Alegreya',\n 'Alegreya Sans',\n 'Anton',\n 'Archivo',\n 'Ar"
},
{
"path": "src/main.js",
"chars": 763,
"preview": "import './assets/iconfont/iconfont.css';\nimport './style.css';\nimport '@mdi/font/css/materialdesignicons.css';\nimport { "
},
{
"path": "src/router/index.js",
"chars": 1543,
"preview": "import { createRouter, createWebHistory } from 'vue-router';\n\nconst router = createRouter({\n history: createWebHistory("
},
{
"path": "src/stores/store.js",
"chars": 3383,
"preview": "import { nextTick, ref } from 'vue';\nimport { defineStore } from 'pinia';\n\nconst hasDom = typeof window !== 'undefined' "
},
{
"path": "src/style.css",
"chars": 4732,
"preview": "@import url(\"https://fonts.googleapis.com/css2?family=Arizonia&family=Inter:wght@100&display=swap\");\n@import 'tailwindcs"
},
{
"path": "src/styles/vuetify-settings.scss",
"chars": 149,
"preview": "// Disable optional Vuetify packs to keep the generated CSS minimal.\n@forward 'vuetify/settings' with (\n $color-pack: f"
},
{
"path": "src/utils/fontLoader.js",
"chars": 986,
"preview": "const loadedFonts = new Set();\nconst pendingFonts = new Set();\nconst hasDOM = typeof window !== 'undefined' && typeof do"
},
{
"path": "src/utils/persistentState.js",
"chars": 3882,
"preview": "const GENERATOR_STATE_STORAGE_KEY = 'logoly-generator-state';\nconst PERSISTED_FIELDS = [\n 'prefix',\n 'suffix',\n 'font"
},
{
"path": "src/views/AboutView.vue",
"chars": 319,
"preview": "<template>\n <div>\n <h3 class=\"text-xl font-semibold mb-1\">Logoly.pro</h3>\n <p class=\"text-white\">\n This proj"
},
{
"path": "tailwind.config.js",
"chars": 240,
"preview": "/** @type {import('tailwindcss').Config} */\nexport default {\n content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}"
},
{
"path": "vite.config.js",
"chars": 3820,
"preview": "import process from 'node:process';\nimport { fileURLToPath, URL } from 'node:url';\n\nimport { defineConfig } from 'vite';"
},
{
"path": "vitest.setup.js",
"chars": 1913,
"preview": "import { config } from '@vue/test-utils';\nimport { vi } from 'vitest';\n\nconst slotStub = {\n inheritAttrs: false,\n temp"
}
]
About this extraction
This page contains the full source code of the bestony/logoly GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 61 files (116.3 KB), approximately 33.8k tokens, and a symbol index with 51 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.