diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..6635cf5 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/website/.npmrc b/website/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/website/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..5c91169 --- /dev/null +++ b/website/README.md @@ -0,0 +1,38 @@ +# create-svelte + +Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm create svelte@latest + +# create a new project in my-app +npm create svelte@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..b95621a --- /dev/null +++ b/website/package.json @@ -0,0 +1,35 @@ +{ + "name": "mydocs", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^1.0.0", + "@sveltejs/kit": "^1.0.0", + "svelte": "^3.54.0", + "vite": "^4.0.0" + }, + "type": "module", + "dependencies": { + "@iconify-json/ri": "^1.1.4", + "@rollup/pluginutils": "^5.0.2", + "@sveltejs/adapter-static": "^1.0.1", + "@svelteness/kit-docs": "link:/home/yanick/work/javascript/kit-docs/packages/kit-docs", + "globby": "^13.1.3", + "gray-matter": "^4.0.3", + "kit-docs-workspace": "github:svelteness/kit-docs", + "kleur": "^4.1.5", + "lru-cache": "^7.14.1", + "markdown-it": "^13.0.1", + "markdown-it-anchor": "^8.6.6", + "markdown-it-container": "^3.0.0", + "markdown-it-emoji": "^2.0.2", + "shiki": "^0.12.1", + "toml": "^3.0.0", + "unplugin-icons": "^0.15.1" + } +} diff --git a/website/src/app.html b/website/src/app.html new file mode 100644 index 0000000..cb4f358 --- /dev/null +++ b/website/src/app.html @@ -0,0 +1,24 @@ + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/website/src/kit-docs/Info.svelte b/website/src/kit-docs/Info.svelte new file mode 100644 index 0000000..14a6a45 --- /dev/null +++ b/website/src/kit-docs/Info.svelte @@ -0,0 +1,24 @@ +
+ +

💡 Info

+ + + +
+ + diff --git a/website/src/node/handlers/index.ts b/website/src/node/handlers/index.ts new file mode 100644 index 0000000..73169de --- /dev/null +++ b/website/src/node/handlers/index.ts @@ -0,0 +1,401 @@ +import { type FilterPattern, createFilter } from '@rollup/pluginutils'; +import { json, RequestHandler } from '@sveltejs/kit'; +import { readFileSync } from 'fs'; +import { globbySync } from 'globby'; +import kleur from 'kleur'; +import path from 'path'; + +import { + type MarkdownParser, + type ParsedMarkdownResult, + createMarkdownParser, + getFrontmatter, + parseMarkdown, +} from '../markdown-plugin/parser'; +import { readDirDeepSync, sortOrderedFiles } from '../utils/fs'; +import { kebabToTitleCase } from '../utils/string'; +import { isString } from '../utils/unit'; + +const CWD = process.cwd(); +const ROUTES_DIR = path.resolve(CWD, 'src/routes'); + +let parser: MarkdownParser; + +const restParamsRE = /\[\.\.\.(.*?)\]/g; +const restPropsRE = /\[\.\.\.(.*?)\]/; +const deepMatchRE = /\[\.\.\..*?_deep\]/; +const layoutNameRE = /@.+/g; +const defaultIncludeRE = /\.md($|\?)/; + +export type NoValue = null | undefined | void; + +export type FalsyValue = false | NoValue; + +export type HandleMetaRequestOptions = { + extensions?: string[]; + filter?: (file: string) => boolean; + resolve?: FileResolver | null | (FileResolver | FalsyValue)[]; + transform?: MetaTransform | null | (MetaTransform | FalsyValue)[]; +}; + +export type FileResolver = ( + slug: string, + helpers: { resolve: typeof resolveSlug }, +) => ResolvedFile | FalsyValue | Promise; + +export type ResolvedFile = + | string + | { file: string; transform: MetaTransform | (MetaTransform | FalsyValue)[] }; + +export type MetaTransform = ( + data: { slug: string; filePath: string; parser: MarkdownParser } & ParsedMarkdownResult, +) => void | Promise; + +/** + * Careful this function will throw if it can't match the `slug` param to a file. + */ +export async function handleMetaRequest(slugParam: string, options: HandleMetaRequestOptions = {}) { + const { filter, extensions, resolve, transform } = options; + + const slug = paramToSlug(slugParam); + + const resolverArgs: Parameters = [slug, { resolve: resolveSlug }]; + + let resolution: ResolvedFile | FalsyValue = null; + + if (Array.isArray(resolve)) { + for (const resolver of resolve) { + if (resolver) resolution = await resolver?.(...resolverArgs); + if (resolution) break; + } + } else { + resolution = await resolve?.(...resolverArgs); + } + + if (!resolution) { + resolution = resolveSlug(slug, { extensions }); + } + + const resolvedFile = isString(resolution) ? resolution : resolution?.file; + const resolvedTransform = isString(resolution) ? null : resolution?.transform; + if (!resolvedFile) { + throw Error('Could not find file.'); + } + + if (filter && !filter(`/${cleanFilePath(resolvedFile)}`)) { + return null; + } + + const filePath = path.isAbsolute(resolvedFile) ? resolvedFile : path.resolve(CWD, resolvedFile); + const content = readFileSync(filePath).toString(); + if (!parser) { + parser = await createMarkdownParser(); + } + + let result = parseMarkdown(parser, content, filePath); + result = JSON.parse(JSON.stringify(result)); + + const transformerArgs: Parameters = [{ slug, filePath, parser, ...result }]; + + const runTransform = async (transform?: HandleMetaRequestOptions['transform']) => { + if (Array.isArray(transform)) { + for (const transformer of transform) { + if (transformer) await transformer?.(...transformerArgs); + } + } else { + await transform?.(...transformerArgs); + } + }; + + await runTransform(transform); + await runTransform(resolvedTransform); + return result; +} + +export type CreateMetaRequestHandlerOptions = { + include?: FilterPattern; + exclude?: FilterPattern; + debug?: boolean; +} & HandleMetaRequestOptions; + +export function createMetaRequestHandler( + options: CreateMetaRequestHandlerOptions = {}, +): RequestHandler { + const { include, exclude, debug, ...handlerOptions } = options; + + const filter = createFilter( + include ?? handlerOptions.extensions?.map((ext) => new RegExp(`${ext}$`)) ?? defaultIncludeRE, + exclude, + ); + + return async ({ params }) => { + try { + const res = await handleMetaRequest(params.slug as string, { filter, ...handlerOptions }); + if (!res) return new Response(null); + return json(res.meta); + } catch (e) { + if (debug) { + console.log(kleur.bold(kleur.red(`\n[kit-docs]: failed to handle meta request.`))); + console.log(`\n\n${e}\n`); + } + } + + return new Response(null); + }; +} + +const headingRE = /#\s(.*?)($|\n|\r)/; + +export type HandleSidebarRequestOptions = { + extensions?: string[]; + filter?: (file: string) => boolean; + resolveTitle?: SidebarMetaResolver; + resolveCategory?: SidebarMetaResolver; + resolveSlug?: SidebarMetaResolver; + formatCategoryName?: (name: string, helpers: { format: (name: string) => string }) => string; +}; + +export type SidebarMetaResolver = (data: { + filePath: string; + relativeFilePath: string; + cleanFilePath: string; + dirname: string; + cleanDirname: string; + frontmatter: Record; + fileContent: string; + resolve: () => string; + slugify: typeof slugifyFilePath; +}) => string | void | null | undefined | Promise; + +/** + * Careful this function will throw if it can't match the `dir` param to a directory. + */ +export async function handleSidebarRequest( + dirParam: string, + options: HandleSidebarRequestOptions = {}, +) { + const { extensions, filter, formatCategoryName, resolveTitle, resolveCategory, resolveSlug } = + options; + + const exts = extensions ?? ['.md']; + const globExt = + exts.length > 1 ? `.{${exts.map((ext) => ext.replace(/^\./, '')).join(',')}}` : exts[0]; + + const directory = paramToDir(dirParam); + const dirPath = path.resolve(ROUTES_DIR, directory); + + const filePaths = sortOrderedFiles(readDirDeepSync(dirPath)); + + const links: Record = {}; + + // Root at top. + links['.'] = []; + let hasRoot = false; + + for (const filePath of filePaths) { + const filename = path.basename(filePath); + const relativeFilePath = path.relative(ROUTES_DIR, filePath); + const dirs = path.dirname(relativeFilePath).split('/'); + const cleanPath = cleanFilePath(filePath); + const cleanDirs = path.dirname(cleanPath).split('/').slice(0, -1); + const cleanDirsReversed = cleanDirs.slice().reverse(); + const isIndexFile = /\/\+page\./.test(cleanPath); + const isShallowRoot = cleanDirs.length === 0; + const isRoot = isShallowRoot || deepMatchRE.test(dirs[1]); + let isDeepMatch = false; + let isValidDeepMatch = false; + + if (deepMatchRE.test(relativeFilePath)) { + const deepMatchDir = dirs.findIndex((dir) => deepMatchRE.test(dir)); + isDeepMatch = deepMatchDir >= 0; + + const glob = (depth: number) => + `src/routes/*${cleanDirs.slice(0, depth).join('/*')}/*+page*${globExt}`; + + let file = isDeepMatch ? globbySync(glob(deepMatchDir + 1))?.[0] : null; + + if (isDeepMatch && !file) { + file = isDeepMatch ? globbySync(glob(deepMatchDir + 2))?.[0] : null; + } + + isValidDeepMatch = isDeepMatch ? file === `src/routes/${relativeFilePath}` : false; + } + + if ( + filename.startsWith('_') || + filename.startsWith('.') || + (isShallowRoot && isIndexFile) || + (isDeepMatch && !isValidDeepMatch) || + !(filter?.(`/${cleanPath}`) ?? true) + ) { + continue; + } + + const fileContent = readFileSync(filePath).toString(); + const frontmatter = getFrontmatter(fileContent); + + const resolverData = { + filePath, + relativeFilePath, + cleanFilePath: cleanPath, + frontmatter, + fileContent, + dirname: path.dirname(filePath), + cleanDirname: path.dirname(cleanPath), + slugify: slugifyFilePath, + }; + + const categoryFormatter = formatCategoryName ?? kebabToTitleCase; + + const formatCategory = (dirname: string) => + categoryFormatter(dirname, { format: (name) => kebabToTitleCase(name) }); + + const resolveDefaultTitle = () => + frontmatter.sidebar_title ?? + frontmatter.title ?? + (isDeepMatch ? formatCategory(cleanDirsReversed[0]) : null) ?? + fileContent.match(headingRE)?.[1] ?? + kebabToTitleCase(path.basename(cleanPath, path.extname(cleanPath))); + + const resolveDefaultCategory = () => + isRoot ? '.' : cleanDirsReversed[isIndexFile && isDeepMatch ? 1 : 0]; + + const resolveDefaultSlug = () => slugifyFilePath(filePath); + + const category = formatCategory( + (await resolveCategory?.({ ...resolverData, resolve: resolveDefaultCategory })) ?? + resolveDefaultCategory(), + ); + + const title = + (await resolveTitle?.({ ...resolverData, resolve: resolveDefaultTitle })) ?? + resolveDefaultTitle(); + + const slug = + (await resolveSlug?.({ ...resolverData, resolve: resolveDefaultSlug })) ?? + resolveDefaultSlug(); + + const match = isDeepMatch ? 'deep' : undefined; + + (links[category] ??= []).push({ title, slug, match }); + if (!hasRoot) hasRoot = category === '.'; + } + + if (!hasRoot) { + delete links['.']; + } + + return { links }; +} + +export type CreateSidebarRequestHandlerOptions = { + include?: FilterPattern; + exclude?: FilterPattern; + debug?: boolean; +} & HandleSidebarRequestOptions; + +export function createSidebarRequestHandler( + options: CreateSidebarRequestHandlerOptions = {}, +): RequestHandler { + const { include, debug, exclude, ...handlerOptions } = options; + + const filter = createFilter( + include ?? handlerOptions.extensions?.map((ext) => new RegExp(`${ext}$`)) ?? defaultIncludeRE, + exclude, + ); + + return async ({ params }) => { + try { + const { links } = await handleSidebarRequest(params.dir as string, { + filter, + ...handlerOptions, + }); + + return json({ links }); + } catch (e) { + if (debug) { + console.log(kleur.bold(kleur.red(`\n[kit-docs]: failed to handle sidebar request.`))); + console.log(`\n\n${e}\n`); + } + } + + return new Response(null); + }; +} + +export type ResolveSlugOptions = { + extensions?: string[]; +}; + +/** + * Attempts to resolve the given slug to a file in the `routes` directory. This function returns + * a relative file path. + */ +export function resolveSlug(slug: string, options: ResolveSlugOptions = {}): string | null { + const { extensions } = options; + + const exts = extensions ?? ['.md']; + + const globExt = + exts.length > 1 ? `.{${exts.map((ext) => ext.replace(/^\./, '')).join(',')}}` : exts[0]; + + const fileGlobBase = `src/routes/${slug + .split('/') + .slice(0, -1) + .map((s) => `*${s}`) + .join('/')}`; + + const glob = `${fileGlobBase}/*${path.basename(slug)}/*${globExt}`; + let file = globbySync(glob)?.[0]; + + if (!file) { + const glob = `${fileGlobBase}/*${path.basename(slug)}/*index*${globExt}`; + file = globbySync(glob)?.[0]; + } + + if (!file) { + return null; + } + + const matchedSlug = file + .replace(restParamsRE, '') + .replace(layoutNameRE, '') + .replace(path.extname(file), '') + .replace(/\/index$/, slug === 'index' ? '/index' : ''); + + if (matchedSlug !== `src/routes/${slug}/+page` || !exts.some((ext) => file.endsWith(ext))) { + return null; + } + return file; +} + +/** + * Takes an absolute or relative file path and maps it to a relative path to `src/routes`, and + * strips out rest params and layout ids `{[...1]}index{@layout-id}.md`. + * + * @example `src/routes/docs/[...1getting-started]/[...1]intro.md` = `docs/getting-started/intro.md` + */ +export function cleanFilePath(filePath: string) { + const relativePath = path.relative(ROUTES_DIR, filePath); + return relativePath.replace(restParamsRE, '').replace(layoutNameRE, path.extname(filePath)); +} + +export function paramToSlug(param: string) { + return param.replace(/_/g, '/').replace(/\.html/, ''); +} + +export function paramToDir(param: string) { + return paramToSlug(param); +} + +/** + * Maps a path that points to a file in the `routes` directory to a slug. The file path + * can be absolute or relative to the `routes` directory. + */ +export function slugifyFilePath(filePath: string) { + const cleanPath = cleanFilePath(filePath); + return `/${cleanPath + .replace(path.extname(cleanPath), '') + .replace(/\/?index$/, '') + .replace(/\/\+page$/, '')}`; +} diff --git a/website/src/node/highlight-plugin.ts b/website/src/node/highlight-plugin.ts new file mode 100644 index 0000000..6a1ed73 --- /dev/null +++ b/website/src/node/highlight-plugin.ts @@ -0,0 +1,51 @@ +import path from 'path'; +import { + type Highlighter, + type HighlighterOptions, + type Lang, + getHighlighter, + renderToHtml, +} from 'shiki'; +import { type Plugin } from 'vite'; + +const PLUGIN_NAME = '@svelteness/highlight' as const; + +export type HighlightPluginOptions = HighlighterOptions; + +export const kitDocsHighlightPlugin = (options: HighlightPluginOptions = {}): Plugin => { + let highlighter: Highlighter; + + const highlightQueryRE = /\?highlight/; + + return { + name: PLUGIN_NAME, + enforce: 'pre' as const, + async configResolved() { + highlighter = await getHighlighter({ + theme: 'material-palenight', + langs: [], + ...options, + }); + }, + transform(code, id) { + if (!highlightQueryRE.test(id)) { + return null; + } + + const lang = (new URLSearchParams(id).get('lang') ?? + path.extname(id.replace(highlightQueryRE, '')).slice(1)) as Lang; + + const tokens = highlighter.codeToThemedTokens(code, lang); + + const html = renderToHtml(tokens) + .replace(/\sclass="shiki" style=".*?"/, '') + .trim(); + + return ` + export const tokens = ${JSON.stringify(tokens)} + export const code = ${JSON.stringify(code)} + export const hlCode = ${JSON.stringify(html)} + `; + }, + }; +}; diff --git a/website/src/node/index.ts b/website/src/node/index.ts new file mode 100644 index 0000000..da6a09b --- /dev/null +++ b/website/src/node/index.ts @@ -0,0 +1,7 @@ +export * from './handlers'; +export * from './highlight-plugin'; +export * from './kit-docs-plugin'; +export { kitDocsPlugin as default } from './kit-docs-plugin'; +export * from './markdown-plugin'; +export * from './markdown-plugin/parser'; +export { kebabToTitleCase } from './utils/string'; diff --git a/website/src/node/kit-docs-plugin.ts b/website/src/node/kit-docs-plugin.ts new file mode 100644 index 0000000..75f8f09 --- /dev/null +++ b/website/src/node/kit-docs-plugin.ts @@ -0,0 +1,64 @@ +import { resolve } from 'path'; +import { type HighlighterOptions } from 'shiki'; +import { type Plugin } from 'vite'; + +import { kitDocsHighlightPlugin } from './highlight-plugin'; +import { type MarkdownPluginOptions, kitDocsMarkdownPlugin } from './markdown-plugin'; + +const __cwd = process.cwd(); + +export type KitDocsPluginOptions = { + highlight?: false; + shiki?: HighlighterOptions; + markdown?: MarkdownPluginOptions; +}; + +export const kitDocsPlugin = (options: KitDocsPluginOptions = {}): Plugin[] => + [ + corePlugin(), + options.highlight !== false && kitDocsHighlightPlugin(options.shiki), + kitDocsMarkdownPlugin({ ...options.markdown, shiki: options.shiki }), + ].filter(Boolean) as Plugin[]; + +function corePlugin(): Plugin { + return { + name: '@svelteness/kit-docs', + enforce: 'pre', + config(config) { + const userAlias = config.resolve?.alias; + + const aliasKeys: string[] = !Array.isArray(userAlias) + ? Object.keys(userAlias ?? {}) + : userAlias.map((alias) => alias.find) ?? []; + + const hasAlias = (alias: string) => aliasKeys.includes(alias); + + const alias = { + $fonts: resolve(__cwd, 'src/fonts'), + $img: resolve(__cwd, 'src/img'), + $kitDocs: resolve(__cwd, 'src/kit-docs'), + }; + + for (const find of Object.keys(alias)) { + if (hasAlias(find)) { + delete alias[find]; + } + } + + return { + optimizeDeps: { + include: ['shiki'], + exclude: ['@svelteness/kit-docs'], + }, + resolve: { + alias, + }, + build: { + rollupOptions: { + external: ['@svelteness/kit-docs/node'], + }, + }, + }; + }, + }; +} diff --git a/website/src/node/markdown-plugin/index.ts b/website/src/node/markdown-plugin/index.ts new file mode 100644 index 0000000..b87997e --- /dev/null +++ b/website/src/node/markdown-plugin/index.ts @@ -0,0 +1,192 @@ +import { type FilterPattern, createFilter, normalizePath } from '@rollup/pluginutils'; +import { globbySync } from 'globby'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { type Plugin } from 'vite'; + +import { isLocalEnv } from '../utils/env'; +import { getFileNameFromPath } from '../utils/path'; +import { + type MarkdownComponents, + type MarkdownParser, + type MarkdownParserOptions, + type ParseMarkdownOptions, + AddTopLevelHtmlTags, + clearMarkdownCaches, + createMarkdownParser, + MarkdownComponentContainer, + parseMarkdownToSvelte, +} from './parser'; + +const PLUGIN_NAME = '@svelteness/markdown' as const; + +const __cwd = process.cwd(); +// @ts-ignore +const __dirname = fileURLToPath(import.meta.url); + +export type MarkdownPluginOptions = MarkdownParserOptions & { + /** + * The markdown files to be parsed and rendered as Svelte components. + * + * @defaultValue /\+page\.md($|\?)/ + */ + include?: FilterPattern; + /** + * The markdown files to _not_ be parsed. + * + * @defaultValue `null` + */ + exclude?: FilterPattern; + /** + * A glob pointing to Svelte component files that will be imported into every single + * markdown file. + * + * @defaultValue 'src/kit-docs/**\/[^_]*.svelte' + */ + globalComponents?: string; + /** + * Add custom top-level tags (e.g., ``, `'].join('\n'), + ); + } + + hoistedTags.push(...(options.topLevelHtmlTags?.({ fileName, filePath, meta }) ?? [])); + + if (options.globalComponentFiles) { + addGlobalImports(hoistedTags, options.globalComponentFiles); + } + + const component = + dedupeHoistedTags(hoistedTags).join('\n') + `\n\n${uncommentTemplateTags(html)}`; + + const result: ParseMarkdownToSvelteResult = { + component, + meta, + }; + + svelteCache.set(cacheKey, result); + return result; +} + +function addGlobalImports(tags: string[], files: string[]) { + const globalImports = files + .map((filePath) => { + const componentName = getFileNameFromPath(filePath); + return `import ${componentName} from '/${filePath.replace(/^\//, '')}';`; + }) + .join('\n'); + + tags.push([''].join('\n')); +} + +const frontmatterCache = new LRUCache({ max: 1024 }); +export function getFrontmatter(source: string): Record { + const cacheKey = hashString(source); + + if (frontmatterCache.has(cacheKey)) return frontmatterCache.get(cacheKey)!; + + const { data: frontmatter } = matter(source, { + excerpt_separator: '', + engines: { + toml: toml.parse.bind(toml), + }, + }); + + frontmatterCache.set(cacheKey, frontmatter ?? {}); + return frontmatter ?? {}; +} + +const mdCache = new LRUCache({ max: 1024 }); +export function parseMarkdown( + parser: MarkdownParser, + source: string, + filePath: string, + options: ParseMarkdownOptions = {}, +): ParsedMarkdownResult { + const isProd = options.mode === 'production'; + const cacheKey = !isProd ? hashString(filePath + source) : ''; + + if (!isProd && mdCache.has(cacheKey)) return mdCache.get(cacheKey)!; + + const { + data: frontmatter, + content, + excerpt, + } = matter(source, { + excerpt_separator: '', + engines: { + toml: toml.parse.bind(toml), + }, + }); + + const parserEnv: MarkdownParserEnv = { + filePath, + frontmatter, + }; + + let html = parser.render(content, parserEnv); + + const excerptHtml = parser.render(excerpt ?? ''); + + if (options.escapeConstants) { + html = preventViteReplace(html, options.define); + } + + const { headers = [], importedFiles = [], links = [], title = '' } = parserEnv; + + const _title = frontmatter.title ?? title; + const description = frontmatter.description; + + const result: ParsedMarkdownResult = { + content, + html, + links, + importedFiles, + env: parserEnv, + meta: { + excerpt: excerptHtml, + headers, + title: _title, + description, + frontmatter, + lastUpdated: Math.round(fs.statSync(filePath).mtimeMs), + }, + }; + + mdCache.set(cacheKey, result); + return result; +} + +const OPENING_SCRIPT_TAG_RE = /<\s*script[^>]*>/; +const OPENING_SCRIPT_MODULE_TAG_RE = /<\s*script[^>]*\scontext="module"\s*[^>]*>/; +const CLOSING_SCRIPT_TAG_RE = /<\/script>/; +const OPENING_STYLE_TAG_RE = /<\s*style[^>]*>/; +const CLOSING_STYLE_TAG_RE = /<\/style>/; +const OPENING_SVELTE_HEAD_TAG_RE = /<\s*svelte:head[^>]*>/; +const CLOSING_SVELTE_HEAD_TAG_RE = /<\/svelte:head>/; +function dedupeHoistedTags(tags: string[] = []): string[] { + const dedupe = new Map(); + + const merge = (key: string, tag: string, openingTagRe: RegExp, closingTagRE: RegExp) => { + if (!dedupe.has(key)) { + dedupe.set(key, tag); + return; + } + + const block = dedupe.get(key)!; + dedupe.set(key, block.replace(closingTagRE, tag.replace(openingTagRe, ''))); + }; + + tags.forEach((tag) => { + if (OPENING_SCRIPT_MODULE_TAG_RE.test(tag)) { + merge('module', tag, OPENING_SCRIPT_MODULE_TAG_RE, CLOSING_SCRIPT_TAG_RE); + } else if (OPENING_SCRIPT_TAG_RE.test(tag)) { + merge('script', tag, OPENING_SCRIPT_TAG_RE, CLOSING_SCRIPT_TAG_RE); + } else if (OPENING_STYLE_TAG_RE.test(tag)) { + merge('style', tag, OPENING_STYLE_TAG_RE, CLOSING_STYLE_TAG_RE); + } else if (OPENING_SVELTE_HEAD_TAG_RE.test(tag)) { + merge('svelte:head', tag, OPENING_SVELTE_HEAD_TAG_RE, CLOSING_SVELTE_HEAD_TAG_RE); + } else { + // Treat unknowns as unique and leave them as-is. + dedupe.set(Symbol(), tag); + } + }); + + return Array.from(dedupe.values()); +} + +export function clearMarkdownCaches() { + frontmatterCache.clear(); + mdCache.clear(); + svelteCache.clear(); +} diff --git a/website/src/node/markdown-plugin/parser/plugins/anchorPlugin.ts b/website/src/node/markdown-plugin/parser/plugins/anchorPlugin.ts new file mode 100644 index 0000000..7e3964f --- /dev/null +++ b/website/src/node/markdown-plugin/parser/plugins/anchorPlugin.ts @@ -0,0 +1,18 @@ +import type { PluginSimple } from 'markdown-it'; +import rawAnchorPlugin from 'markdown-it-anchor'; + +import { slugify } from '../utils/slugify'; + +export const anchorPlugin: PluginSimple = (parser) => { + return rawAnchorPlugin(parser, { + level: [2, 3, 4, 5, 6], + slugify, + permalink: rawAnchorPlugin.permalink.ariaHidden({ + class: 'header-anchor', + symbol: '#', + space: true, + placement: 'before', + // renderAttrs: () => ({ 'sveltekit:noscroll': '' }) + }), + }); +}; diff --git a/website/src/node/markdown-plugin/parser/plugins/codePlugin/codePlugin.ts b/website/src/node/markdown-plugin/parser/plugins/codePlugin/codePlugin.ts new file mode 100644 index 0000000..29f8696 --- /dev/null +++ b/website/src/node/markdown-plugin/parser/plugins/codePlugin/codePlugin.ts @@ -0,0 +1,77 @@ +import type { PluginSimple } from 'markdown-it'; + +import { uncommentTemplateTags } from '../../utils/htmlEscape'; +import { resolveHighlightLines } from './resolveHighlightLines'; +import { resolveLanguage } from './resolveLanguage'; + +/** + * Plugin to enable styled code fences with line numbers, syntax highlighting, etc. + */ +export const codePlugin: PluginSimple = (parser) => { + parser.renderer.rules.code_inline = (tokens, idx) => { + const token = tokens[idx]; + const code = token.content; + const props = [`code={${JSON.stringify(code)}}`].join(' '); + return ``; + }; + + // Override default fence renderer. + parser.renderer.rules.fence = (tokens, idx, options) => { + const token = tokens[idx]; + + // Get token info. + const info = token.info ? parser.utils.unescapeAll(token.info).trim() : ''; + + // Resolve language from token info. + const language = resolveLanguage(info); + + // Get un-escaped code content. + const content = uncommentTemplateTags(token.content); + + // Try to get highlighted code. + const html = + options.highlight?.(content, language.name, '') || parser.utils.escapeHtml(content); + + const code = html.replace(/\sclass="shiki" style=".*?"/, '').trim(); + + const rawCode = token.content + .replace(/ + + + + + + diff --git a/website/src/routes/+page.js b/website/src/routes/+page.js new file mode 100644 index 0000000..7953927 --- /dev/null +++ b/website/src/routes/+page.js @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; + +export const prerender = true; + +/** @type {import('@sveltejs/kit').PageLoad} */ +export async function load() { + throw redirect(307, `/latest/get-started`); +} diff --git a/website/src/routes/+page.md.tmp.a b/website/src/routes/+page.md.tmp.a new file mode 100644 index 0000000..08734b9 Binary files /dev/null and b/website/src/routes/+page.md.tmp.a differ diff --git a/website/src/routes/kit-docs/[dir].sidebar/+server.js b/website/src/routes/kit-docs/[dir].sidebar/+server.js new file mode 100644 index 0000000..bf8ceb7 --- /dev/null +++ b/website/src/routes/kit-docs/[dir].sidebar/+server.js @@ -0,0 +1,5 @@ +import { createSidebarRequestHandler } from '../../../node/handlers'; + +export const prerender = false; + +export const GET = createSidebarRequestHandler(); diff --git a/website/src/routes/kit-docs/[slug].meta/+server.js b/website/src/routes/kit-docs/[slug].meta/+server.js new file mode 100644 index 0000000..e7e966c --- /dev/null +++ b/website/src/routes/kit-docs/[slug].meta/+server.js @@ -0,0 +1,5 @@ +import { createMetaRequestHandler } from '../../../node/handlers'; + +export const prerender = true; + +export const GET = createMetaRequestHandler(); diff --git a/website/src/routes/latest/+page.js b/website/src/routes/latest/+page.js new file mode 100644 index 0000000..7953927 --- /dev/null +++ b/website/src/routes/latest/+page.js @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; + +export const prerender = true; + +/** @type {import('@sveltejs/kit').PageLoad} */ +export async function load() { + throw redirect(307, `/latest/get-started`); +} diff --git a/website/src/routes/latest/api/+page.md b/website/src/routes/latest/api/+page.md new file mode 100644 index 0000000..3d4889d --- /dev/null +++ b/website/src/routes/latest/api/+page.md @@ -0,0 +1,317 @@ +--- +title: API +--- + +# API + +:::info + +All functions are curried, Remeda-style, so if you see `f(dataIn, ...others)`, it can be called with either `f(dataIn, ...others)` or `f(...others)(dataIn)`. + +::: + +## Importing + +`updeep-remeda` exports a default function that is an alias to `u.update` and +has all the other functions available as props. + +``` +import u from '@yanick/updeep-remeda'; + +const foo = u({a:1}, { a: x => x + 1 }); + +const bar = u.updateIn({ a: { b: 2 } }, 'a.b', 3 ); +``` + +Or you can import the functions piecemeal: + +``` +import { updateIn, omit } from '@yanick/updeep-remeda'; +``` + + +## `u(dataIn, updates)` +## `u.update(dataIn, updates)` + +Update as many values as you want, as deeply as you want. The `updates` parameter can either be an object, a function, or a value. Everything returned from `u` is frozen recursively. + +If `updates` is an object, for each key/value, it will apply the updates specified in the value to `object[key]`. + +If `updates` is a function, it will call the function with `object` and return the value. + +If `updates` is a value, it will return that value. + +Sometimes, you may want to set an entire object to a property, or a function. In that case, you'll need to use a function to return that value, otherwise it would be interpreted as an update. Ex. `function() { return { a: 0 }; }`. + +Also available at `u.update(...)`. + +### Simple update + +Object properties: + +```js +const person = { + name: { + first: "Jane", + last: "West", + }, +}; + +const result = u(person, { name: { first: "Susan" } }); + +expect(result).to.eql({ name: { first: "Susan", last: "West" } }); +``` + +Array elements: + +```js +const scoreboard = { + scores: [12, 28], +}; + +const result = u(scoreboard, { scores: { 1: 36 } }); + +expect(result).to.eql({ scores: [12, 36] }); +``` + +### Multiple updates + +```js +const person = { + name: { + first: "Mike", + last: "Smith", + }, + scores: [12, 28], +}; + +const result = u(person, { name: { last: "Jones" }, scores: { 1: 36 } }); + +expect(result).to.eql({ + name: { first: "Mike", last: "Jones" }, + scores: [12, 36], +}); +``` + +### Use a function + +```js +const increment = (i) => i + 1; + +var scoreboard = { + scores: { + team1: 0, + team2: 0, + }, +}; + +const result = u(scoreboard, { scores: { team2: increment } }); + +expect(result).to.eql({ scores: { team1: 0, team2: 1 } }); +``` + +### Array Manipulation + +Non-trivial array manipulations, such as element removal/insertion/sorting, can be implemented with functions. Because there are so many possible manipulations, we don't provide any helpers and leave this up to you. Simply ensure your function is pure and does not mutate its arguments. + +```js +function addTodo(todos) { + return [].concat(todos, [{ done: false }]); +} + +const state = { + todos: [{ done: false }, { done: false }], +}; + +const result = u({ todos: addTodo }, state); + +expect(result).to.eql({ + todos: [{ done: false }, { done: false }, { done: false }], +}); +``` + +Remeda is one of the many libraries providing good utility functions for +such manipulations. + +```js +import { reject, concat, prop } from "remeda"; + +let state = { + todos: [{ done: true }, { done: false }], +}; + +// add a new todo +state = u(state, { todos: concat({ done: false }) }); +expect(state).to.eql({ + todos: [{ done: true }, { done: false }, { done: false }], +}); + +// remove all done todos +state = u(state, { todos: reject(prop("done")) }); +expect(state).to.eql({ todos: [{ done: false }, { done: false }] }); +``` + +### Default input data + +When the input data is null or undefined, updeep uses a empty plain object. + +```javascript +const result = u(null, { foo: "bar" }); +expect(result).to.eql({ foo: "bar" }); +``` + +### Partial application + +```js +const inc = (i) => i + 1; + +const addOneYear = u({ age: increment }); +const result = addOneYear({ name: "Shannon Barnes", age: 62 }); + +expect(result).to.eql({ name: "Shannon Barnes", age: 63 }); +``` + +## `u.freeze(dataIn)` + +Freeze your initial state to protect against mutations. Only performs the freezing in development, and returns the original object unchanged in production. + +```js +const state = u.freeze({ someKey: "Some Value" }); +state.someKey = "Mutate"; // ERROR in development +``` + +## `u.updateIn(dataIn, path, value)` + +Update a single value with a simple string or array path. Can be use to update nested objects, arrays, or a combination. Can also be used to update every element of a nested array with `'*'`. + +```js +const result = u.updateIn( + { bunny: { color: "black" } }, + "bunny.color", + "brown" +); + +expect(result).to.eql({ bunny: { color: "brown" } }); +``` + +```js +const result = u.updateIn( + "0.1.color", + "brown" +)([[{ color: "blue" }, { color: "red" }], []]); + +expect(result).to.eql([[{ color: "blue" }, { color: "brown" }], []]); +``` + +```js +const incr = (i) => i + 1; + +const result = u.updateIn("bunny.age", incr)({ bunny: { age: 2 } }); + +expect(result).to.eql({ bunny: { age: 3 } }); +``` + +```js +const result = u( + { pets: [{ bunny: { age: 2 } }] } + { pets: u.updateIn([0, "bunny", "age"], 3) }, +); + +expect(result).to.eql({ pets: [{ bunny: { age: 3 } }] }); +``` + +```js +const result = u.updateIn( + "todos.*.done", + true +)({ + todos: [{ done: false }, { done: false }], +}); + +expect(result).to.eql({ + todos: [{ done: true }, { done: true }], +}); +``` + +## `u.constant(dataIn)` + +Sometimes, you want to replace an object outright rather than merging it. +You'll need to use a function that returns the new object. +`u.constant` creates that function for you. + +```js +const user = { + name: "Mitch", + favorites: { + band: "Nirvana", + movie: "The Matrix", + }, +}; + +const newFavorites = { + band: "Coldplay", +}; + +const result = u(user, { favorites: u.constant(newFavorites) }); + +expect(result).to.eql({ name: "Mitch", favorites: { band: "Coldplay" } }); +``` + +```js +const alwaysFour = u.constant(4); +expect(alwaysFour(32)).to.eql(4); +``` + +## `u.if(dataIn, predicate, updates)` + +Apply `updates` if `predicate` is truthy, or if `predicate` is a function. +It evaluates to truthy when called with `object`. + +```js +function isEven(x) { + return x % 2 === 0; +} +function increment(x) { + return x + 1; +} + +const result = u({ value: 2 }, { value: u.if(isEven, increment) }); + +expect(result).to.eql({ value: 3 }); +``` + +## `u.filter(arrayIn, predicate)` + +## `u.reject(arrayIn, predicate)` + +## `u.pickBy(objectIn, predicate)` + +## `u.omitBy(objectIn, predicate)` + +## `u.pick(objectIn, keys)` + +## `u.omit(objectIn, keys)` + +Essentially the same as their Remeda counterparts. The difference being +that if the transformation results in no change, the original object/array is +returned. + +## `u.matches(dataIn, condition)` + +Do a deep comparison with `condition`, and returns +`true` if the `dataIn` object matches. + +Scalar values are verified for equality (i.e., `{foo: 12}` +will verify that the object has the prop `foo` set to `12`), and +functions are going to be invoked with the object value of the object and +expected to return `true` upon matching. + +```js +u.matches( + { name: "Bob", age: 32, address: "..." }, + { + name: "Bob", + age: (age) => age > 30, + } +); // true +``` diff --git a/website/src/routes/latest/get-started/+page.md b/website/src/routes/latest/get-started/+page.md new file mode 100644 index 0000000..74c7bf4 --- /dev/null +++ b/website/src/routes/latest/get-started/+page.md @@ -0,0 +1,111 @@ +--- +title: Get Started +--- + +# updeep-remeda + +> Easily update nested frozen objects and arrays in a declarative and immutable +> manner. + +## About + +:::info + +This is a fork of the main updeep package. For ease of reading — not to +mention ease of shamelessly lifting large pieces of the original +documentation — in this documentation all mentions of `updeep` refers to this +fork. + +::: + +updeep makes updating deeply nested objects/arrays painless by allowing you to +declare the updates you would like to make and it will take care of the rest. It +will recursively return the same instance if no changes have been made, making +it ideal for using reference equality checks to detect changes. + +Because of this, everything returned by updeep is frozen. Not only that, but +updeep assumes that every object passed in to update is immutable, so it may +freeze objects passed in as well. Note that the freezing only happens in +development. + +This fork of updeep requires Remeda, but works very well with any other utility function ([lodash], [Ramda], etc). + +## Differences with the original Updeep + +* Under the hood, the use of lodash has +been replaced by Remeda (for better type support and tree-shaking abilities). + +* The codebase has been ported to TypeScript (mostly for the lulz). + +* The order of parameters in the non-curryied invocation of functions has been modified. In the original updeep the input object is the last parameter, whereas here it's the first. + +```js +// original updeep +const dataIn = { a: 1, b: 2 }; + +let dataOut = u({ c: 3 }, dataIn); // simple call +dataOut = u({ c: 3 })(dataIn); // curried + +// updeep-remeda +dataOut = u(dataIn, { c: 3 }); // simple call +dataOut = u({ c: 3 })(dataIn); // curried +``` + +* `withDefault` has been removed as the behavior can be implemented using + Remeda's `pipe`, or a simple `??`. + +* `u.omitted` has been renamed `u.skip`. + +## Installation + +```bash +$ npm install @yanick/updeep-remeda +# or +$ pnpm install @yanick/updeep-remeda +``` + +## Full example + +```js +import u from "@yanick/updeep-remeda"; + +const person = { + name: { first: "Bill", last: "Sagat" }, + children: [ + { name: "Mary-Kate", age: 7 }, + { name: "Ashley", age: 7 }, + ], + todo: ["Be funny", "Manage household"], + email: "bill@example.com", + version: 1, +}; + +const inc = (i) => i + 1; + +const eq = (x) => (y) => x === y; + +const newPerson = u(person, { + // Change first name + name: { first: "Bob" }, + // Increment all children's ages + children: u.map({ age: inc }), + // Update email + email: "bob@example.com", + // Remove todo + todo: u.reject(eq("Be funny")), + // Increment version + version: inc, +}); +// => { +// name: { first: 'Bob', last: 'Sagat' }, +// children: [ +// { name: 'Mary-Kate', age: 8 }, +// { name: 'Ashley', age: 8 } +// ], +// todo: [ +// 'Manage household' +// ], +// email: 'bob@example.com', +// version: 2 +//} +``` diff --git a/website/static/favicon.png b/website/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/website/static/favicon.png differ diff --git a/website/svelte.config.js b/website/svelte.config.js new file mode 100644 index 0000000..563028a --- /dev/null +++ b/website/svelte.config.js @@ -0,0 +1,11 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + extensions: ['.svelte', '.md'], + kit: { + adapter: adapter({ strict: false }) + } +}; + +export default config; diff --git a/website/tsconfig-build.json b/website/tsconfig-build.json new file mode 100644 index 0000000..04418a3 --- /dev/null +++ b/website/tsconfig-build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "preserveWatchOutput": true, + "noEmit": false, + "importHelpers": true, + "incremental": false, + "sourceMap": true, + "useDefineForClassFields": false + } +} diff --git a/website/tsconfig.json b/website/tsconfig.json new file mode 100644 index 0000000..c2d3dc6 --- /dev/null +++ b/website/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$img/*": ["./src/img/*"] + } + } +} diff --git a/website/vite.config.js b/website/vite.config.js new file mode 100644 index 0000000..9a7e5fb --- /dev/null +++ b/website/vite.config.js @@ -0,0 +1,30 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import kitDocs from '@svelteness/kit-docs/node'; +import { resolve } from 'path'; +import icons from 'unplugin-icons/vite'; + +const config = { + resolve: { + alias: { + $fonts: resolve(process.cwd(), 'src/lib/fonts'), + }, + }, + server: { + fs: { + strict: false, + }, + }, + plugins: [ + + icons({ compiler: 'svelte' }), + kitDocs({ + markdown: { + shiki: { + theme: 'material-ocean', + }, + }, + }), + sveltekit()] +}; + +export default config;