diff --git a/package.json b/package.json index 310f9bd..cc37fff 100755 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "astroplate", - "version": "2.4.0", + "version": "2.5.0", "description": "Astro and Tailwindcss boilerplate", "author": "zeon.studio", "license": "MIT", "packageManager": "yarn@1.22.19", "scripts": { - "dev": "astro dev", - "build": "astro build", + "dev": "yarn generate-json && astro dev", + "build": "yarn generate-json && astro build", "preview": "astro preview", "format": "prettier -w ./src", "generate-json": "node scripts/jsonGenerator.js", @@ -24,7 +24,6 @@ "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", "disqus-react": "^1.1.5", - "fuse.js": "^7.0.0", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "marked": "^11.0.0", diff --git a/public/images/no-search-found.png b/public/images/no-search-found.png deleted file mode 100755 index 1e1e6e1..0000000 Binary files a/public/images/no-search-found.png and /dev/null differ diff --git a/scripts/jsonGenerator.js b/scripts/jsonGenerator.js index 6c2d20b..5a9f995 100644 --- a/scripts/jsonGenerator.js +++ b/scripts/jsonGenerator.js @@ -1,44 +1,80 @@ const fs = require("fs"); const path = require("path"); const matter = require("gray-matter"); -const config = require("../src/config/config.json"); -const { blog_folder } = config.settings; -const jsonDir = "./.json"; + +const CONTENT_DEPTH = 2; +const JSON_FOLDER = "./.json"; +const BLOG_FOLDER = "src/content/blog"; // get data from markdown -const getData = (folder) => { +const getData = (folder, groupDepth) => { const getPath = fs.readdirSync(path.join(folder)); - const sanitizeData = getPath.filter((item) => item.includes(".md")); - const filterData = sanitizeData.filter((item) => item.match(/^(?!_)/)); - const getData = filterData.map((filename) => { - const file = fs.readFileSync(path.join(folder, filename), "utf-8"); - const { data } = matter(file); - const content = matter(file).content; - const slug = data.slug ? data.slug : filename.replace(".md", ""); + const removeIndex = getPath.filter((item) => item.match(/^(?!-)/)); - return { - frontmatter: data, - content: content, - slug: slug, - }; + const getPaths = removeIndex.map((filename) => { + const filepath = path.join(folder, filename); + const stats = fs.statSync(filepath); + const isFolder = stats.isDirectory(); + + if (isFolder) { + return getData(filepath, groupDepth); + } else if (filename.endsWith(".md") || filename.endsWith(".mdx")) { + const file = fs.readFileSync(path.join(folder, filename), "utf-8"); + const { data } = matter(file); + const content = matter(file).content; + const removeExtension = filepath.replace(/\.[^/.]+$/, ""); + const slug = data.slug + ? data.slug + : removeExtension + .split("/") + .slice(CONTENT_DEPTH, removeExtension.split("/").length) + .join("/"); + + const group = removeExtension.split("/")[Number(groupDepth)]; + + return { + group: group, + slug: slug, + frontmatter: data, + content: content, + }; + } }); - const publishedPages = getData.filter( - (page) => !page.frontmatter?.draft && page + + const publishedPages = getPaths.filter( + (page) => !page.frontmatter?.draft && page, ); return publishedPages; }; -// get post data -const posts = getData(`src/content/${blog_folder}`); +// flatten nested arrays +const flatten = (arr) => { + return arr.reduce((result, element) => { + if (Array.isArray(element)) { + result.push(...flatten(element)); + } else { + result.push(element); + } + return result; + }, []); +}; try { - // creare folder if it doesn't exist - if (!fs.existsSync(jsonDir)) { - fs.mkdirSync(jsonDir); + // create folder if it doesn't exist + if (!fs.existsSync(JSON_FOLDER)) { + fs.mkdirSync(JSON_FOLDER); } - // create posts.json file - fs.writeFileSync(`${jsonDir}/posts.json`, JSON.stringify(posts)); + // create json files + fs.writeFileSync( + `${JSON_FOLDER}/posts.json`, + JSON.stringify(flatten(getData(BLOG_FOLDER, 2))), + ); + + // merger json files for search + const posts = require(`../${JSON_FOLDER}/posts.json`); + const search = [...posts]; + fs.writeFileSync(`${JSON_FOLDER}/search.json`, JSON.stringify(search)); } catch (err) { console.error(err); } diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index f0f7fe8..52d113f 100755 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -6,8 +6,8 @@ import { plainify } from "@/lib/utils/textConverter"; import Footer from "@/partials/Footer.astro"; import Header from "@/partials/Header.astro"; import "@/styles/main.scss"; -import { ViewTransitions } from 'astro:transitions'; - +import { ViewTransitions } from "astro:transitions"; +import SearchModal from "./helpers/SearchModal"; // font families const pf = theme.fonts.font_family.primary; @@ -28,7 +28,7 @@ const { title, meta_title, description, image, noindex, canonical } = Astro.props; --- - + @@ -80,7 +80,7 @@ const { title, meta_title, description, image, noindex, canonical } = @@ -93,7 +93,7 @@ const { title, meta_title, description, image, noindex, canonical } = @@ -101,7 +101,7 @@ const { title, meta_title, description, image, noindex, canonical } = @@ -114,7 +114,7 @@ const { title, meta_title, description, image, noindex, canonical } = @@ -122,7 +122,7 @@ const { title, meta_title, description, image, noindex, canonical } = @@ -146,6 +146,7 @@ const { title, meta_title, description, image, noindex, canonical } =
+
diff --git a/src/layouts/Search.tsx b/src/layouts/Search.tsx deleted file mode 100755 index 56976e7..0000000 --- a/src/layouts/Search.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import config from "@/config/config.json"; -import { humanize, plainify, slugify } from "@/lib/utils/textConverter"; -import Fuse from "fuse.js"; -import React, { useEffect, useRef, useState } from "react"; -import { - FaRegFolder, - FaRegUserCircle, - FaSearch, -} from "react-icons/fa/index.js"; - -const { summary_length, blog_folder } = config.settings; - -export type SearchItem = { - slug: string; - data: any; - content: any; -}; - -interface Props { - searchList: SearchItem[]; -} - -interface SearchResult { - item: SearchItem; - refIndex: number; -} - -const SearchLayout = ({ searchList }: Props) => { - const inputRef = useRef(null); - const [inputVal, setInputVal] = useState(""); - const [searchResults, setSearchResults] = useState([]); - - const handleChange = (e: React.FormEvent) => { - setInputVal(e.currentTarget.value); - }; - - const fuse = new Fuse(searchList, { - keys: ["data.title", "data.categories", "data.tags"], - includeMatches: true, - minMatchCharLength: 3, - threshold: 0.5, - }); - - useEffect(() => { - const searchUrl = new URLSearchParams(window.location.search); - const searchStr = searchUrl.get("q"); - if (searchStr) setInputVal(searchStr); - - setTimeout(function () { - inputRef.current!.selectionStart = inputRef.current!.selectionEnd = - searchStr?.length || 0; - }, 50); - }, []); - - useEffect(() => { - let inputResult = inputVal.length > 2 ? fuse.search(inputVal) : []; - setSearchResults(inputResult); - - if (inputVal.length > 0) { - const searchParams = new URLSearchParams(window.location.search); - searchParams.set("q", inputVal); - const newRelativePathQuery = - window.location.pathname + "?" + searchParams.toString(); - history.pushState(null, "", newRelativePathQuery); - } else { - history.pushState(null, "", window.location.pathname); - } - }, [inputVal]); - - return ( -
-
-
-
-
- - -
-
-
- - {/* {inputVal.length > 1 && ( -
- Found {searchResults?.length} - {searchResults?.length && searchResults?.length === 1 - ? " result" - : " results"}{" "} - for '{inputVal}' -
- )} */} -
- {searchResults?.length < 1 ? ( -
- no-search-found -

- {inputVal.length < 1 ? "Search Post Here" : "No Search Found!"} -

-

- {inputVal.length < 1 - ? "Search for posts by title, category, or tag." - : "We couldn't find what you searched for. Try searching again."} -

-
- ) : ( - searchResults?.map(({ item }, index) => ( -
-
- {item.data.image && ( - {item.data.title} - )} -

- - {item.data.title} - -

- -

- {plainify(item.content?.slice(0, Number(summary_length)))} -

- - read more - -
-
- )) - )} -
-
-
- ); -}; - -export default SearchLayout; diff --git a/src/layouts/helpers/SearchModal.tsx b/src/layouts/helpers/SearchModal.tsx new file mode 100644 index 0000000..142f242 --- /dev/null +++ b/src/layouts/helpers/SearchModal.tsx @@ -0,0 +1,257 @@ +import searchData from ".json/search.json"; +import React, { useEffect, useRef, useState } from "react"; +import SearchResult, { type ISearchItem } from "./SearchResult"; + +const SearchModal = () => { + const searchInputRef = useRef(null); + const [searchString, setSearchString] = useState(""); + + // handle input change + const handleSearch = (e: React.FormEvent) => { + setSearchString(e.currentTarget.value.toLowerCase()); + }; + + // set input value from url + useEffect(() => { + const searchUrl = new URLSearchParams(window.location.search); + const searchStr = searchUrl.get("q"); + searchStr && setSearchString(searchStr.toLowerCase()); + + // set cursor position + setTimeout(function () { + searchInputRef.current!.selectionStart = + searchInputRef.current!.selectionEnd = searchStr?.length || 0; + }, 50); + }, []); + + // update url + useEffect(() => { + if (searchString.length > 0) { + const searchParams = new URLSearchParams(window.location.search); + searchParams.set("s", searchString); + const newRelativePathQuery = + window.location.pathname + "?" + searchParams.toString(); + history.pushState(null, "", newRelativePathQuery); + } else { + history.pushState(null, "", window.location.pathname); + } + }, [searchString]); + + // generate search result + const doSearch = (searchData: ISearchItem[]) => { + const regex = new RegExp(`${searchString}`, "gi"); + if (searchString === "") { + return []; + } else { + const searchResult = searchData.filter((item) => { + const title = item.frontmatter.title.toLowerCase().match(regex); + const description = item.frontmatter.description + ?.toLowerCase() + .match(regex); + const categories = item.frontmatter.categories + ?.join(" ") + .toLowerCase() + .match(regex); + const tags = item.frontmatter.tags + ?.join(" ") + .toLowerCase() + .match(regex); + const content = item.content.toLowerCase().match(regex); + + if (title || content || description || categories || tags) { + return item; + } + }); + return searchResult; + } + }; + + // get search result + const startTime = performance.now(); + const searchResult = doSearch(searchData); + const endTime = performance.now(); + const totalTime = ((endTime - startTime) / 1000).toFixed(3); + + // search dom manipulation + useEffect(() => { + const searchModal = document.getElementById("searchModal"); + const searchInput = document.getElementById("searchInput"); + const searchModalOverlay = document.getElementById("searchModalOverlay"); + const searchResultItems = document.querySelectorAll("#searchItem"); + const searchModalTriggers = document.querySelectorAll( + "[data-search-trigger]", + ); + + // search modal open + searchModalTriggers.forEach((button) => { + button.addEventListener("click", function () { + const searchModal = document.getElementById("searchModal"); + searchModal!.classList.add("show"); + searchInput!.focus(); + }); + }); + + // search modal close + searchModalOverlay!.addEventListener("click", function () { + searchModal!.classList.remove("show"); + }); + + // keyboard navigation + let selectedIndex = -1; + + const updateSelection = () => { + searchResultItems.forEach((item, index) => { + if (index === selectedIndex) { + item.classList.add("search-result-item-active"); + } else { + item.classList.remove("search-result-item-active"); + } + }); + + searchResultItems[selectedIndex]?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }; + + document.addEventListener("keydown", function (event) { + if ((event.metaKey || event.ctrlKey) && event.key === "k") { + searchModal!.classList.add("show"); + searchInput!.focus(); + updateSelection(); + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + } + + if (event.key === "Escape") { + searchModal!.classList.remove("show"); + } + + if (event.key === "ArrowUp" && selectedIndex > 0) { + selectedIndex--; + } else if ( + event.key === "ArrowDown" && + selectedIndex < searchResultItems.length - 1 + ) { + selectedIndex++; + } else if (event.key === "Enter") { + const activeLink = document.querySelector( + ".search-result-item-active a", + ) as HTMLAnchorElement; + if (activeLink) { + activeLink?.click(); + } + } + + updateSelection(); + }); + }, [searchString]); + + return ( +
+
+
+
+ + +
+ +
+ + + + + + + + + + + + to navigate + + + + + + + + to select + + {searchString && ( + + {searchResult.length} results - in{" "} + {totalTime} seconds + + )} + + ESC to close + +
+
+
+ ); +}; + +export default SearchModal; diff --git a/src/layouts/helpers/SearchResult.tsx b/src/layouts/helpers/SearchResult.tsx new file mode 100755 index 0000000..6c0efae --- /dev/null +++ b/src/layouts/helpers/SearchResult.tsx @@ -0,0 +1,260 @@ +import { plainify, titleify } from "@/lib/utils/textConverter"; +import React from "react"; + +export interface ISearchItem { + group: string; + slug: string; + frontmatter: { + title: string; + image?: string; + description?: string; + categories?: string[]; + tags?: string[]; + }; + content: string; +} + +export interface ISearchGroup { + group: string; + groupItems: { + slug: string; + frontmatter: { + title: string; + image?: string; + description?: string; + categories?: string[]; + tags?: string[]; + }; + content: string; + }[]; +} + +// search result component +const SearchResult = ({ + searchResult, + searchString, +}: { + searchResult: ISearchItem[]; + searchString: string; +}) => { + // generate search result group + const generateSearchGroup = (searchResult: ISearchItem[]) => { + const joinDataByGroup: ISearchGroup[] = searchResult.reduce( + (groupItems: ISearchGroup[], item: ISearchItem) => { + const groupIndex = groupItems.findIndex( + (group) => group.group === item.group, + ); + if (groupIndex === -1) { + groupItems.push({ + group: item.group, + groupItems: [ + { + frontmatter: { ...item.frontmatter }, + slug: item.slug, + content: item.content, + }, + ], + }); + } else { + groupItems[groupIndex].groupItems.push({ + frontmatter: { ...item.frontmatter }, + slug: item.slug, + content: item.content, + }); + } + + return groupItems; + }, + [], + ); + return joinDataByGroup; + }; + const finalResult = generateSearchGroup(searchResult); + + // match marker + const matchMarker = (text: string, substring: string) => { + const parts = text.split(new RegExp(`(${substring})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === substring.toLowerCase() ? ( + {part} + ) : ( + part + ), + ); + }; + + // match underline + const matchUnderline = (text: string, substring: string) => { + const parts = text?.split(new RegExp(`(${substring})`, "gi")); + return parts?.map((part, index) => + part.toLowerCase() === substring.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); + }; + + // match content + const matchContent = (content: string, substring: string) => { + const plainContent = plainify(content); + const position = plainContent + .toLowerCase() + .indexOf(substring.toLowerCase()); + + // Find the start of the word containing the substring + let wordStart = position; + while (wordStart > 0 && plainContent[wordStart - 1] !== " ") { + wordStart--; + } + + const matches = plainContent.substring( + wordStart, + substring.length + position, + ); + const matchesAfter = plainContent.substring( + substring.length + position, + substring.length + position + 80, + ); + return ( + <> + {matchMarker(matches, substring)} + {matchesAfter} + + ); + }; + + return ( +
+ {searchString ? ( +
+ {finalResult.length > 0 ? ( + finalResult.map((result) => ( +
+

+ {titleify(result.group)} +

+ + {result.groupItems.map((item) => ( +
+ {item.frontmatter.image && ( +
+ {item.frontmatter.title} +
+ )} +
+ + {matchUnderline(item.frontmatter.title, searchString)} + + {item.frontmatter.description && ( +

+ {matchUnderline( + item.frontmatter.description, + searchString, + )} +

+ )} + {item.content && ( +

+ {matchContent(item.content, searchString)} +

+ )} +
+ {item.frontmatter.categories && ( +
+ + + + {item.frontmatter.categories.map( + (category, index) => ( + + {matchUnderline(category, searchString)} + {item.frontmatter.categories && + index !== + item.frontmatter.categories.length - + 1 && <>, } + + ), + )} +
+ )} + {item.frontmatter.tags && ( +
+ + + + + {item.frontmatter.tags.map((tag, index) => ( + + {matchUnderline(tag, searchString)} + {item.frontmatter.tags && + index !== + item.frontmatter.tags.length - 1 && <>, } + + ))} +
+ )} +
+
+
+ ))} +
+ )) + ) : ( +
+ + + + +

+ No results for "{searchString}" +

+
+ )} +
+ ) : ( +
Type something to search...
+ )} +
+ ); +}; + +export default SearchResult; diff --git a/src/layouts/partials/Header.astro b/src/layouts/partials/Header.astro index ebb33e4..1f70aad 100755 --- a/src/layouts/partials/Header.astro +++ b/src/layouts/partials/Header.astro @@ -119,13 +119,13 @@ const { pathname } = Astro.url;
{ settings.search && ( - - + ) } diff --git a/src/lib/utils/textConverter.ts b/src/lib/utils/textConverter.ts index ebf060e..8200bf3 100644 --- a/src/lib/utils/textConverter.ts +++ b/src/lib/utils/textConverter.ts @@ -16,14 +16,24 @@ export const humanize = (content: string) => { return content .replace(/^[\s_]+|[\s_]+$/g, "") .replace(/[_\s]+/g, " ") + .replace(/[-\s]+/g, " ") .replace(/^[a-z]/, function (m) { return m.toUpperCase(); }); }; +// titleify +export const titleify = (content: string) => { + const humanized = humanize(content); + return humanized + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + // plainify export const plainify = (content: string) => { - const parseMarkdown = marked.parse(content); + const parseMarkdown: any = marked.parse(content); const filterBrackets = parseMarkdown.replace(/<\/?[^>]+(>|$)/gm, ""); const filterSpaces = filterBrackets.replace(/[\r\n]\s*[\r\n]/gm, ""); const stripHTML = htmlEntityDecoder(filterSpaces); diff --git a/src/pages/search.astro b/src/pages/search.astro deleted file mode 100755 index 0c15b9a..0000000 --- a/src/pages/search.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import Base from "@/layouts/Base.astro"; -import SearchLayout from "@/layouts/Search"; -import { getSinglePage } from "@/lib/contentParser.astro"; - -const BLOG_FOLDER = "blog"; -const posts = await getSinglePage(BLOG_FOLDER); - -// List of items to search in -const searchList = posts.map((item) => ({ - slug: item.slug, - data: item.data, - content: item.body, -})); ---- - - - - diff --git a/src/styles/main.scss b/src/styles/main.scss index 1875eba..9a3d421 100755 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -10,6 +10,7 @@ @import "components"; @import "navigation"; @import "buttons"; + @import "search"; } @layer utilities { diff --git a/src/styles/search.scss b/src/styles/search.scss new file mode 100644 index 0000000..ab22252 --- /dev/null +++ b/src/styles/search.scss @@ -0,0 +1,96 @@ +.search { + &-modal { + @apply z-50 fixed top-0 left-0 w-full h-full flex items-start justify-center invisible opacity-0; + &.show { + @apply visible opacity-100; + } + &-overlay { + @apply fixed top-0 left-0 w-full h-full bg-black opacity-50; + } + } + &-wrapper { + @apply bg-white dark:bg-darkmode-body w-[660px] max-w-[96%] mt-24 rounded shadow-lg relative z-10; + &-header { + @apply p-4 relative; + &-input { + @apply border border-solid w-full focus:ring-0 focus:border-dark border-border rounded-[4px] h-12 pr-4 pl-10 transition duration-200 outline-none dark:bg-darkmode-theme-light dark:text-darkmode-text dark:border-darkmode-border dark:focus:border-darkmode-primary; + } + } + &-body { + @apply dark:bg-darkmode-theme-light dark:shadow-none max-h-[calc(100vh-350px)] overflow-y-auto bg-theme-light shadow-[inset_0_2px_18px_#ddd] p-4 rounded; + } + &-footer { + @apply text-xs select-none leading-none md:flex items-center px-3.5 py-2 hidden; + kbd { + @apply bg-theme-light dark:bg-darkmode-theme-light text-xs leading-none text-center mr-[3px] px-1 py-0.5 rounded-[3px]; + } + span:not(:last-child) { + @apply mr-4; + } + span:last-child { + @apply ml-auto; + } + } + } + &-result { + &-empty { + @apply text-center cursor-text select-none px-0 py-8; + } + &-group { + @apply mb-4; + &-title { + @apply text-lg text-dark dark:text-darkmode-dark mb-[5px] px-3; + } + } + &-item { + @apply rounded border bg-white dark:bg-darkmode-body dark:border-darkmode-border flex items-start mb-1 p-4 scroll-my-[30px] border-solid border-border relative; + mark { + @apply bg-yellow-200 rounded-[2px]; + } + &-title { + @apply text-lg font-bold text-dark dark:text-darkmode-dark leading-none; + } + &-link::after { + @apply absolute top-0 right-0 bottom-0 left-0 z-10 content-[""]; + } + &-image { + @apply shrink-0 mr-3.5; + img { + @apply w-[60px] h-[60px] md:w-[100px] md:h-[100px] rounded-[4px] object-cover; + } + } + &-description { + @apply text-sm line-clamp-1 mt-1; + } + &-content { + @apply mx-0 my-1.5 empty:hidden line-clamp-1; + } + &-taxonomies { + @apply text-sm flex flex-wrap items-center text-light dark:text-darkmode-light; + svg { + @apply inline-block mr-1; + } + } + + &-active, + &:focus, + &:hover { + @apply bg-dark dark:bg-dark; + .search-result-item { + &-title { + @apply text-white; + } + &-description { + @apply text-white/80; + } + &-content { + @apply text-white/90; + } + &-taxonomies { + @apply text-white/90; + } + } + } + } + } +}