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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"assets/javascripts/bundle.js": "assets/javascripts/bundle.f566fb0d.min.js", "assets/javascripts/bundle.js": "assets/javascripts/bundle.0e463231.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.f566fb0d.min.js.map", "assets/javascripts/bundle.js.map": "assets/javascripts/bundle.0e463231.min.js.map",
"assets/javascripts/worker/packer.js": "assets/javascripts/worker/packer.c14659e8.min.js", "assets/javascripts/worker/packer.js": "assets/javascripts/worker/packer.c14659e8.min.js",
"assets/javascripts/worker/packer.js.map": "assets/javascripts/worker/packer.c14659e8.min.js.map", "assets/javascripts/worker/packer.js.map": "assets/javascripts/worker/packer.c14659e8.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.0a5433f7.min.js", "assets/javascripts/worker/search.js": "assets/javascripts/worker/search.0a5433f7.min.js",

View File

@ -190,7 +190,7 @@
{% endblock %} {% endblock %}
</div> </div>
{% block scripts %} {% block scripts %}
<script src="{{ 'assets/javascripts/bundle.f566fb0d.min.js' | url }}"></script> <script src="{{ 'assets/javascripts/bundle.0e463231.min.js' | url }}"></script>
<script id="__lang" type="application/json"> <script id="__lang" type="application/json">
{%- set translations = {} -%} {%- set translations = {} -%}
{%- for key in [ {%- for key in [

View File

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

View File

@ -28,7 +28,6 @@ import "../stylesheets/app-palette.scss"
import { values } from "ramda" import { values } from "ramda"
import { import {
EMPTY,
merge, merge,
of, of,
combineLatest, combineLatest,
@ -45,7 +44,6 @@ import {
} from "rxjs/operators" } from "rxjs/operators"
import { import {
getElement,
watchToggle, watchToggle,
getElements, getElements,
watchMedia, watchMedia,
@ -60,7 +58,7 @@ import {
mayReceiveKeyboardEvents mayReceiveKeyboardEvents
} from "./observables" } from "./observables"
import { setupSearchWorker } from "./workers" import { setupSearchWorker } from "./workers"
import { renderSource } from "templates"
import { setToggle, setScrollLock, resetScrollLock } from "actions" import { setToggle, setScrollLock, resetScrollLock } from "actions"
import { import {
mountHeader, mountHeader,
@ -74,10 +72,10 @@ import {
watchComponentMap, watchComponentMap,
mountHeaderTitle mountHeaderTitle
} from "components" } from "components"
import { mountClipboard } from "./integrations/clipboard" import { setupClipboard } from "./integrations/clipboard"
import { patchTables, patchDetails, patchScrollfix } from "patches" import { patchTables, patchDetails, patchScrollfix } from "patches"
import { takeIf, not, isConfig } from "utilities" import { takeIf, not, isConfig } from "utilities"
import { fetchSourceFacts } from "integrations/source" import { setupSourceFacts } from "integrations/source"
import { renderDialog } from "templates/dialog" import { renderDialog } from "templates/dialog"
/* ------------------------------------------------------------------------- */ /* ------------------------------------------------------------------------- */
@ -89,31 +87,6 @@ document.documentElement.classList.add("js")
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/g)) if (navigator.userAgent.match(/(iPad|iPhone|iPod)/g))
document.documentElement.classList.add("ios") 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 * Functions
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
@ -214,7 +187,7 @@ export function initialize(config: unknown) {
const dialog = renderDialog("Copied to Clipboard") const dialog = renderDialog("Copied to Clipboard")
// snackbar for copy to clipboard // snackbar for copy to clipboard
mountClipboard({ document$ }) setupClipboard({ document$ })
.pipe( .pipe(
switchMap(ev => { switchMap(ev => {
ev.clearSelection() ev.clearSelection()
@ -235,7 +208,7 @@ export function initialize(config: unknown) {
patchTables({ document$ }) patchTables({ document$ })
.subscribe() .subscribe()
patchDetails({ document$ }) patchDetails({ document$, hash$ })
.subscribe() .subscribe()
/* Force 1px scroll offset to trigger overflow scrolling */ /* Force 1px scroll offset to trigger overflow scrolling */
@ -244,6 +217,7 @@ export function initialize(config: unknown) {
.subscribe() .subscribe()
// TODO: general keyboard handler... // TODO: general keyboard handler...
// put into main!? // put into main!?
@ -277,28 +251,6 @@ export function initialize(config: unknown) {
) )
.subscribe() .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 // Scroll lock
const toggle$ = useToggle("search") const toggle$ = useToggle("search")
combineLatest([ combineLatest([
@ -353,20 +305,9 @@ export function initialize(config: unknown) {
// build a notification component! feed txt into it... // build a notification component! feed txt into it...
/* ----------------------------------------------------------------------- */
// TODO: WIP repo rendering // TODO: WIP repo rendering
repository().subscribe(facts => { setupSourceFacts({ document$ })
if (facts.length) { .subscribe()
const sources = getElements(".md-source__repository")
sources.forEach(repo => {
repo.dataset.mdState = "done"
repo.appendChild(
renderSource(facts)
)
})
}
})
/* ----------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- */

View File

@ -32,9 +32,9 @@ import { renderClipboard } from "templates"
* ------------------------------------------------------------------------- */ * ------------------------------------------------------------------------- */
/** /**
* Mount options * Setup options
*/ */
interface MountOptions { interface SetupOptions {
document$: Observable<Document> /* Document observable */ 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 * This function implements the Clipboard.js integration and injects a button
* into all code blocks when the document changes. * into all code blocks when the document changes.
@ -52,8 +52,8 @@ interface MountOptions {
* *
* @return Clipboard observable * @return Clipboard observable
*/ */
export function mountClipboard( export function setupClipboard(
{ document$ }: MountOptions { document$ }: SetupOptions
): Observable<ClipboardJS.Event> { ): Observable<ClipboardJS.Event> {
if (ClipboardJS.isSupported()) { if (ClipboardJS.isSupported()) {
return document$ return document$
@ -69,7 +69,7 @@ export function mountClipboard(
} }
}), }),
/* Initialize and mount clipboard */ /* Initialize and setup clipboard */
switchMap(() => { switchMap(() => {
return fromEventPattern<ClipboardJS.Event>(next => { return fromEventPattern<ClipboardJS.Event>(next => {
const clipboard = new ClipboardJS(".md-clipboard") 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 { ajax } from "rxjs/ajax"
import { filter, pluck, shareReplay, switchMap } from "rxjs/operators" import { filter, pluck, shareReplay, switchMap } from "rxjs/operators"
import { SourceFacts, roundSourceFactValue } from "../_" import { round } from "utilities"
import { SourceFacts } from ".."
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
@ -57,15 +59,15 @@ export function fetchSourceFactsFromGitHub(
if (typeof repo !== "undefined") { if (typeof repo !== "undefined") {
const { stargazers_count, forks_count }: Repo = data const { stargazers_count, forks_count }: Repo = data
return of([ return of([
`${roundSourceFactValue(stargazers_count || 0)} Stars`, `${round(stargazers_count || 0)} Stars`,
`${roundSourceFactValue(forks_count || 0)} Forks` `${round(forks_count || 0)} Forks`
]) ])
/* GitHub user/organization */ /* GitHub user/organization */
} else { } else {
const { public_repos }: User = data const { public_repos }: User = data
return of([ 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 { ajax } from "rxjs/ajax"
import { filter, map, pluck, shareReplay } from "rxjs/operators" import { filter, map, pluck, shareReplay } from "rxjs/operators"
import { SourceFacts, roundSourceFactValue } from "../_" import { round } from "utilities"
import { SourceFacts } from ".."
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Functions * Functions
@ -50,8 +52,8 @@ export function fetchSourceFactsFromGitLab(
filter(({ status }) => status === 200), filter(({ status }) => status === 200),
pluck("response"), pluck("response"),
map(({ star_count, forks_count }: ProjectSchema) => ([ map(({ star_count, forks_count }: ProjectSchema) => ([
`${roundSourceFactValue(star_count || 0)} Stars`, `${round(star_count)} Stars`,
`${roundSourceFactValue(forks_count || 0)} Forks` `${round(forks_count)} Forks`
])), ])),
shareReplay(1) shareReplay(1)
) )

View File

@ -20,5 +20,128 @@
* IN THE SOFTWARE. * IN THE SOFTWARE.
*/ */
export * from "./_" import { NEVER, Observable, of } from "rxjs"
export * from "./github" 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 { identity } from "ramda"
import { Observable, fromEvent, merge } from "rxjs" import { Observable, animationFrameScheduler, fromEvent, merge, of } from "rxjs"
import { import {
filter, filter,
map, map,
observeOn,
shareReplay, shareReplay,
switchMap,
switchMapTo, switchMapTo,
tap tap
} from "rxjs/operators" } from "rxjs/operators"
import { getElements, watchMedia } from "observables" import { getElement, getElements, watchMedia } from "observables"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
* Helper types * Helper types
@ -41,6 +43,7 @@ import { getElements, watchMedia } from "observables"
*/ */
interface MountOptions { interface MountOptions {
document$: Observable<Document> /* Document observable */ document$: Observable<Document> /* Document observable */
hash$: Observable<string> /* Location hash observable */
} }
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@ -58,19 +61,52 @@ interface MountOptions {
* @return Details elements observable * @return Details elements observable
*/ */
export function patchDetails( export function patchDetails(
{ document$ }: MountOptions { document$, hash$ }: MountOptions
): Observable<HTMLDetailsElement[]> { ): 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 watchMedia("print").pipe(filter(identity)), // Webkit
fromEvent(window, "beforeprint") // IE, FF fromEvent(window, "beforeprint") // IE, FF
) )
.pipe( .pipe(
switchMapTo(document$), switchMapTo(els$)
map(() => getElements<HTMLDetailsElement>("details")),
tap(els => {
for (const detail of els)
detail.setAttribute("open", "")
}),
shareReplay(1)
) )
.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. * IN THE SOFTWARE.
*/ */
import { Observable } from "rxjs" import { Observable, of } from "rxjs"
import { map } from "rxjs/operators" import { map } from "rxjs/operators"
/* ---------------------------------------------------------------------------- /* ----------------------------------------------------------------------------
@ -42,3 +42,39 @@ export function not(
map(active => !active) 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 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
}