Compare commits
No commits in common. "f3a8fa17e6742221f1c47c1dfe8d4473410b5efe" and "2a6523b9f132c51ce94bef9a90f58a1c2047dbbc" have entirely different histories.
f3a8fa17e6
...
2a6523b9f1
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
.svelte-kit/
|
||||
.vscode/
|
||||
|
||||
node_modules/
|
||||
test-results/
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# tests
|
||||
test-results
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
29
.vscode/settings.json
vendored
Normal file
29
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"devcontainers",
|
||||
"doctypes",
|
||||
"esbenp",
|
||||
"iconify",
|
||||
"pandoc",
|
||||
"pdoc",
|
||||
"rehype",
|
||||
"resumarkdown",
|
||||
"résumé",
|
||||
"spacebar",
|
||||
"tablist",
|
||||
"testid",
|
||||
"texlive",
|
||||
"textbox"
|
||||
],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"files.insertFinalNewline": true,
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"[javascript][json][jsonc][typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
}
|
||||
}
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
# obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
FROM node:22.11.0-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install && npm run build
|
||||
|
||||
FROM node:22.11.0-bookworm-slim AS runner
|
||||
LABEL maintainer="Nicola Clark <nicola@slottedspoon.dev>"
|
||||
EXPOSE 3000
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY --from=builder /app/build ./build
|
||||
RUN npm ci --omit dev
|
||||
CMD ["node", "build"]
|
21
LICENSE.up-to-ea30f9
Normal file
21
LICENSE.up-to-ea30f9
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Nicola Clark
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
22
README.md
22
README.md
@ -1,3 +1,23 @@
|
||||
# resumarkdown
|
||||
|
||||
Create a résumé with Markdown and CSS
|
||||
Create a resume with Markdown and CSS
|
||||
|
||||
## Previous location
|
||||
|
||||
This repository was previously hosted on
|
||||
[GitHub](https://github.com/tweakdeveloper/resumarkdown). Due to GitHub's
|
||||
expansion of Copilot (see [the License section](#license) for further details),
|
||||
it has been moved to this self-hosted repository.
|
||||
|
||||
## License
|
||||
|
||||
This repository's code was licensed under the terms of
|
||||
[the MIT license](LICENSE.up-to-ea30f9) up to and including commit ea30f9. You
|
||||
may apply this license's terms to all work that appears in the repository prior
|
||||
to this commit ("this commit" referring to the commit immediately following
|
||||
ea30f9).
|
||||
|
||||
From this commit forward, the code in this repo is licensed under the terms of
|
||||
the [Mozilla Public License 2.0](https://mozilla.org/MPL/2.0/). This change was
|
||||
made due to GitHub's lack of transparency vis-à-vis Copilot's use of public
|
||||
repositories for the purposes of training their LLM.
|
||||
|
3891
package-lock.json
generated
Normal file
3891
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "resumarkdown",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "MPL-2.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npm run test:integration && npm run test:unit",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:integration": "playwright test",
|
||||
"test:unit": "vitest",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"hast": "^0.0.2",
|
||||
"hastscript": "^9.0.0",
|
||||
"iconify-icon": "^2.1.0",
|
||||
"rehype-document": "^7.0.3",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"ua-parser-js": "^1.0.39",
|
||||
"unified": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.2.9",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/user-agents": "^1.0.4",
|
||||
"less": "^4.2.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^5.0.0-next.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"typescript": "^5.5.0",
|
||||
"user-agents": "^1.1.325",
|
||||
"vite": "^5.4.4",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
28
playwright.config.ts
Normal file
28
playwright.config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
webServer: {
|
||||
command: 'npm run build && npm run preview',
|
||||
port: 4173,
|
||||
},
|
||||
expect: {
|
||||
timeout: 2.5 * 1000,
|
||||
},
|
||||
testDir: 'tests',
|
||||
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
||||
timeout: 5 * 1000,
|
||||
use: {
|
||||
viewport: {
|
||||
height: 900,
|
||||
width: 1600,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
19
src/app.d.ts
vendored
Normal file
19
src/app.d.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
17
src/app.html
Normal file
17
src/app.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
25
src/app.less
Normal file
25
src/app.less
Normal file
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@import 'styles.less';
|
||||
|
||||
@import (css) url('https://fonts.googleapis.com/css2?family=Rubik:wght@400..700&display=swap');
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: @fonts-sans;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: @font-w-bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
13
src/index.test.ts
Normal file
13
src/index.test.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
23
src/lib/components/code-input.svelte
Normal file
23
src/lib/components/code-input.svelte
Normal file
@ -0,0 +1,23 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
code: string;
|
||||
}
|
||||
|
||||
let { code = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea bind:value={code}></textarea>
|
||||
|
||||
<style lang="less">
|
||||
textarea {
|
||||
.container-content();
|
||||
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
42
src/lib/components/editor.svelte
Normal file
42
src/lib/components/editor.svelte
Normal file
@ -0,0 +1,42 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
import { pane as navStore, type Pane } from '$lib/stores/nav.js';
|
||||
|
||||
interface Props {
|
||||
pane: Pane;
|
||||
testid?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { pane, testid, children }: Props = $props();
|
||||
|
||||
// don't show pane if we're not the current pane
|
||||
let hidden: boolean = $derived(pane !== $navStore);
|
||||
</script>
|
||||
|
||||
<div id={`pane-${pane}`} data-testid={testid} role="tabpanel" class:hidden>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
div {
|
||||
.container();
|
||||
|
||||
grid-area: editor;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
border-right: 2px solid @color-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
33
src/lib/components/headline.svelte
Normal file
33
src/lib/components/headline.svelte
Normal file
@ -0,0 +1,33 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<h1>{@render children()}</h1>
|
||||
</header>
|
||||
|
||||
<style lang="less">
|
||||
h1 {
|
||||
background-color: @color-dark;
|
||||
font-size: @font-s-lg;
|
||||
color: @color-light;
|
||||
padding: @padding-md;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header {
|
||||
grid-area: headline;
|
||||
}
|
||||
</style>
|
72
src/lib/components/nav-item.svelte
Normal file
72
src/lib/components/nav-item.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
import { pane, type Pane } from '$lib/stores/nav.js';
|
||||
|
||||
interface Props {
|
||||
destination: Pane;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { destination, children }: Props = $props();
|
||||
|
||||
let selected: boolean = $derived(destination === $pane);
|
||||
|
||||
function handleKey({ key }: KeyboardEvent) {
|
||||
if (key === ' ' || key === 'Enter' || key === 'Spacebar') {
|
||||
navigate();
|
||||
}
|
||||
}
|
||||
|
||||
function navigate() {
|
||||
pane.set(destination);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li
|
||||
aria-controls={`pane-${destination}`}
|
||||
aria-selected={selected}
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
class:selected
|
||||
onclick={navigate}
|
||||
onkeyup={handleKey}
|
||||
>
|
||||
{@render children()}
|
||||
</li>
|
||||
|
||||
<style lang="less">
|
||||
li {
|
||||
.selectable();
|
||||
|
||||
@separator: 1px solid @color-dark;
|
||||
|
||||
&.selected {
|
||||
background-color: darken(@color-light, 20%);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: darken(@color-light, 10%);
|
||||
}
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: @separator;
|
||||
}
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
flex: 1;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: none;
|
||||
border-right: @separator;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
34
src/lib/components/nav-toggle.svelte
Normal file
34
src/lib/components/nav-toggle.svelte
Normal file
@ -0,0 +1,34 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
navOpen: boolean;
|
||||
}
|
||||
|
||||
let { navOpen = $bindable() }: Props = $props();
|
||||
|
||||
// if nav is open, we should "hide" it on press. if not, we should "show" it.
|
||||
let action: string = $derived(navOpen ? 'hide' : 'show');
|
||||
|
||||
function toggle() {
|
||||
navOpen = !navOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={toggle}>{action} navigation</button>
|
||||
|
||||
<style lang="less">
|
||||
button {
|
||||
background-color: lighten(@color-dark, 10%);
|
||||
border: none;
|
||||
color: @color-light;
|
||||
font-size: @font-s-md;
|
||||
font-weight: @font-w-semibold;
|
||||
padding: @padding-sm;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
56
src/lib/components/nav-tree.svelte
Normal file
56
src/lib/components/nav-tree.svelte
Normal file
@ -0,0 +1,56 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
import NavToggle from './nav-toggle.svelte';
|
||||
|
||||
interface Props {
|
||||
mobile?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { mobile = true, children }: Props = $props();
|
||||
|
||||
let open: boolean = $state(false);
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
{#if mobile}
|
||||
<NavToggle bind:navOpen={open} />
|
||||
{/if}
|
||||
{#if open || !mobile}
|
||||
<ul role="tablist" transition:slide={{ duration: 300 }}>
|
||||
{@render children()}
|
||||
</ul>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style lang="less">
|
||||
nav {
|
||||
grid-area: navtree;
|
||||
}
|
||||
|
||||
ul {
|
||||
@border: 2px solid @color-dark;
|
||||
|
||||
border-bottom: @border;
|
||||
box-sizing: border-box;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
align-items: stretch;
|
||||
border-right: @border;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
102
src/lib/components/preview.svelte
Normal file
102
src/lib/components/preview.svelte
Normal file
@ -0,0 +1,102 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import 'iconify-icon';
|
||||
|
||||
import { pane } from '$lib/stores/nav.js';
|
||||
import renderPreview from '$lib/render-preview';
|
||||
|
||||
interface Props {
|
||||
mobile?: boolean;
|
||||
markdown: string;
|
||||
stylesheet: string;
|
||||
}
|
||||
|
||||
let { mobile = true, markdown, stylesheet }: Props = $props();
|
||||
|
||||
let hidden: boolean = $derived(mobile ? $pane !== 'preview' : true);
|
||||
|
||||
let output: Promise<string> = $derived(renderPreview(markdown, stylesheet));
|
||||
|
||||
let previewFrame = $state<HTMLIFrameElement | undefined>();
|
||||
|
||||
async function performRender(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
previewFrame?.contentWindow?.postMessage('print');
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class:hidden class:mobile data-testid="preview-pane">
|
||||
{#await output}
|
||||
<p>processing...</p>
|
||||
{:then result}
|
||||
<iframe title="résumé preview" srcdoc={result} bind:this={previewFrame}></iframe>
|
||||
<button type="submit" onclick={performRender}>
|
||||
<span>download</span>
|
||||
<iconify-icon icon="ion:download-outline" height="1.25em"></iconify-icon>
|
||||
</button>
|
||||
{:catch err}
|
||||
<p style:color="red">error: {err}!</p>
|
||||
{/await}
|
||||
</main>
|
||||
|
||||
<style lang="less">
|
||||
main {
|
||||
.container();
|
||||
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: @padding-lg-y;
|
||||
grid-area: preview;
|
||||
|
||||
button {
|
||||
.selectable();
|
||||
|
||||
background-color: lighten(@color-dark, 10%);
|
||||
border: 1px solid @color-dark;
|
||||
border-radius: @border-r-xl;
|
||||
color: @color-light;
|
||||
font-size: @font-s-sm;
|
||||
font-weight: @font-w-semibold;
|
||||
padding: @padding-md;
|
||||
|
||||
&:focus {
|
||||
background-color: lighten(@color-dark, 20%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: lighten(@color-dark, 15%);
|
||||
}
|
||||
|
||||
& > iconify-icon,
|
||||
& > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
iconify-icon {
|
||||
height: 1.25em;
|
||||
width: 1.25em;
|
||||
}
|
||||
}
|
||||
|
||||
iframe {
|
||||
.container-content();
|
||||
}
|
||||
|
||||
&.mobile {
|
||||
grid-area: editor;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
gap: @padding-xl-y;
|
||||
}
|
||||
}
|
||||
</style>
|
79
src/lib/render-preview.ts
Normal file
79
src/lib/render-preview.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { type Element as HastElement, type Root as HastRoot } from 'hast';
|
||||
import { h } from 'hastscript';
|
||||
import rehypeDocument from 'rehype-document';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { unified, type Plugin } from 'unified';
|
||||
|
||||
const printScript = `
|
||||
window.addEventListener('message', (evt) => {
|
||||
if (evt.data === 'print') {
|
||||
window.print();
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
const removePrintMarginsStyle = `
|
||||
@page {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const injectPrintScript: Plugin<[], HastRoot> = function () {
|
||||
return (tree) => {
|
||||
const printScriptNode = h('script', printScript);
|
||||
const htmlNode = tree.children.find(
|
||||
(node) => node.type === 'element' && node.tagName === 'html',
|
||||
) as HastElement;
|
||||
const bodyNode = htmlNode.children.find(
|
||||
(node) => node.type === 'element' && node.tagName === 'body',
|
||||
) as HastElement;
|
||||
bodyNode.children.push(printScriptNode);
|
||||
};
|
||||
};
|
||||
|
||||
const injectRemovePrintMarginsStyle: Plugin<[], HastRoot> = function () {
|
||||
return (tree) => {
|
||||
const removePrintMarginsStyleNode = h('style', removePrintMarginsStyle);
|
||||
const htmlNode = tree.children.find(
|
||||
(node) => node.type === 'element' && node.tagName === 'html',
|
||||
) as HastElement;
|
||||
const headNode = htmlNode.children.find(
|
||||
(node) => node.type === 'element' && node.tagName === 'head',
|
||||
) as HastElement;
|
||||
headNode.children.push(removePrintMarginsStyleNode);
|
||||
};
|
||||
};
|
||||
|
||||
const renderPreview = async (markdown: string, stylesheet: string): Promise<string> => {
|
||||
let allowedTags: string[] = ['body', 'head', 'html', 'style'];
|
||||
if (defaultSchema.tagNames) {
|
||||
allowedTags = [...defaultSchema.tagNames, ...allowedTags];
|
||||
}
|
||||
return String(
|
||||
await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype)
|
||||
.use(rehypeDocument, { style: stylesheet })
|
||||
.use(rehypeSanitize, {
|
||||
...defaultSchema,
|
||||
allowDoctypes: true,
|
||||
tagNames: allowedTags,
|
||||
})
|
||||
.use(injectRemovePrintMarginsStyle)
|
||||
.use(injectPrintScript)
|
||||
.use(rehypeStringify)
|
||||
.process(markdown),
|
||||
);
|
||||
};
|
||||
|
||||
export default renderPreview;
|
11
src/lib/stores/nav.ts
Normal file
11
src/lib/stores/nav.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type Pane = 'content' | 'style' | 'preview';
|
||||
|
||||
export const pane = writable<Pane>('content');
|
20
src/routes/+layout.server.ts
Normal file
20
src/routes/+layout.server.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
export async function load({ request }: RequestEvent) {
|
||||
const ua = request.headers.get('user-agent');
|
||||
|
||||
if (!ua) {
|
||||
return { mobile: true };
|
||||
}
|
||||
|
||||
const { device } = UAParser(ua);
|
||||
return { mobile: device.type === 'mobile' };
|
||||
}
|
19
src/routes/+layout.svelte
Normal file
19
src/routes/+layout.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
import '../app.less';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
143
src/routes/+page.svelte
Normal file
143
src/routes/+page.svelte
Normal file
@ -0,0 +1,143 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import type { PageData } from './$types';
|
||||
|
||||
import { pane } from '$lib/stores/nav.js';
|
||||
|
||||
import CodeInput from '$lib/components/code-input.svelte';
|
||||
import Editor from '$lib/components/editor.svelte';
|
||||
import Headline from '$lib/components/headline.svelte';
|
||||
import NavItem from '$lib/components/nav-item.svelte';
|
||||
import NavTree from '$lib/components/nav-tree.svelte';
|
||||
import Preview from '$lib/components/preview.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let mobile: boolean = $state(data.mobile);
|
||||
|
||||
function checkIsDesktop() {
|
||||
// TODO: figure out how to remove hard-coded value here
|
||||
mobile = !window.matchMedia('screen and (min-width: 1280px)').matches;
|
||||
|
||||
if (!mobile && get(pane) === 'preview') {
|
||||
pane.set('content');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
checkIsDesktop();
|
||||
|
||||
window.addEventListener('resize', checkIsDesktop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIsDesktop);
|
||||
};
|
||||
});
|
||||
|
||||
let markdown: string = $state('');
|
||||
let stylesheet: string = $state('');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>resumarkdown</title>
|
||||
</svelte:head>
|
||||
<div>
|
||||
<Headline>resumarkdown</Headline>
|
||||
<NavTree {mobile}>
|
||||
<NavItem destination="content">content</NavItem>
|
||||
<NavItem destination="style">style</NavItem>
|
||||
{#if mobile}
|
||||
<NavItem destination="preview">preview</NavItem>
|
||||
{/if}
|
||||
</NavTree>
|
||||
<Editor pane="content" testid="content-pane">
|
||||
<CodeInput bind:code={markdown} />
|
||||
</Editor>
|
||||
<Editor pane="style" testid="style-pane">
|
||||
<CodeInput bind:code={stylesheet} />
|
||||
</Editor>
|
||||
<Preview {mobile} {markdown} {stylesheet} />
|
||||
<footer>
|
||||
<p>
|
||||
made with ❤ by
|
||||
<a href="https://github.com/tweakdeveloper" title="my github profile">@tweakdeveloper</a>
|
||||
</p>
|
||||
<a href="/attributions">attributions</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
div {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'headline'
|
||||
'navtree'
|
||||
'editor'
|
||||
'footer';
|
||||
grid-template-rows: min-content min-content 1fr;
|
||||
height: 100%;
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-areas:
|
||||
'headline headline'
|
||||
'navtree preview'
|
||||
'editor preview'
|
||||
'footer footer';
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
align-items: center;
|
||||
background-color: @color-dark;
|
||||
border-top: 2px solid @color-dark;
|
||||
box-sizing: border-box;
|
||||
color: @color-light;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: @padding-md-y;
|
||||
grid-area: footer;
|
||||
justify-content: space-between;
|
||||
padding: @padding-lg;
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
flex-flow: row nowrap;
|
||||
gap: @padding-sm-y;
|
||||
padding: @padding-md;
|
||||
}
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: @color-light;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:visited {
|
||||
color: darken(@color-light, 30%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: darken(@color-light, 20%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: darken(@color-light, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
6
src/routes/attributions/+page.svelte
Normal file
6
src/routes/attributions/+page.svelte
Normal file
@ -0,0 +1,6 @@
|
||||
<!--
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
obtain one at https://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
78
src/styles.less
Normal file
78
src/styles.less
Normal file
@ -0,0 +1,78 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
// colors
|
||||
@color-accent-dark: #a8c3b5;
|
||||
@color-accent-light: #558b6e;
|
||||
@color-danger: #d64933;
|
||||
@color-dark: #2e282a;
|
||||
@color-light: #fbfbfb;
|
||||
|
||||
// fonts
|
||||
@fonts-sans: Rubik, Arial, sans-serif;
|
||||
@font-s-lg: 2rem;
|
||||
@font-s-md: 1.25rem;
|
||||
@font-s-sm: 1rem;
|
||||
@font-w-bold: 700;
|
||||
@font-w-semibold: 600;
|
||||
|
||||
// layout
|
||||
@border-r-xl: 0.75em;
|
||||
@padding-md: @padding-md-y @padding-md-x;
|
||||
@padding-md-x: 1em;
|
||||
@padding-md-y: 0.5em;
|
||||
@padding-lg: @padding-lg-y @padding-lg-x;
|
||||
@padding-lg-x: 1.5em;
|
||||
@padding-lg-y: 1em;
|
||||
@padding-sm: @padding-sm-y @padding-sm-x;
|
||||
@padding-sm-x: 0.875em;
|
||||
@padding-sm-y: 0.375em;
|
||||
@padding-xl: @padding-xl-y @padding-xl-x;
|
||||
@padding-xl-x: 2em;
|
||||
@padding-xl-y: 1.5em;
|
||||
|
||||
// sizes
|
||||
@sizes: {
|
||||
lg: 1280px;
|
||||
};
|
||||
|
||||
// utils
|
||||
.full-without-padding(@padding) {
|
||||
@result: calc(100% - @padding * 2);
|
||||
}
|
||||
|
||||
// shared sizing mixins
|
||||
.container() {
|
||||
box-sizing: border-box;
|
||||
padding: @padding-lg;
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
padding: @padding-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.container-content() {
|
||||
border: 1px solid @color-dark;
|
||||
border-radius: @border-r-xl;
|
||||
box-sizing: border-box;
|
||||
font-size: @font-s-md;
|
||||
height: 100%;
|
||||
padding: @padding-lg;
|
||||
width: 100%;
|
||||
|
||||
@media screen and (min-width: @sizes[lg]) {
|
||||
font-size: unset;
|
||||
}
|
||||
}
|
||||
|
||||
// shared interactivity mixins
|
||||
.selectable() {
|
||||
font-weight: @font-w-semibold;
|
||||
margin: 0;
|
||||
padding: @padding-md;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
33
svelte.config.js
Normal file
33
svelte.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
import { sveltePreprocess } from 'svelte-preprocess';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: sveltePreprocess({
|
||||
less: {
|
||||
prependData: `@import 'src/styles.less';`,
|
||||
},
|
||||
}),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
},
|
||||
|
||||
compilerOptions: {
|
||||
runes: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
28
tests/desktop.test.ts
Normal file
28
tests/desktop.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('desktop page has nav tree', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('desktop page does not have nav toggle', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation').getByRole('button')).toBeHidden();
|
||||
});
|
||||
|
||||
test('desktop page has two-column layout', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('content-pane')).toBeVisible();
|
||||
await expect(page.getByTestId('preview-pane')).toBeVisible();
|
||||
});
|
||||
|
||||
test('desktop page has no "preview" nav item', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('tab').filter({ hasText: 'preview' })).toBeHidden();
|
||||
});
|
41
tests/general.test.ts
Normal file
41
tests/general.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('page has headline', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('banner')).toHaveText('resumarkdown');
|
||||
});
|
||||
|
||||
test('nav items work', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
for (const tab of await page.getByRole('tab').all()) {
|
||||
await tab.click();
|
||||
await expect(page.getByTestId(`${await tab.textContent()}-pane`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('preview accepts content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByText('content').click();
|
||||
await page.getByRole('textbox').fill('# résumé');
|
||||
|
||||
const previewFrame = page.getByTitle('résumé preview').contentFrame();
|
||||
await expect(previewFrame.getByRole('heading')).toHaveText('résumé');
|
||||
});
|
||||
|
||||
test('preview accepts styles', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByText('content').click();
|
||||
await page.getByRole('textbox').fill('# blah');
|
||||
await page.getByText('style', { exact: true }).click();
|
||||
await page.getByRole('textbox').fill('h1 { color: red; }');
|
||||
|
||||
const previewFrame = page.getByTitle('résumé preview').contentFrame();
|
||||
await expect(previewFrame.getByRole('heading')).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||
});
|
44
tests/mobile.test.ts
Normal file
44
tests/mobile.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import UserAgent from 'user-agents';
|
||||
|
||||
test.use({
|
||||
viewport: { height: 2556, width: 1179 },
|
||||
userAgent: new UserAgent({ deviceCategory: 'mobile' }).toString(),
|
||||
});
|
||||
|
||||
test('mobile page has nav tree hidden by default', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation').getByRole('tablist')).toBeHidden();
|
||||
});
|
||||
|
||||
test('mobile page has nav toggle', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation').getByRole('button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('nav toggle works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByText('show navigation').click();
|
||||
await expect(page.getByRole('navigation').getByRole('tablist')).toBeVisible();
|
||||
await page.getByText('hide navigation').click();
|
||||
await expect(page.getByRole('navigation').getByRole('tablist')).toBeHidden();
|
||||
});
|
||||
|
||||
test('mobile page has single-column layout', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('content-pane')).toBeVisible();
|
||||
await expect(page.getByTestId('preview-pane')).toBeHidden();
|
||||
});
|
||||
|
||||
test('mobile page has preview nav item', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByText('show navigation').click();
|
||||
await expect(page.getByRole('tab').filter({ hasText: 'preview' })).toBeVisible();
|
||||
});
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public License,
|
||||
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
||||
* obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
},
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user