Restructured observables

This commit is contained in:
squidfunk
2020-02-19 08:57:36 +01:00
parent 46ecf3b055
commit d1afa51726
17 changed files with 300 additions and 212 deletions

View File

@@ -20,5 +20,5 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./shadow"
export * from "./title"

View File

@@ -28,7 +28,6 @@ import "../stylesheets/app-palette.scss"
import { values } from "ramda"
import {
EMPTY,
merge,
of,
combineLatest,
@@ -45,7 +44,6 @@ import {
} from "rxjs/operators"
import {
getElement,
watchToggle,
getElements,
watchMedia,
@@ -60,7 +58,7 @@ import {
mayReceiveKeyboardEvents
} from "./observables"
import { setupSearchWorker } from "./workers"
import { renderSource } from "templates"
import { setToggle, setScrollLock, resetScrollLock } from "actions"
import {
mountHeader,
@@ -74,10 +72,10 @@ import {
watchComponentMap,
mountHeaderTitle
} from "components"
import { mountClipboard } from "./integrations/clipboard"
import { setupClipboard } from "./integrations/clipboard"
import { patchTables, patchDetails, patchScrollfix } from "patches"
import { takeIf, not, isConfig } from "utilities"
import { fetchSourceFacts } from "integrations/source"
import { setupSourceFacts } from "integrations/source"
import { renderDialog } from "templates/dialog"
/* ------------------------------------------------------------------------- */
@@ -89,31 +87,6 @@ document.documentElement.classList.add("js")
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/g))
document.documentElement.classList.add("ios")
/* ------------------------------------------------------------------------- */
/**
* Yes, this is a super hacky implementation. Needs clean up.
*/
function repository() {
const el = getElement<HTMLAnchorElement>(".md-source[href]") // TODO: dont use classes
// console.log(el)
if (!el)
return EMPTY
const data = sessionStorage.getItem("repository")
if (data) {
const x = JSON.parse(data)
return of(x)
}
return fetchSourceFacts(el.href)
.pipe(
tap(data => sessionStorage.setItem("repository", JSON.stringify(data)))
)
}
// memoize
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@@ -214,7 +187,7 @@ export function initialize(config: unknown) {
const dialog = renderDialog("Copied to Clipboard")
// snackbar for copy to clipboard
mountClipboard({ document$ })
setupClipboard({ document$ })
.pipe(
switchMap(ev => {
ev.clearSelection()
@@ -235,7 +208,7 @@ export function initialize(config: unknown) {
patchTables({ document$ })
.subscribe()
patchDetails({ document$ })
patchDetails({ document$, hash$ })
.subscribe()
/* Force 1px scroll offset to trigger overflow scrolling */
@@ -244,6 +217,7 @@ export function initialize(config: unknown) {
.subscribe()
// TODO: general keyboard handler...
// put into main!?
@@ -277,28 +251,6 @@ export function initialize(config: unknown) {
)
.subscribe()
// TODO: patch details!
/* Open details after anchor jump */
merge(hash$, of(location.hash)) // getLocationHash
.subscribe(hash => {
const el = getElement(hash)
console.log("jump to", hash)
if (typeof el !== "undefined") {
const parent = el.closest("details")
if (parent && !parent.open) { // only if it is not open!
parent.open = true
/* Hack: force reload for repositioning */ // TODO. what happens here!?
location.hash = "" // reset
requestAnimationFrame(() => {
location.hash = hash // tslint:disable-line
})
// TODO: setLocationHash() + forceLocationHashChange
}
}
})
// Scroll lock
const toggle$ = useToggle("search")
combineLatest([
@@ -353,20 +305,9 @@ export function initialize(config: unknown) {
// build a notification component! feed txt into it...
/* ----------------------------------------------------------------------- */
// TODO: WIP repo rendering
repository().subscribe(facts => {
if (facts.length) {
const sources = getElements(".md-source__repository")
sources.forEach(repo => {
repo.dataset.mdState = "done"
repo.appendChild(
renderSource(facts)
)
})
}
})
setupSourceFacts({ document$ })
.subscribe()
/* ----------------------------------------------------------------------- */

View File

@@ -32,9 +32,9 @@ import { renderClipboard } from "templates"
* ------------------------------------------------------------------------- */
/**
* Mount options
* Setup options
*/
interface MountOptions {
interface SetupOptions {
document$: Observable<Document> /* Document observable */
}
@@ -43,7 +43,7 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount clipboard
* Setup clipboard
*
* This function implements the Clipboard.js integration and injects a button
* into all code blocks when the document changes.
@@ -52,8 +52,8 @@ interface MountOptions {
*
* @return Clipboard observable
*/
export function mountClipboard(
{ document$ }: MountOptions
export function setupClipboard(
{ document$ }: SetupOptions
): Observable<ClipboardJS.Event> {
if (ClipboardJS.isSupported()) {
return document$
@@ -69,7 +69,7 @@ export function mountClipboard(
}
}),
/* Initialize and mount clipboard */
/* Initialize and setup clipboard */
switchMap(() => {
return fromEventPattern<ClipboardJS.Event>(next => {
const clipboard = new ClipboardJS(".md-clipboard")

View File

@@ -1,89 +0,0 @@
/*
* Copyright (c) 2016-2020 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, of } from "rxjs"
import { fetchSourceFactsFromGitHub } from "../github"
import { fetchSourceFactsFromGitLab } from "../gitlab"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Source facts
*/
export type SourceFacts = string[]
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch source facts
*
* @param url - Source repository URL
*
* @return Source facts observable
*/
export function fetchSourceFacts(
url: string
): Observable<SourceFacts> {
const [type] = url.match(/(git(?:hub|lab))/i) || []
switch (type.toLowerCase()) {
/* GitHub repository */
case "github":
const [, user, repo] = url.match(/^.+github\.com\/([^\/]+)\/?([^\/]+)/i)
return fetchSourceFactsFromGitHub(user, repo)
/* GitLab repository */
case "gitlab":
const [, base, project] = url.match(/^.+?([^\/]*gitlab[^\/]+)\/(.+)/i)
return fetchSourceFactsFromGitLab(base, project)
/* Everything else */
default:
return of([])
}
}
/* ------------------------------------------------------------------------- */
/**
* Round a number for display with source facts
*
* This is a reverse engineered implementation of GitHub's weird rounding
* algorithm for stars, forks and all other numbers. Probably incorrect.
*
* @param value - Original value
*
* @return Rounded value
*/
export function roundSourceFactValue(value: number) {
if (value > 999) {
const digits = +((value - 950) % 1000 > 99)
return `${((value + 1) / 1000).toFixed(digits)}k`
} else {
return value.toString()
}
}

View File

@@ -25,7 +25,9 @@ import { Observable, of } from "rxjs"
import { ajax } from "rxjs/ajax"
import { filter, pluck, shareReplay, switchMap } from "rxjs/operators"
import { SourceFacts, roundSourceFactValue } from "../_"
import { round } from "utilities"
import { SourceFacts } from ".."
/* ----------------------------------------------------------------------------
* Functions
@@ -57,15 +59,15 @@ export function fetchSourceFactsFromGitHub(
if (typeof repo !== "undefined") {
const { stargazers_count, forks_count }: Repo = data
return of([
`${roundSourceFactValue(stargazers_count || 0)} Stars`,
`${roundSourceFactValue(forks_count || 0)} Forks`
`${round(stargazers_count || 0)} Stars`,
`${round(forks_count || 0)} Forks`
])
/* GitHub user/organization */
} else {
const { public_repos }: User = data
return of([
`${roundSourceFactValue(public_repos || 0)} Repositories`
`${round(public_repos || 0)} Repositories`
])
}
}),

View File

@@ -25,7 +25,9 @@ import { Observable } from "rxjs"
import { ajax } from "rxjs/ajax"
import { filter, map, pluck, shareReplay } from "rxjs/operators"
import { SourceFacts, roundSourceFactValue } from "../_"
import { round } from "utilities"
import { SourceFacts } from ".."
/* ----------------------------------------------------------------------------
* Functions
@@ -50,8 +52,8 @@ export function fetchSourceFactsFromGitLab(
filter(({ status }) => status === 200),
pluck("response"),
map(({ star_count, forks_count }: ProjectSchema) => ([
`${roundSourceFactValue(star_count || 0)} Stars`,
`${roundSourceFactValue(forks_count || 0)} Forks`
`${round(star_count)} Stars`,
`${round(forks_count)} Forks`
])),
shareReplay(1)
)

View File

@@ -20,5 +20,128 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./github"
import { NEVER, Observable, of } from "rxjs"
import { switchMap, tap } from "rxjs/operators"
import { getElement, getElements } from "observables"
import { renderSource } from "templates"
import { cache } from "utilities"
import { fetchSourceFactsFromGitHub } from "./github"
import { fetchSourceFactsFromGitLab } from "./gitlab"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Source facts
*/
export type SourceFacts = string[]
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Fetch source facts
*
* @param url - Source repository URL
*
* @return Source facts observable
*/
function fetchSourceFacts(
url: string
): Observable<SourceFacts> {
const [type] = url.match(/(git(?:hub|lab))/i) || []
switch (type.toLowerCase()) {
/* GitHub repository */
case "github":
const [, user, repo] = url.match(/^.+github\.com\/([^\/]+)\/?([^\/]+)/i)
return fetchSourceFactsFromGitHub(user, repo)
/* GitLab repository */
case "gitlab":
const [, base, project] = url.match(/^.+?([^\/]*gitlab[^\/]+)\/(.+)/i)
return fetchSourceFactsFromGitLab(base, project)
/* Everything else */
default:
return of([])
}
}
// provide a function to set a unique key?
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
// TODO: this doesnt belong here...
// cache type.. session or local storage...
// subscribe to observable and cache automatically
// withCache or useCache
/**
* Setup source facts
*
* @param options - Options
*
* @return Source facts observable
*/
export function setupSourceFacts(
{ document$ }: SetupOptions
): Observable<any> {
return document$
.pipe(
switchMap(() => cache("repository", () => {
tap(console.log)
const el = getElement<HTMLAnchorElement>(".md-source[href]") // TODO: useElement( document$ ) ? doesnt emit if no result
if (!el) {
return NEVER
} else {
// TODO: hash should be calculated from el.href
return fetchSourceFacts(el.href)
}
})),
tap(facts => {
console.log(facts)
if (facts.length) {
const sources = getElements(".md-source__repository")
sources.forEach(repo => {
repo.dataset.mdState = "done"
repo.appendChild(renderSource(facts))
})
}
})
// TODO: use URL? to dinstinguish
)
}
// /**
// * Only emit a value if it is non-empty
// *
// * @template T - Value type
// *
// * @return Operator function
// */
// export function exists<T>(): OperatorFunction<T, NonNullable<T>> {
// return pipe(
// switchMap(value => !!value ? NEVER : of(value as NonNullable<T>))
// )
// }

View File

@@ -21,16 +21,18 @@
*/
import { identity } from "ramda"
import { Observable, fromEvent, merge } from "rxjs"
import { Observable, animationFrameScheduler, fromEvent, merge, of } from "rxjs"
import {
filter,
map,
observeOn,
shareReplay,
switchMap,
switchMapTo,
tap
} from "rxjs/operators"
import { getElements, watchMedia } from "observables"
import { getElement, getElements, watchMedia } from "observables"
/* ----------------------------------------------------------------------------
* Helper types
@@ -41,6 +43,7 @@ import { getElements, watchMedia } from "observables"
*/
interface MountOptions {
document$: Observable<Document> /* Document observable */
hash$: Observable<string> /* Location hash observable */
}
/* ----------------------------------------------------------------------------
@@ -58,19 +61,52 @@ interface MountOptions {
* @return Details elements observable
*/
export function patchDetails(
{ document$ }: MountOptions
{ document$, hash$ }: MountOptions
): Observable<HTMLDetailsElement[]> {
return merge(
const els$ = document$
.pipe(
map(() => getElements<HTMLDetailsElement>("details")),
shareReplay(1)
)
/* Open all details before printing */
merge(
watchMedia("print").pipe(filter(identity)), // Webkit
fromEvent(window, "beforeprint") // IE, FF
)
.pipe(
switchMapTo(document$),
map(() => getElements<HTMLDetailsElement>("details")),
tap(els => {
for (const detail of els)
detail.setAttribute("open", "")
}),
shareReplay(1)
switchMapTo(els$)
)
.subscribe(els => {
for (const el of els)
el.setAttribute("open", "")
})
/* Open details before anchor jump */
merge(hash$, of(location.hash))
.pipe(
filter(hash => !!hash.length),
switchMap(hash => of(getElement<HTMLElement>(hash) || document.body)
.pipe(
map(el => el.closest("details")!),
filter(el => el && !el.open),
/* Open details and temporarily reset anchor */
tap(el => {
el.setAttribute("open", "")
location.hash = ""
}),
/* Defer anchor jump to next animation frame */
observeOn(animationFrameScheduler),
tap(() => {
location.hash = hash
})
)
)
)
.subscribe()
/* Return details elements */
return els$
}

View File

@@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { Observable } from "rxjs"
import { Observable, of } from "rxjs"
import { map } from "rxjs/operators"
/* ----------------------------------------------------------------------------
@@ -42,3 +42,39 @@ export function not(
map(active => !active)
)
}
/**
* Cache the last value emitted by an observable in session storage
*
* Note that the value must be serializable as `JSON`.
*
* @template T - Value type
*
* @param key - Cache key
* @param factory - Observable factory
*
* @return Value observable
*/
export function cache<T>(
key: string, factory: () => Observable<T>
): Observable<T> {
const data = sessionStorage.getItem(key)
if (data) {
return of(JSON.parse(data) as T)
/* Retrieve value from observable factory and write to storage */
} else {
const value$ = factory()
value$
.subscribe(value => {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch (err) {
/* Just swallow */
}
})
/* Return value observable */
return value$
}
}

View File

@@ -77,3 +77,40 @@ export function truncate(value: string, n: number): string {
}
return value
}
/**
* Round a number for display with source facts
*
* This is a reverse engineered implementation of GitHub's weird rounding
* algorithm for stars, forks and all other numbers. Probably incorrect.
*
* @param value - Original value
*
* @return Rounded value
*/
export function round(value: number): string {
if (value > 999) {
const digits = +((value - 950) % 1000 > 99)
return `${((value + 1) / 1000).toFixed(digits)}k`
} else {
return value.toString()
}
}
/**
* Simple hash function
*
* @see https://bit.ly/2wsVjJ4 - Original source
*
* @param value - Value to be hashed
*
* @return Hash
*/
export function hash(value: string): number {
let k = 0
for (let i = 0, len = value.length; i < len; i++) {
k = ((k << 5) - k) + value.charCodeAt(i)
k |= 0 // Convert to 32bit integer
}
return k
}