0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Imported code and history from Sodo-Search

refs https://github.com/TryGhost/Toolbox/issues/400

- this allows for a better dev experience by having the code in the
  monorepo
This commit is contained in:
Daniel Lockyer 2023-03-17 10:31:20 +01:00
commit 9cc202aae3
No known key found for this signature in database
37 changed files with 14174 additions and 0 deletions

View file

@ -0,0 +1,26 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.hbs]
insert_final_newline = false
[*.json]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab

View file

@ -0,0 +1,12 @@
name: Test
on:
pull_request:
push:
branches:
- main
- 'renovate/*'
jobs:
test:
uses: tryghost/actions/.github/workflows/test.yml@main
secrets:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

86
ghost/sodo-search/.gitignore vendored Normal file
View file

@ -0,0 +1,86 @@
# Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# IDE
.idea/*
*.iml
*.sublime-*
.vscode/*
# OSX
.DS_Store
# Comments Ui Custom
# dotenv environment variables file
.env.*
# Membersjs build folders
umd/
build/
# Allow .env file
!.env
## We use .env file to define NODE_PATH as recommended test-utils setup pattern to avoid relative imports.
# Refs: https://testing-library.com/docs/react-testing-library/setup#jest-and-create-react-app
# CRA also suggests `.env` files should be checked into source control
# Ref: https://create-react-app.dev/docs/adding-custom-environment-variables/#adding-development-environment-variables-in-env
public/main.css

21
ghost/sodo-search/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2013-2022 Ghost Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,77 @@
# Sodo Search
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `yarn build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
# Copyright & License
Copyright (c) 2013-2022 Ghost Foundation - Released under the [MIT license](LICENSE).

View file

@ -0,0 +1,100 @@
{
"name": "@tryghost/sodo-search",
"version": "1.1.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:TryGhost/sodo-search.git"
},
"author": "Ghost Foundation",
"unpkg": "umd/sodo-search.min.js",
"files": [
"umd/",
"LICENSE",
"README.md"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.2",
"@testing-library/user-event": "14.0.0",
"@tryghost/content-api": "1.11.0",
"flexsearch": "0.7.21",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "BROWSER=none react-scripts start",
"start:combined": "BROWSER=none node ./scripts/start-combined.js",
"start:dev": "node ./scripts/start-mode.js",
"dev": "node ./scripts/start-mode.js",
"tailwind": "npx tailwindcss -i ./src/index.css -o ./public/main.css --watch --minify",
"old:dev": "node ./scripts/dev-mode.js",
"build": "npm run build:combined",
"build:original": "react-scripts build",
"build:combined": "node ./scripts/build-combined.js",
"build:bundle": "webpack --config webpack.config.js",
"test:ui": "react-scripts test",
"test": "yarn test:ui --watchAll=false --coverage",
"eject": "react-scripts eject",
"lint": "eslint src --ext .js --cache",
"preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn publish && git push ${GHOST_UPSTREAM:-upstream} main --follow-tags; fi",
"posttest": "yarn lint",
"analyze": "source-map-explorer 'umd/*.js'",
"prepublishOnly": "yarn build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"plugin:ghost/browser"
],
"plugins": [
"ghost"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"coverageReporters": [
"cobertura",
"text-summary",
"html"
]
},
"devDependencies": {
"autoprefixer": "^10.4.7",
"chalk": "4.1.2",
"chokidar": "3.5.2",
"copy-webpack-plugin": "6.4.1",
"eslint-plugin-ghost": "2.12.0",
"minimist": "1.2.5",
"nock": "13.2.8",
"ora": "5.4.1",
"postcss": "^8.4.14",
"rewire": "6.0.0",
"serve-handler": "6.1.3",
"source-map-explorer": "2.5.2",
"tailwindcss": "^3.1.4",
"webpack-cli": "3.3.12"
},
"resolutions": {
"//": "See https://github.com/facebook/create-react-app/issues/11773",
"react-error-overlay": "6.0.9"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,40 @@
const rewire = require('rewire');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const defaults = rewire('react-scripts/scripts/build.js');
let config = defaults.__get__('config');
const fs = require('fs');
/* eslint-disable no-console */
const log = console.log;
/* eslint-enable no-console */
fs.copyFile('./public/main.css', './umd/main.css', (err) => {
if (err) {
throw err;
}
log('Copied main.css');
});
config.optimization.splitChunks = {
cacheGroups: {
default: false
}
};
config.optimization.runtimeChunk = false;
// JS: Save built file in `/umd`
config.output.filename = '../umd/sodo-search.min.js';
// CSS: Remove MiniCssPlugin from list of plugins
config.plugins = config.plugins.filter(plugin => !(plugin instanceof MiniCssExtractPlugin));
// CSS: replaces all MiniCssExtractPlugin.loader with style-loader to embed CSS in JS
config.module.rules[1].oneOf = config.module.rules[1].oneOf.map((rule) => {
if (!Object.prototype.hasOwnProperty.call(rule, 'use')) {
return rule;
}
return Object.assign({}, rule, {
use: rule.use.map(options => (/mini-css-extract-plugin/.test(options.loader)
? {loader: require.resolve('style-loader'), options: {}}
: options))
});
});

View file

@ -0,0 +1,220 @@
const handler = require('serve-handler');
const http = require('http');
const chokidar = require('chokidar');
const chalk = require('chalk');
const {spawn} = require('child_process');
const minimist = require('minimist');
const ora = require('ora');
/* eslint-disable no-console */
const log = console.log;
/* eslint-enable no-console */
let buildProcess;
let fileChanges = [];
let spinner;
let stdOutChunks = [];
let stdErrChunks = [];
const {v, verbose, port = 5370, basic, b} = minimist(process.argv.slice(2));
const showVerbose = !!(v || verbose);
const showBasic = !!(b || basic);
function clearConsole({withHistory = true} = {}) {
if (!withHistory) {
process.stdout.write('\x1Bc');
return;
}
process.stdout.write(
process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'
);
}
function maybePluralize(count, noun, suffix = 's') {
return `${count} ${noun}${count !== 1 ? suffix : ''}`;
}
function printFileChanges() {
if (fileChanges.length > 0) {
const prefix = maybePluralize(fileChanges.length, 'file');
log(chalk.bold.hex('#ffa300').underline(`${prefix} changed`));
const message = fileChanges.map((path) => {
return chalk.hex('#ffa300').dim(`${path}`);
}).join('\n');
log(message);
log();
}
}
function printBuildSuccessDetails() {
if (showBasic) {
return;
}
if ((stdOutChunks && stdOutChunks.length > 0)) {
const detail = Buffer.concat(stdOutChunks.slice(4,7)).toString();
log();
log(chalk.dim(detail));
}
}
function printBuildErrorDetails() {
if ((stdOutChunks && stdOutChunks.length > 0)) {
const failDetails = Buffer.concat(stdOutChunks.slice(4, stdOutChunks.length - 1)).toString().replace(/^(?=\n)$|\s*$|\n\n+/gm, '');
log(chalk(failDetails));
}
if (stdErrChunks && stdErrChunks.length > 0) {
const stderrContent = Buffer.concat(stdErrChunks).toString();
log(chalk.dim(stderrContent));
}
}
function printBuildComplete(code) {
if (code === 0) {
if (!showVerbose) {
spinner && spinner.succeed(chalk.greenBright.bold('Build finished'));
printBuildSuccessDetails();
} else {
log();
log(chalk.bold.greenBright.bgBlackBright(`${'-'.repeat(25)}Build Success${'-'.repeat(25)}`));
}
} else {
if (!showVerbose) {
spinner && spinner.fail(chalk.redBright.bold('Build failed'));
printBuildErrorDetails();
} else {
log(chalk.bold.redBright.bgBlackBright(`${'-'.repeat(25)}Build finished: Failed${'-'.repeat(25)}`));
}
}
log();
}
function printConfigInstruction() {
const data = {
portal: {
url: `http://localhost:${port}/sodo-search`
}
};
const stringifedData = JSON.stringify(data, null, 2);
const splitData = stringifedData.split('\n');
log();
splitData.forEach((_data, idx, arr) => {
if (idx === 0 || idx === arr.length - 1) {
log(chalk.grey(_data));
} else {
log(chalk.bold.whiteBright(_data));
}
});
log();
}
function printInstructions() {
log();
log(chalk.yellowBright.underline(`Add portal to your local Ghost config`));
printConfigInstruction();
log(chalk.cyanBright('='.repeat(50)));
log();
}
function printBuildStart() {
if (showVerbose) {
log(chalk.bold.greenBright.bgBlackBright(`${'-'.repeat(32)}Building${'-'.repeat(32)}`));
log();
} else {
spinner = ora(chalk.magentaBright.bold('Bundling files, hang on...')).start();
}
}
function onBuildComplete(code) {
buildProcess = null;
printBuildComplete(code);
stdErrChunks = [];
stdOutChunks = [];
if (fileChanges.length > 0) {
buildPortal();
} else {
log(chalk.yellowBright.bold.underline(`Watching file changes...\n`));
}
}
function getBuildOptions() {
process.env.FORCE_COLOR = 'true';
const options = {
shell: true,
env: process.env
};
if (showVerbose) {
options.stdio = 'inherit';
}
return options;
}
function buildPortal() {
if (buildProcess) {
return;
}
printFileChanges();
printBuildStart();
fileChanges = [];
const options = getBuildOptions();
buildProcess = spawn('yarn build', options);
buildProcess.on('close', onBuildComplete);
if (!showVerbose) {
buildProcess.stdout.on('data', (data) => {
stdOutChunks.push(data);
});
buildProcess.stderr.on('data', (data) => {
stdErrChunks.push(data);
});
}
}
function watchFiles() {
const watcher = chokidar.watch('.', {
ignored: /build|node_modules|.git|public|umd|scripts|(^|[\/\\])\../
});
watcher.on('ready', () => {
buildPortal();
}).on('change', (path) => {
if (!fileChanges.includes(path)) {
fileChanges.push(path);
}
if (!buildProcess) {
buildPortal();
}
});
}
function startDevServer() {
const server = http.createServer((request, response) => {
return handler(request, response, {
rewrites: [
{source: '/sodo-search', destination: 'umd/sodo-search.min.js'},
{source: '/sodo-search.min.js.map', destination: 'umd/sodo-search.min.js.map'}
],
headers: [
{
source: '**',
headers: [{
key: 'Cache-Control',
value: 'no-cache'
},{
key: 'Access-Control-Allow-Origin',
value: '*'
}]
}
]
});
});
server.listen(port, () => {
log(chalk.whiteBright(`Portal dev server is running on http://localhost:${port}`));
printInstructions();
watchFiles();
});
}
clearConsole({withHistory: false});
startDevServer();

View file

@ -0,0 +1,8 @@
/** Script to load Portal bundle for local development */
function loadScript(src) {
var script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
}
loadScript('http://localhost:3000/static/js/bundle.js');

View file

@ -0,0 +1,14 @@
const rewire = require('rewire');
const defaults = rewire('react-scripts/scripts/start.js');
let configFactory = defaults.__get__('configFactory');
defaults.__set__('configFactory', (env) => {
const config = configFactory(env);
config.optimization.splitChunks = {
cacheGroups: {
default: false
}
};
config.optimization.runtimeChunk = false;
return config;
});

View file

@ -0,0 +1,181 @@
const handler = require('serve-handler');
const http = require('http');
const chalk = require('chalk');
const {spawn} = require('child_process');
const minimist = require('minimist');
/* eslint-disable no-console */
const log = console.log;
/* eslint-enable no-console */
let yarnStartProcess;
let tailwindServerProcess;
let stdOutChunks = [];
let stdErrChunks = [];
let startYarnOutput = false;
const {v, verbose, port = 5370} = minimist(process.argv.slice(2));
const showVerbose = !!(v || verbose);
function clearConsole({withHistory = true} = {}) {
if (!withHistory) {
process.stdout.write('\x1Bc');
return;
}
process.stdout.write(
process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'
);
}
function printConfigInstruction() {
const data = {
portal: {
url: `http://localhost:${port}/sodo-search`
}
};
const stringifedData = JSON.stringify(data, null, 2);
const splitData = stringifedData.split('\n');
log();
splitData.forEach((data, idx, arr) => {
if (idx === 0 || idx === arr.length - 1) {
log(chalk.grey(data));
} else {
log(chalk.bold.whiteBright(data));
}
});
log();
}
function printInstructions() {
log();
log(chalk.yellowBright.underline(`Add portal to your local Ghost config`));
printConfigInstruction();
log(chalk.cyanBright('='.repeat(50)));
log();
}
function onProcessClose(code) {
yarnStartProcess = null;
tailwindServerProcess = null;
stdErrChunks = [];
stdOutChunks = [];
log(chalk.redBright.bold.underline(`Please restart the script...\n`));
}
function getBuildOptions() {
process.env.FORCE_COLOR = 'true';
const options = {
shell: true,
env: process.env
};
if (showVerbose) {
options.stdio = 'inherit';
}
return options;
}
function doYarnStart() {
if (yarnStartProcess) {
return;
}
const options = getBuildOptions();
yarnStartProcess = spawn('yarn start:combined', options);
['SIGINT', 'SIGTERM'].forEach(function (sig) {
yarnStartProcess.on(sig, function () {
yarnStartProcess && yarnStartProcess.exit();
});
});
yarnStartProcess.on('close', onProcessClose);
if (!showVerbose) {
yarnStartProcess.stdout.on('data', (data) => {
stdOutChunks.push(data);
printYarnProcessOutput(data);
});
yarnStartProcess.stderr.on('data', (data) => {
log(Buffer.from(data).toString());
stdErrChunks.push(data);
});
}
}
function doTailwindServerStart() {
if (tailwindServerProcess) {
return;
}
const options = getBuildOptions();
tailwindServerProcess = spawn('yarn tailwind', options);
['SIGINT', 'SIGTERM'].forEach(function (sig) {
tailwindServerProcess.on(sig, function () {
tailwindServerProcess && tailwindServerProcess.exit();
});
});
tailwindServerProcess.on('close', onProcessClose);
if (!showVerbose) {
tailwindServerProcess.stdout.on('data', (data) => {
stdOutChunks.push(data);
printYarnProcessOutput(data);
});
tailwindServerProcess.stderr.on('data', (data) => {
log(Buffer.from(data).toString());
stdErrChunks.push(data);
});
}
}
function printYarnProcessOutput(data) {
const dataStr = Buffer.from(data).toString();
const dataArr = dataStr.split('\n').filter((d) => {
return /\S/.test(d.trim());
});
if (dataArr.find(d => d.includes('Starting the development'))) {
startYarnOutput = true;
log(chalk.yellowBright('Starting the development server...\n'));
return;
}
dataArr.forEach((dataOut) => {
if (startYarnOutput) {
log(dataOut);
}
});
if (startYarnOutput) {
log();
}
}
function startDevServer() {
const server = http.createServer((request, response) => {
return handler(request, response, {
rewrites: [
{source: '/sodo-search', destination: 'scripts/load-portal.js'}
],
headers: [
{
source: '**',
headers: [{
key: 'Cache-Control',
value: 'no-cache'
},{
key: 'Access-Control-Allow-Origin',
value: '*'
}]
}
]
});
});
server.listen(port, () => {
log(chalk.whiteBright(`Portal dev server is running on http://localhost:${port}`));
printInstructions();
doYarnStart();
doTailwindServerStart();
});
}
clearConsole({withHistory: false});
startDevServer();

View file

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,157 @@
import React from 'react';
import './App.css';
import AppContext from './AppContext';
import PopupModal from './components/PopupModal';
import SearchIndex from './search-index.js';
export default class App extends React.Component {
constructor(props) {
super(props);
const searchIndex = new SearchIndex({
adminUrl: props.adminUrl,
apiKey: props.apiKey
});
this.state = {
searchIndex,
showPopup: false,
indexStarted: false,
indexComplete: false
};
}
componentDidMount() {
this.initSetup();
}
componentDidUpdate(_prevProps, prevState) {
if (prevState.showPopup !== this.state.showPopup) {
/** Remove background scroll when popup is opened */
try {
if (this.state.showPopup) {
/** When modal is opened, store current overflow and set as hidden */
this.bodyScroll = window.document?.body?.style?.overflow;
window.document.body.style.overflow = 'hidden';
} else {
/** When the modal is hidden, reset overflow property for body */
window.document.body.style.overflow = this.bodyScroll || '';
}
} catch (e) {
/** Ignore any errors for scroll handling */
}
}
if (this.state.showPopup !== prevState?.showPopup && !this.state.showPopup) {
this.setState({
searchValue: ''
});
}
if (this.state.showPopup && !this.state.indexStarted) {
this.setupSearchIndex();
}
}
async setupSearchIndex() {
this.setState({
indexStarted: true
});
await this.state.searchIndex.init();
this.setState({
indexComplete: true
});
}
componentWillUnmount() {
/**Clear timeouts and event listeners on unmount */
window.removeEventListener('hashchange', this.hashHandler, false);
window.removeEventListener('keydown', this.handleKeyDown, false);
}
initSetup() {
// Listen to preview mode changes
this.handleSearchUrl();
this.addKeyboardShortcuts();
this.setupCustomTriggerButton();
this.hashHandler = () => {
this.handleSearchUrl();
};
window.addEventListener('hashchange', this.hashHandler, false);
}
/** Setup custom trigger buttons handling on page */
setupCustomTriggerButton() {
// Handler for custom buttons
this.clickHandler = (event) => {
event.preventDefault();
this.setState({
showPopup: true
});
};
this.customTriggerButtons = this.getCustomTriggerButtons();
this.customTriggerButtons.forEach((customTriggerButton) => {
customTriggerButton.removeEventListener('click', this.clickHandler);
customTriggerButton.addEventListener('click', this.clickHandler);
});
}
getCustomTriggerButtons() {
const customTriggerSelector = '[data-ghost-search]';
return document.querySelectorAll(customTriggerSelector) || [];
}
handleSearchUrl() {
const [path] = window.location.hash.substr(1).split('?');
if (path === '/search' || path === '/search/') {
this.setState({
showPopup: true
});
window.history.replaceState('', document.title, window.location.pathname);
}
}
addKeyboardShortcuts() {
const customTriggerButtons = this.getCustomTriggerButtons();
if (!customTriggerButtons?.length) {
return;
}
this.handleKeyDown = (e) => {
if (e.key === 'k' && e.metaKey) {
this.setState({
showPopup: true
});
e.preventDefault();
e.stopPropagation();
return false;
}
};
document.addEventListener('keydown', this.handleKeyDown);
}
render() {
return (
<AppContext.Provider value={{
page: 'search',
showPopup: this.state.showPopup,
adminUrl: this.props.adminUrl,
stylesUrl: this.props.stylesUrl,
searchIndex: this.state.searchIndex,
indexComplete: this.state.indexComplete,
searchValue: this.state.searchValue,
onAction: () => {},
dispatch: (action, data) => {
if (action === 'update') {
this.setState({
...this.state,
...data
});
}
}
}}>
<PopupModal />
</AppContext.Provider>
);
}
}

View file

@ -0,0 +1,12 @@
import {render} from '@testing-library/react';
import App from './App';
import React from 'react';
test('renders Sodo Search app component', () => {
window.location.hash = '#/search';
render(<App adminUrl="http://localhost" apiKey="69010382388f9de5869ad6e558" />);
// const containerElement = screen.getElementsByClassName('gh-portal-popup-container');
// eslint-disable-next-line testing-library/no-node-access
const containerElement = document.querySelector('.gh-root-frame');
expect(containerElement).toBeInTheDocument();
});

View file

@ -0,0 +1,18 @@
// Ref: https://reactjs.org/docs/context.html
const React = require('react');
const AppContext = React.createContext({
posts: [],
authors: [],
tags: [],
action: '',
lastPage: '',
page: '',
pageData: {},
dispatch: (_action, _data) => {},
searchIndex: null,
indexComplete: false,
searchValue: ''
});
export default AppContext;

View file

@ -0,0 +1,35 @@
import React, {Component} from 'react';
import {createPortal} from 'react-dom';
export default class Frame extends Component {
componentDidMount() {
this.node.addEventListener('load', this.handleLoad);
}
handleLoad = () => {
this.setupFrameBaseStyle();
};
componentWillUnmout() {
this.node.removeEventListener('load', this.handleLoad);
}
setupFrameBaseStyle() {
if (this.node.contentDocument) {
this.iframeHtml = this.node.contentDocument.documentElement;
this.iframeHead = this.node.contentDocument.head;
this.iframeRoot = this.node.contentDocument.body;
this.forceUpdate();
}
}
render() {
const {children, head, title = '', style = {}, ...rest} = this.props;
return (
<iframe srcDoc={`<!DOCTYPE html>`} {...rest} ref={node => (this.node = node)} title={title} style={style} frameBorder="0">
{this.iframeHead && createPortal(head, this.iframeHead)}
{this.iframeRoot && createPortal(children, this.iframeRoot)}
</iframe>
);
}
}

View file

@ -0,0 +1,685 @@
import Frame from './Frame';
import AppContext from '../AppContext';
import {ReactComponent as SearchIcon} from '../icons/search.svg';
import {ReactComponent as ClearIcon} from '../icons/clear.svg';
import {ReactComponent as CircleAnimated} from '../icons/circle-anim.svg';
import {useContext, useEffect, useMemo, useRef, useState} from 'react';
const React = require('react');
const DEFAULT_MAX_POSTS = 10;
const STEP_MAX_POSTS = 10;
const StylesWrapper = () => {
return {
modalContainer: {
zIndex: '3999999',
position: 'fixed',
left: '0',
top: '0',
width: '100%',
height: '100%',
overflow: 'hidden'
},
frame: {
common: {
margin: 'auto',
position: 'relative',
padding: '0',
outline: '0',
width: '100%',
opacity: '1',
overflow: 'hidden',
height: '100%'
}
},
page: {
links: {
width: '600px'
}
}
};
};
class PopupContent extends React.Component {
static contextType = AppContext;
componentDidMount() {
this.sendContainerHeightChangeEvent();
}
sendContainerHeightChangeEvent() {
}
componentDidUpdate() {
this.sendContainerHeightChangeEvent();
}
handlePopupClose(e) {
e.preventDefault();
if (e.target === e.currentTarget) {
this.context.dispatch('update', {
showPopup: false
});
}
}
render() {
return (
<Search />
);
}
}
function SearchBox() {
const {searchValue, dispatch} = useContext(AppContext);
const inputRef = useRef(null);
const containerRef = useRef(null);
useEffect(() => {
setTimeout(() => {
inputRef?.current?.focus();
}, 150);
let keyUphandler = (event) => {
if (event.key === 'Escape') {
dispatch('update', {
showPopup: false
});
}
};
const containeRefNode = containerRef?.current;
containeRefNode?.ownerDocument.removeEventListener('keyup', keyUphandler);
containeRefNode?.ownerDocument.addEventListener('keyup', keyUphandler);
return () => {
containeRefNode?.ownerDocument.removeEventListener('keyup', keyUphandler);
};
}, [dispatch]);
let className = 'z-10 relative flex items-center py-5 px-4 sm:px-7 bg-white rounded-t-lg shadow';
if (!searchValue) {
className = 'z-10 relative flex items-center py-5 px-4 sm:px-7 bg-white rounded-lg';
}
return (
<div className={className} ref={containerRef}>
<div className='flex items-center justify-center w-4 h-4 mr-3'>
<SearchClearIcon />
</div>
<input
ref={inputRef}
value={searchValue || ''}
onChange={(e) => {
dispatch('update', {
searchValue: e.target.value
});
}}
onKeyDown={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
}
}}
className='grow -my-5 py-5 -ml-3 pl-3 text-[1.65rem] focus-visible:outline-none placeholder:text-gray-400 outline-none truncate'
placeholder='Search posts, tags and authors'
/>
<Loading />
<CancelButton />
</div>
);
}
function SearchClearIcon() {
const {searchValue = '', dispatch} = useContext(AppContext);
if (!searchValue) {
return (
<SearchIcon className='text-neutral-900' alt='Search' />
);
}
return (
<button alt='Clear' className='-mb-[1px]' onClick={() => {
dispatch('update', {
searchValue: ''
});
}}>
<ClearIcon className='text-neutral-900 hover:text-neutral-500 h-[1.1rem] w-[1.1rem]' />
</button>
);
}
function Loading() {
const {indexComplete, searchValue} = useContext(AppContext);
if (!indexComplete && searchValue) {
return (
<CircleAnimated className='shrink-0' />
);
}
return null;
}
function CancelButton() {
const {dispatch} = useContext(AppContext);
return (
<button
className='ml-3 text-sm text-neutral-500 sm:hidden' alt='Cancel'
onClick={() => {
dispatch('update', {
showPopup: false
});
}}
>
Cancel
</button>
);
}
function TagListItem({tag, selectedResult, setSelectedResult}) {
const {name, url, id} = tag;
let className = 'flex items-center py-3 -mx-4 sm:-mx-7 px-4 sm:px-7 cursor-pointer';
if (id === selectedResult) {
className += ' bg-neutral-100';
}
return (
<div
className={className}
onClick={() => {
if (url) {
window.location.href = url;
}
}}
onMouseEnter={() => {
setSelectedResult(id);
}}
>
<p className='mr-2 text-sm font-bold text-neutral-400'>#</p>
<h2 className='text-[1.65rem] font-medium leading-tight text-neutral-900 truncate'>{name}</h2>
</div>
);
}
function TagResults({tags, selectedResult, setSelectedResult}) {
if (!tags?.length) {
return null;
}
const TagItems = tags.map((d) => {
return (
<TagListItem
key={d.name}
tag={d}
{...{selectedResult, setSelectedResult}}
/>
);
});
return (
<div className='border-t border-gray-200 py-3 px-4 sm:px-7'>
<h1 className='uppercase text-xs text-neutral-400 font-semibold mb-1 tracking-wide'>Tags</h1>
{TagItems}
</div>
);
}
function PostListItem({post, selectedResult, setSelectedResult}) {
const {searchValue} = useContext(AppContext);
const {title, excerpt, url, id} = post;
let className = 'py-3 -mx-4 sm:-mx-7 px-4 sm:px-7 cursor-pointer';
if (id === selectedResult) {
className += ' bg-neutral-100';
}
return (
<div
className={className}
onClick={() => {
if (url) {
window.location.href = url;
}
}}
onMouseEnter={() => {
setSelectedResult(id);
}}
>
<h2 className='text-[1.65rem] font-medium leading-tight text-neutral-800'>
<HighlightedSection text={title} highlight={searchValue} isExcerpt={false} />
</h2>
<p className='text-neutral-400 leading-normal text-sm mt-0 mb-0 truncate'>
<HighlightedSection text={excerpt} highlight={searchValue} isExcerpt={true} />
</p>
</div>
);
}
function getMatchIndexes({text, highlight}) {
let highlightRegexText = '';
highlight?.split(' ').forEach((d, idx) => {
if (idx > 0) {
highlightRegexText += `|^` + d + `|\\s` + d;
} else {
highlightRegexText = `^` + d + `|\\s` + d;
}
});
const matchRegex = new RegExp(`${highlightRegexText}`, 'ig');
let matches = text?.matchAll(matchRegex);
const indexes = [];
for (const match of matches) {
indexes.push({
startIdx: match?.index,
endIdx: (match?.index || 0) + (match?.[0].length || 0)
});
}
return indexes;
}
function getHighlightParts({text, highlight}) {
const highlightIndexes = getMatchIndexes({text, highlight});
const parts = [];
let lastIdx = 0;
highlightIndexes.forEach((highlightIdx) => {
if (lastIdx === highlightIdx.startIdx) {
parts.push({
text: text?.slice(highlightIdx.startIdx, highlightIdx.endIdx),
type: 'highlight'
});
lastIdx = highlightIdx.endIdx;
} else {
parts.push({
text: text?.slice(lastIdx, highlightIdx.startIdx),
type: 'normal'
});
parts.push({
text: text?.slice(highlightIdx.startIdx, highlightIdx.endIdx),
type: 'highlight'
});
lastIdx = highlightIdx.endIdx;
}
});
if (lastIdx < text?.length) {
parts.push({
text: text?.slice(lastIdx, text.length),
type: 'normal'
});
}
return {
parts,
highlightIndexes
};
}
function HighlightedSection({text = '', highlight = '', isExcerpt}) {
text = text || '';
highlight = highlight || '';
let {parts, highlightIndexes} = getHighlightParts({text, highlight});
if (isExcerpt && highlightIndexes?.[0]) {
const startIdx = highlightIndexes?.[0]?.startIdx;
if (startIdx > 50) {
text = '...' + text?.slice(startIdx - 20);
const {parts: updatedParts} = getHighlightParts({text, highlight});
parts = updatedParts;
}
}
const wordMap = parts.map((d, idx) => {
if (d?.type === 'highlight') {
return (
<React.Fragment key={idx}>
<HighlightWord word={d.text} isExcerpt={isExcerpt}/>
</React.Fragment>
);
} else {
return (
<React.Fragment key={idx}>
{d.text}
</React.Fragment>
);
}
});
return (
<>
{wordMap}
</>
);
}
function HighlightWord({word, isExcerpt}) {
if (isExcerpt) {
return (
<>
<span className='font-bold'>{word}</span>
</>
);
}
return (
<>
<span className='font-bold text-neutral-900'>{word}</span>
</>
);
}
function ShowMoreButton({posts, maxPosts, setMaxPosts}) {
if (!posts?.length || maxPosts >= posts?.length) {
return null;
}
return (
<button
className='w-full my-3 p-[1rem] border border-neutral-200 hover:border-neutral-300 text-neutral-800 hover:text-black font-semibold rounded transition duration-150 ease hover:ease'
onClick={() => {
const updatedMaxPosts = maxPosts + STEP_MAX_POSTS;
setMaxPosts(updatedMaxPosts);
}}
>
Show more results
</button>
);
}
function PostResults({posts, selectedResult, setSelectedResult}) {
const [maxPosts, setMaxPosts] = useState(DEFAULT_MAX_POSTS);
useEffect(() => {
setMaxPosts(DEFAULT_MAX_POSTS);
}, [posts]);
if (!posts?.length) {
return null;
}
const paginatedPosts = posts?.slice(0, maxPosts);
const PostItems = paginatedPosts.map((d) => {
return (
<PostListItem
key={d.title}
post={d}
{...{selectedResult, setSelectedResult}}
/>
);
});
return (
<div className='border-t border-neutral-200 py-3 px-4 sm:px-7'>
<h1 className='uppercase text-xs text-neutral-400 font-semibold mb-1 tracking-wide'>Posts</h1>
{PostItems}
<ShowMoreButton setMaxPosts={setMaxPosts} maxPosts={maxPosts} posts={posts} />
</div>
);
}
function AuthorListItem({author, selectedResult, setSelectedResult}) {
const {name, profile_image: profileImage, url, id} = author;
let className = 'py-[1rem] -mx-4 sm:-mx-7 px-4 sm:px-7 cursor-pointer flex items-center';
if (id === selectedResult) {
className += ' bg-neutral-100';
}
return (
<div
className={className}
onClick={() => {
if (url) {
window.location.href = url;
}
}}
onMouseEnter={() => {
setSelectedResult(id);
}}
>
<AuthorAvatar name={name} avatar={profileImage} />
<h2 className='text-[1.65rem] font-medium leading-tight text-neutral-900 truncate'>{name}</h2>
</div>
);
}
function AuthorAvatar({name, avatar}) {
const Avatar = avatar?.length;
const Character = name.charAt(0);
if (Avatar) {
return (
<img className='rounded-full bg-neutral-300 w-7 h-7 mr-2 object-cover' src={avatar} alt={name}/>
);
}
return (
<div className='rounded-full bg-neutral-200 w-7 h-7 mr-2 flex items-center justify-center font-bold'><span className="text-neutral-400">{Character}</span></div>
);
}
function AuthorResults({authors, selectedResult, setSelectedResult}) {
if (!authors?.length) {
return null;
}
const AuthorItems = authors.map((d) => {
return (
<AuthorListItem
key={d.name}
author={d}
{...{selectedResult, setSelectedResult}}
/>
);
});
return (
<div className='border-t border-neutral-200 py-3 px-4 sm:px-7'>
<h1 className='uppercase text-xs text-neutral-400 font-semibold mb-1 tracking-wide'>Authors</h1>
{AuthorItems}
</div>
);
}
function SearchResultBox() {
const {searchValue = '', searchIndex, indexComplete} = useContext(AppContext);
let searchResults = null;
let filteredTags = [];
let filteredPosts = [];
let filteredAuthors = [];
if (indexComplete && searchValue) {
searchResults = searchIndex?.search(searchValue);
filteredPosts = searchResults?.posts || [];
filteredAuthors = searchResults?.authors || [];
filteredTags = searchResults?.tags || [];
}
filteredAuthors = filteredAuthors.filter((author) => {
const invalidUrlRegex = /\/404\/$/;
return !(author?.url && invalidUrlRegex.test(author?.url));
});
filteredTags = filteredTags.filter((tag) => {
const invalidUrlRegex = /\/404\/$/;
return !(tag?.url && invalidUrlRegex.test(tag?.url));
});
const hasResults = filteredPosts?.length || filteredAuthors?.length || filteredTags?.length;
if (hasResults) {
return (
<Results posts={filteredPosts} authors={filteredAuthors} tags={filteredTags} />
);
} else if (searchValue) {
return (
<NoResultsBox />
);
}
return null;
}
function Results({posts, authors, tags}) {
const {searchValue} = useContext(AppContext);
const allResults = useMemo(() => {
return [
...authors,
...tags,
...posts
];
}, [authors, tags, posts]);
const defaultId = allResults?.[0]?.id || null;
const [selectedResult, setSelectedResult] = useState(defaultId);
const containerRef = useRef(null);
useEffect(() => {
setSelectedResult(allResults?.[0]?.id || null);
}, [allResults]);
useEffect(() => {
let keyUphandler = (event) => {
const selectedResultIdx = allResults.findIndex((d) => {
return d.id === selectedResult;
});
let nextResult = allResults[selectedResultIdx + 1];
let prevResult = allResults[selectedResultIdx - 1];
if (event.key === 'ArrowUp' && prevResult) {
setSelectedResult(prevResult?.id);
} else if (event.key === 'ArrowDown' && nextResult) {
setSelectedResult(nextResult?.id);
}
if (event.key === 'Enter') {
const selectedResultData = allResults.find((d) => {
return d.id === selectedResult;
});
window.location.href = selectedResultData?.url;
}
};
const containeRefNode = containerRef?.current;
containeRefNode?.ownerDocument.removeEventListener('keyup', keyUphandler);
containeRefNode?.ownerDocument.addEventListener('keyup', keyUphandler);
return () => {
containeRefNode?.ownerDocument?.removeEventListener('keyup', keyUphandler);
};
}, [allResults, selectedResult]);
if (!searchValue) {
return null;
}
return (
<div className='overflow-y-auto max-h-[calc(100vh-172px)] sm:max-h-[70vh] -mt-[1px]' ref={containerRef}>
<AuthorResults
authors={authors}
selectedResult={selectedResult}
setSelectedResult={setSelectedResult}
/>
<TagResults
tags={tags}
selectedResult={selectedResult}
setSelectedResult={setSelectedResult}
/>
<PostResults
posts={posts}
selectedResult={selectedResult}
setSelectedResult={setSelectedResult}
/>
</div>
);
}
function NoResultsBox() {
return (
<div className='py-4 px-7'>
<p className='text-[1.65rem] text-neutral-400 leading-normal'>No matches found</p>
</div>
);
}
function Search() {
const {dispatch} = useContext(AppContext);
return (
<>
<div
className='h-screen w-screen pt-20 antialiased z-50 relative ghost-display'
onClick={(e) => {
e.preventDefault();
if (e.target === e.currentTarget) {
dispatch('update', {
showPopup: false
});
}
}}
>
<div className='bg-white w-full max-w-[95vw] sm:max-w-lg rounded-lg shadow-xl m-auto relative translate-z-0 animate-popup'>
<SearchBox />
<SearchResultBox />
</div>
</div>
</>
);
}
export default class PopupModal extends React.Component {
static contextType = AppContext;
constructor(props) {
super(props);
this.state = {
height: null
};
}
onHeightChange(height) {
this.setState({height});
}
handlePopupClose(e) {
e.preventDefault();
if (e.target === e.currentTarget) {
this.context.dispatch('update', {
showPopup: false
});
}
}
renderFrameStyles() {
const styles = `
:root {
--brandcolor: ${this.context.brandColor || ''}
}
.ghost-display {
display: none;
}
`;
const stylesUrl = this.context.stylesUrl;
if (stylesUrl) {
return (
<>
<link rel='stylesheet' href={stylesUrl} />
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
</>
);
}
return (
<>
<style dangerouslySetInnerHTML={{__html: styles}} />
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1' />
</>
);
}
renderFrameContainer() {
const Styles = StylesWrapper();
const frameStyle = {
...Styles.frame.common
};
return (
<div style={Styles.modalContainer} className='gh-root-frame'>
<Frame style={frameStyle} title='portal-popup' head={this.renderFrameStyles()}>
<div
onClick = {e => this.handlePopupClose(e)}
className='absolute top-0 bottom-0 left-0 right-0 block backdrop-blur-[2px] animate-fadein z-0 bg-gradient-to-br from-[rgba(0,0,0,0.2)] to-[rgba(0,0,0,0.1)]' />
<PopupContent />
</Frame>
</div>
);
}
render() {
const {showPopup} = this.context;
if (showPopup) {
return this.renderFrameContainer();
}
return null;
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 16 16"><title>circle anim</title><g fill="#40413F" class="nc-icon-wrapper"><g class="nc-loop-circle-16-icon-f"><path d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z" fill="#D4D4D4"></path><path d="M8 0v2a6.006 6.006 0 0 1 6 6h2a8.009 8.009 0 0 0-8-8z" data-color="color-2"></path></g><style>.nc-loop-circle-16-icon-f{--animation-duration:0.5s;transform-origin:8px 8px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>

After

Width:  |  Height:  |  Size: 658 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16"><path stroke-linecap="round" stroke-width=".4" fill="none" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 511 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16"><path d="M23.38,21.62l-6.53-6.53a9.15,9.15,0,0,0,1.9-5.59,9.27,9.27,0,1,0-3.66,7.36l6.53,6.53a1.26,1.26,0,0,0,1.76,0A1.25,1.25,0,0,0,23.38,21.62ZM2.75,9.5A6.75,6.75,0,1,1,9.5,16.25,6.76,6.76,0,0,1,2.75,9.5Z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 324 B

View file

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom reset */
html {
font-size: 62.5%;
}
body {
font-size: 1.5rem;
}
.ghost-display {
display: block !important;
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
const ROOT_DIV_ID = 'sodo-search-root';
function addRootDiv() {
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
document.body.appendChild(elem);
}
function getSiteData() {
/**
* @type {HTMLElement}
*/
const scriptTag = document.querySelector('script[data-sodo-search]');
if (scriptTag) {
const adminUrl = scriptTag.dataset.sodoSearch;
const apiKey = scriptTag.dataset.key;
const stylesUrl = scriptTag.dataset.styles;
return {adminUrl, apiKey, stylesUrl};
}
return {};
}
function setup() {
addRootDiv();
}
function init() {
const {adminUrl, apiKey, stylesUrl} = getSiteData();
const adminBaseUrl = (adminUrl || window.location.origin)?.replace(/\/+$/, '');
setup();
ReactDOM.render(
<React.StrictMode>
<App
adminUrl={adminBaseUrl} apiKey={apiKey}
stylesUrl={stylesUrl}
/>
</React.StrictMode>,
document.getElementById(ROOT_DIV_ID)
);
}
init();

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,136 @@
import {Document} from 'flexsearch';
import GhostContentAPI from '@tryghost/content-api';
export default class SearchIndex {
constructor({adminUrl, apiKey}) {
this.api = new GhostContentAPI({
url: adminUrl,
key: apiKey,
version: 'v5.0'
});
this.postsIndex = new Document({
tokenize: 'forward',
document: {
id: 'id',
index: ['title', 'excerpt'],
store: true
}
});
this.authorsIndex = new Document({
tokenize: 'forward',
document: {
id: 'id',
index: ['name'],
store: true
}
});
this.tagsIndex = new Document({
tokenize: 'forward',
document: {
id: 'id',
index: ['name'],
store: true
}
});
this.init = this.init.bind(this);
this.search = this.search.bind(this);
}
#updatePostIndex(posts) {
posts.forEach((post) => {
this.postsIndex.add(post);
});
}
#updateAuthorsIndex(authors) {
authors.forEach((author) => {
this.authorsIndex.add(author);
});
}
#updateTagsIndex(tags) {
tags.forEach((tag) => {
this.tagsIndex.add(tag);
});
}
async init() {
let posts = await this.api.posts.browse({
limit: '10000',
fields: 'id,slug,title,excerpt,url,updated_at,visibility',
order: 'updated_at DESC'
});
if (posts || posts.length > 0) {
if (!posts.length) {
posts = [posts];
}
this.#updatePostIndex(posts);
}
let authors = await this.api.authors.browse({
limit: '10000',
fields: 'id,slug,name,url,profile_image',
order: 'updated_at DESC'
});
if (authors || authors.length > 0) {
if (!authors.length) {
authors = [authors];
}
this.#updateAuthorsIndex(authors);
}
let tags = await this.api.tags.browse({
limit: '10000',
fields: 'id,slug,name,url',
order: 'updated_at DESC',
filter: 'visibility:public'
});
if (tags || tags.length > 0) {
if (!tags.length) {
tags = [tags];
}
this.#updateTagsIndex(tags);
}
}
#normalizeSearchResult(result) {
const normalized = [];
const usedIds = {};
result.forEach((resultItem) => {
resultItem.result.forEach((doc) => {
if (!usedIds[doc.id]) {
normalized.push(doc.doc);
usedIds[doc.id] = true;
}
});
});
return normalized;
}
search(value) {
const posts = this.postsIndex.search(value, {
enrich: true
});
const authors = this.authorsIndex.search(value, {
enrich: true
});
const tags = this.tagsIndex.search(value, {
enrich: true
});
return {
posts: this.#normalizeSearchResult(posts),
authors: this.#normalizeSearchResult(authors),
tags: this.#normalizeSearchResult(tags)
};
}
}

View file

@ -0,0 +1,97 @@
import SearchIndex from './search-index';
import nock from 'nock';
describe('search index', function () {
test('initializes search index', async () => {
const adminUrl = 'http://localhost';
const apiKey = '69010382388f9de5869ad6e558';
const searchIndex = new SearchIndex({adminUrl, apiKey, storage: localStorage});
const scope = nock('http://localhost/ghost/api/content')
.get('/posts/?key=69010382388f9de5869ad6e558&limit=10000&fields=id%2Cslug%2Ctitle%2Cexcerpt%2Curl%2Cupdated_at%2Cvisibility&order=updated_at%20DESC')
.reply(200, {
posts: []
})
.get('/authors/?key=69010382388f9de5869ad6e558&limit=10000&fields=id,slug,name,url,profile_image&order=updated_at%20DESC')
.reply(200, {
authors: []
})
.get('/tags/?key=69010382388f9de5869ad6e558&&limit=10000&fields=id,slug,name,url&order=updated_at%20DESC&filter=visibility%3Apublic')
.reply(200, {
tags: []
});
await searchIndex.init();
expect(scope.isDone()).toBeTruthy();
const searchResults = searchIndex.search('find nothing');
expect(searchResults.posts.length).toEqual(0);
expect(searchResults.authors.length).toEqual(0);
expect(searchResults.tags.length).toEqual(0);
});
test('allows to search for indexed posts and authors', async () => {
const adminUrl = 'http://localhost';
const apiKey = '69010382388f9de5869ad6e558';
const searchIndex = new SearchIndex({adminUrl, apiKey, storage: localStorage});
nock('http://localhost/ghost/api/content')
.get('/posts/?key=69010382388f9de5869ad6e558&limit=10000&fields=id%2Cslug%2Ctitle%2Cexcerpt%2Curl%2Cupdated_at%2Cvisibility&order=updated_at%20DESC')
.reply(200, {
posts: [{
id: 'sounique',
title: 'Awesome Barcelona Life',
excerpt: 'We are sitting by the pool and smashing out search features. Barcelona life is great!',
url: 'http://localhost/ghost/awesome-barcelona-life/'
}]
})
.get('/authors/?key=69010382388f9de5869ad6e558&limit=10000&fields=id,slug,name,url,profile_image&order=updated_at%20DESC')
.reply(200, {
authors: [{
id: 'different_uniq',
slug: 'barcelona-author',
name: 'Barcelona Author',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/barcelona-author/'
}, {
id: 'different_uniq_2',
slug: 'bob',
name: 'Bob',
profile_image: 'https://url_to_avatar/barcelona.png',
url: 'http://localhost/ghost/authors/bob/'
}]
})
.get('/tags/?key=69010382388f9de5869ad6e558&&limit=10000&fields=id,slug,name,url&order=updated_at%20DESC&filter=visibility%3Apublic')
.reply(200, {
tags: [{
id: 'uniq_tag',
slug: 'barcelona-tag',
name: 'Barcelona Tag',
url: 'http://localhost/ghost/tags/barcelona-tag/'
}]
});
await searchIndex.init();
let searchResults = searchIndex.search('Barcelo');
expect(searchResults.posts.length).toEqual(1);
expect(searchResults.posts[0].title).toEqual('Awesome Barcelona Life');
expect(searchResults.posts[0].url).toEqual('http://localhost/ghost/awesome-barcelona-life/');
expect(searchResults.authors.length).toEqual(1);
expect(searchResults.authors[0].name).toEqual('Barcelona Author');
expect(searchResults.authors[0].url).toEqual('http://localhost/ghost/authors/barcelona-author/');
expect(searchResults.authors[0].profile_image).toEqual('https://url_to_avatar/barcelona.png');
expect(searchResults.tags.length).toEqual(1);
expect(searchResults.tags[0].name).toEqual('Barcelona Tag');
expect(searchResults.tags[0].url).toEqual('http://localhost/ghost/tags/barcelona-tag/');
searchResults = searchIndex.search('Nothing like this');
expect(searchResults.posts.length).toEqual(0);
expect(searchResults.authors.length).toEqual(0);
expect(searchResults.tags.length).toEqual(0);
});
});

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

View file

@ -0,0 +1,116 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1400px'
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.2rem',
1: '0.4rem',
1.5: '0.6rem',
2: '0.8rem',
2.5: '1rem',
3: '1.2rem',
3.5: '1.4rem',
4: '1.6rem',
5: '2rem',
6: '2.4rem',
7: '2.8rem',
8: '3.2rem',
9: '3.6rem',
10: '4rem',
11: '4.4rem',
12: '4.8rem',
14: '5.6rem',
16: '6.4rem',
20: '8rem',
24: '9.6rem',
28: '11.2rem',
32: '12.8rem',
36: '14.4rem',
40: '16rem',
44: '17.6rem',
48: '19.2rem',
52: '20.8rem',
56: '22.4rem',
60: '24rem',
64: '25.6rem',
72: '28.8rem',
80: '32rem',
96: '38.4rem'
},
maxWidth: {
none: 'none',
0: '0rem',
xs: '32rem',
sm: '38.4rem',
md: '44.8rem',
lg: '51.2rem',
xl: '57.6rem',
'2xl': '67.2rem',
'3xl': '76.8rem',
'4xl': '89.6rem',
'5xl': '102.4rem',
'6xl': '115.2rem',
'7xl': '128rem',
'8xl': '140rem',
'9xl': '156rem',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
prose: '65ch'
},
borderRadius: {
sm: '0.2rem',
DEFAULT: '0.4rem',
md: '0.6rem',
lg: '0.8rem',
xl: '1.2rem',
'2xl': '1.6rem',
'3xl': '2.4rem',
full: '9999px'
},
fontSize: {
xs: '1.2rem',
sm: '1.4rem',
md: '1.5rem',
lg: '1.8rem',
xl: '2rem',
'2xl': '2.4rem',
'3xl': '3rem',
'4xl': '3.6rem',
'5xl': ['4.8rem', '1.15'],
'6xl': ['6rem', '1'],
'7xl': ['7.2rem', '1'],
'8xl': ['9.6rem', '1'],
'9xl': ['12.8rem', '1']
},
animation: {
'popup': 'popup 0.15s ease',
'fadein': 'fadein 0.15s'
},
keyframes: {
popup: {
'0%': { transform: 'translateY(-20px)', opacity: '0' },
'1%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1.0' }
},
fadein: {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
}
}
},
content: [
'./src/**/*.{js,jsx,ts,tsx}'
],
plugins: []
};

View file

@ -0,0 +1,34 @@
const path = require('path');
const glob = require('glob');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
'bundle.js': glob.sync('build/static/?(js|css)/main.*.?(js|css)').map(f => path.resolve(__dirname, f))
},
output: {
filename: 'sodo-search.min.js',
path: __dirname + '/umd'
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
},
plugins: [
new CopyPlugin({
patterns: [
{from: './build/static/js/main.js.map', to: './umd/sodo-search.min.js.map'}
]
})
],
performance: {
hints: false,
maxEntrypointSize: 560,
maxAssetSize: 5600
}
};

11912
ghost/sodo-search/yarn.lock Normal file

File diff suppressed because it is too large Load diff