add Astro Hashnode integration and dependencies
This commit is contained in:
parent
61f90b9bb1
commit
26dab56bd3
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Adam Matthiesen
|
||||
Copyright (c) 2024 MatthiesenXYZ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
25
README.md
25
README.md
|
@ -1,9 +1,30 @@
|
|||
# PACKAGE-NAME
|
||||
# Astro Hashnode
|
||||
|
||||
DESCRIPTION
|
||||
An Integration to bring your Hashnode Headless Blog content into Astro!
|
||||
|
||||
To see how to get started, check out the [package README](./package/README.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is structured as a monorepo:
|
||||
|
||||
- `playground` contains code for testing the package
|
||||
- `package` contains the actual package
|
||||
|
||||
Install dependencies using pnpm:
|
||||
|
||||
```bash
|
||||
pnpm i --frozen-lockfile
|
||||
```
|
||||
|
||||
Start the playground:
|
||||
|
||||
```bash
|
||||
pnpm playground:dev
|
||||
```
|
||||
|
||||
You can now edit files in `package`. Please note that making changes to those files may require restarting the playground dev server.
|
||||
|
||||
## Licensing
|
||||
|
||||
[MIT Licensed](./LICENSE). Made with ❤️ by [Adam M.](https://github.com/adammatthiesen).
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Adam Matthiesen
|
||||
Copyright (c) 2024 MatthiesenXYZ
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
# PACKAGE-NAME
|
||||
# Astro Hashnode
|
||||
|
||||
DESCRIPTION
|
||||
An Integration to bring your Hashnode Headless Blog content into Astro!
|
||||
|
||||
## Installation
|
||||
|
||||
Install the integration **automatically** using the Astro CLI:
|
||||
|
||||
```bash
|
||||
pnpm astro add PACKAGE-NAME
|
||||
pnpm astro add @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
```bash
|
||||
npm astro add PACKAGE-NAME
|
||||
npm astro add @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn astro add PACKAGE-NAME
|
||||
yarn astro add @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
Or install it **manually**:
|
||||
|
@ -23,53 +23,43 @@ Or install it **manually**:
|
|||
1. Install the required dependencies
|
||||
|
||||
```bash
|
||||
pnpm add PACKAGE-NAME
|
||||
pnpm add @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
```bash
|
||||
npm install PACKAGE-NAME
|
||||
npm install @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn add PACKAGE-NAME
|
||||
yarn add @matthiesenxyz/astro-hashnode
|
||||
```
|
||||
|
||||
2. Add the integration to your astro config
|
||||
|
||||
```diff
|
||||
+import PACKAGE-NAME from "PACKAGE-NAME";
|
||||
+import astroHashnode from "@matthiesenxyz/astro-hashnode";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
+ PACKAGE-NAME(),
|
||||
+ astroHashnode({
|
||||
+ hashnodeURL: 'astroplayground.hashnode.dev'
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
## Full Configuration Options
|
||||
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
This package is structured as a monorepo:
|
||||
|
||||
- `playground` contains code for testing the package
|
||||
- `package` contains the actual package
|
||||
|
||||
Install dependencies using pnpm:
|
||||
|
||||
```bash
|
||||
pnpm i --frozen-lockfile
|
||||
```ts
|
||||
astroHashnode({
|
||||
hashnodeURL: 'astroplayground.hashnode.dev', // Your hashnode URL
|
||||
landingPage: true, // Lets you disable the default landing page!
|
||||
layoutComponent: './src/layouts/YourLayout.astro' // Lets you change the default Layout.astro being used by the Integration Pages.
|
||||
verbose: false // Change to Verbose console output
|
||||
})
|
||||
```
|
||||
|
||||
Start the playground:
|
||||
|
||||
```bash
|
||||
pnpm playground:dev
|
||||
```
|
||||
|
||||
You can now edit files in `package`. Please note that making changes to those files may require restarting the playground dev server.
|
||||
Node: This Integration uses the new Tailwind v4 There is no config options in this version of tailwindCSS, and applyBaseStyles is enabled! So if you are building your own LayoutComponent feel free to use TailwindCSS!
|
||||
|
||||
## Licensing
|
||||
|
||||
|
@ -77,4 +67,9 @@ You can now edit files in `package`. Please note that making changes to those fi
|
|||
|
||||
## Acknowledgements
|
||||
|
||||
TODO:
|
||||
- [`astro-integration-kit`](https://github.com/florian-lefebvre/astro-integration-kit) by Florian
|
||||
- [`Hashnode - HeadlessCMS`](https://hashnode.com/headless) by the Hashnode
|
||||
- [`TailwindCSS v4`](https://tailwindcss.com/blog/tailwindcss-v4-alpha) by the TailwindCSS team
|
||||
- [`Astro-Font`](https://github.com/rishi-raj-jain/astro-font) by Rishi
|
||||
- [`Astro-SEO`](https://github.com/jonasmerlin/astro-seo) by Jonas
|
||||
- [`Astro-Remote`](https://github.com/natemoo-re/astro-remote) by Nate
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "PACKAGE-NAME",
|
||||
"name": "@matthiesenxyz/astro-hashnode",
|
||||
"version": "0.0.0",
|
||||
"description": "DESCRIPTION",
|
||||
"description": "An Integration to bring your Hashnode Headless Blog content into Astro!",
|
||||
"author": {
|
||||
"email": "adam@matthiesen.xyz",
|
||||
"name": "Adam Matthiesen",
|
||||
|
@ -10,12 +10,18 @@
|
|||
"license": "MIT",
|
||||
"keywords": [
|
||||
"astro-integration",
|
||||
"astro-component",
|
||||
"withastro",
|
||||
"astro",
|
||||
"commercejs"
|
||||
"hashnode",
|
||||
"blog",
|
||||
"graphql",
|
||||
"hashnode-headless"
|
||||
],
|
||||
"homepage": "https://github.com/adammatthiesen/astro-commercejs",
|
||||
"homepage": "https://github.com/matthiesenxyz/astro-hashnode",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/MatthiesenXYZ/astro-hashnode.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
@ -29,7 +35,19 @@
|
|||
"peerDependencies": {
|
||||
"astro": ">=4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro-integration-kit": "^0.5.1"
|
||||
"@tailwindcss/vite": "4.0.0-alpha.7",
|
||||
"astro-font": "0.0.77",
|
||||
"astro-integration-kit": "^0.5.1",
|
||||
"astro-remote": "^0.3.2",
|
||||
"astro-seo": "^0.8.3",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-request": "^6.1.0",
|
||||
"picocolors": "1.0.0",
|
||||
"tailwindcss": "4.0.0-alpha.7",
|
||||
"ultrahtml": "^1.5.3"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 8.6 MiB |
|
@ -0,0 +1,136 @@
|
|||
import { createResolver, defineIntegration } from "astro-integration-kit";
|
||||
import { addDtsPlugin, corePlugins } from "astro-integration-kit/plugins";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { optionsSchema } from "./schemas/user-config";
|
||||
import c from "picocolors";
|
||||
import { AstroError } from "astro/errors";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Astro-Hashnode Integration
|
||||
*/
|
||||
export default defineIntegration({
|
||||
name: "@matthiesenxyz/astro-hashnode",
|
||||
optionsSchema,
|
||||
plugins: [ ...corePlugins, addDtsPlugin ],
|
||||
setup({ options }) {
|
||||
|
||||
return {
|
||||
"astro:config:setup": ({
|
||||
watchIntegration,
|
||||
addVitePlugin,
|
||||
addVirtualImports,
|
||||
addDts,
|
||||
injectScript,
|
||||
injectRoute,
|
||||
updateConfig,
|
||||
config,
|
||||
logger,
|
||||
}) => {
|
||||
logger.info("Initializing...")
|
||||
// Create Resolvers
|
||||
const { resolve } = createResolver(import.meta.url);
|
||||
const { resolve: rootResolve} = createResolver(config.root.pathname)
|
||||
|
||||
// Watch Integration for changes in DEV
|
||||
watchIntegration(resolve())
|
||||
|
||||
const HashLogger = logger.fork(c.bold(c.blue("Astro-Hashnode")));
|
||||
const hashLogNoVerbose = (message:string) => {
|
||||
HashLogger.info(c.magenta(message))
|
||||
}
|
||||
const hashLog = (message:string) => {
|
||||
if (options.verbose) {
|
||||
HashLogger.info(c.magenta(message))
|
||||
}
|
||||
}
|
||||
const hashError = (message:string) => {
|
||||
HashLogger.error(c.magenta(message))
|
||||
throw new AstroError(message)
|
||||
}
|
||||
|
||||
hashLogNoVerbose("Setting up Astro-Hashnode Integration")
|
||||
|
||||
// Check for Hashnode URL
|
||||
if (!options.hashnodeURL) {
|
||||
hashError("You must provide a hashnodeURL in your astro.config.mjs file. Use the hashnodeURL from your hashnode blog. It should look something like this: 'yourblog.hashnode.dev'")
|
||||
}
|
||||
|
||||
hashLog(`Using Hashnode URL: ${options.hashnodeURL}`)
|
||||
|
||||
hashLog("Setting up Virtual Imports and Layout Component")
|
||||
// Setup Layout Component
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: This is a false positive
|
||||
let layoutComponentPath
|
||||
|
||||
if (options.layoutComponent) {
|
||||
layoutComponentPath = rootResolve(options.layoutComponent)
|
||||
hashLog('Using user defined layout component')
|
||||
} else {
|
||||
layoutComponentPath = resolve('./layouts/Layout.astro')
|
||||
}
|
||||
|
||||
addVirtualImports({
|
||||
'virtual:astro-hashnode/config': `export default ${JSON.stringify(options) }`,
|
||||
'virtual:astro-hashnode/components': `export { default as Layout } from "${layoutComponentPath}";`,
|
||||
})
|
||||
|
||||
addDts({
|
||||
name: 'astro-hashnode',
|
||||
content: readFileSync(resolve("./definitions/astro-hashnode.d.ts"), "utf-8"),
|
||||
})
|
||||
|
||||
hashLog("Setting up 'Tailwind CSS v4' Integration")
|
||||
// Add & Setup Tailwind CSS
|
||||
const twplugin = tailwindcss();
|
||||
for (const twp of twplugin) {
|
||||
addVitePlugin(twp);
|
||||
}
|
||||
injectScript(
|
||||
"page-ssr",
|
||||
`import "${resolve("./styles/tailwind.css")}";`
|
||||
);
|
||||
updateConfig({
|
||||
vite: {
|
||||
css: { transformer: "lightningcss" },
|
||||
}
|
||||
})
|
||||
|
||||
hashLog("Setting up Page Routes")
|
||||
// Add Page Routes
|
||||
if (options.landingPage) {
|
||||
injectRoute({
|
||||
pattern: config.base,
|
||||
entrypoint: resolve("./pages/index.astro"),
|
||||
});
|
||||
}
|
||||
injectRoute({
|
||||
pattern: `${config.base}blog`,
|
||||
entrypoint: resolve("./pages/blog/index.astro"),
|
||||
})
|
||||
injectRoute({
|
||||
pattern: `${config.base}blog/[slug]`,
|
||||
entrypoint: resolve("./pages/blog/[slug].astro"),
|
||||
})
|
||||
injectRoute({
|
||||
pattern: `${config.base}blog/about`,
|
||||
entrypoint: resolve("./pages/blog/about.astro"),
|
||||
})
|
||||
injectRoute({
|
||||
pattern: `${config.base}blog/tags/[tag]`,
|
||||
entrypoint: resolve("./pages/blog/tags/[tag].astro"),
|
||||
})
|
||||
},
|
||||
"astro:config:done": ({ logger }) => {
|
||||
const HashLogger = logger.fork(c.bold(c.blue("Astro-Hashnode")));
|
||||
const hashLog = (message:string) => {
|
||||
HashLogger.info(c.green(message))
|
||||
}
|
||||
hashLog("Astro-Hashnode Integration Setup Complete")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export { type AstroHashnodeLayoutProps } from "./proptypes/layouttypes"
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { Post } from '../hn-gql';
|
||||
import {getFormattedDate} from '../utils/utility';
|
||||
|
||||
interface Props {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative flex flex-row flex-wrap items-center justify-center">
|
||||
<div class="mb-5 flex w-full flex-row items-center justify-center md:mb-0 md:w-auto md:justify-start">
|
||||
<div class="flex mr-1">
|
||||
<Image
|
||||
src={post.author.profilePicture}
|
||||
alt={post.author.name}
|
||||
width={50}
|
||||
height={50}
|
||||
class="rounded-3xl mr-3"
|
||||
loading="eager"
|
||||
/>
|
||||
<div class="mt-3 flex">
|
||||
<span>{post.author.name}</span>
|
||||
<span class="mx-3 block font-bold text-slate-500">.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5 flex w-full flex-row items-center justify-center md:mb-0 md:w-auto md:justify-start">
|
||||
<span>{getFormattedDate(post.publishedAt)}</span>
|
||||
<span class="mx-3 block font-bold text-slate-500">.</span>
|
||||
<span>{post.readTimeInMinutes} min read</span>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
import { getPublication } from "../hn-gql"
|
||||
import Social from './Social.astro';
|
||||
|
||||
const pub = await getPublication();
|
||||
|
||||
const { links, preferences } = pub;
|
||||
|
||||
const { disableFooterBranding } = preferences;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class=" mt-3 flex flex-col items-center justify-center bg-blue-200 p-1">
|
||||
<div class="mt-1">
|
||||
<center>
|
||||
<p class="text-sm text-gray-400 p-2">
|
||||
© {currentYear} {pub.title}. All rights reserved.
|
||||
</p>
|
||||
{!disableFooterBranding && (<p class="text-sm text-gray-400 p-2">
|
||||
Made with ❤️ and passion using Headless Hashnode and Astro.
|
||||
</p>)}
|
||||
</center>
|
||||
</div>
|
||||
<div class="mt-3 flex">
|
||||
{links.dailydev && <Social url={links.dailydev} displayName="DailyDev" />}
|
||||
{links.github && <Social url={links.github} displayName="Github" />}
|
||||
{links.hashnode && <Social url={links.hashnode} displayName="Hashnode" />}
|
||||
{links.twitter && <Social url={links.twitter} displayName="Twitter" />}
|
||||
{links.instagram && <Social url={links.instagram} displayName="Instagram" />}
|
||||
{links.website && <Social url={links.website} displayName="Website" />}
|
||||
{links.youtube && <Social url={links.youtube} displayName="Youtube" />}
|
||||
{links.linkedin && <Social url={links.linkedin} displayName="Linkedin" />}
|
||||
{links.mastodon && <Social url={links.mastodon} displayName="Mastodon" />}
|
||||
</div>
|
||||
</footer>
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { getAboutPage, getPublication } from "../hn-gql"
|
||||
|
||||
const publication = await getPublication();
|
||||
const aboutPageData = await getAboutPage();
|
||||
|
||||
const baseURL = import.meta.env.BASE_URL;
|
||||
|
||||
---
|
||||
<header class="flex bg-blue-200 w-full p-3">
|
||||
<h1 class="text-2xl">
|
||||
<a href={baseURL}>
|
||||
{ publication.preferences.logo ?
|
||||
<Image
|
||||
src={publication.preferences.logo}
|
||||
height={32}
|
||||
width={150}
|
||||
class="inline"
|
||||
alt={publication.title}
|
||||
loading="eager"
|
||||
/> :
|
||||
<>
|
||||
{ publication.favicon && <Image
|
||||
src={publication.favicon}
|
||||
height={32}
|
||||
width={32}
|
||||
class="inline"
|
||||
alt={publication.title}
|
||||
loading="eager"
|
||||
/> }
|
||||
{publication.title}
|
||||
</> }
|
||||
|
||||
</a>
|
||||
</h1>
|
||||
<div class="ml-5 pt-0.5 text-lg">
|
||||
<a class="mr-3" href={baseURL}>Home</a>
|
||||
<a class="mr-3" href={`${baseURL}blog`}>Blog</a>
|
||||
{aboutPageData && <a href={`${baseURL}blog/about/`}>About</a>}
|
||||
</div>
|
||||
</header>
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import { getPost, type Post } from '../hn-gql';
|
||||
import Author from './Author.astro';
|
||||
import Tag from './Tag.astro';
|
||||
|
||||
type TagType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
post: Post
|
||||
}
|
||||
|
||||
const baseURL = import.meta.env.BASE_URL;
|
||||
const { post } = Astro.props;
|
||||
const p = await getPost(post.slug);
|
||||
---
|
||||
<a href={`${baseURL}blog/${post.slug}`} aria-label="Post">
|
||||
<div class="p-6 bg-white rounded shadow-sm my-4">
|
||||
<h2 class="text-4xl pb-5 font-semibold">{post.title}</h2>
|
||||
<div class="flex lg:flex-row md:flex-col max-sm:flex-col ">
|
||||
<Image
|
||||
class="w-full rounded-lg shadow-xl"
|
||||
src={p.coverImage.url}
|
||||
alt={post.title}
|
||||
inferSize={true}
|
||||
/>
|
||||
<div class="flex flex-col m-4">
|
||||
<p class="mb-2 text-lg">{post.brief}</p>
|
||||
<div class="mt-5 mb-5">
|
||||
<Author post={post} />
|
||||
</div>
|
||||
<div class="flex justify-center items-center">
|
||||
{post.tags.map((tag: TagType) => <Tag tag={tag} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
import PostCard from './PostCard.astro';
|
||||
import type { AllPostsGenerated } from '../hn-gql'
|
||||
|
||||
interface Props{
|
||||
allPosts: AllPostsGenerated[]
|
||||
}
|
||||
|
||||
const { allPosts } = Astro.props;
|
||||
---
|
||||
|
||||
<div>
|
||||
{allPosts.map((post: AllPostsGenerated) => <PostCard post={post.node} />)}
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
interface Props {
|
||||
url: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const { url, displayName } = Astro.props;
|
||||
---
|
||||
<a
|
||||
class="p-1 rounded-lg text-md mr-2 bg-purple-800 text-white"
|
||||
href={url}
|
||||
target="_blank">
|
||||
{displayName}
|
||||
</a>
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
interface Props {
|
||||
tag: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const baseURL = import.meta.env.BASE_URL;
|
||||
const { tag } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="m-1 bg-purple-500 text-white rounded-md p-1 text-sm">
|
||||
<a href={`${baseURL}blog/tags/${tag.slug}`}>{tag.name}</a>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const {
|
||||
alt = "No Alt Text",
|
||||
src,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
---
|
||||
<Image
|
||||
class="rounded-lg"
|
||||
src={src}
|
||||
alt={alt}
|
||||
inferSize={true}
|
||||
loading="lazy"
|
||||
{...rest}
|
||||
/>
|
|
@ -0,0 +1,8 @@
|
|||
declare module 'virtual:astro-hashnode/config' {
|
||||
const userConfig: import("./src/schemas/user-config").Options;
|
||||
export default config as userConfig;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro-hashnode/components' {
|
||||
export const Layout: typeof import("./src/layouts/Layout.astro").default
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
import { gql, GraphQLClient } from "graphql-request";
|
||||
import type { AllPostsData, PostOrPageData, PublicationData } from "./schema";
|
||||
import Config from "virtual:astro-hashnode/config";
|
||||
|
||||
export const getClient = () => {
|
||||
return new GraphQLClient("https://gql.hashnode.com")
|
||||
}
|
||||
|
||||
export const getAllPosts = async () => {
|
||||
const client = getClient();
|
||||
|
||||
const allPosts = await client.request<AllPostsData>(
|
||||
gql`
|
||||
query allPosts {
|
||||
publication(host: "${Config.hashnodeURL}") {
|
||||
title
|
||||
posts(first: 20) {
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
author{
|
||||
name
|
||||
profilePicture
|
||||
}
|
||||
title
|
||||
subtitle
|
||||
brief
|
||||
slug
|
||||
coverImage {
|
||||
url
|
||||
}
|
||||
tags {
|
||||
name
|
||||
slug
|
||||
}
|
||||
publishedAt
|
||||
readTimeInMinutes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
return allPosts;
|
||||
};
|
||||
|
||||
|
||||
export const getPost = async (slug: string) => {
|
||||
const client = getClient();
|
||||
|
||||
const data = await client.request<PostOrPageData>(
|
||||
gql`
|
||||
query postDetails($slug: String!) {
|
||||
publication(host: "${Config.hashnodeURL}") {
|
||||
post(slug: $slug) {
|
||||
author{
|
||||
name
|
||||
profilePicture
|
||||
}
|
||||
publishedAt
|
||||
title
|
||||
subtitle
|
||||
readTimeInMinutes
|
||||
content{
|
||||
html
|
||||
}
|
||||
tags {
|
||||
name
|
||||
slug
|
||||
}
|
||||
coverImage {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ slug: slug }
|
||||
);
|
||||
|
||||
return data.publication.post;
|
||||
};
|
||||
|
||||
export const getAboutPage = async () => {
|
||||
const client = getClient();
|
||||
|
||||
const page = await client.request<PostOrPageData>(
|
||||
gql`
|
||||
query pageData {
|
||||
publication(host: "${Config.hashnodeURL}") {
|
||||
staticPage(slug: "about") {
|
||||
title
|
||||
content {
|
||||
markdown
|
||||
}
|
||||
}
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
return page.publication.staticPage;
|
||||
};
|
||||
|
||||
export const getPublication = async () => {
|
||||
const client = getClient();
|
||||
|
||||
const data = await client.request<PublicationData>(
|
||||
gql`
|
||||
query pubData {
|
||||
publication(host: "${Config.hashnodeURL}") {
|
||||
title
|
||||
displayTitle
|
||||
descriptionSEO
|
||||
favicon
|
||||
preferences {
|
||||
logo
|
||||
disableFooterBranding
|
||||
}
|
||||
links{
|
||||
twitter
|
||||
instagram
|
||||
github
|
||||
website
|
||||
hashnode
|
||||
youtube
|
||||
dailydev
|
||||
linkedin
|
||||
mastodon
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
return data.publication;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./client";
|
||||
export * from "./schema";
|
|
@ -0,0 +1,89 @@
|
|||
import { z } from "astro/zod";
|
||||
|
||||
export const PostSchema = z.object({
|
||||
author: z.object({
|
||||
name: z.string(),
|
||||
profilePicture: z.string(),
|
||||
}),
|
||||
publishedAt: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string(),
|
||||
brief: z.string(),
|
||||
slug: z.string(),
|
||||
readTimeInMinutes: z.number(),
|
||||
content: z.object({
|
||||
html: z.string(),
|
||||
}),
|
||||
tags: z.array(z.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
})),
|
||||
coverImage: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const PageSchema = z.object({
|
||||
title: z.string(),
|
||||
content: z.object({
|
||||
markdown: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const PostOrPageDataSchema = z.object({
|
||||
publication: z.object({
|
||||
title: z.string(),
|
||||
post: PostSchema,
|
||||
staticPage: PageSchema,
|
||||
}),
|
||||
|
||||
})
|
||||
|
||||
export const AllPostsDataSchema = z.object({
|
||||
publication: z.object({
|
||||
title: z.string(),
|
||||
posts: z.object({
|
||||
pageInfo: z.object({
|
||||
hasNextPage: z.boolean(),
|
||||
endCursor: z.string(),
|
||||
}),
|
||||
edges: z.array(z.object({
|
||||
node: PostSchema,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const PublicationDataSchema = z.object({
|
||||
publication: z.object({
|
||||
title: z.string(),
|
||||
displayTitle: z.string(),
|
||||
descriptionSEO: z.string(),
|
||||
favicon: z.string(),
|
||||
preferences: z.object({
|
||||
logo: z.string(),
|
||||
disableFooterBranding: z.boolean(),
|
||||
}),
|
||||
links: z.object({
|
||||
twitter: z.string(),
|
||||
instagram: z.string(),
|
||||
github: z.string(),
|
||||
website: z.string(),
|
||||
hashnode: z.string(),
|
||||
youtube: z.string(),
|
||||
dailydev: z.string(),
|
||||
linkedin: z.string(),
|
||||
mastodon: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const AllPostsGeneratedSchema = z.object({
|
||||
node: PostSchema,
|
||||
})
|
||||
|
||||
export type PostOrPageData = z.infer<typeof PostOrPageDataSchema>
|
||||
export type AllPostsData = z.infer<typeof AllPostsDataSchema>
|
||||
export type PublicationData = z.infer<typeof PublicationDataSchema>
|
||||
export type AllPostsGenerated = z.infer<typeof AllPostsGeneratedSchema>
|
||||
export type Post = z.infer<typeof PostSchema>
|
|
@ -1,3 +1,3 @@
|
|||
import integration from "./integration.js";
|
||||
import astroHashnode from "./astro-hashnode.js";
|
||||
|
||||
export default integration;
|
||||
export default astroHashnode;
|
|
@ -1,24 +0,0 @@
|
|||
import {
|
||||
createResolver,
|
||||
defineIntegration,
|
||||
} from "astro-integration-kit";
|
||||
import { corePlugins } from "astro-integration-kit/plugins";
|
||||
import { z } from "astro/zod";
|
||||
|
||||
export default defineIntegration({
|
||||
name: "PACKAGE-NAME",
|
||||
optionsSchema: z.object({
|
||||
/** A comment */
|
||||
foo: z.string().optional().default("bar"),
|
||||
}),
|
||||
plugins: [...corePlugins],
|
||||
setup({ options }) {
|
||||
const { resolve } = createResolver(import.meta.url);
|
||||
|
||||
return {
|
||||
"astro:config:setup": ({ watchIntegration }) => {
|
||||
watchIntegration(resolve())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -0,0 +1,67 @@
|
|||
---
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import '../styles/global.css';
|
||||
import { getPublication } from '../hn-gql'
|
||||
import { SEO } from "astro-seo";
|
||||
import { AstroFont } from "astro-font";
|
||||
import type { AstroHashnodeLayoutProps } from '../proptypes/layouttypes'
|
||||
|
||||
const pubData = await getPublication();
|
||||
|
||||
const pubHeader = pubData.title;
|
||||
|
||||
const { pageTitle, hideFooter, hideHeader, ogImage } = Astro.props as AstroHashnodeLayoutProps;
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<AstroFont
|
||||
config={[
|
||||
{
|
||||
src: [],
|
||||
name: "Poppins",
|
||||
googleFontsURL: 'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,600;1,400;1,700&display=swap',
|
||||
preload: true,
|
||||
display: "swap",
|
||||
selector: "body",
|
||||
cssVariable: "astro-font",
|
||||
fallback: "sans-serif",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SEO
|
||||
title={pageTitle ? pageTitle + " | " + pubHeader : pubHeader}
|
||||
description={pubData.descriptionSEO}
|
||||
charset='utf-8'
|
||||
openGraph={{
|
||||
basic: {
|
||||
title: pageTitle ? pageTitle + " | " + pubHeader : pubHeader,
|
||||
type: 'text',
|
||||
image: ogImage || pubData.favicon,
|
||||
},
|
||||
optional: {
|
||||
description: pubData.descriptionSEO,
|
||||
siteName: pubHeader,
|
||||
}
|
||||
}}
|
||||
extend={{
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: pubData.favicon },
|
||||
],
|
||||
meta: [
|
||||
{ name: 'viewport', content: "width=device-width, initial-scale=1" },
|
||||
{ name: 'generator', content: Astro.generator },
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col">
|
||||
{!hideHeader && <Header />}
|
||||
<div class="flex flex-wrap flex-col mt-0 mr-auto mb-0 ml-auto lg:w-[60%]">
|
||||
<slot />
|
||||
</div>
|
||||
{!hideFooter && <Footer />}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
import { Layout } from "virtual:astro-hashnode/components";
|
||||
import { getAllPosts, getPost } from '../../hn-gql';
|
||||
import Tag from '../../components/Tag.astro';
|
||||
import Author from '../../components/Author.astro';
|
||||
import { Markup } from 'astro-remote';
|
||||
import { Image } from 'astro:assets';
|
||||
import RemoteImage from '../../components/astro-remote/RemoteImage.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const data = await getAllPosts();
|
||||
const allPosts = data.publication.posts.edges;
|
||||
return allPosts.map((post) => {
|
||||
return {
|
||||
params: { slug: post.node.slug },
|
||||
}
|
||||
})
|
||||
}
|
||||
const { slug } = Astro.params;
|
||||
const post = await getPost(slug);
|
||||
---
|
||||
<Layout pageTitle={post.title} ogImage={post.coverImage.url}>
|
||||
<article class="bg-white p-3 mt-3 flex flex-col">
|
||||
<Image
|
||||
class="rounded-lg"
|
||||
src={post.coverImage.url}
|
||||
alt={post.title}
|
||||
inferSize={true}
|
||||
loading="eager"
|
||||
/>
|
||||
<h1 class="text-4xl font-bold pt-5">{post.title}</h1>
|
||||
<h2 class="text-xl pt-3 pb-3" aria-label="CoverPhoto Subtitle">{post.subtitle}</h2>
|
||||
|
||||
<Author post={post} />
|
||||
|
||||
<div class="flex flex-wrap justify-center items-center mt-5 mb-5">
|
||||
{post.tags && post.tags.map((tag) => <Tag tag={tag} />)}
|
||||
</div>
|
||||
|
||||
<div class="post-details">
|
||||
<Markup
|
||||
content={post.content.html}
|
||||
components={{
|
||||
img: RemoteImage
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
</Layout>
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
import { Layout } from "virtual:astro-hashnode/components";
|
||||
import { getAboutPage } from '../../hn-gql';
|
||||
import { Markdown } from 'astro-remote';
|
||||
import RemoteImage from '../../components/astro-remote/RemoteImage.astro';
|
||||
|
||||
const data = await getAboutPage();
|
||||
---
|
||||
|
||||
<Layout pageTitle="About">
|
||||
<div class="flex flex-col justify-center p-2">
|
||||
<h2 class="text-3xl mb-3">{data.title} Page</h2>
|
||||
<div class="about-content">
|
||||
<Markdown content={data.content.markdown}
|
||||
components={{
|
||||
img: RemoteImage
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
import { Layout } from "virtual:astro-hashnode/components";
|
||||
import Posts from '../../components/Posts.astro';
|
||||
import { getAllPosts, getPublication } from '../../hn-gql';
|
||||
|
||||
const data = await getAllPosts();
|
||||
const pub = await getPublication();
|
||||
const allPosts = data.publication.posts.edges;
|
||||
|
||||
---
|
||||
<Layout pageTitle="Blog">
|
||||
<div class="flex flex-col justify-center items-center p-2">
|
||||
<h2 class="text-2xl pt-2 font-semibold">{`Welcome to ${pub.displayTitle || pub.title}`}</h2>
|
||||
<Posts allPosts={allPosts}/>
|
||||
|
||||
</div>
|
||||
</Layout>
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
import { Layout } from "virtual:astro-hashnode/components";
|
||||
import Posts from '../../../components/Posts.astro';
|
||||
import {getAllPosts} from '../../../hn-gql';
|
||||
import Taged from '../../../components/Tag.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const data = await getAllPosts();
|
||||
const allPosts = data.publication.posts.edges;
|
||||
|
||||
const allTags = [...new Set(allPosts.map((post) => post.node.tags).flat())];
|
||||
const jsonObject = allTags.map((object) => JSON.stringify(object));
|
||||
const uniqueSet = new Set(jsonObject);
|
||||
const uniqueTags = Array.from(uniqueSet).map((u) => JSON.parse(u));
|
||||
|
||||
return uniqueTags.map((uTag) => {
|
||||
const filteredPosts: { node: { author: { name: string; profilePicture: string; }; publishedAt: string; title: string; subtitle: string; brief: string; slug: string; readTimeInMinutes: number; content: { html: string; }; tags: { name: string; slug: string; }[]; coverImage: { url: string; }; }; }[] = [];
|
||||
allPosts.forEach((post) => {
|
||||
const tags = post.node.tags;
|
||||
tags.forEach((tag) => {
|
||||
if(tag.slug === uTag.slug) {
|
||||
filteredPosts.push(post)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
params: { tag: uTag.slug },
|
||||
props: { posts: filteredPosts, matchedTag: uTag },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { tag } = Astro.params;
|
||||
const { posts, matchedTag } = Astro.props;
|
||||
const baseURL = import.meta.env.BASE_URL;
|
||||
---
|
||||
|
||||
<Layout pageTitle={tag}>
|
||||
<div class="flex pt-3">
|
||||
<p class="text-lg pt-1 px-1 mr-1">{posts.length} post(s) matched the tag</p>
|
||||
<Taged tag={matchedTag} />
|
||||
<span class="mx-3 mt-1 block font-bold text-slate-500 text-xl"> | </span>
|
||||
<a class="mt-1.5" href={`${baseURL}blog`}>See all posts</a>
|
||||
</div>
|
||||
<Posts allPosts={posts} />
|
||||
</Layout>
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
import { Image } from "astro:assets"
|
||||
import { Layout } from "virtual:astro-hashnode/components";
|
||||
import background from "../assets/blog.jpg";
|
||||
---
|
||||
|
||||
<Layout pageTitle="Home" hideFooter hideHeader>
|
||||
<div class="flex relative flex-col justify-center items-center h-screen">
|
||||
<Image
|
||||
class="w-full h-screen bg-center bg-cover blur-sm"
|
||||
alt="Background"
|
||||
src={background}
|
||||
height={1080}
|
||||
width={1920}
|
||||
loading="eager"
|
||||
/>
|
||||
<div class="absolute p-2 flex flex-col justify-center items-center z-10 bg-purple-50 lg:w-2/5 h-1/4 rounded-md">
|
||||
<div class="flex pb-5 mb-5 text-5xl text-purple-800">
|
||||
<p>Hashnode Blog</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/blog" class="bg-purple-700 text-white hover:bg-purple-900 p-3 rounded-sm text-lg text mr-2">
|
||||
TAKE ME TO THE BLOG
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
|
@ -0,0 +1,6 @@
|
|||
export type AstroHashnodeLayoutProps = {
|
||||
pageTitle: string;
|
||||
hideHeader?: boolean;
|
||||
hideFooter?: boolean;
|
||||
ogImage?: string;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "astro/zod";
|
||||
import { createResolver } from 'astro-integration-kit';
|
||||
|
||||
const { resolve } = createResolver(import.meta.url)
|
||||
|
||||
export function LayoutConfigSchema() {
|
||||
return z
|
||||
.string()
|
||||
.optional()
|
||||
}
|
||||
export const optionsSchema = z.object({
|
||||
hashnodeURL: z.string(),
|
||||
landingPage: z.boolean().default(true),
|
||||
layoutComponent: LayoutConfigSchema(),
|
||||
verbose: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type Options = z.infer<typeof optionsSchema>;
|
|
@ -0,0 +1,117 @@
|
|||
html {
|
||||
background-color: #f1f5f9;
|
||||
font-family: var(--astro-font);
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 560px) {
|
||||
body {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr{
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
background: #f9f9f9;
|
||||
border-left: 10px solid #ccc;
|
||||
margin: 1.5em 10px;
|
||||
padding: 0.5em 10px;
|
||||
quotes: "\201C""\201D""\2018""\2019";
|
||||
}
|
||||
blockquote:before {
|
||||
color: #ccc;
|
||||
content: open-quote;
|
||||
font-size: 4em;
|
||||
line-height: 0.1em;
|
||||
margin-right: 0.25em;
|
||||
vertical-align: -0.4em;
|
||||
}
|
||||
blockquote p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.post-details p{
|
||||
color: rgb(17, 22, 20);
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.post-details h2{
|
||||
font-size: 25px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-details h3{
|
||||
font-size: 20px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-details img{
|
||||
margin: 0 auto;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.post-details UL {
|
||||
margin: 5px;
|
||||
padding: 15px;
|
||||
}
|
||||
.post-details li{
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
.post-details p{
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.post-details a{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-details pre{
|
||||
border: 1px solid #ebebeb;
|
||||
border-radius: 5px;
|
||||
padding: 2px;
|
||||
margin: 2px;
|
||||
background-color: rgb(246, 244, 244);
|
||||
}
|
||||
|
||||
.about-content h1{
|
||||
font-size: 25px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.about-content p{
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.about-content UL{
|
||||
margin: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.about-content UL LI {
|
||||
padding: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.about-content UL LI a{
|
||||
margin-right: 2px;
|
||||
text-decoration: underline;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import "tailwindcss";
|
|
@ -0,0 +1,7 @@
|
|||
export const getFormattedDate = (dateString: string) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(dateString));
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strictest",
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
declare module 'virtual:astro-hashnode/config' {
|
||||
const userConfig: import("./src/schemas/user-config").Options;
|
||||
export default config as userConfig;
|
||||
}
|
||||
|
||||
declare module 'virtual:astro-hashnode/components' {
|
||||
export const Layout: typeof import("./src/layouts/Layout.astro").default
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
import packagename from "@adammatthiesen/astro-commercejs";
|
||||
import astroHashnode from "@matthiesenxyz/astro-hashnode";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [packagename()],
|
||||
integrations: [
|
||||
astroHashnode({
|
||||
hashnodeURL: "astroplayground.hashnode.dev",
|
||||
verbose: true,
|
||||
})
|
||||
],
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"astro": "^4.3.5",
|
||||
"PACKAGE-NAME": "workspace:*"
|
||||
"@matthiesenxyz/astro-hashnode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.5.1",
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
Before Width: | Height: | Size: 749 B |
|
@ -1 +1,3 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference types="../.astro/astro-hashnode.d.ts" />
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<h1>Hello World!</h1>
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue