Compare commits
2 commits
main
...
private/sk
Author | SHA1 | Date | |
---|---|---|---|
Skyler Grey | c6cca82e73 | ||
Skyler Grey | ed0b08f81e |
|
@ -1,25 +1,25 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
import rehypeStringify from 'rehype-stringify';
|
import rehypeStringify from "rehype-stringify";
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from "rehype-raw";
|
||||||
import remarkParse from 'remark-parse';
|
import remarkParse from "remark-parse";
|
||||||
import remarkRehype from 'remark-rehype';
|
import remarkRehype from "remark-rehype";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
markdown: {
|
markdown: {
|
||||||
remarkRehype: {
|
remarkRehype: {
|
||||||
allowDangerousHtml: true
|
allowDangerousHtml: true,
|
||||||
// This is fine because we are using rehypeSanitize to sanitize XSS.
|
// This is fine because we are using rehypeSanitize to sanitize XSS.
|
||||||
// See https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#example-supporting-html-in-markdown-properly
|
// See https://github.com/remarkjs/remark-rehype?tab=readme-ov-file#example-supporting-html-in-markdown-properly
|
||||||
},
|
},
|
||||||
remarkPlugins: [
|
remarkPlugins: [
|
||||||
remarkParse,
|
remarkParse,
|
||||||
remarkRehype,
|
remarkRehype,
|
||||||
rehypeRaw,
|
rehypeRaw,
|
||||||
rehypeSanitize,
|
rehypeSanitize,
|
||||||
rehypeStringify,
|
rehypeStringify,
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
35
flake.lock
35
flake.lock
|
@ -86,11 +86,28 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1708475490,
|
||||||
|
"narHash": "sha256-g1v0TsWBQPX97ziznfJdWhgMyMGtoBFs102xSYO4syU=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "0e74ca98a74bc7270d28838369593635a5db3260",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"snowfall-lib": "snowfall-lib",
|
"snowfall-lib": "snowfall-lib",
|
||||||
|
"treefmt-nix": "treefmt-nix",
|
||||||
"wiki": "wiki"
|
"wiki": "wiki"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -146,6 +163,24 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"treefmt-nix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1715940852,
|
||||||
|
"narHash": "sha256-wJqHMg/K6X3JGAE9YLM0LsuKrKb4XiBeVaoeMNlReZg=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"rev": "2fba33a182602b9d49f0b2440513e5ee091d838b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "treefmt-nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"wiki": {
|
"wiki": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
|
|
12
flake.nix
12
flake.nix
|
@ -12,6 +12,8 @@
|
||||||
flake = false;
|
flake = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs:
|
inputs:
|
||||||
inputs.snowfall-lib.mkFlake {
|
inputs.snowfall-lib.mkFlake {
|
||||||
|
@ -26,6 +28,14 @@
|
||||||
namespace = "auxolotl--docs-site";
|
namespace = "auxolotl--docs-site";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs-builder = channels: { formatter = channels.nixpkgs.nixfmt-rfc-style; };
|
outputs-builder =
|
||||||
|
channels:
|
||||||
|
let
|
||||||
|
treefmt = inputs.treefmt-nix.lib.evalModule channels.nixpkgs ./treefmt.nix;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
formatter = treefmt.config.build.wrapper;
|
||||||
|
checks.formatting = treefmt.config.build.check inputs.self;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,4 +13,4 @@ const { Content } = await post.render();
|
||||||
|
|
||||||
<div class="contents">
|
<div class="contents">
|
||||||
<Content />
|
<Content />
|
||||||
</div>
|
</div>s
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,162 +2,181 @@ import type { CollectionEntry } from "astro:content";
|
||||||
import { parse, join, sep } from "node:path";
|
import { parse, join, sep } from "node:path";
|
||||||
|
|
||||||
export interface PageLinkData {
|
export interface PageLinkData {
|
||||||
id: string;
|
id: string;
|
||||||
data: { title: string; };
|
data: { title: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
type AllPathInformation = Map<string, CollectionEntry<"wiki"> | null>;
|
type AllPathInformation = Map<string, CollectionEntry<"wiki"> | null>;
|
||||||
|
|
||||||
export interface Paths {
|
export interface Paths {
|
||||||
siblingPages: PageLinkData[];
|
siblingPages: PageLinkData[];
|
||||||
siblingDirectories: PageLinkData[];
|
siblingDirectories: PageLinkData[];
|
||||||
childPages: PageLinkData[];
|
childPages: PageLinkData[];
|
||||||
childDirectories: PageLinkData[];
|
childDirectories: PageLinkData[];
|
||||||
|
|
||||||
parentDirectory: PageLinkData | null;
|
parentDirectory: PageLinkData | null;
|
||||||
currentPage: PageLinkData;
|
currentPage: PageLinkData;
|
||||||
};
|
|
||||||
|
|
||||||
export function relativePagePaths(wikiEntries: PageLinkData[], currentPath: string): Paths {
|
|
||||||
let currentPage: PageLinkData | undefined;
|
|
||||||
let parentDirectory: PageLinkData | undefined | null;
|
|
||||||
const siblingPages: Map<string, PageLinkData> = new Map();
|
|
||||||
const childPages: Map<string, PageLinkData> = new Map();
|
|
||||||
|
|
||||||
const currentPathParsed = parse(currentPath);
|
|
||||||
const currentPathExtensionless = join(currentPathParsed.dir, currentPathParsed.name);
|
|
||||||
|
|
||||||
const childDirectoryPaths: Set<string> = new Set();
|
|
||||||
const siblingDirectoryPaths: Set<string> = new Set();
|
|
||||||
|
|
||||||
for (const entry of wikiEntries) {
|
|
||||||
const pagePathParsed = parse(entry.id);
|
|
||||||
const pagePathExtensionless = join(pagePathParsed.dir, pagePathParsed.name);
|
|
||||||
|
|
||||||
if (pagePathExtensionless === currentPathExtensionless) {
|
|
||||||
currentPage = entry
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isInCurrentDirectory = pagePathParsed.dir === currentPathParsed.dir;
|
|
||||||
if (isInCurrentDirectory) {
|
|
||||||
siblingPages.set(pagePathExtensionless, entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirectChild = pagePathParsed.dir === currentPathExtensionless;
|
|
||||||
if (isDirectChild) {
|
|
||||||
childPages.set(pagePathExtensionless, entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isIndirectChild = pagePathParsed.dir.startsWith(currentPathExtensionless + sep);
|
|
||||||
if (isIndirectChild) {
|
|
||||||
const nextPathSeparator = pagePathParsed.dir.indexOf(sep, currentPathExtensionless.length + 1);
|
|
||||||
|
|
||||||
if (nextPathSeparator === -1) {
|
|
||||||
childDirectoryPaths.add(pagePathParsed.dir);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
childDirectoryPaths.add(pagePathParsed.dir.slice(0, nextPathSeparator));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isIndirectInCurrentDirectory = currentPathParsed.dir === "" || pagePathParsed.dir.startsWith(currentPathParsed.dir + sep);
|
|
||||||
if (isIndirectInCurrentDirectory) {
|
|
||||||
const nextPathSeparator = pagePathParsed.dir.indexOf(sep, currentPathParsed.dir.length + 1);
|
|
||||||
|
|
||||||
if (nextPathSeparator === -1) {
|
|
||||||
siblingDirectoryPaths.add(pagePathParsed.dir);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
siblingDirectoryPaths.add(pagePathParsed.dir.slice(0, nextPathSeparator));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isParentDirectory = pagePathExtensionless === currentPathParsed.dir;
|
|
||||||
if (isParentDirectory) {
|
|
||||||
parentDirectory = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const childDirectories: PageLinkData[] = [];
|
|
||||||
for (const childDirectoryPath of childDirectoryPaths.values()) {
|
|
||||||
const childDirectoryPage = childPages.get(childDirectoryPath);
|
|
||||||
if (childDirectoryPage) {
|
|
||||||
childDirectories.push(childDirectoryPage);
|
|
||||||
childPages.delete(childDirectoryPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childDirectoryPathParsed = parse(childDirectoryPath);
|
|
||||||
childDirectories.push({
|
|
||||||
id: childDirectoryPath,
|
|
||||||
data: { title: childDirectoryPathParsed.name }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const siblingDirectories: PageLinkData[] = [];
|
|
||||||
for (const siblingDirectoryPath of siblingDirectoryPaths.values()) {
|
|
||||||
const siblingDirectoryPage = siblingPages.get(siblingDirectoryPath);
|
|
||||||
if (siblingDirectoryPage) {
|
|
||||||
siblingDirectories.push(siblingDirectoryPage);
|
|
||||||
siblingPages.delete(siblingDirectoryPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const siblingDirectoryPathParsed = parse(siblingDirectoryPath);
|
|
||||||
siblingDirectories.push({
|
|
||||||
id: siblingDirectoryPath,
|
|
||||||
data: { title: siblingDirectoryPathParsed.name }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPage === undefined) {
|
|
||||||
currentPage = {
|
|
||||||
id: currentPath,
|
|
||||||
data: { title: currentPathParsed.name }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentDirectory === undefined) {
|
|
||||||
if (currentPathParsed.dir) {
|
|
||||||
const parentDirectoryPathParsed = parse(currentPathParsed.dir);
|
|
||||||
parentDirectory = {
|
|
||||||
id: currentPathParsed.dir,
|
|
||||||
data: { title: parentDirectoryPathParsed.name }
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
parentDirectory = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
siblingPages: Array.from(siblingPages.values()).sort(),
|
|
||||||
childPages: Array.from(childPages.values()).sort(),
|
|
||||||
|
|
||||||
siblingDirectories: siblingDirectories.sort(),
|
|
||||||
childDirectories: childDirectories.sort(),
|
|
||||||
|
|
||||||
currentPage,
|
|
||||||
parentDirectory,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allPageAndDirectoryPaths(wikiEntries: CollectionEntry<"wiki">[]): AllPathInformation {
|
export function relativePagePaths(
|
||||||
const pathInformation: Map<string, CollectionEntry<"wiki"> | null> = new Map();
|
wikiEntries: PageLinkData[],
|
||||||
|
currentPath: string,
|
||||||
|
): Paths {
|
||||||
|
let currentPage: PageLinkData | undefined;
|
||||||
|
let parentDirectory: PageLinkData | undefined | null;
|
||||||
|
const siblingPages: Map<string, PageLinkData> = new Map();
|
||||||
|
const childPages: Map<string, PageLinkData> = new Map();
|
||||||
|
|
||||||
for (const entry of wikiEntries) {
|
const currentPathParsed = parse(currentPath);
|
||||||
pathInformation.set(entry.id, entry);
|
const currentPathExtensionless = join(
|
||||||
|
currentPathParsed.dir,
|
||||||
|
currentPathParsed.name,
|
||||||
|
);
|
||||||
|
|
||||||
let parsedEntryPath = parse(entry.id);
|
const childDirectoryPaths: Set<string> = new Set();
|
||||||
while (parsedEntryPath.dir) {
|
const siblingDirectoryPaths: Set<string> = new Set();
|
||||||
pathInformation.set(parsedEntryPath.dir, null);
|
|
||||||
parsedEntryPath = parse(parsedEntryPath.dir);
|
for (const entry of wikiEntries) {
|
||||||
}
|
const pagePathParsed = parse(entry.id);
|
||||||
|
const pagePathExtensionless = join(pagePathParsed.dir, pagePathParsed.name);
|
||||||
|
|
||||||
|
if (pagePathExtensionless === currentPathExtensionless) {
|
||||||
|
currentPage = entry;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pathInformation;
|
const isInCurrentDirectory = pagePathParsed.dir === currentPathParsed.dir;
|
||||||
|
if (isInCurrentDirectory) {
|
||||||
|
siblingPages.set(pagePathExtensionless, entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectChild = pagePathParsed.dir === currentPathExtensionless;
|
||||||
|
if (isDirectChild) {
|
||||||
|
childPages.set(pagePathExtensionless, entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIndirectChild = pagePathParsed.dir.startsWith(
|
||||||
|
currentPathExtensionless + sep,
|
||||||
|
);
|
||||||
|
if (isIndirectChild) {
|
||||||
|
const nextPathSeparator = pagePathParsed.dir.indexOf(
|
||||||
|
sep,
|
||||||
|
currentPathExtensionless.length + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextPathSeparator === -1) {
|
||||||
|
childDirectoryPaths.add(pagePathParsed.dir);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
childDirectoryPaths.add(pagePathParsed.dir.slice(0, nextPathSeparator));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIndirectInCurrentDirectory =
|
||||||
|
currentPathParsed.dir === "" ||
|
||||||
|
pagePathParsed.dir.startsWith(currentPathParsed.dir + sep);
|
||||||
|
if (isIndirectInCurrentDirectory) {
|
||||||
|
const nextPathSeparator = pagePathParsed.dir.indexOf(
|
||||||
|
sep,
|
||||||
|
currentPathParsed.dir.length + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextPathSeparator === -1) {
|
||||||
|
siblingDirectoryPaths.add(pagePathParsed.dir);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
siblingDirectoryPaths.add(pagePathParsed.dir.slice(0, nextPathSeparator));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParentDirectory = pagePathExtensionless === currentPathParsed.dir;
|
||||||
|
if (isParentDirectory) {
|
||||||
|
parentDirectory = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childDirectories: PageLinkData[] = [];
|
||||||
|
for (const childDirectoryPath of childDirectoryPaths.values()) {
|
||||||
|
const childDirectoryPage = childPages.get(childDirectoryPath);
|
||||||
|
if (childDirectoryPage) {
|
||||||
|
childDirectories.push(childDirectoryPage);
|
||||||
|
childPages.delete(childDirectoryPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childDirectoryPathParsed = parse(childDirectoryPath);
|
||||||
|
childDirectories.push({
|
||||||
|
id: childDirectoryPath,
|
||||||
|
data: { title: childDirectoryPathParsed.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingDirectories: PageLinkData[] = [];
|
||||||
|
for (const siblingDirectoryPath of siblingDirectoryPaths.values()) {
|
||||||
|
const siblingDirectoryPage = siblingPages.get(siblingDirectoryPath);
|
||||||
|
if (siblingDirectoryPage) {
|
||||||
|
siblingDirectories.push(siblingDirectoryPage);
|
||||||
|
siblingPages.delete(siblingDirectoryPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingDirectoryPathParsed = parse(siblingDirectoryPath);
|
||||||
|
siblingDirectories.push({
|
||||||
|
id: siblingDirectoryPath,
|
||||||
|
data: { title: siblingDirectoryPathParsed.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage === undefined) {
|
||||||
|
currentPage = {
|
||||||
|
id: currentPath,
|
||||||
|
data: { title: currentPathParsed.name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentDirectory === undefined) {
|
||||||
|
if (currentPathParsed.dir) {
|
||||||
|
const parentDirectoryPathParsed = parse(currentPathParsed.dir);
|
||||||
|
parentDirectory = {
|
||||||
|
id: currentPathParsed.dir,
|
||||||
|
data: { title: parentDirectoryPathParsed.name },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
parentDirectory = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
siblingPages: Array.from(siblingPages.values()).sort(),
|
||||||
|
childPages: Array.from(childPages.values()).sort(),
|
||||||
|
|
||||||
|
siblingDirectories: siblingDirectories.sort(),
|
||||||
|
childDirectories: childDirectories.sort(),
|
||||||
|
|
||||||
|
currentPage,
|
||||||
|
parentDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allPageAndDirectoryPaths(
|
||||||
|
wikiEntries: CollectionEntry<"wiki">[],
|
||||||
|
): AllPathInformation {
|
||||||
|
const pathInformation: Map<string, CollectionEntry<"wiki"> | null> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
|
for (const entry of wikiEntries) {
|
||||||
|
pathInformation.set(entry.id, entry);
|
||||||
|
|
||||||
|
let parsedEntryPath = parse(entry.id);
|
||||||
|
while (parsedEntryPath.dir) {
|
||||||
|
pathInformation.set(parsedEntryPath.dir, null);
|
||||||
|
parsedEntryPath = parse(parsedEntryPath.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathInformation;
|
||||||
}
|
}
|
|
@ -1,15 +1,15 @@
|
||||||
.contents table {
|
.contents table {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents td {
|
.contents td {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents {
|
.contents {
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
padding: 0em 2em;
|
padding: 0em 2em;
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
a:visited {
|
a:visited {
|
||||||
color: blue;
|
color: blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contents {
|
.contents {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
28
treefmt.nix
Normal file
28
treefmt.nix
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# treefmt.nix
|
||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
# Used to find the project root
|
||||||
|
projectRootFile = "flake.nix";
|
||||||
|
|
||||||
|
# go
|
||||||
|
programs.gofmt.enable = true;
|
||||||
|
|
||||||
|
# js/ts
|
||||||
|
programs.prettier.enable = true;
|
||||||
|
|
||||||
|
# markdown
|
||||||
|
programs.mdformat.enable = true;
|
||||||
|
|
||||||
|
# nix
|
||||||
|
programs.nixfmt-rfc-style.enable = true;
|
||||||
|
programs.statix.enable = true;
|
||||||
|
|
||||||
|
# python
|
||||||
|
programs.black.enable = true;
|
||||||
|
|
||||||
|
# rust
|
||||||
|
programs.rustfmt.enable = true;
|
||||||
|
|
||||||
|
# swift
|
||||||
|
programs.swift-format.enable = true;
|
||||||
|
}
|
Loading…
Reference in a new issue