initial setup from aria theme

This commit is contained in:
Jalil Arfaoui 2024-08-18 19:06:50 +02:00
commit bfcb5d6d70
78 changed files with 14752 additions and 0 deletions

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

1
.node-version Normal file
View file

@ -0,0 +1 @@
v20

4
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

13
README.md Normal file
View file

@ -0,0 +1,13 @@
# Aria Template
This is a personal blog, portfolio, or blog template created for [Astro](https://astro.build).
Astro port of [aria](https://github.com/static-templates/aria).
![Aria Template Cover Photo](https://github.com/ccbikai/astro-aria/blob/main/public/assets/images/cover.png?raw=true)
You can install this theme with the [Astro](https://astro.build) command like so:
```js
npm create astro@latest -- --template ccbikai/astro-aria
```

8
astro.config.mjs Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
});

17
biome.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
"organizeImports": {
"enabled": true
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}

7630
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "jalil.arfaoui.net",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"check": "biome check --apply-unsafe ."
},
"devDependencies": {
"@astrojs/check": "^0.6.0",
"@astrojs/tailwind": "^5.1.0",
"@biomejs/biome": "1.7.3",
"@tailwindcss/typography": "^0.5.13",
"astro": "^4.8.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5"
},
"dependencies": {
"@astrojs/check": "^0.9.2",
"typescript": "^5.5.4"
}
}

4847
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

68
src/assets/css/main.css Normal file
View file

@ -0,0 +1,68 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Add Your Custom CSS Here */
.prose img {
border-radius: 30px;
}
#sun {
transform: translate3d(0, 0px, 0);
}
#moon {
transform: translate3d(0, 0px, 0);
}
#darkToggle:hover #sun {
transform: translate3d(0, 10px, 0);
}
#darkToggle:hover #moon {
transform: translate3d(0, 10px, 0);
}
html.dark #darkToggle:hover .horizon {
border-color: #718096 !important;
}
.horizon .setting {
animation: 1s ease 0s 1 setting;
}
.horizon .rising {
animation: 1s ease 0s 1 rising;
}
@keyframes setting {
0% {
transform: translate3d(0, 10px, 0)
}
40% {
transform: translate3d(0, -2px, 0)
}
to {
transform: translate3d(0, 30px, 0)
}
}
@keyframes rising {
0% {
opacity: 0;
transform: translate3d(0, 30px, 0)
}
40% {
opacity: 1;
transform: translate3d(0, -2px, 0)
}
to {
opacity: 1;
transform: translate3d(0, 10, 0)
}
}

175
src/assets/js/main.js Normal file
View file

@ -0,0 +1,175 @@
// Add your javascript here
window.darkMode = false;
const stickyClasses = ["fixed", "h-14"];
const unstickyClasses = ["absolute", "h-20"];
const stickyClassesContainer = [
"border-neutral-300/50",
"bg-white/80",
"dark:border-neutral-600/40",
"dark:bg-neutral-900/60",
"backdrop-blur-2xl",
];
const unstickyClassesContainer = ["border-transparent"];
let headerElement = null;
document.addEventListener("DOMContentLoaded", () => {
headerElement = document.getElementById("header");
if (
localStorage.getItem("dark_mode") &&
localStorage.getItem("dark_mode") === "true"
) {
window.darkMode = true;
showNight();
} else {
showDay();
}
stickyHeaderFuncionality();
applyMenuItemClasses();
evaluateHeaderPosition();
mobileMenuFunctionality();
});
// window.toggleDarkMode = function(){
// document.documentElement.classList.toggle('dark');
// if(document.documentElement.classList.contains('dark')){
// localStorage.setItem('dark_mode', true);
// window.darkMode = true;
// } else {
// window.darkMode = false;
// localStorage.setItem('dark_mode', false);
// }
// }
window.stickyHeaderFuncionality = () => {
window.addEventListener("scroll", () => {
evaluateHeaderPosition();
});
};
window.evaluateHeaderPosition = () => {
if (window.scrollY > 16) {
headerElement.firstElementChild.classList.add(...stickyClassesContainer);
headerElement.firstElementChild.classList.remove(
...unstickyClassesContainer,
);
headerElement.classList.add(...stickyClasses);
headerElement.classList.remove(...unstickyClasses);
document.getElementById("menu").classList.add("top-[56px]");
document.getElementById("menu").classList.remove("top-[75px]");
} else {
headerElement.firstElementChild.classList.remove(...stickyClassesContainer);
headerElement.firstElementChild.classList.add(...unstickyClassesContainer);
headerElement.classList.add(...unstickyClasses);
headerElement.classList.remove(...stickyClasses);
document.getElementById("menu").classList.remove("top-[56px]");
document.getElementById("menu").classList.add("top-[75px]");
}
};
document.getElementById("darkToggle").addEventListener("click", () => {
document.documentElement.classList.add("duration-300");
if (document.documentElement.classList.contains("dark")) {
localStorage.removeItem("dark_mode");
showDay(true);
} else {
localStorage.setItem("dark_mode", true);
showNight(true);
}
});
function showDay(animate) {
document.getElementById("sun").classList.remove("setting");
document.getElementById("moon").classList.remove("rising");
let timeout = 0;
if (animate) {
timeout = 500;
document.getElementById("moon").classList.add("setting");
}
setTimeout(() => {
document.getElementById("dayText").classList.remove("hidden");
document.getElementById("nightText").classList.add("hidden");
document.getElementById("moon").classList.add("hidden");
document.getElementById("sun").classList.remove("hidden");
if (animate) {
document.documentElement.classList.remove("dark");
document.getElementById("sun").classList.add("rising");
}
}, timeout);
}
function showNight(animate) {
document.getElementById("moon").classList.remove("setting");
document.getElementById("sun").classList.remove("rising");
let timeout = 0;
if (animate) {
timeout = 500;
document.getElementById("sun").classList.add("setting");
}
setTimeout(() => {
document.getElementById("nightText").classList.remove("hidden");
document.getElementById("dayText").classList.add("hidden");
document.getElementById("sun").classList.add("hidden");
document.getElementById("moon").classList.remove("hidden");
if (animate) {
document.documentElement.classList.add("dark");
document.getElementById("moon").classList.add("rising");
}
}, timeout);
}
window.applyMenuItemClasses = () => {
const menuItems = document.querySelectorAll("#menu a");
for (let i = 0; i < menuItems.length; i++) {
if (menuItems[i].pathname === window.location.pathname) {
menuItems[i].classList.add("text-neutral-900", "dark:text-white");
}
}
//:class="{ 'text-neutral-900 dark:text-white': window.location.pathname == '{menu.url}', 'text-neutral-700 dark:text-neutral-400': window.location.pathname != '{menu.url}' }"
};
function mobileMenuFunctionality() {
document.getElementById("openMenu").addEventListener("click", () => {
openMobileMenu();
});
document.getElementById("closeMenu").addEventListener("click", () => {
closeMobileMenu();
});
}
window.openMobileMenu = () => {
document.getElementById("openMenu").classList.add("hidden");
document.getElementById("closeMenu").classList.remove("hidden");
document.getElementById("menu").classList.remove("hidden");
document.getElementById("mobileMenuBackground").classList.add("opacity-0");
document.getElementById("mobileMenuBackground").classList.remove("hidden");
setTimeout(() => {
document
.getElementById("mobileMenuBackground")
.classList.remove("opacity-0");
}, 1);
};
window.closeMobileMenu = () => {
document.getElementById("closeMenu").classList.add("hidden");
document.getElementById("openMenu").classList.remove("hidden");
document.getElementById("menu").classList.add("hidden");
document.getElementById("mobileMenuBackground").classList.add("hidden");
};

0
src/collections/.gitkeep Normal file
View file

View file

@ -0,0 +1,23 @@
[
{
"dates": "June 2018 · Present",
"role": "Front-end Engineer",
"company": "Full Truck Alliance",
"description": "Responsible for customer service and CRM system front-end development.",
"logo": "/assets/images/experiences/fta.ico"
},
{
"dates": "July 2015 · June 2018",
"role": "Front-end Engineer",
"company": "YOHO!",
"description": "Responsible for mobile front-end development of e-commerce platform.",
"logo": "/assets/images/experiences/yoho.ico"
},
{
"dates": "September 2014 · July 2015 ",
"role": "Node.JS Developer",
"company": "WuLian",
"description": "Intern, involved in the development of Internet of Things cloud systems.",
"logo": "/assets/images/experiences/wulian.ico"
}
]

18
src/collections/menu.json Normal file
View file

@ -0,0 +1,18 @@
[
{
"name": "Home",
"url": "/"
},
{
"name": "Posts",
"url": "/posts"
},
{
"name": "Projects",
"url": "/projects"
},
{
"name": "About",
"url": "/about"
}
]

View file

@ -0,0 +1,20 @@
[
{
"name": "Email.ML",
"description": "Minimalist temporary Email.",
"image": "/assets/images/projects/email.ml.png",
"url": "https://email.ml"
},
{
"name": "DNS.Surf",
"description": "Querying DNS Resolution Results in Different Regions Worldwide.",
"image": "/assets/images/projects/dns.surf.png",
"url": "https://dns.surf"
},
{
"name": "HTML.ZONE",
"description": "Web Toolbox.",
"image": "/assets/images/projects/html.zone.png",
"url": "https://html.zone"
}
]

View file

@ -0,0 +1,22 @@
---
const { logo, dates, role, company, description } = Astro.props;
---
<div class="relative flex flex-col justify-start pl-12">
<div
class="absolute top-0 left-0 z-40 flex items-center justify-center -translate-x-1/2 bg-white border rounded-full dark:bg-neutral-950 w-14 h-14 border-neutral-300 dark:border-neutral-700"
>
<img src={logo} alt={company} class="w-8 h-8" />
</div>
<p
class="text-xs uppercase text-neutral-400 dark:text-neutral-500 trackign-widest"
>
{dates}
</p>
<h3 class="my-1 text-lg font-bold dark:text-neutral-100">{role}</h3>
<p class="mb-1 text-sm font-medium dark:text-neutral-300">{company}</p>
<p class="text-sm font-light text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>

View file

@ -0,0 +1,10 @@
---
const { link, text } = Astro.props;
---
<a
href={link}
class="inline-flex w-auto px-4 py-2 mt-5 text-xs font-semibold duration-300 ease-out border rounded-full bg-neutral-900 dark:bg-white dark:text-neutral-900 text-neutral-100 hover:border-neutral-700 border-neutral-900 dark:hover:border-neutral-300 hover:bg-white dark:hover:bg-black dark:hover:text-white hover:text-neutral-900"
>
{text}
</a>

View file

@ -0,0 +1,80 @@
---
import Logo from "../components/logo.astro";
---
<section
class="text-gray-700 bg-white border-t sm:mt-20 dark:bg-neutral-950 border-neutral-200 dark:border-neutral-800"
>
<div
class="container flex flex-col items-center py-8 mx-auto px-7 max-w-7xl sm:flex-row"
>
<Logo />
<p
class="mt-4 text-sm text-neutral-700 dark:text-neutral-100 sm:ml-4 sm:pl-4 sm:border-l sm:border-neutral-300 dark:sm:border-neutral-700 sm:mt-0"
>
© {new Date().getFullYear()} Aria
</p>
<span
class="inline-flex justify-center mt-4 space-x-5 sm:ml-auto sm:mt-0 sm:justify-start"
>
<a
href="https://instagram.com/ccbikai"
target="_blank"
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
>
<span class="sr-only">Instagram</span>
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
><path
fill-rule="evenodd"
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
clip-rule="evenodd"></path></svg
>
</a>
<a
href="https://twitter.com/0xKaiBi"
target="_blank"
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
>
<span class="sr-only">𝕏</span>
<svg
class="w-6 h-6 dark:stroke-black stroke-white"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><path
fill-rule="evenodd"
clip-rule="evenodd"
d="M95 50c0 24.853-20.147 45-45 45S5 74.853 5 50 25.147 5 50 5s45 20.147 45 45Zm-51.21 2.688-21.51-28.76h16.578l14.1 18.855 17.453-18.855h4.872L55.135 45.694l22.72 30.377H61.279L45.967 55.598l-18.95 20.473h-4.873L43.79 52.688Zm-6.73-25.172h-7.616l33.63 44.967h7.616L37.06 27.516Z"
fill="currentColor"></path><path
d="M22.28 23.928v-.5h-.998l.597.8.4-.3Zm21.51 28.76.366.34.283-.306-.25-.334-.4.3Zm-4.932-28.76.4-.3-.15-.2h-.25v.5Zm14.1 18.855-.4.3.36.48.408-.44-.367-.34Zm17.453-18.855v-.5h-.219l-.148.16.367.34Zm4.872 0 .367.34.777-.84h-1.144v.5ZM55.135 45.694l-.367-.34-.282.306.249.333.4-.3Zm22.72 30.377v.5h.999l-.598-.8-.4.3Zm-16.577 0-.4.3.15.2h.25v-.5ZM45.967 55.598l.4-.3-.36-.48-.407.44.367.34Zm-18.95 20.473v.5h.218l.148-.16-.367-.34Zm-4.873 0-.367-.34-.777.84h1.144v-.5Zm7.3-48.554v-.5h-.998l.598.799.4-.3Zm7.616 0 .4-.3-.15-.2h-.25v.5Zm26.015 44.966-.4.3.15.2h.25v-.5Zm7.615 0v.5h.999l-.598-.8-.4.3ZM50 95.5c25.129 0 45.5-20.371 45.5-45.5h-1c0 24.577-19.923 44.5-44.5 44.5v1ZM4.5 50c0 25.129 20.371 45.5 45.5 45.5v-1C25.423 94.5 5.5 74.577 5.5 50h-1ZM50 4.5C24.871 4.5 4.5 24.871 4.5 50h1C5.5 25.423 25.423 5.5 50 5.5v-1ZM95.5 50C95.5 24.871 75.129 4.5 50 4.5v1c24.577 0 44.5 19.923 44.5 44.5h1ZM21.88 24.228l21.509 28.76.8-.6-21.509-28.76-.8.6Zm16.978-.8H22.28v1h16.578v-1Zm14.501 19.055L39.258 23.63l-.8.599 14.1 18.854.801-.599ZM70.044 23.59 52.592 42.443l.734.68 17.452-18.855-.734-.68Zm5.239-.16H70.41v1h4.872v-1Zm-19.78 22.605L75.65 24.268l-.734-.68-20.148 21.766.734.68Zm22.753 29.738-22.72-30.378-.801.6 22.72 30.377.8-.6Zm-16.978.799h16.578v-1H61.278v1ZM45.566 55.898 60.877 76.37l.801-.599L46.368 55.3l-.802.599ZM27.383 76.41l18.95-20.473-.733-.68-18.95 20.473.733.68Zm-5.239.16h4.872v-1h-4.872v1Zm21.278-24.223L21.777 75.731l.734.68 21.645-23.383-.734-.68ZM29.444 28.017h7.616v-1h-7.616v1Zm34.031 44.166-33.63-44.966-.801.599 33.63 44.966.801-.599Zm7.215-.2h-7.615v1h7.615v-1ZM36.66 27.816l33.63 44.966.8-.599-33.63-44.966-.8.599Z"
fill="currentStroke"></path></svg
>
</a>
<a
href="https://github.com/ccbikai"
target="_blank"
class="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
>
<span class="sr-only">GitHub</span>
<svg
class="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
><path
fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd"></path></svg
>
</a>
</span>
</div>
</section>

116
src/components/header.astro Normal file
View file

@ -0,0 +1,116 @@
---
import menus from "../collections/menu.json";
import Logo from "../components/logo.astro";
---
<!-- This is an invisible div with relative position so that it takes up the height of the menu (because menu is absolute/fixed) -->
<div class="relative w-full h-20 opacity-0 pointer-events-none"></div>
<header id="header" class="absolute top-0 z-50 w-full h-20">
<div
class="flex items-center justify-between h-full max-w-5xl pl-6 pr-4 mx-auto border-b border-l-0 border-r-0 border-transparent select-none lg:border-r lg:border-l lg:rounded-b-xl"
>
<Logo />
<div
id="mobileMenuBackground"
onclick="closeMobileMenu()"
class="fixed inset-0 z-20 hidden w-screen h-screen duration-300 ease-out bg-white/90 dark:bg-neutral-950/90"
>
</div>
<nav
class="relative z-30 flex flex-row-reverse justify-start w-full text-sm sm:justify-end text-neutral-500 dark:text-neutral-400 sm:flex-row"
>
<div
id="openMenu"
class="flex flex-col items-end justify-center w-6 h-6 ml-4 cursor-pointer sm:hidden"
>
<svg
class="w-8 h-8 dark:text-neutral-200"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"><path d="M4 8h16M4 16h16"></path></svg
>
</div>
<div
id="closeMenu"
class="flex flex-col items-end justify-center hidden w-6 h-6 ml-4 -translate-x-1 cursor-pointer sm:hidden"
>
<svg
class="w-6 h-6 text-neutral-600 dark:text-neutral-200"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"></path></svg
>
</div>
<div
id="menu"
class="fixed top-[75px] ease-out duration-300 sm:top-0 w-full left-0 sm:py-0 pt-7 pb-4 dm:mx-0 left-0 z-40 flex-col items-end justify-start hidden w-full h-auto text-sm sm:text-base sm:h-auto sm:relative sm:flex-row sm:flex sm:text-sm sm:w-auto sm:pr-0 sm:pt-0"
>
<div
class="absolute inset-0 top-0 right-0 block w-full h-full px-3 sm:hidden"
>
<div
class="relative w-full h-full bg-white border border-dashed border-neutral-300 dark:border-neutral-700 backdrop-blur-sm rounded-xl dark:bg-neutral-950"
>
</div>
</div>
{
menus.map((menu) => {
return (
<a
href={menu.url}
class="relative flex items-center justify-center w-full px-3 py-2 font-medium tracking-wide text-center duration-200 ease-out sm:py-0 sm:mb-0 md:w-auto hover:text-neutral-900 dark:hover:text-white"
>
{menu.name}
</a>
)
})
}
</div>
<div
id="darkToggle"
class="relative flex items-center pl-6 ml-4 font-medium tracking-wide cursor-pointer text-neutral-800 group dark:text-white"
>
<div
class="absolute left-0 flex items-center justify-center w-6 h-6 overflow-hidden border-b border-transparent horizon group-hover:border-neutral-600"
>
<svg
class="absolute w-6 h-6 transition duration-700 transform ease"
id="sun"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
><path
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
></path></svg
>
<svg
class="absolute hidden w-6 h-6 transition duration-700 transform ease"
id="moon"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
><path
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
></path></svg
>
</div>
<span class="hidden sm:inline-block">
<span id="dayText" class="ml-2">Day mode</span>
<span id="nightText" class="hidden ml-2">Night mode</span>
</span>
</div>
</nav>
</div>
</header>

View file

@ -0,0 +1,37 @@
---
import projects from "../../collections/projects.json";
import Button from "../button.astro";
import Project from "../project.astro";
---
<section class="max-w-4xl mx-auto px-7 lg:px-0">
<h2
class="text-2xl font-bold leading-10 tracking-tight text-neutral-900 dark:text-neutral-100"
>
My Projects
</h2>
<p class="mb-6 text-base text-neutral-600 dark:text-neutral-400">
Here are some of my recent projects. I'm always working on something new, so
check back often!
</p>
<div
class="grid items-stretch w-full sm:grid-cols-2 md:grid-cols-3 gap-7 mt-7"
>
{
projects.map((project) => {
return (
<Project
name={project.name}
description={project.description}
image={project.image}
url={project.url}
/>
)
})
}
</div>
<div class="flex items-center justify-center w-full py-5">
<Button text="View All My Projects" link="/projects" />
</div>
</section>

View file

@ -0,0 +1,37 @@
---
const { text } = Astro.props;
---
<div class="relative my-32">
<div class="relative w-full pl-5 overflow-x-hidden md:pl-0">
<div
class="absolute w-full h-px bg-gradient-to-r from-transparent to-white md:from-white dark:from-transparent dark:to-neutral-950 md:dark:from-neutral-950 md:via-transparent md:dark:via-transparent md:to-white md:dark:to-neutral-950"
>
</div>
<div
class="w-full h-px border-t border-dashed border-neutral-300 dark:border-neutral-600"
>
</div>
</div>
<div
class="absolute flex items-center justify-center w-auto h-auto px-3 py-1.5 uppercase tracking-widest space-x-1 text-[0.6rem] md:-translate-x-1/2 -translate-y-1/2 border rounded-full bg-white dark:bg-neutral-900 text-neutral-400 left-0 md:ml-0 ml-5 md:left-1/2 border-neutral-100 dark:border-neutral-800 shadow-sm"
>
<p class="leading-none">{text}</p>
<div
class="flex items-center justify-center w-5 h-5 translate-x-1 border rounded-full border-neutral-100 dark:border-neutral-800"
>
<svg
class="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"></path></svg
>
</div>
</div>
</div>

View file

@ -0,0 +1,87 @@
---
import Button from "../button.astro";
import PostsLoop from "../posts-loop.astro";
const feed = "https://feed.miantiao.me/";
---
<section class="max-w-4xl mx-auto px-7 lg:px-0">
<h2
class="text-2xl font-bold leading-10 tracking-tight text-neutral-900 dark:text-neutral-100"
>
My Writings
</h2>
<p class="mb-6 text-base text-neutral-600 dark:text-neutral-400">
Along with coding I also like to write about life and technology. Here are
some of my recent posts.
</p>
<div class="w-full max-w-4xl mx-auto my-7 xl:px-0">
<div
class="flex flex-col items-start justify-start md:flex-row md:space-x-7"
>
<div class="w-full md:w-2/3 space-y-7">
<PostsLoop count="3" />
<div class="flex items-center justify-center w-full py-5">
<Button text="View All My Writing" link="/posts" />
</div>
</div>
<div class="w-full mt-10 md:w-1/3 md:mt-0">
<form
method="get"
action={feed}
class="p-6 border border-dashed rounded-2xl border-neutral-300 dark:border-neutral-600"
>
<div class="relative flex items-center space-x-2">
<svg
class="flex-none w-6 h-6 text-neutral-700 dark:text-neutral-200"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
></path></svg
>
<h2
class="flex text-sm font-semibold text-neutral-900 dark:text-neutral-100"
>
Subscribe my blog
</h2>
</div>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Get my blog updates via <a
class="font-bold"
href={`https://feedly.com/i/subscription/feed%2F${encodeURIComponent(feed)}`}
>Feedly</a
>, <a
class="font-bold"
href={`https://www.inoreader.com/feed/${encodeURIComponent(feed)}`}
>Inoreader</a
> or <a class="font-bold" href={feed}>RSS</a>.
</p>
<div class="flex flex-col items-center w-full mt-4 space-y-3">
<input
type="url"
readonly
placeholder="Email address"
aria-label="Email address"
required=""
value={feed}
class="w-full h-10 px-3 text-sm border border-dashed rounded-md focus:ring-0 focus:outline-black border-neutral-400 dark:border-neutral-600 dark:bg-neutral-800 dark:placeholder-neutral-400 dark:text-white"
/>
<button
type="submit"
class="block w-full px-4 py-2 mt-5 text-xs font-semibold text-center duration-300 ease-out border rounded bg-neutral-900 dark:bg-neutral-100 dark:hover:border-neutral-300 dark:text-neutral-800 dark:hover:bg-neutral-950 dark:hover:text-neutral-100 text-neutral-100 border-neutral-900 hover:bg-white hover:text-neutral-900"
>Subscribe</button
>
</div>
</form>
</div>
</div>
</div>
</section>

11
src/components/logo.astro Normal file
View file

@ -0,0 +1,11 @@
<a
href="/"
class="h-5 text-base group relative z-30 flex items-center space-x-1.5 text-black dark:text-white font-semibold"
>
<span
class="text-xl -translate-y-0.5 group-hover:-rotate-12 group-hover:scale-[1.2] ease-in-out duration-300"
>✦</span
>
<!-- Logo Text -->
<span class="-translate-y-0.5"> aria</span>
</a>

View file

@ -0,0 +1,16 @@
---
const { title, description } = Astro.props;
---
<div class="relative z-20 w-full mx-auto lg:mx-0">
<h2
class="text-2xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 sm:text-3xl lg:text-4xl"
>
{title}
</h2>
<p
class="mt-3 text-sm leading-6 text-neutral-600 dark:text-neutral-400 sm:mt-4 lg:mt-6 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
>
{description}
</p>
</div>

View file

@ -0,0 +1,82 @@
---
import { getCollection } from "astro:content";
const allPosts = await getCollection("post");
const { count } = Astro.props;
const postsLoop = allPosts.slice(0, count).map((post) => {
return {
...(post.data || {}),
link: `/post/${post.slug}`,
};
});
---
{
postsLoop.map((post) => {
return (
<div
class="relative border border-transparent border-dashed cursor-pointer p-7 group rounded-2xl"
onclick={`location.href = '${post.link}'`}
>
<div class="absolute inset-0 z-20 w-full h-full duration-300 ease-out bg-white border border-dashed dark:bg-neutral-950 rounded-2xl border-neutral-300 dark:border-neutral-600 group-hover:-translate-x-1 group-hover:-translate-y-1" />
<div class="absolute inset-0 z-10 w-full h-full duration-300 ease-out border border-dashed rounded-2xl border-neutral-300 dark:border-neutral-600 group-hover:translate-x-1 group-hover:translate-y-1" />
<div class="relative z-30 duration-300 ease-out group-hover:-translate-x-1 group-hover:-translate-y-1">
<h2 class="flex items-center mb-3">
<a
href={post.link}
class="text-xl font-bold leading-tight tracking-tight sm:text-2xl dark:text-neutral-100"
>
{post.title}
</a>
<svg
class="group-hover:translate-x-0 flex-shrink-0 translate-y-0.5 -translate-x-1 w-2.5 h-2.5 stroke-current ml-1 transition-all ease-in-out duration-200 transform"
viewBox="0 0 13 15"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
>
<g
id="svg"
transform="translate(0.666667, 2.333333)"
stroke="currentColor"
stroke-width="2.4"
>
<g>
<>
<polyline
class="transition-all duration-200 ease-out opacity-0 delay-0 group-hover:opacity-100"
points="5.33333333 0 10.8333333 5.5 5.33333333 11"
/>
<line
class="transition-all duration-200 ease-out transform -translate-x-1 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 group-hover:ml-0"
x1="10.8333333"
y1="5.5"
x2="0.833333333"
y2="5.16666667"
/>
</>
</g>
</g>
</g>
</svg>
</h2>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
<span>{post.description}</span>
</p>
<div class="mt-2.5 text-xs font-medium text-neutral-800 dark:text-neutral-300">
Posted on {post.dateFormatted}
</div>
</div>
</div>
)
})
}

View file

@ -0,0 +1,65 @@
---
const { name, description, url, image } = Astro.props;
---
<a
href={url}
target="_blank"
class="relative flex flex-col items-stretch duration-300 ease-out p-7 sm:p-3 group h-100 rounded-2xl"
>
<span
class="absolute inset-0 z-20 block w-full h-full duration-300 ease-out bg-transparent border border-transparent border-dashed group-hover:-translate-x-1 group-hover:-translate-y-1 group-hover:border group-hover:border-neutral-300 dark:group-hover:border-neutral-600 group-hover:border-dashed rounded-2xl group-hover:bg-white dark:group-hover:bg-neutral-950"
></span>
<span
class="absolute inset-0 z-10 block w-full h-full duration-300 ease-out border border-dashed rounded-2xl border-neutral-300 dark:border-neutral-600 group-hover:translate-x-1 group-hover:translate-y-1"
></span>
<span
class="relative z-30 block duration-300 ease-out group-hover:-translate-x-1 group-hover:-translate-y-1"
>
<span class="block w-full">
<img src={image} class="w-full h-auto rounded-lg aspect-[16/9]" />
</span>
<span class="block w-full px-1 mt-5 mb-1 sm:mt-3">
<span
class="flex items-center mb-0 text-base font-semibold tracking-tight text-neutral-900 dark:text-neutral-100"
>
<span>{name}</span>
<svg
class="group-hover:translate-x-0 group-hover:translate-y-0 -rotate-45 translate-y-1 -translate-x-1 w-2.5 h-2.5 stroke-current ml-1 transition-all ease-in-out duration-200 transform"
viewBox="0 0 13 15"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
><g
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
><g
id="svg"
transform="translate(0.666667, 2.333333)"
stroke="currentColor"
stroke-width="2.4"
><g
><polyline
class="transition-all duration-200 ease-out opacity-0 delay-0 group-hover:opacity-100"
points="5.33333333 0 10.8333333 5.5 5.33333333 11"
></polyline><line
class="transition-all duration-200 ease-out transform -translate-x-1 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 group-hover:ml-0"
x1="10.8333333"
y1="5.5"
x2="0.833333333"
y2="5.16666667"></line></g
></g
></g
></svg
>
</span>
<span class="text-sm text-neutral-600 dark:text-neutral-400"
>{description}</span
>
</span>
</span>
</a>

View file

@ -0,0 +1,17 @@
---
import Square from "./square.astro";
---
<div
class="relative flex w-full divide-x h-[30px] sm:h-[45px] md:h-[60px] xl:h-[88px] divide-neutral-300 dark:divide-neutral-700 divide-dashed"
>
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
</div>

View file

@ -0,0 +1,43 @@
---
import SquareLine from "./square-line.astro";
---
<div class="absolute w-full h-auto" style="z-index:-1">
<div
class="absolute top-0 left-0 w-1/2 h-auto bg-neutral-100 dark:bg-neutral-800"
>
<div
class="absolute inset-0 z-30 w-full h-full pointer-events-none bg-gradient-to-tl from-white dark:from-neutral-950 from-50% via-90% to-100% via-transparent to-transparent"
>
</div>
<div
class="flex flex-col w-full h-full border-t border-l divide-y divide-dashed divide-neutral-300 dark:divide-neutral-700 border-neutral-300 dark:border-neutral-900"
>
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
</div>
</div>
<div
class="absolute top-0 right-0 w-1/2 h-auto bg-neutral-100 dark:bg-neutral-800"
>
<div
class="absolute inset-0 z-30 w-full h-full pointer-events-none bg-gradient-to-tr from-white dark:from-neutral-950 from-50% via-90% to-100% via-transparent to-transparent"
>
</div>
<div
class="flex flex-col w-full h-full border-t border-l divide-y divide-dashed divide-neutral-300 dark:divide-neutral-700 border-neutral-300 dark:border-neutral-900"
>
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
</div>
</div>
</div>

View file

@ -0,0 +1,2 @@
<div class="w-full h-auto bg-white dark:bg-neutral-950 aspect-square {classes}">
</div>

14
src/content/config.js Normal file
View file

@ -0,0 +1,14 @@
import { defineCollection, z } from "astro:content";
const postCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
dateFormatted: z.string(),
}),
});
export const collections = {
post: postCollection,
};

View file

@ -0,0 +1,18 @@
---
layout: ../../layouts/post.astro
title: Browser locally uses AI to remove image backgrounds
description: Browser locally uses AI to remove image backgrounds
dateFormatted: Jul 14th, 2024
---
Yo, so I've been digging into this whole AI thing for front-end development lately, and stumbled upon this cool Transformers.js example. Turned it into a sweet little tool, check it out!
Basically, it uses Transformers.js in a WebWorker to tap into WebGPU and run this RMBG-1.4 model. Long story short, you can now use AI to nuke image backgrounds right in your browser. And get this, it only takes half a second to process a 4K image on my M1 PRO!
Here's the link to the tool: [https://html.zone/background-remover](https://html.zone/background-remover)
[![AI background remover](https://og-image.html.zone/https://html.zone/background-remover)](https://html.zone/background-remover)
* * *
Wanna build it yourself? Head over to [https://github.com/xenova/transformers.js/tree/main/examples/remove-background-client](https://github.com/xenova/transformers.js/tree/main/examples/remove-background-client) for the source code. Oh, and heads up, you gotta be on Transformers.js V3 to mess with WebGPU.

View file

@ -0,0 +1,14 @@
---
layout: ../../layouts/post.astro
title: Aria - a minimalist Astro homepage template
description: Aria is a template for Astro
dateFormatted: Jun 6th, 2024
---
[![GitHub](https://github.html.zone/ccbikai/astro-aria)](https://github.com/ccbikai/astro-aria)
Aria is a template I found on [https://aria.devdojo.io/](https://aria.devdojo.io/). It's clean and beautiful, so I decided to use it for my own homepage and ported it to Astro.
It's already open source, so feel free to use it if you're interested.
<https://github.com/ccbikai/astro-aria>

View file

@ -0,0 +1,32 @@
---
layout: ../../layouts/post.astro
title: BroadcastChannel - Turn your Telegram Channel into a MicroBlog
description: Turn your Telegram Channel into a MicroBlog
dateFormatted: Aug 11th, 2024
---
I have been sharing some interesting tools on [X](https://x.com/ccbikai) and also synchronizing them to my Telegram Channel. I saw that [Austin mentioned he is preparing to create a website](https://x.com/austinit/status/1817832660758081651) to compile all the shared content. This reminded me of a template I recently came across called [Sepia](https://github.com/Planetable/SiteTemplateSepia), and I thought about converting the Telegram Channel into a microblog.
The difficulty wasn't high; I completed the main functionality over a weekend. During the process, I achieved a browser-side implementation with zero JavaScript and would like to share some interesting technical points:
1. The anti-spoiler mode and the hidden display of the mobile search box were implemented using the CSS ":checked pseudo-class" and the "+ adjacent sibling combinator." [Reference](https://www.tpisoftware.com/tpu/articleDetails/2744)
2. The transition animations utilized CSS View Transitions. [Reference](https://liruifengv.com/posts/zero-js-view-transitions/)
3. The image lightbox used the HTML popover attribute. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/popover)
4. The display and hiding of the "back to top" feature were implemented using CSS animation-timeline, exclusive to Chrome version 115 and above. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/CSS/animation-timeline/view)
5. The multi-image masonry layout was achieved using grid layout. [Reference](https://www.smashingmagazine.com/native-css-masonry-layout-css-grid/)
6. The visit statistics were tracked using a 1px transparent image as the logo background, an ancient technique that is now rarely supported by visit statistics software.
7. JavaScript execution on the browser side was prohibited using the Content-Security-Policy's script-src 'none'. [Reference](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Security-Policy/script-src)
After completing the project, I open-sourced it, and I was pleasantly surprised by the number of people who liked it; I received over 800 stars in just a week.
If you're interested, you can check it out on GitHub.
<https://github.com/ccbikai/BroadcastChannel>
[![BroadcastChannel repository on GitHub](https://github.html.zone/ccbikai/BroadcastChannel)](https://github.com/ccbikai/BroadcastChannel)

View file

@ -0,0 +1,70 @@
---
layout: ../../layouts/post.astro
title: Solving the issue of Cloudflare Web Analytics being blocked by AdBlock
description: Solving the issue of Cloudflare Web Analytics being blocked by AdBlock
dateFormatted: Jan 8th, 2024
---
Earlier, we solved the issues of [Vercel Analytics](https://dev.to/ccbikai/jie-jue-vercel-analytics-bei-adblock-ping-bi-wen-ti-1o21-temp-slug-5601874) and [Umami](https://dev.to/ccbikai/jie-jue-umami-bei-adblock-ping-bi-wen-ti-3kc2-temp-slug-2355567) being blocked by AdBlock, and now we are also going to solve the problem for [Email.ML](https://email.ml/) which uses [Cloudflare Web Analytics](https://www.cloudflare.com/zh-cn/web-analytics/).
Cloudflare Web Analytics is blocked by the `||cloudflareinsights.com^` rule. Its script address is `https://static.cloudflareinsights.com/beacon.min.js`, and the data reporting address is `https://cloudflareinsights.com/cdn-cgi/rum`.
![||cloudflareinsights.com^](https://static.miantiao.me/share/2024/U4WHW7/GtPNhj.png)
So, just like Umami, we will proxy the script address and forward the data to the data reporting address.
## Solution
Create a Worker in Cloudflare Workers and paste the following JavaScript code. Configure the domain and test if the script address can be accessed properly. Mine is [https://cwa.miantiao.me/mt-demo.js](https://cwa.miantiao.me/mt-demo.js). The `mt-demo` can be replaced with any disguise address, the script above is already adapted.
```js
const CWA_API = 'https://cloudflareinsights.com/cdn-cgi/rum'
const CWA_SCRIPT = 'https://static.cloudflareinsights.com/beacon.min.js'
export default {
async fetch(request, env, ctx) {
let { pathname, search } = new URL(request.url)
if (pathname.endsWith('.js')) {
let response = await caches.default.match(request)
if (!response) {
response = await fetch(CWA_SCRIPT, request)
ctx.waitUntil(caches.default.put(request, response.clone()))
}
return response
}
const req = new Request(request)
req.headers.delete("cookie")
const response = await fetch(`${CWA_API}${search}`, req)
const headers = Object.fromEntries(response.headers.entries())
if (!response.headers.has('Access-Control-Allow-Origin')) {
headers['Access-Control-Allow-Origin'] = request.headers.get('Origin') || '*'
}
if (!response.headers.has('Access-Control-Allow-Headers')) {
headers['Access-Control-Allow-Headers'] = 'content-type'
}
if (!response.headers.has('Access-Control-Allow-Credentials')) {
headers['Access-Control-Allow-Credentials'] = 'true'
}
return new Response(response.body, {
status: response.status,
headers
})
},
};
```
Then inject the script into your website project, referring to my code:
```html
<script async src='https://cwa.miantiao.me/mt-demo.js' data-cf-beacon='{"send":{"to": "https://cwa.miantiao.me/mt-demo"},"token": "5403f4dc926c4e61a757d630b1ec21ad"}'></script>
```
`src` is the script address, replace `mt-demo` with any disguise address. `data-cf-beacon` contains the send to data reporting address, replace `mt-demo` with any disguise address, the script is already adapted. Remember to change the `token` to your site's token.
You can verify it on [Email.ML](https://email.ml/) or [HTML.ZONE](https://html.zone/).
**Note that using this solution requires disabling automatic configuration, otherwise the data will not be counted.**
![Disable automatic configuration](https://static.miantiao.me/share/2024/AnFeat/jqthrz.png)

View file

@ -0,0 +1,104 @@
---
layout: ../../layouts/post.astro
title: Processing Images with Cloudflare Worker
description: Processing Images with Cloudflare Worker
dateFormatted: Nov 18th, 2023
---
## Background
Previously, I set up a 10GB storage, unlimited bandwidth cloud storage using [Backblaze B2](https://www.backblaze.com/cloud-storage) and Cloudflare, which I use for daily file sharing and as an image hosting service for my blog. It works well with uPic. However, when using it as an image hosting service for my blog, I found that it doesn't support image resizing/cropping. I often use Alibaba Cloud OSS for image processing at work, and I couldn't stand the limitation, so I decided to create my own service.
> The free version of Workers only has a CPU limit of 10ms, and it frequently exceeds the resource usage limit, resulting in a high rate of image cracking. Now it has been adapted to Vercel Edge, which can be used with a CDN. See [https://chi.miantiao.me/post/cloudflare-worker-image/](https://chi.miantiao.me/post/cloudflare-worker-image/)
## Process
After some research, I considered two options:
1. Use Cloudflare to proxy [Vercel Image](https://vercel.com/docs/image-optimization). With this option, the traffic goes through Cloudflare -> Vercel -> Cloudflare -> Backblaze, which is not ideal in terms of stability and speed. Additionally, it only allows 1000 image processing requests per month, which is quite limited.
2. Use the public service [wsrv.nl](https://images.weserv.nl/). With this option, the traffic goes through Cloudflare -> wsrv.nl -> Cloudflare -> Backblaze, and the domain is not under my control. If I want to control the domain, I would have to go through Cloudflare Worker again, which adds complexity.
Since neither option was ideal, I kept looking for alternatives. Last week, when I was working on an Email Worker, I discovered that Cloudflare Worker supports [WebAssembly (Wasm)](https://developers.cloudflare.com/workers/runtime-apis/webassembly/), which sparked the idea of using Worker + WebAssembly to process images.
Initially, I wanted to use [sharp](https://sharp.pixelplumbing.com/), which I had used when working with Node.js. However, the author mentioned that Cloudflare Worker does not support multithreading, so sharp cannot run on Cloudflare Worker in the short term.
I searched online and found that a popular Rust library for image processing is [Photon](https://silvia-odwyer.github.io/photon/), and there is also a [demo](https://github.com/techwithdeo/cloudflare-workers/tree/main/photon-library) in the community. I tried it out and confirmed that it can run on Cloudflare Worker. However, the demo has two drawbacks:
1. Photon needs to be manually updated and cannot keep up with the official updates as quickly.
2. It can only output images in PNG format, and the file size of JPG images actually becomes larger after resizing.
## Result
Based on the keywords "Photon + Worker", I did further research and came up with a new solution inspired by [DenoFlare](https://denoflare.dev/examples/transform-images-wasm) and [jSquash](https://github.com/jamsinclair/jSquash). In the end, I used the official Photon (with patch-package as a dependency), Squash WebAssembly, and Cloudflare Worker to create an image processing service for resizing images. _I originally wanted to support output in AVIF and JPEG XL formats, but due to the 1MB size limit of the free version of Workers, I had to give up this feature_.
Supported features:
1. Supports processing of PNG, JPG, BMP, ICO, and TIFF format images.
2. Can output images in JPG, PNG, and WEBP formats, with WEBP being the default.
3. Supports pipelining, allowing multiple operations to be executed.
4. Supports Cloudflare caching.
5. Supports whitelisting of image URLs to prevent abuse.
6. Degrades gracefully in case of exceptions, returning the original image (exceptions are not cached).
## Demo
### Format Conversion
#### webp
![webp](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=webp)
#### jpg
![jpg](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=jpg)
#### png
![png](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=png)
### Resizing
![resize](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2)
### Rotation
![rotate](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=rotate!90)
### Cropping
![rotate](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=crop!0,0,1000,1000)
### Filters
![filter](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=filter%21obsidian)
### Image Watermark
![watermark](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=watermark!https%3A%2F%2Fstatic.miantiao.me%2Fshare%2F6qIq4w%2FFhSUzU.png,20,20)
### Text Watermark
![draw_text](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=draw_text!miantiao.me,20,20)
### Pipeline Operations
#### Resize + Rotate + Text Watermark
![resize & rotate & draw_text](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2%7Crotate!180%7Cdraw_text!miantiao.me,10,10)
#### Resize + Image Watermark
![resize & watermark](https://image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2%7Cwatermark!https%3A%2F%2Fstatic.miantiao.me%2Fshare%2F6qIq4w%2FFhSUzU.png,10,10)
In theory, it supports all the operations of Photon. If you are interested, you can check the image URLs and modify the parameters according to the [Photon documentation](https://docs.rs/photon-rs/latest/photon_rs/) to try it out yourself. If you encounter any issues, feel free to leave a comment and provide feedback.
## Sharing
I have open-sourced this solution on my GitHub. If you need it, you can follow the documentation to deploy it.
[![ccbikai/cloudflare-worker-image - GitHub](https://github.html.zone/ccbikai/cloudflare-worker-image)](https://github.com/ccbikai/cloudflare-worker-image)
* * *
[![Buy Me A Coffee](https://static.miantiao.me/share/0WmsVP/CcmGr8.png)](https://www.buymeacoffee.com/ccbikai)

View file

@ -0,0 +1,48 @@
---
layout: ../../layouts/post.astro
title: Low-Cost Deployment of Federated Universe Personal Instances
description: Low-Cost Deployment of Federated Universe Personal Instances
dateFormatted: Nov 27th, 2023
---
I came across the concept of the Fediverse at the beginning of this year and found that it is the social network I have always envisioned: each instance is like an isolated island, connected through the network to communicate with each other.
> To learn more about the Fediverse, you can read the blog posts from these individuals:
>
> - [Introduction to the Fediverse](https://zerovip.vercel.app/zh/59563/)
> - [Fediverse: The Federated Universe](https://wzyboy.im/post/1486.html)
> - [What is the Fediverse and Can It Decentralize the Internet?](https://fermi.ink/posts/2022/11/22/01/)
> - [What is Mastodon and How to Use It](https://limboy.me/posts/mastodon/)
> - [Fediverse Guide for Twitter Users](https://wzyboy.im/post/1513.html)
As a self-hosting enthusiast, I wanted to deploy my own instance. I asked about the cost of self-hosting on Mastodon and found that the minimum cost is $15/year for a server and domain name. In order to reduce costs, I didn't purchase a VPS and instead deployed my own instance on my Homelab. It has been running for half a year with a few issues (mainly due to my tinkering) such as internet or power outages at home. Since downtime results in lost messages, I decided to migrate to a server.
Among the popular software, Mastodon has more features but consumes more resources, so I chose [Pleroma](https://pleroma.social/) which consumes fewer resources but still meets my needs. I deployed it on various free services, achieving a server cost of $0 with only the domain name cost remaining. It has been running stable for a quarter.
![chi@miantiao.me](https://static.miantiao.me/share/nNbzS2/miantiao.me_chi.jpg)
Therefore, I would like to share this solution:
- Cloud platforms:
1. [Koyeb](https://app.koyeb.com/)
2. [Northflank](https://northflank.com/)
3. [Zeabur](https://s.mt.ci/WrK7Dc) (Originally free, but now only available through subscription plans (free plan is for testing only))
- Database:
1. [Aiven](https://s.mt.ci/dgQGhM)
2. [Neon](https://neon.tech/)
- Cloud storage:
1. [Cloudflare R2](https://www.cloudflare.com/zh-cn/developer-platform/r2/)
2. [Backblaze B2](https://www.backblaze.com/)
- CDN:
1. [Cloudflare](https://www.cloudflare.com/)
Deployment tutorial:
[![ccbikai/pleroma-on-cloud - GitHub](https://github.html.zone/ccbikai/pleroma-on-cloud)](https://github.com/ccbikai/pleroma-on-cloud)
Remember, free things are often the most expensive. It is important to regularly back up the database and cloud storage.
**Lastly, feel free to follow me on the Fediverse (Mastodon, Pleroma, etc.) at [@chi@miantiao.me](https://miantiao.me/@chi).**

View file

@ -0,0 +1,21 @@
---
layout: ../../layouts/post.astro
title: DNS.Surf - check DNS resolution results in different regions
description: DNS.Surf - check DNS resolution results in different regions
dateFormatted: Nov 8th, 2023
---
[**DNS.Surf**](https://dns.surf/) is like a traveler that helps you explore the scenery of DNS resolution results in different regions.
It provides resolution services from 18 regions and has over 100 optional DNS resolvers, just like choosing how to travel between different cities and countries.
This website runs entirely on Vercel, like a stable and efficient means of transportation, providing you with fast and reliable service.
## Privacy
For privacy concerns, you can use it with confidence, as the website does not collect or store any user information. It's like enjoying the scenery during your travels without worrying about personal information leakage.
## Website
[https://dns.surf/](https://dns.surf/)

View file

@ -0,0 +1,26 @@
---
layout: ../../layouts/post.astro
title: Email.ML - minimalistic temporary email
description: Email.ML - minimalistic temporary email
dateFormatted: Jun 6th, 2024
---
[**Email.ML**](https://email.ml/) is a minimalistic temporary email service.
You can get a temporary email without revealing any personal information, which greatly protects your privacy.
It supports selecting multiple domain names, making it convenient for you to use in different scenarios.
100% running on the **Cloudflare** network, providing you with a super-fast experience.
## Statement
This service is not available in China Mainland.
## Privacy
This site only stores an email name for this session, and the emails are temporarily stored in **Cloudflare** data centers. They will be completely deleted after the email expires.
## Website
[https://email.ml/](https://email.ml/)

View file

@ -0,0 +1,54 @@
---
layout: ../../layouts/post.astro
title: Extract GitHub OpenGraph Images for Card Previews
description: Extract GitHub OpenGraph Images for Card Previews
dateFormatted: Dec 19th, 2023
---
Previously, when sharing GitHub on my blog, I always used [GitHub Repository Card](https://gh-card.dev/) for sharing, but it doesn't have good support for Chinese and doesn't support line breaks.
[![ccbikai/cloudflare-worker-image - GitHub](https://gh-card.dev/repos/ccbikai/cloudflare-worker-image.svg?fullname=)](https://github.com/ccbikai/cloudflare-worker-image)
Originally, I planned to create my own using [@vercel/og](https://vercel.com/docs/functions/edge-functions/og-image-generation), but I accidentally discovered that GitHub provides comprehensive and beautiful Open Graph images on Twitter. So, I wrote a script to extract and use them for blog previews.
## Demo
![nasa/fprime - GitHub](https://github.html.zone/nasa/fprime)
![A framework for building Open Graph images](https://static.miantiao.me/share/9ZxTs8/RZHfnD.png)
In addition to repositories, GitHub's Open Graph also supports previews for Issue, Pull Request, Discussion, and Commit modules.
## Usage
**Modify `.com` to `.html.zone` on any GitHub page**.
For example, [https://github.com/vercel/next.js](https://github.com/vercel/next.js) => [https://github.html.zone/vercel/next.js](https://github.html.zone/vercel/next.js).
### Previews
#### Repo
![Repo](https://github.html.zone/vercel/next.js)
#### Issue
![Issue](https://github.html.zone/vuejs/core/issues/9862)
#### Pull Request
![Pull Request](https://github.html.zone/lobehub/lobe-chat/pull/529)
#### Discussion
![Discussion](https://github.html.zone/lobehub/lobe-chat/discussions/551)
#### Commit
![Commit](https://github.html.zone/vercel/next.js/commit/a65fb162989fd00ca21534947538b8dbb6bf7f86)
## Source Code
The code has been shared on GitHub for those interested to explore.
[![ccbikai/github-og-image - GitHub](https://github.html.zone/ccbikai/github-og-image)](https://github.com/ccbikai/github-og-image)

View file

@ -0,0 +1,64 @@
---
layout: ../../layouts/post.astro
title: How to Replace Google Safe Browsing with Cloudflare Zero Trust
description: How to Replace Google Safe Browsing with Cloudflare Zero Trust
dateFormatted: Jul 14th, 2024
---
So, get this, right? I built the first version of [L(O\*62).ONG](https://loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong/) using server-side redirects, but Google slapped me with a security warning the very next day. Talk about a buzzkill! I had to scramble and switch to local redirects with a warning message before sending folks on their way. Then came the fun part begging Google for forgiveness.
Now, the smart money would've been on using Google Safe Browsing for redirects. But here's the catch: Safe Browsing's got a daily limit 10,000 calls, and that's it. Plus, no custom lists. And since I'm all about keeping things simple and sticking with Cloudflare, Safe Browsing was a no-go.
Fast forward to a while back, I was chewing the fat with someone online, and bam! It hit me like a bolt of lightning. Why not use a secure DNS server with built-in filters for adult content and all that shady stuff to check if a domain's on the up-and-up? Figured I'd give [Family 1.1.1.1](https://blog.cloudflare.com/zh-cn/introducing-1-1-1-1-for-families-zh-cn/) a shot, and guess what? It actually worked! Problem was, no custom lists there either. Then I remembered messing around with Cloudflare Zero Trust Gateway back in my [HomeLab](https://www.awesome-homelab.com/) days. Turns out, that was the golden ticket a solution so good, it's almost criminal.
**Here's the deal: Cloudflare Zero Trust's Gateway comes packing a built-in DNS (DoH) server and lets you set up firewall rules like a boss. You can block stuff based on how risky a domain is, what kind of content it has, and even use your own custom naughty-and-nice lists. And get this it pulls data from Cloudflare's own stash, over 30 open intelligence sources, fancy machine learning models, and even feedback from the community. Talk about covering all the bases! Want the nitty-gritty? Hit up the [official documentation](https://developers.cloudflare.com/cloudflare-one/policies/gateway/domain-categories/#docs-content).**
So, I went ahead and blocked all the high-risk categories adult stuff, gambling sites, government domains, anything NSFW, newly registered domains, you name it. Plus, I've got my own little blacklists and whitelists that I keep nice and tidy.
![Risk List](https://static.miantiao.me/share/2024/ROJmki/CleanShot%202024-07-07%20at%2022.22.25.png)
Once I was done tweaking the settings, I got myself a shiny new DoH address:
![DoH](https://static.miantiao.me/share/2024/iY5dK8/CleanShot%202024-07-07%20at%2022.26.23.png)
To hook it up to my project, I used this handy-dandy code:
```
async function isSafeUrl(
url,
DoH = "https://family.cloudflare-dns.com/dns-query"
) {
let safe = false;
try {
const { hostname } = new URL(url);
const res = await fetch(`${DoH}?type=A&name=${hostname}`, {
headers: {
accept: "application/dns-json",
},
cf: {
cacheEverything: true,
cacheTtlByStatus: { "200-299": 86400 },
},
});
const dnsResult = await res.json();
if (dnsResult && Array.isArray(dnsResult.Answer)) {
const isBlock = dnsResult.Answer.some(
answer => answer.data === "0.0.0.0"
);
safe = !isBlock;
}
} catch (e) {
console.warn("isSafeUrl fail: ", url, e);
}
return safe;
}
```
And here's the kicker: Cloudflare Zero Trust's management panel has this sweet visualization interface that lets you see what's getting blocked and what's not. You can see for yourself it's got the kibosh on some adult sites and those brand-spanking-new domains.
![Visualization Interface](https://static.miantiao.me/share/2024/5hOp5X/CleanShot%202024-07-07%20at%2022.30.36.png)
Oh, and if a domain ends up on the wrong side of the tracks, you can always check the log to see what went down.
![Log](https://static.miantiao.me/share/2024/EmRMB3/52WCkd.png)

View file

@ -0,0 +1,20 @@
---
layout: ../../layouts/post.astro
title: L(O*62).ONG - Make your URL longer
description: loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong is the longest domain name
dateFormatted: Jun 1th, 2024
---
[![GitHub](https://github.html.zone/ccbikai/loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong)](https://github.com/ccbikai/loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong)
This little toy was finished last week. Just a few lines of code.
Encountered many issues during deployment, mainly related to HTTPS certificates.
The longest segment of the domain name is 63 characters. The commonName of the HTTPS certificate can be up to 64 characters.
This caused Cloudflare, Vercel, and Netlify to be unable to use Let's Encrypt to sign HTTPS certificates (because they use the domain name in commonName), but Zeabur can use Let's Encrypt to sign HTTPS certificates.
Finally, switching the Cloudflare certificate to Google Trust Services LLC successfully signed the certificate.
You can view the relevant certificates at [https://crt.sh/?q=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong](https://crt.sh/?q=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.ong).

60
src/content/post/sink.md Normal file
View file

@ -0,0 +1,60 @@
---
layout: ../../layouts/post.astro
title: Sink - A short link system based on Cloudflare with visit statistics
description: A short link system based on Cloudflare with visit statistics
dateFormatted: Jun 4th, 2024
---
I previously shared some websites on [Twitter](https://x.com/0xKaiBi) using short links to make it easier to see if people are interested. Among these link shortening systems, Dub provides the best user experience, but it has a fatal flaw: once the monthly clicks exceed 1000, you can no longer view the statistics.
While surfing the internet at home during the Qingming Festival, I discovered that [Cloudflare Workers Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/) supports data writing and API data querying. So, I created an MVP version myself, capable of handling statistics for up to 3,000,000 visits per month. Cloudflare's backend likely uses Clickhouse, so performance shouldn't be a significant issue.
During the Labor Day holiday, I improved the frontend UI at home and used it for about half a month, finding it satisfactory. I have open-sourced it for everyone to use.
## Features
- Link shortening
- Visit statistics
- Serverless deployment
- Custom Slug
- 🪄 AI-generated Slug
- Link expiration
## Demo
[Sink.Cool](https://sink.cool/dashboard)
Site Token: `SinkCool`
### Site-wide Analysis
![Site-wide Analysis](https://static.miantiao.me/share/CBuVes/sink.cool_dashboard.png)
<details>
<summary><b>Link Management</b></summary>
<img alt="Link Management" src="https://static.miantiao.me/share/uQVX7Q/sink.cool_dashboard_links.png"/>
</details>
<details>
<summary><b>Individual Link Analysis</b></summary>
<img alt="Individual Link Analysis" src="https://static.miantiao.me/share/WfyCXT/sink.cool_dashboard_link_slug=0.png"/>
</details>
## Open Source
[![ccbikai/sink - GitHub](https://github.html.zone/ccbikai/sink)](https://github.com/ccbikai/sink)
## Roadmap (WIP)
- Browser extension
- Raycast extension
- Apple Shortcuts
- Enhanced link management (based on Cloudflare D1)
- Enhanced analysis (support filtering)
- Panel performance optimization (support infinite loading)
- Support for other platforms (maybe)
---
Finally, feel free to follow me on [Twitter](https://x.com/0xKaiBi) for updates on development progress and to share some web development news.

View file

@ -0,0 +1,52 @@
---
layout: ../../layouts/post.astro
title: Resolving Umami Blocked by AdBlock Issue
description: Resolving Umami Blocked by AdBlock Issue
dateFormatted: Jan 6th, 2024
---
I recently redesigned my [personal homepage](https://mt.ci/) and used Umami for website analytics. However, there is an ongoing issue: users who have AdBlock installed are causing the analytics to fail.
For more information on how AdBlock works, you can refer to [Resolving Vercel Analytics Blocked by AdBlock Issue](11). The rule that blocks Umami is `||umami.is^$3p`, which blocks the script and data reporting URLs. To overcome this, we can use [Cloudflare Workers](https://workers.cloudflare.com/) to proxy Umami.
![||umami.is^$3p](https://static.miantiao.me/share/2024/CNrM78/ha30pV.png)
## Solution
Create a Cloudflare Worker and paste the following JavaScript code. If you are using the official Umami service, you don't need to modify the code (remember to change UMAMI\_HOST to your service URL). If you are using a self-hosted service, you can define the script and data reporting URLs using the `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` environment variables, without the need for proxying.
```js
const UMAMI_HOST = 'https://eu.umami.is'
export default {
async fetch(request, env, ctx) {
const { pathname, search } = new URL(request.url)
if (pathname.endsWith('.js')) {
let response = await caches.default.match(request)
if (!response) {
response = await fetch(`${UMAMI_HOST}/script.js`, request)
ctx.waitUntil(caches.default.put(request, response.clone()))
}
return response
}
const req = new Request(request)
req.headers.delete("cookie")
req.headers.append('x-client-ip', req.headers.get('cf-connecting-ip'))
return fetch(`${UMAMI_HOST}${pathname}${search}`, req)
},
};
```
Once you have created the Worker, configure the domain and test if the script URL can be accessed correctly. In my case, it is [https://ums.miantiao.me/mt-demo.js](https://ums.miantiao.me/mt-demo.js). You can replace "mt-demo" with any disguised URL, as the script has already been adapted.
Next, inject the script into your website project. You can refer to the official documentation at [https://umami.is/docs/tracker-configuration](https://umami.is/docs/tracker-configuration) or use the following code as a reference:
```html
<script defer src="https://ums.miantiao.me/mt-demo.js" data-host-url="https://ums.miantiao.me" data-website-id="0a10de75-03be-4fec-a521-4c62b91650ac"></script>
```
In the above code, `src` refers to the script URL, `data-host-url` refers to the data reporting URL, and `data-website-id` refers to the website ID. Make sure to provide the correct website ID to ensure data reporting.
You can verify the implementation on [Noodle Lab](https://mt.ci/) or this website.

View file

@ -0,0 +1,80 @@
---
layout: ../../layouts/post.astro
title: Using Vercel Edge to Process Images
description: Using Vercel Edge to Process Images
dateFormatted: Dec 17th, 2023
---
Previously, I shared an article on [using Cloudflare Worker to process images](https://dev.to/ccbikai/shi-yong-cloudflare-worker-chu-li-tu-pian-38dl-temp-slug-7437591). However, due to the limitations of the free version of Worker, which only allows for 10ms of CPU usage, there were frequent resource overages and high failure rates. Today, I had some free time, so I decided to try using Vercel Edge instead and share my findings with those who are interested.
The official version of Vercel also supports image processing, but it has a limit of 1000 original images per month and only supports scaling. By using Vercel Edge to process images, you can have additional features such as scaling, cropping, watermarking, and filters. However, please note that the free version of Vercel only allows for 100GB of monthly traffic, so it is recommended to use it in conjunction with a CDN for actual usage.
Supported features:
1. Support for processing PNG, JPG, BMP, ICO, and TIFF format images
2. Output images in JPG, PNG, and WEBP formats, with WEBP being the default
3. Support for pipelining, allowing for multiple operations to be performed
4. Support for whitelisting image URLs to prevent abuse
5. Graceful degradation in case of processing failure, returning the original image (exceptions are not cached)
## Demo
### Format Conversion
#### WEBP
![webp](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=webp)
#### JPG
![jpg](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=jpg)
#### PNG
![png](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&format=png)
### Scaling
![resize](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2)
### Rotation
![rotate](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=rotate!90)
### Cropping
![rotate](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=crop!0,0,1000,1000)
### Filters
![filter](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=filter%21obsidian)
### Image Watermark
![watermark](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=watermark!https%3A%2F%2Fstatic.miantiao.me%2Fshare%2F6qIq4w%2FFhSUzU.png,20,20)
### Text Watermark
![draw_text](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=draw_text!miantiao.me,20,20)
### Pipelining
#### Scaling + Rotation + Text Watermark
![resize & rotate & draw_text](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2%7Crotate!180%7Cdraw_text!miantiao.me,10,10)
#### Scaling + Image Watermark
![resize & watermark](https://edge-image.miantiao.me/?url=https%3A%2F%2Fstatic.miantiao.me%2Fshare%2FMTyerw%2Fbanner-2048.jpeg&action=resize!830,400,2%7Cwatermark!https%3A%2F%2Fstatic.miantiao.me%2Fshare%2F6qIq4w%2FFhSUzU.png,10,10)
In theory, it supports various operations available in Photon. If you are interested, you can check the image URLs and modify the parameters according to the [Photon documentation](https://docs.rs/photon-rs/latest/photon_rs/) to try it out yourself. If you encounter any issues, please leave a comment and provide feedback.
## Sharing
I have open-sourced this solution on my GitHub repository, and you can deploy it by following the documentation.
[![ccbikai/vercel-edge-image - GitHub](https://github.html.zone/ccbikai/vercel-edge-image)](https://github.com/ccbikai/vercel-edge-image)
* * *
[![Buy Me A Coffee](https://static.miantiao.me/share/0WmsVP/CcmGr8.png)](https://www.buymeacoffee.com/ccbikai)

View file

@ -0,0 +1,44 @@
---
layout: ../../layouts/post.astro
title: Solving Vercel Analytics Blocked by AdBlock Issue
description: Solving Vercel Analytics Blocked by AdBlock Issue
dateFormatted: Jun 6th, 2024
---
[DNS.Surf](https://dns.surf/) runs 100% on Vercel, so Vercel Analytics is used for access statistics. However, many users who have AdBlock installed experience issues with access statistics not being recorded. Today, we will solve the problem of AdBlock blocking access statistics, while still relying on Vercel 100%.
The core principle of AdBlock is to block certain network requests and page elements using rules. Vercel Analytics is blocked by the rule `/_vercel/insights/script.js`, and it may also block `/_vercel/insights/event`. To solve this problem, we just need to make these two URLs less recognizable.
![/_vercel/insights/script.js](https://static.miantiao.me/share/2024/JbSVLo/5aOZdV.png)
## Solution
Vercel comes with a Rewrite feature, so we just need to rewrite the disguised path `/mt-demo` to `/_vercel/insights`. The disguised path can be any unique path that does not conflict with existing paths. If it gets blocked, just use a different one. The vercel.json configuration is as follows:
```js
{
"rewrites": [
{
"source": "/mt-demo/:match*",
"destination": "https://dns.surf/_vercel/insights/:match*"
}
]
}
```
Note that the destination should be the complete URL, otherwise it will not work.
In the official tutorial, different frameworks use [@vercel/analytics](https://vercel.com/docs/analytics/package) to inject the analytics script into the page, but it does not support custom scripts and data reporting URLs. Therefore, we need to use the HTML method to inject the script.
```html
<script>
window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
</script>
<script async src="/mt-demo/script.js" data-endpoint="/mt-demo"></script>
```
`src` is the script URL, and `data-endpoint` is the data reporting URL. Although it is not mentioned in the official documentation, the script does support it. Remember to replace `mt-demo` with your disguised path.
If you are using a different framework, you can look for the method to inject scripts in that framework to adapt it to your own usage.
You can verify the effect using [DNS.Surf](https://dns.surf/).

2
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

44
src/layouts/main.astro Normal file
View file

@ -0,0 +1,44 @@
---
import Footer from "../components/footer.astro";
import Header from "../components/header.astro";
import SquareLines from "../components/square-lines.astro";
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<!-- Used to add dark mode right away, adding here prevents any flicker -->
<script is:inline>
if (typeof Storage !== 'undefined') {
if (
localStorage.getItem('dark_mode') &&
localStorage.getItem('dark_mode') == 'true'
) {
document.documentElement.classList.add('dark')
}
}
</script>
<style>
.prose img {
border-radius: 20px;
}
</style>
<link rel="icon" type="image/x-icon" href="../assets/images/favicon.png" />
<script src="../assets/css/main.css"></script>
<Fragment set:html={import.meta.env.HEADER_INJECT} />
</head>
<body class="antialiased bg-white dark:bg-neutral-950">
<SquareLines />
<Header />
<slot />
<Footer />
<script src="../assets/js/main.js"></script>
<Fragment set:html={import.meta.env.FOOTER_INJECT} />
</body>
</html>

34
src/layouts/post.astro Normal file
View file

@ -0,0 +1,34 @@
---
import Layout from "./main.astro";
const { frontmatter } = Astro.props;
---
<Layout title={frontmatter.title}>
<main
class="relative z-30 max-w-4xl pb-1 mx-auto mt-10 bg-white dark:bg-neutral-950 md:rounded-t-md text-neutral-900"
>
<div
class="relative flex flex-col px-5 pt-6 border-t border-b-0 md:border-r md:border-l md:pt-20 lg:px-0 justify-stretch md:rounded-t-2xl border-neutral-200 dark:border-neutral-800"
>
<div
class="absolute top-0 left-0 hidden w-px h-full mt-1 -translate-x-px md:block bg-gradient-to-b from-transparent to-white dark:to-neutral-950"
>
</div>
<div
class="absolute top-0 right-0 hidden w-px h-full mt-1 translate-x-px md:block bg-gradient-to-b from-transparent to-white dark:to-neutral-950"
>
</div>
<h1
class="w-full max-w-2xl mx-auto text-3xl font-bold leading-tight tracking-tighter text-left md:mb-12 md:text-4xl dark:text-neutral-100 lg:text-5xl md:leading-none"
>
{frontmatter.title}
</h1>
</div>
<article
class="w-full max-w-2xl mx-auto mb-20 prose-sm prose px-7 lg:px-0 lg:prose-lg dark:prose-invert"
>
<slot />
</article>
</main>
</Layout>

72
src/pages/about.astro Normal file
View file

@ -0,0 +1,72 @@
---
import experiences from "../collections/experiences.json";
import AboutExperience from "../components/about-experience.astro";
import PageHeading from "../components/page-heading.astro";
import Layout from "../layouts/main.astro";
---
<Layout title="About Me">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="About Me"
description="Hello 👋 I'm a frontend engineer from Nanjing, China. I'm passionate about building new products and learning new technology."
/>
<img src="/assets/images/about.jpg" class="relative z-30 w-full my-10 rounded-xl" />
<h2 class="mb-2 text-2xl font-bold dark:text-neutral-200">Short Bio</h2>
<p
class="text-sm leading-6 text-gray-600 dark:text-neutral-400 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
>
Front-end cutter 🧑🏻‍💻, back-end amateur 🤷🏻‍♂️, operations digging holes person 🤦🏻‍♂️.
</p>
<h2
class="mt-5 mb-2 text-2xl font-bold lg:mt-10 sm:mt-6 dark:text-neutral-200"
>
Experience
</h2>
<div class="px-5 py-10">
{
experiences.map((experience, loop) => {
return (
<>
{loop == 0 || loop == 1 ? (
<div class="pb-10 border-l border-gray-200 dark:border-neutral-700">
<AboutExperience
dates={experience.dates}
role={experience.role}
company={experience.company}
description={experience.description}
logo={experience.logo}
/>
</div>
) : (
<AboutExperience
dates={experience.dates}
role={experience.role}
company={experience.company}
description={experience.description}
logo={experience.logo}
/>
)}
</>
)
})
}
</div>
<h2 class="mt-5 mb-2 text-2xl font-bold lg:mt-10 sm:mt-6 dark:text-neutral-200">Let's Connect</h2>
<p
class="text-sm leading-6 text-gray-600 dark:text-neutral-400 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
>
If you want to stay up to date with my work be sure to <a
href="https://twitter.com/ccikai"
target="_blank"
class="text-indigo-600 underline">follow me on twitter</a
>, or you can send me an <a href="mailto:astro-aria#miantiao.me" class="text-indigo-600 underline"
>email</a
> and I'll be sure to get back to you.
</p>
</section>
</Layout>

76
src/pages/index.astro Normal file
View file

@ -0,0 +1,76 @@
---
import Button from "../components/button.astro";
import Projects from "../components/home/projects.astro";
import Separator from "../components/home/separator.astro";
import Writings from "../components/home/writings.astro";
import Layout from "../layouts/main.astro";
---
<Layout title="Kai">
<div
class="relative z-20 w-full max-w-4xl mx-auto mt-16 px-7 md:mt-24 lg:mt-32 xl:px-0"
>
<div class="flex flex-col items-center md:flex-row">
<div class="relative w-full md:w-1/2">
<h1
class="mb-5 text-4xl font-bold leading-tight md:text-4xl lg:text-6xl dark:text-white"
>
Hello, I'm Kai.
</h1>
<p class="mb-6 text-base text-neutral-600 dark:text-neutral-400">
I'm a front-end programmer living in Nanjing. <br
class="hidden lg:block"
/>I focus on Web development.
</p>
<p class="mb-2 font-semibold text-neutral-800 dark:text-neutral-200">
I can help you out with:
</p>
<ul
class="py-2 space-y-[3px] text-sm list-disc list-inside text-neutral-500 dark:text-neutral-400"
>
<li>Vue.js Development</li>
<li>React.js Development</li>
<li>Node.js Development</li>
<li>Website Design</li>
<li>and more...</li>
</ul>
<Button text="Follow me on 𝕏" link="https://twitter.com/0xKaiBi" />
</div>
<div
class="relative justify-end hidden w-full mt-10 md:flex md:pl-10 md:w-1/2 md:mt-0 md:translate-y-4 xl:translate-y-0"
>
<div class="relative z-50 w-full">
<div
class="absolute bottom-0 z-40 w-16 h-16 -translate-x-6 -translate-y-1/2 lg:top-auto top-0 lg:-translate-y-[330px] rounded-full"
>
<span
class="relative z-20 flex items-center justify-center w-full h-full text-2xl border-8 border-white rounded-full dark:border-neutral-950 bg-neutral-100 dark:bg-neutral-900"
>
<span
class="flex items-center justify-center w-full h-full bg-white border border-dashed rounded-full dark:bg-neutral-950 border-neutral-300 dark:border-neutral-700"
>👋</span
>
</span>
</div>
<div class="relative z-30 px-10">
<img
src="/assets/images/photo.png"
loading="eager"
decoding="auto"
class="relative z-30 w-full aspect-[790/1189] md:max-w-md mx-auto dark:-translate-y-0.5"
/>
</div>
<div
class="absolute bottom-0 right-0 z-20 w-full h-full lg:h-[420px] translate-x-0 -translate-y-px border border-dashed rounded-2xl bg-gradient-to-r dark:from-neutral-950 dark:via-black dark:to-neutral-950 from-white via-neutral-50 to-white border-neutral-300 dark:border-neutral-700"
>
</div>
</div>
</div>
</div>
</div>
<Separator text="Check out my projects" />
<Projects />
<Separator text="Some of my writing" />
<Writings />
</Layout>

View file

@ -0,0 +1,16 @@
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const postEntries = await getCollection("post");
return postEntries.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<Content />

18
src/pages/posts.astro Normal file
View file

@ -0,0 +1,18 @@
---
import PageHeading from "../components/page-heading.astro";
import PostsLoop from "../components/posts-loop.astro";
import Layout from "../layouts/main.astro";
---
<Layout title="My Writing">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="My Writing"
description="Dive into my musings on life and tech in my latest posts; a blend of introspection and innovation. Keep an eye out for fresh insights and updates!"
/>
<div class="z-50 flex flex-col items-stretch w-full gap-5 my-8">
<PostsLoop count="999999999" />
</div>
</section>
</Layout>

32
src/pages/projects.astro Normal file
View file

@ -0,0 +1,32 @@
---
import projects from "../collections/projects.json";
import PageHeading from "../components/page-heading.astro";
import Project from "../components/project.astro";
import Layout from "../layouts/main.astro";
---
<Layout title="My Projects">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="My Projects"
description="Here are some of the current projects I've been working on. I really enjoy creating new projects and coming up with new ideas. I'm always working on something new, so check back often!"
/>
<div
class="z-50 grid items-stretch w-full grid-cols-1 my-8 gap-7 sm:gap-5 sm:grid-cols-2"
>
{
projects.map((project) => {
return (
<Project
name={project.name}
description={project.description}
image={project.image}
url={project.url}
/>
)
})
}
</div>
</section>
</Layout>

9
tailwind.config.mjs Normal file
View file

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};

3
tsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}