Compare commits

..

No commits in common. "f3a8fa17e6742221f1c47c1dfe8d4473410b5efe" and "2a6523b9f132c51ce94bef9a90f58a1c2047dbbc" have entirely different histories.

37 changed files with 5073 additions and 2 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
.svelte-kit/
.vscode/
node_modules/
test-results/

24
.gitignore vendored Normal file
View 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

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

8
.prettierrc Normal file
View 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
View 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
View 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"]

View File

@ -35,7 +35,7 @@ Mozilla Public License Version 2.0
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"

21
LICENSE.up-to-ea30f9 Normal file
View 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.

View File

@ -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

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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);
});
});

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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');

View 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
View 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
View 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 &#x2764; 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}'],
},
});