Merge of Insiders features tied to 'Black Pearl' funding goal

This commit is contained in:
squidfunk
2021-03-29 18:46:57 +02:00
parent 8677190f3f
commit ca3da9e3ca
51 changed files with 867 additions and 194 deletions

View File

@@ -38,6 +38,7 @@ export type ComponentType =
| "header-title" /* Header title */
| "header-topic" /* Header topic */
| "main" /* Main area */
| "palette" /* Color palette */
| "search" /* Search */
| "search-query" /* Search input */
| "search-result" /* Search results */
@@ -46,6 +47,7 @@ export type ComponentType =
| "source" /* Repository information */
| "tabs" /* Navigation tabs */
| "toc" /* Table of contents */
| "top" /* Back-to-top button */
/**
* A component
@@ -77,6 +79,7 @@ interface ComponentTypeMap {
"header-title": HTMLElement /* Header title */
"header-topic": HTMLElement /* Header topic */
"main": HTMLElement /* Main area */
"palette": HTMLElement /* Color palette */
"search": HTMLElement /* Search */
"search-query": HTMLInputElement /* Search input */
"search-result": HTMLElement /* Search results */
@@ -85,6 +88,7 @@ interface ComponentTypeMap {
"source": HTMLAnchorElement /* Repository information */
"tabs": HTMLElement /* Navigation tabs */
"toc": HTMLElement /* Table of contents */
"top": HTMLAnchorElement /* Back-to-top button */
}
/* ----------------------------------------------------------------------------

View File

@@ -22,4 +22,5 @@
export * from "./_"
export * from "./code"
export * from "./details"
export * from "./table"

View File

@@ -120,7 +120,7 @@ function isHidden({ viewport$ }: WatchOptions): Observable<boolean> {
distinctUntilChanged()
)
/* Compute threshold for autohiding */
/* Compute threshold for hiding */
const search$ = watchToggle("search")
return combineLatest([viewport$, search$])
.pipe(

View File

@@ -25,8 +25,10 @@ export * from "./content"
export * from "./dialog"
export * from "./header"
export * from "./main"
export * from "./palette"
export * from "./search"
export * from "./sidebar"
export * from "./source"
export * from "./tabs"
export * from "./toc"
export * from "./top"

View File

@@ -0,0 +1,147 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* 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 NON-INFRINGEMENT. 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.
*/
import {
Observable,
Subject,
fromEvent,
of
} from "rxjs"
import {
finalize,
map,
mapTo,
mergeMap,
shareReplay,
startWith,
tap
} from "rxjs/operators"
import { getElements } from "~/browser"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Palette colors
*/
export interface PaletteColor {
scheme?: string /* Color scheme */
primary?: string /* Primary color */
accent?: string /* Accent color */
}
/**
* Palette
*/
export interface Palette {
index: number /* Palette index */
color: PaletteColor /* Palette colors */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch color palette
*
* @param inputs - Color palette element
*
* @returns Color palette observable
*/
export function watchPalette(
inputs: HTMLInputElement[]
): Observable<Palette> {
const data = localStorage.getItem(__prefix("__palette"))!
const current = JSON.parse(data) || {
index: inputs.findIndex(input => (
matchMedia(input.getAttribute("data-md-color-media")!).matches
))
}
/* Emit changes in color palette */
const palette$ = of(...inputs)
.pipe(
mergeMap(input => fromEvent(input, "change")
.pipe(
mapTo(input)
)
),
startWith(inputs[Math.max(0, current.index)]),
map(input => ({
index: inputs.indexOf(input),
color: {
scheme: input.getAttribute("data-md-color-scheme"),
primary: input.getAttribute("data-md-color-primary"),
accent: input.getAttribute("data-md-color-accent")
}
} as Palette)),
shareReplay(1)
)
/* Persist preference in local storage */
palette$.subscribe(palette => {
localStorage.setItem(__prefix("__palette"), JSON.stringify(palette))
})
/* Return palette */
return palette$
}
/**
* Mount color palette
*
* @param el - Color palette element
*
* @returns Color palette component observable
*/
export function mountPalette(
el: HTMLElement
): Observable<Component<Palette>> {
const internal$ = new Subject<Palette>()
/* Set color palette */
internal$.subscribe(palette => {
for (const [key, value] of Object.entries(palette.color))
if (typeof value === "string")
document.body.setAttribute(`data-md-color-${key}`, value)
/* Toggle visibility */
for (let index = 0; index < inputs.length; index++) {
const label = inputs[index].nextElementSibling as HTMLElement
label.hidden = palette.index !== index
}
})
/* Create and return component */
const inputs = getElements<HTMLInputElement>("input", el)
return watchPalette(inputs)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@@ -32,7 +32,6 @@ import {
import { setSourceFacts, setSourceState } from "~/actions"
import { renderSourceFacts } from "~/templates"
import { digest } from "~/utilities"
import { Component } from "../../_"
import { SourceFacts, fetchSourceFacts } from "../facts"
@@ -75,14 +74,14 @@ export function watchSource(
el: HTMLAnchorElement
): Observable<Source> {
return fetch$ ||= defer(() => {
const data = sessionStorage.getItem(digest("__repo"))
const data = sessionStorage.getItem(__prefix("__source"))
if (data) {
return of<SourceFacts>(JSON.parse(data))
} else {
const value$ = fetchSourceFacts(el.href)
value$.subscribe(value => {
try {
sessionStorage.setItem(digest("__repo"), JSON.stringify(value))
sessionStorage.setItem(__prefix("__source"), JSON.stringify(value))
} catch (err) {
/* Uncritical, just swallow */
}
@@ -94,7 +93,7 @@ export function watchSource(
})
.pipe(
catchError(() => NEVER),
filter(facts => facts.length > 0),
filter(facts => Object.keys(facts).length > 0),
map(facts => ({ facts })),
shareReplay(1)
)

View File

@@ -29,10 +29,30 @@ import { fetchSourceFactsFromGitLab } from "../gitlab"
* Types
* ------------------------------------------------------------------------- */
/**
* Repository facts for repositories
*/
export interface RepositoryFacts {
stars?: number /* Number of stars */
forks?: number /* Number of forks */
version?: string /* Latest version */
}
/**
* Repository facts for organizations
*/
export interface OrganizationFacts {
repositories?: number /* Number of repositories */
}
/* ------------------------------------------------------------------------- */
/**
* Repository facts
*/
export type SourceFacts = string[]
export type SourceFacts =
| RepositoryFacts
| OrganizationFacts
/* ----------------------------------------------------------------------------
* Functions

View File

@@ -21,14 +21,24 @@
*/
import { Repo, User } from "github-types"
import { Observable } from "rxjs"
import { Observable, zip } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { requestJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* GitHub release (partial)
*/
interface Release {
tag_name: string /* Tag name */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@@ -44,29 +54,42 @@ import { SourceFacts } from "../_"
export function fetchSourceFactsFromGitHub(
user: string, repo?: string
): Observable<SourceFacts> {
const url = typeof repo !== "undefined"
? `https://api.github.com/repos/${user}/${repo}`
: `https://api.github.com/users/${user}`
return requestJSON<Repo & User>(url)
.pipe(
map(data => {
if (typeof repo !== "undefined") {
const url = `https://api.github.com/repos/${user}/${repo}`
return zip(
/* GitHub repository */
if (typeof repo !== "undefined") {
const { stargazers_count, forks_count }: Repo = data
return [
`${round(stargazers_count!)} Stars`,
`${round(forks_count!)} Forks`
]
/* Fetch version */
requestJSON<Release>(`${url}/releases/latest`)
.pipe(
map(release => ({
version: release.tag_name
})),
defaultIfEmpty({})
),
/* GitHub user/organization */
} else {
const { public_repos }: User = data
return [
`${round(public_repos!)} Repositories`
]
}
}),
defaultIfEmpty([])
/* Fetch stars and forks */
requestJSON<Repo>(url)
.pipe(
map(info => ({
stars: info.stargazers_count,
forks: info.forks_count
})),
defaultIfEmpty({})
)
)
.pipe(
map(([release, info]) => ({ ...release, ...info }))
)
/* User or organization */
} else {
const url = `https://api.github.com/repos/${user}`
return requestJSON<User>(url)
.pipe(
map(info => ({
repositories: info.public_repos
})),
defaultIfEmpty({})
)
}
}

View File

@@ -25,7 +25,6 @@ import { Observable } from "rxjs"
import { defaultIfEmpty, map } from "rxjs/operators"
import { requestJSON } from "~/browser"
import { round } from "~/utilities"
import { SourceFacts } from "../_"
@@ -47,10 +46,10 @@ export function fetchSourceFactsFromGitLab(
const url = `https://${base}/api/v4/projects/${encodeURIComponent(project)}`
return requestJSON<ProjectSchema>(url)
.pipe(
map(({ star_count, forks_count }) => ([
`${round(star_count)} Stars`,
`${round(forks_count)} Forks`
])),
defaultIfEmpty([])
map(({ star_count, forks_count }) => ({
stars: star_count,
forks: forks_count
})),
defaultIfEmpty({})
)
}

View File

@@ -26,11 +26,16 @@ import {
finalize,
map,
observeOn,
switchMap,
tap
} from "rxjs/operators"
import { resetTabsState, setTabsState } from "~/actions"
import { Viewport, watchViewportAt } from "~/browser"
import {
Viewport,
watchElementSize,
watchViewportAt
} from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
@@ -81,8 +86,9 @@ interface MountOptions {
export function watchTabs(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Tabs> {
return watchViewportAt(el, { header$, viewport$ })
return watchElementSize(document.body)
.pipe(
switchMap(() => watchViewportAt(el, { header$, viewport$ })),
map(({ offset: { y } }) => {
return {
hidden: y >= 10

View File

@@ -0,0 +1,160 @@
/*
* Copyright (c) 2016-2021 Martin Donath <martin.donath@squidfunk.com>
*
* 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 NON-INFRINGEMENT. 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.
*/
import {
Observable,
Subject,
animationFrameScheduler,
combineLatest
} from "rxjs"
import {
bufferCount,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
tap
} from "rxjs/operators"
import { resetBackToTopState, setBackToTopState } from "~/actions"
import { Viewport } from "~/browser"
import { Component } from "../_"
import { Main } from "../main"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Back-to-top button
*/
export interface BackToTop {
hidden: boolean /* User scrolled up */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
main$: Observable<Main> /* Main area observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
main$: Observable<Main> /* Main area observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch back-to-top
*
* @param _el - Back-to-top element
* @param options - Options
*
* @returns Back-to-top observable
*/
export function watchBackToTop(
_el: HTMLElement, { viewport$, main$ }: WatchOptions
): Observable<BackToTop> {
/* Compute direction */
const direction$ = viewport$
.pipe(
map(({ offset: { y } }) => y),
bufferCount(2, 1),
map(([a, b]) => a > b),
distinctUntilChanged()
)
/* Compute whether button should be hidden */
const hidden$ = main$
.pipe(
distinctUntilKeyChanged("active")
)
/* Compute threshold for hiding */
return combineLatest([hidden$, direction$])
.pipe(
map(([{ active }, direction]) => ({
hidden: !(active && direction)
})),
distinctUntilChanged((a, b) => (
a.hidden === b.hidden
))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount back-to-top
*
* @param el - Back-to-top element
* @param options - Options
*
* @returns Back-to-top component observable
*/
export function mountBackToTop(
el: HTMLElement, options: MountOptions
): Observable<Component<BackToTop>> {
const internal$ = new Subject<BackToTop>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe({
/* Update state */
next({ hidden }) {
if (hidden)
setBackToTopState(el, "hidden")
else
resetBackToTopState(el)
},
/* Reset on complete */
complete() {
resetBackToTopState(el)
}
})
/* Create and return component */
return watchBackToTop(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}